feat: replace 'Is a' / 'is_a' with 'type' as the canonical frontmatter key

- Rust Frontmatter struct now parses both 'type' (new) and 'Is A'/'is_a' (legacy),
  with 'type' taking precedence via resolve_type()
- Added migrate_is_a_to_type() vault-wide migration (runs on startup, also exposed as Tauri command)
- Frontend: buildNoteContent and resolveNewType now emit 'type:' in YAML
- Inspector SKIP_KEYS hides 'is_a', 'Is A', 'type', 'title' from property list
  (TypeRow already renders the type with its dedicated UI)
- frontmatterToEntryPatch handles both 'type' and legacy 'is_a' keys
- Mock data updated: all YAML content uses 'type:' instead of 'is_a:'/'Is A:'
- Tests updated to expect 'type:' in generated frontmatter

Product decision: internal VaultEntry field stays as `isA` (TS) / `is_a` (Rust)
to minimize blast radius. The user-facing change is the YAML key and inspector.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-23 15:24:57 +01:00
parent d54c956082
commit c8e87f0ab9
6 changed files with 196 additions and 51 deletions

View File

@@ -100,6 +100,11 @@ fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
vault::purge_trash(&vault_path)
}
#[tauri::command]
fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
vault::migrate_is_a_to_type(&vault_path)
}
#[tauri::command]
fn get_settings() -> Result<Settings, String> {
settings::get_settings()
@@ -154,7 +159,8 @@ pub fn run() {
.map(|h| h.join("Laputa"))
.unwrap_or_default();
if vault_path.is_dir() {
match vault::purge_trash(vault_path.to_str().unwrap_or_default()) {
let vp_str = vault_path.to_str().unwrap_or_default();
match vault::purge_trash(vp_str) {
Ok(deleted) if !deleted.is_empty() => {
log::info!("Purged {} trashed files on startup", deleted.len());
}
@@ -163,6 +169,16 @@ pub fn run() {
}
_ => {}
}
// Migrate legacy is_a/Is A frontmatter to type
match vault::migrate_is_a_to_type(vp_str) {
Ok(n) if n > 0 => {
log::info!("Migrated {} files from is_a to type on startup", n);
}
Err(e) => {
log::warn!("Failed to migrate is_a on startup: {}", e);
}
_ => {}
}
}
Ok(())
@@ -183,6 +199,7 @@ pub fn run() {
ai_chat,
save_image,
purge_trash,
migrate_is_a_to_type,
get_settings,
save_settings,
github_list_repos,

View File

@@ -50,8 +50,12 @@ pub struct VaultEntry {
/// Intermediate struct to capture YAML frontmatter fields.
#[derive(Debug, Deserialize, Default)]
struct Frontmatter {
#[serde(rename = "Is A")]
/// Legacy "Is A" / "is_a" field — migrated to `type` in new notes.
#[serde(rename = "Is A", alias = "is_a")]
is_a: Option<StringOrList>,
/// New canonical "type" field (preferred over is_a).
#[serde(default, rename = "type")]
type_field: Option<StringOrList>,
#[serde(default)]
aliases: Option<StringOrList>,
#[serde(rename = "Belongs to")]
@@ -232,6 +236,8 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
/// Only skip keys that can never contain wikilinks.
const SKIP_KEYS: &[&str] = &[
"is a",
"is_a",
"type",
"aliases",
"status",
"cadence",
@@ -313,9 +319,14 @@ fn infer_type_from_folder(folder: &str) -> String {
.to_string()
}
/// Resolve `is_a` from frontmatter, falling back to parent folder inference.
fn resolve_is_a(fm_is_a: Option<StringOrList>, path: &Path) -> Option<String> {
fm_is_a
/// Resolve note type: prefer `type` field, fall back to `is_a`, then parent folder inference.
fn resolve_type(
type_field: Option<StringOrList>,
is_a: Option<StringOrList>,
path: &Path,
) -> Option<String> {
type_field
.or(is_a)
.and_then(|a| a.into_vec().into_iter().next())
.or_else(|| {
path.parent()
@@ -377,7 +388,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
let snippet = extract_snippet(&content);
let (modified_at, file_size) = read_file_metadata(path)?;
let created_at = parse_created_at(&frontmatter);
let is_a = resolve_is_a(frontmatter.is_a, path);
let is_a = resolve_type(frontmatter.type_field, frontmatter.is_a, path);
// Add "Type" relationship: isA becomes a navigable link to the type document.
// Skip for type documents themselves (isA == "Type") to avoid self-referential links.
@@ -480,6 +491,120 @@ fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
}
}
/// Check if a frontmatter section contains legacy `is_a` or `Is A` keys.
fn has_legacy_is_a(fm_content: &str) -> bool {
fm_content.lines().any(|line| {
let t = line.trim_start();
t.starts_with("is_a:")
|| t.starts_with("\"Is A\":")
|| t.starts_with("'Is A':")
|| t.starts_with("Is A:")
})
}
/// Extract the value from a legacy `is_a` / `Is A` line.
fn extract_is_a_value(line: &str) -> Option<&str> {
let t = line.trim_start();
for prefix in &["is_a:", "\"Is A\":", "'Is A':", "Is A:"] {
if let Some(rest) = t.strip_prefix(prefix) {
let v = rest.trim();
return Some(v);
}
}
None
}
/// Migrate a single file's frontmatter from `is_a`/`Is A` to `type`.
/// Returns Ok(true) if the file was modified, Ok(false) if no migration needed.
fn migrate_file_is_a_to_type(path: &Path) -> Result<bool, String> {
let content =
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
if !content.starts_with("---\n") {
return Ok(false);
}
let fm_end = match content[4..].find("\n---") {
Some(i) => i + 4,
None => return Ok(false),
};
let fm_content = &content[4..fm_end];
if !has_legacy_is_a(fm_content) {
return Ok(false);
}
// Check if `type:` already exists
let has_type = fm_content.lines().any(|line| {
let t = line.trim_start();
t.starts_with("type:")
});
let mut new_lines: Vec<String> = Vec::new();
let mut is_a_value: Option<String> = None;
for line in fm_content.lines() {
if let Some(val) = extract_is_a_value(line) {
is_a_value = Some(val.to_string());
// Skip list continuations after is_a
continue;
}
new_lines.push(line.to_string());
}
// If type: doesn't exist and we found an is_a value, add type:
if !has_type {
if let Some(ref val) = is_a_value {
// Insert type: at the beginning (after other keys is fine too, but beginning is clean)
new_lines.insert(0, format!("type: {}", val));
}
}
let rest = &content[fm_end + 4..];
let new_content = format!("---\n{}\n---{}", new_lines.join("\n"), rest);
fs::write(path, &new_content)
.map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
Ok(true)
}
/// Migrate all markdown files in the vault from `is_a`/`Is A` to `type`.
/// Returns the number of files migrated.
pub fn migrate_is_a_to_type(vault_path: &str) -> Result<usize, String> {
let vault = Path::new(vault_path);
if !vault.exists() || !vault.is_dir() {
return Err(format!(
"Vault path does not exist or is not a directory: {}",
vault_path
));
}
let mut migrated = 0;
for entry in WalkDir::new(vault)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() || path.extension().map(|ext| ext != "md").unwrap_or(true) {
continue;
}
match migrate_file_is_a_to_type(path) {
Ok(true) => {
log::info!("Migrated is_a → type: {}", path.display());
migrated += 1;
}
Ok(false) => {}
Err(e) => {
log::warn!("Failed to migrate {}: {}", path.display(), e);
}
}
}
Ok(migrated)
}
/// Scan all markdown files in the vault and delete those where
/// `Trashed at` frontmatter is more than 30 days ago.
/// Returns the list of deleted file paths.