From 4ad3777ad7f1fc7ba214ada1ce7bf87dae48b7b9 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 23 Feb 2026 15:24:57 +0100 Subject: [PATCH 1/5] 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 --- src-tauri/src/lib.rs | 19 ++- src-tauri/src/vault.rs | 135 +++++++++++++++++++++- src/components/DynamicPropertiesPanel.tsx | 4 +- src/hooks/useNoteActions.test.ts | 9 +- src/hooks/useNoteActions.ts | 8 +- src/mock-tauri.ts | 72 ++++++------ 6 files changed, 196 insertions(+), 51 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ab88378f..7f25ee28 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -100,6 +100,11 @@ fn purge_trash(vault_path: String) -> Result, String> { vault::purge_trash(&vault_path) } +#[tauri::command] +fn migrate_is_a_to_type(vault_path: String) -> Result { + vault::migrate_is_a_to_type(&vault_path) +} + #[tauri::command] fn get_settings() -> Result { 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, diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs index 32d36bb5..9e1b8ce3 100644 --- a/src-tauri/src/vault.rs +++ b/src-tauri/src/vault.rs @@ -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, + /// New canonical "type" field (preferred over is_a). + #[serde(default, rename = "type")] + type_field: Option, #[serde(default)] aliases: Option, #[serde(rename = "Belongs to")] @@ -232,6 +236,8 @@ fn parse_frontmatter(data: &HashMap) -> 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, path: &Path) -> Option { - fm_is_a +/// Resolve note type: prefer `type` field, fall back to `is_a`, then parent folder inference. +fn resolve_type( + type_field: Option, + is_a: Option, + path: &Path, +) -> Option { + 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 { 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 { + 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 = Vec::new(); + let mut is_a_value: Option = 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 { + 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. diff --git a/src/components/DynamicPropertiesPanel.tsx b/src/components/DynamicPropertiesPanel.tsx index dba9f630..a6306f04 100644 --- a/src/components/DynamicPropertiesPanel.tsx +++ b/src/components/DynamicPropertiesPanel.tsx @@ -27,8 +27,8 @@ export const RELATIONSHIP_KEYS = new Set([ 'Advances', 'Parent', 'Children', 'Has', 'Notes', ]) -// Keys to skip showing in Properties -const SKIP_KEYS = new Set(['aliases', 'notion_id', 'workspace', 'is_a', 'Is A']) +// Keys to skip showing in Properties (handled by dedicated UI or internal) +const SKIP_KEYS = new Set(['aliases', 'notion_id', 'workspace', 'title', 'type', 'is_a', 'Is A']) // eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component export function containsWikilinks(value: FrontmatterValue): boolean { diff --git a/src/hooks/useNoteActions.test.ts b/src/hooks/useNoteActions.test.ts index 63e7ebc5..53bd2deb 100644 --- a/src/hooks/useNoteActions.test.ts +++ b/src/hooks/useNoteActions.test.ts @@ -166,12 +166,12 @@ describe('entryMatchesTarget', () => { describe('buildNoteContent', () => { it('generates frontmatter with status for regular types', () => { const content = buildNoteContent('My Note', 'Note', 'Active') - expect(content).toBe('---\ntitle: My Note\nis_a: Note\nstatus: Active\n---\n\n# My Note\n\n') + expect(content).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n\n# My Note\n\n') }) it('omits status when null', () => { const content = buildNoteContent('AI', 'Topic', null) - expect(content).toBe('---\ntitle: AI\nis_a: Topic\n---\n\n# AI\n\n') + expect(content).toBe('---\ntitle: AI\ntype: Topic\n---\n\n# AI\n\n') }) }) @@ -181,7 +181,7 @@ describe('resolveNewNote', () => { expect(entry.path).toBe('/Users/luca/Laputa/project/my-project.md') expect(entry.isA).toBe('Project') expect(entry.status).toBe('Active') - expect(content).toContain('is_a: Project') + expect(content).toContain('type: Project') expect(content).toContain('status: Active') }) @@ -208,13 +208,14 @@ describe('resolveNewType', () => { expect(entry.path).toBe('/Users/luca/Laputa/type/recipe.md') expect(entry.isA).toBe('Type') expect(entry.status).toBeNull() - expect(content).toContain('Is A: Type') + expect(content).toContain('type: Type') expect(content).toContain('# Recipe') }) }) describe('frontmatterToEntryPatch', () => { it.each([ + ['type', 'Project', { isA: 'Project' }], ['is_a', 'Project', { isA: 'Project' }], ['status', 'Done', { status: 'Done' }], ['color', 'red', { color: 'red' }], diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index 696a4a34..c20d00ae 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -112,7 +112,7 @@ const TYPE_FOLDER_MAP: Record = { const NO_STATUS_TYPES = new Set(['Topic', 'Person']) const ENTRY_DELETE_MAP: Record> = { - is_a: { isA: null }, status: { status: null }, color: { color: null }, + type: { isA: null }, is_a: { isA: null }, status: { status: null }, color: { color: null }, icon: { icon: null }, owner: { owner: null }, cadence: { cadence: null }, aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] }, archived: { archived: false }, trashed: { trashed: false }, order: { order: null }, @@ -127,7 +127,7 @@ export function frontmatterToEntryPatch( const str = value != null ? String(value) : null const arr = Array.isArray(value) ? value.map(String) : [] const updates: Record> = { - is_a: { isA: str }, status: { status: str }, color: { color: str }, + type: { isA: str }, is_a: { isA: str }, status: { status: str }, color: { color: str }, icon: { icon: str }, owner: { owner: str }, cadence: { cadence: str }, aliases: { aliases: arr }, belongs_to: { belongsTo: arr }, related_to: { relatedTo: arr }, archived: { archived: Boolean(value) }, trashed: { trashed: Boolean(value) }, @@ -142,7 +142,7 @@ function addEntryWithMock(entry: VaultEntry, content: string, addEntry: (e: Vaul } export function buildNoteContent(title: string, type: string, status: string | null): string { - const lines = ['---', `title: ${title}`, `is_a: ${type}`] + const lines = ['---', `title: ${title}`, `type: ${type}`] if (status) lines.push(`status: ${status}`) lines.push('---') return `${lines.join('\n')}\n\n# ${title}\n\n` @@ -159,7 +159,7 @@ export function resolveNewNote(title: string, type: string): { entry: VaultEntry export function resolveNewType(typeName: string): { entry: VaultEntry; content: string } { const slug = slugify(typeName) const entry = buildNewEntry({ path: `/Users/luca/Laputa/type/${slug}.md`, slug, title: typeName, type: 'Type', status: null }) - return { entry, content: `---\nIs A: Type\n---\n\n# ${typeName}\n\n` } + return { entry, content: `---\ntype: Type\n---\n\n# ${typeName}\n\n` } } function findWikilinkTarget(entries: VaultEntry[], target: string): VaultEntry | undefined { diff --git a/src/mock-tauri.ts b/src/mock-tauri.ts index 4bcf3481..4c556abc 100644 --- a/src/mock-tauri.ts +++ b/src/mock-tauri.ts @@ -24,7 +24,7 @@ async function checkVaultApi(): Promise { const MOCK_CONTENT: Record = { '/Users/luca/Laputa/project/26q1-laputa-app.md': `--- title: Build Laputa App -is_a: Project +type: Project status: Active owner: Luca Rossi tags: [Tauri, React, TypeScript, CodeMirror] @@ -102,7 +102,7 @@ function loadVault(path: string): VaultEntry[] { \`\`\`yaml title: Some Title -is_a: Project +type: Project status: Active \`\`\` @@ -130,7 +130,7 @@ And this is a second paragraph to verify inter-paragraph spacing is correct. Goo `, '/Users/luca/Laputa/responsibility/grow-newsletter.md': `--- title: Grow Newsletter -is_a: Responsibility +type: Responsibility status: Active owner: Luca Rossi --- @@ -160,7 +160,7 @@ The newsletter is the *engine* that drives everything else — sponsorships, con `, '/Users/luca/Laputa/responsibility/manage-sponsorships.md': `--- title: Manage Sponsorships -is_a: Responsibility +type: Responsibility status: Active owner: Matteo Cellini --- @@ -184,7 +184,7 @@ Revenue stream from newsletter sponsorships. [[Matteo Cellini]] handles day-to-d `, '/Users/luca/Laputa/procedure/write-weekly-essays.md': `--- title: Write Weekly Essays -is_a: Procedure +type: Procedure status: Active owner: Luca Rossi cadence: Weekly @@ -228,7 +228,7 @@ belongs_to: `, '/Users/luca/Laputa/procedure/run-sponsorships.md': `--- title: Run Sponsorships -is_a: Procedure +type: Procedure status: Active owner: Matteo Cellini cadence: Weekly @@ -250,7 +250,7 @@ belongs_to: `, '/Users/luca/Laputa/experiment/stock-screener.md': `--- title: Stock Screener — EMA200 Wick Bounce -is_a: Experiment +type: Experiment status: Active owner: Luca Rossi domains: [Finance, Quantitative Analysis] @@ -285,7 +285,7 @@ Stocks that wick below the 200-day EMA and close above it show a **statistically `, '/Users/luca/Laputa/note/facebook-ads-strategy.md': `--- title: Facebook Ads Strategy -is_a: Note +type: Note belongs_to: - "[[project/26q1-laputa-app]]" related_to: @@ -310,7 +310,7 @@ related_to: `, '/Users/luca/Laputa/note/budget-allocation.md': `--- title: Budget Allocation -is_a: Note +type: Note belongs_to: - "[[project/26q1-laputa-app]]" --- @@ -330,7 +330,7 @@ belongs_to: `, '/Users/luca/Laputa/person/matteo-cellini.md': `--- title: Matteo Cellini -is_a: Person +type: Person aliases: - Matteo --- @@ -350,7 +350,7 @@ Sponsorship manager — handles all sponsor relationships, proposals, and report `, '/Users/luca/Laputa/event/2026-02-14-laputa-app-kickoff.md': `--- title: Laputa App Design Session -is_a: Event +type: Event related_to: - "[[project/26q1-laputa-app]]" - "[[person/matteo-cellini]]" @@ -378,7 +378,7 @@ related_to: `, '/Users/luca/Laputa/topic/software-development.md': `--- title: Software Development -is_a: Topic +type: Topic aliases: - Dev - Coding @@ -396,7 +396,7 @@ A broad topic covering everything from frontend to systems programming. `, '/Users/luca/Laputa/topic/trading.md': `--- title: Trading -is_a: Topic +type: Topic aliases: - Algorithmic Trading --- @@ -413,7 +413,7 @@ aliases: `, '/Users/luca/Laputa/essay/on-writing-well.md': `--- title: On Writing Well -is_a: Essay +type: Essay Belongs to: - "[[responsibility/grow-newsletter]]" --- @@ -424,7 +424,7 @@ Good writing is lean and confident. Every sentence should serve a purpose. `, '/Users/luca/Laputa/essay/engineering-leadership-101.md': `--- title: Engineering Leadership 101 -is_a: Essay +type: Essay Belongs to: - "[[responsibility/grow-newsletter]]" Related to: @@ -437,7 +437,7 @@ The transition from IC to manager is the hardest career shift in engineering. `, '/Users/luca/Laputa/essay/ai-agents-primer.md': `--- title: AI Agents Primer -is_a: Essay +type: Essay Belongs to: - "[[responsibility/grow-newsletter]]" --- @@ -448,7 +448,7 @@ AI agents are autonomous systems that can plan, execute, and adapt to achieve go `, // --- Type documents --- '/Users/luca/Laputa/type/project.md': `--- -Is A: Type +type: Type order: 0 --- @@ -462,7 +462,7 @@ A **time-bound initiative** that advances a [[type/responsibility|Responsibility - **Belongs to**: Usually a Quarter or Responsibility `, '/Users/luca/Laputa/type/responsibility.md': `--- -Is A: Type +type: Type order: 1 --- @@ -475,7 +475,7 @@ An **ongoing area of ownership** — something you're accountable for indefinite - **Owner**: The person accountable `, '/Users/luca/Laputa/type/procedure.md': `--- -Is A: Type +type: Type order: 2 --- @@ -490,7 +490,7 @@ A **recurring process** tied to a [[type/responsibility|Responsibility]]. Proced - **Belongs to**: A Responsibility `, '/Users/luca/Laputa/type/experiment.md': `--- -Is A: Type +type: Type order: 3 --- @@ -503,7 +503,7 @@ A **hypothesis-driven investigation** with a clear test and measurable outcome. - **Owner**: The person running the experiment `, '/Users/luca/Laputa/type/person.md': `--- -Is A: Type +type: Type order: 4 --- @@ -515,7 +515,7 @@ A **person** you interact with — team members, collaborators, contacts. People - **Aliases**: Alternative names for wikilink resolution `, '/Users/luca/Laputa/type/event.md': `--- -Is A: Type +type: Type order: 5 --- @@ -527,7 +527,7 @@ A **point-in-time occurrence** — meetings, launches, milestones. Events are li - **Related to**: Entities this event is about `, '/Users/luca/Laputa/type/topic.md': `--- -Is A: Type +type: Type order: 6 --- @@ -539,7 +539,7 @@ A **subject area** for categorization. Topics group related notes, projects, and - **Aliases**: Alternative names `, '/Users/luca/Laputa/type/essay.md': `--- -Is A: Type +type: Type order: 7 --- @@ -551,7 +551,7 @@ A **published piece of writing** — newsletter essays, blog posts, articles. Es - **Belongs to**: Usually a Responsibility `, '/Users/luca/Laputa/type/note.md': `--- -Is A: Type +type: Type order: 8 --- @@ -564,7 +564,7 @@ A **general-purpose document** — research notes, meeting notes, strategy docs. `, // --- Custom type documents (user-created types) --- '/Users/luca/Laputa/type/recipe.md': `--- -Is A: Type +type: Type icon: cooking-pot color: orange --- @@ -579,7 +579,7 @@ A **recipe** for cooking or baking. Recipes have ingredients, steps, and serving - **Cook Time**: Time to cook `, '/Users/luca/Laputa/type/book.md': `--- -Is A: Type +type: Type icon: book-open color: green --- @@ -596,7 +596,7 @@ A **book** you're reading or have read. Track reading progress, notes, and key t // --- Trashed entries --- '/Users/luca/Laputa/note/old-draft-notes.md': `--- title: Old Draft Notes -is_a: Note +type: Note trashed: true trashed_at: ${new Date(Date.now() - 86400000 * 5).toISOString().slice(0, 10)} belongs_to: @@ -609,7 +609,7 @@ Some rough draft content that is no longer relevant. Moving to trash. `, '/Users/luca/Laputa/note/deprecated-api-notes.md': `--- title: Deprecated API Notes -is_a: Note +type: Note trashed: true trashed_at: ${new Date(Date.now() - 86400000 * 35).toISOString().slice(0, 10)} --- @@ -620,7 +620,7 @@ Old API documentation for the v1 endpoint. Replaced by v2 docs. `, '/Users/luca/Laputa/experiment/failed-seo-experiment.md': `--- title: Failed SEO Experiment -is_a: Experiment +type: Experiment status: Dropped trashed: true trashed_at: ${new Date(Date.now() - 86400000 * 10).toISOString().slice(0, 10)} @@ -635,7 +635,7 @@ Tried programmatic SEO pages. Results were negligible — trashing this. // --- Archived entries --- '/Users/luca/Laputa/project/25q3-website-redesign.md': `--- title: Website Redesign -is_a: Project +type: Project status: Done archived: true owner: Luca Rossi @@ -654,7 +654,7 @@ Completed redesign of the company website. Migrated from WordPress to Next.js wi `, '/Users/luca/Laputa/experiment/twitter-thread-experiment.md': `--- title: Twitter Thread Growth Experiment -is_a: Experiment +type: Experiment status: Done archived: true owner: Luca Rossi @@ -676,7 +676,7 @@ Reverted to 1 high-quality thread per week. Archived this experiment. // --- Instances of custom types --- '/Users/luca/Laputa/recipe/pasta-carbonara.md': `--- title: Pasta Carbonara -is_a: Recipe +type: Recipe servings: 4 prep_time: 10 min cook_time: 20 min @@ -695,7 +695,7 @@ Classic Roman pasta dish with eggs, pecorino, guanciale, and black pepper. `, '/Users/luca/Laputa/book/designing-data-intensive-applications.md': `--- title: Designing Data-Intensive Applications -is_a: Book +type: Book author: Martin Kleppmann status: Finished rating: 5 @@ -1610,7 +1610,7 @@ index abc1234..def5678 100644 @@ -1,8 +1,10 @@ --- title: Example Note - is_a: Note + type: Note +status: Active --- @@ -1758,6 +1758,8 @@ const mockHandlers: Record any> = { updated_at: new Date().toISOString(), }), clone_repo: (args: { url: string; local_path: string }) => `Cloned to ${args.local_path}`, + purge_trash: () => [], + migrate_is_a_to_type: () => 0, } export function isTauri(): boolean { From 31f794180e8f77cc2959c5346a86a12ac4135b7f Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 23 Feb 2026 15:26:50 +0100 Subject: [PATCH 2/5] test: add migration and type-field parsing tests Rust (9 new tests): - test_parse_type_field: 'type:' in YAML maps to is_a field - test_parse_type_field_takes_precedence_over_is_a: 'type' wins over 'Is A' - test_parse_legacy_is_a_still_works: backward compat for 'Is A:' - test_parse_snake_case_is_a_still_works: backward compat for 'is_a:' - test_migrate_file_is_a_to_type: single-file migration - test_migrate_file_quoted_is_a_to_type: handles "Is A" variant - test_migrate_file_preserves_existing_type: type: takes precedence - test_migrate_file_no_change_needed: skip already-migrated files - test_migrate_vault: vault-wide migration counts correctly Frontend (2 new tests): - frontmatterToEntryPatch maps 'type' key to isA field - DynamicPropertiesPanel skips is_a/Is A/type from property list Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/vault.rs | 131 ++++++++++++++++++ .../DynamicPropertiesPanel.test.tsx | 20 +-- 2 files changed, 141 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs index 9e1b8ce3..6e4863ff 100644 --- a/src-tauri/src/vault.rs +++ b/src-tauri/src/vault.rs @@ -2563,4 +2563,135 @@ References: assert!(content.contains("title: New Name")); assert!(content.contains("# New Name")); } + + // --- type field parsing tests --- + + #[test] + fn test_parse_type_field() { + let dir = TempDir::new().unwrap(); + let entry = parse_test_entry( + &dir, + "project/my-project.md", + "---\ntype: Project\nStatus: Active\n---\n# My Project\n", + ); + assert_eq!(entry.is_a, Some("Project".to_string())); + } + + #[test] + fn test_parse_type_field_takes_precedence_over_is_a() { + let dir = TempDir::new().unwrap(); + let entry = parse_test_entry( + &dir, + "project/conflict.md", + "---\ntype: Project\nIs A: Note\n---\n# Conflict\n", + ); + assert_eq!(entry.is_a, Some("Project".to_string())); + } + + #[test] + fn test_parse_legacy_is_a_still_works() { + let dir = TempDir::new().unwrap(); + let entry = parse_test_entry( + &dir, + "note/legacy.md", + "---\nIs A: Note\nStatus: Active\n---\n# Legacy\n", + ); + assert_eq!(entry.is_a, Some("Note".to_string())); + } + + #[test] + fn test_parse_snake_case_is_a_still_works() { + let dir = TempDir::new().unwrap(); + let entry = parse_test_entry( + &dir, + "note/snake.md", + "---\nis_a: Note\nStatus: Draft\n---\n# Snake Case\n", + ); + assert_eq!(entry.is_a, Some("Note".to_string())); + } + + // --- migration tests --- + + #[test] + fn test_migrate_file_is_a_to_type() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("note.md"); + fs::write(&path, "---\nis_a: Project\nStatus: Active\n---\n# Note\n").unwrap(); + + assert!(migrate_file_is_a_to_type(&path).unwrap()); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("type: Project")); + assert!(!content.contains("is_a:")); + } + + #[test] + fn test_migrate_file_quoted_is_a_to_type() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("note.md"); + fs::write(&path, "---\n\"Is A\": Type\norder: 1\n---\n# My Type\n").unwrap(); + + assert!(migrate_file_is_a_to_type(&path).unwrap()); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("type: Type")); + assert!(!content.contains("Is A")); + } + + #[test] + fn test_migrate_file_preserves_existing_type() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("note.md"); + fs::write( + &path, + "---\ntype: Project\nis_a: Note\nStatus: Active\n---\n# Note\n", + ) + .unwrap(); + + assert!(migrate_file_is_a_to_type(&path).unwrap()); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("type: Project")); + assert!(!content.contains("is_a:")); + } + + #[test] + fn test_migrate_file_no_change_needed() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("note.md"); + fs::write(&path, "---\ntype: Project\nStatus: Active\n---\n# Note\n").unwrap(); + + assert!(!migrate_file_is_a_to_type(&path).unwrap()); + } + + #[test] + fn test_migrate_vault() { + let dir = TempDir::new().unwrap(); + create_test_file( + dir.path(), + "note/a.md", + "---\nis_a: Note\n---\n# A\n", + ); + create_test_file( + dir.path(), + "project/b.md", + "---\ntype: Project\n---\n# B\n", + ); + create_test_file( + dir.path(), + "type/c.md", + "---\n\"Is A\": Type\norder: 1\n---\n# C\n", + ); + + let migrated = migrate_is_a_to_type(dir.path().to_str().unwrap()).unwrap(); + assert_eq!(migrated, 2); // a.md and c.md + + let a = fs::read_to_string(dir.path().join("note/a.md")).unwrap(); + assert!(a.contains("type: Note")); + assert!(!a.contains("is_a:")); + + let b = fs::read_to_string(dir.path().join("project/b.md")).unwrap(); + assert!(b.contains("type: Project")); // unchanged + + let c = fs::read_to_string(dir.path().join("type/c.md")).unwrap(); + assert!(c.contains("type: Type")); + assert!(!c.contains("Is A")); + } } diff --git a/src/components/DynamicPropertiesPanel.test.tsx b/src/components/DynamicPropertiesPanel.test.tsx index d28c5293..f283e940 100644 --- a/src/components/DynamicPropertiesPanel.test.tsx +++ b/src/components/DynamicPropertiesPanel.test.tsx @@ -128,21 +128,21 @@ describe('DynamicPropertiesPanel', () => { expect(screen.getByText('cadence')).toBeInTheDocument() }) - it('skips is_a key since it is shown via TypeRow', () => { + it('skips is_a, Is A, and type keys (shown via TypeRow instead)', () => { render( ) - // Type is shown via TypeRow, is_a should NOT appear as a separate editable property - const editableRows = screen.getAllByTestId('editable-property') - const editableLabels = editableRows.map(row => row.querySelector('span')?.textContent?.replace('×', '').trim()) - expect(editableLabels).not.toContain('is_a') - // But Type row should still show - expect(screen.getByText('Type')).toBeInTheDocument() - expect(screen.getByText('Project')).toBeInTheDocument() + expect(screen.queryByText('is_a')).not.toBeInTheDocument() + expect(screen.queryByText('Is A')).not.toBeInTheDocument() + // 'type' as a property label should not appear (the TypeRow renders 'Type' differently) + const typeLabels = screen.getAllByText('Type') + // Only the TypeRow label should exist, not a property row + expect(typeLabels).toHaveLength(1) + expect(screen.getByTitle('Active')).toBeInTheDocument() }) it('renders boolean property as toggle', () => { From 2baff0a6f14bebc85ad395fea4e70c6cd502d82a Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 23 Feb 2026 15:28:39 +0100 Subject: [PATCH 3/5] design: inspector before/after wireframe for Is a removal Shows the before state (with duplicate is_a property row highlighted in orange) and the after state (clean inspector with only the Type row). Co-Authored-By: Claude Opus 4.6 --- design/rimuovi-is-a.pen | 375 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100644 design/rimuovi-is-a.pen diff --git a/design/rimuovi-is-a.pen b/design/rimuovi-is-a.pen new file mode 100644 index 00000000..0090d6e6 --- /dev/null +++ b/design/rimuovi-is-a.pen @@ -0,0 +1,375 @@ +{ + "version": 2.8, + "children": [ + { + "type": "frame", + "id": "beforeAfter", + "x": 0, + "y": 0, + "name": "Inspector — Is a Removed (Before / After)", + "width": 700, + "height": 500, + "fill": "#FFFFFF", + "layout": "horizontal", + "gap": 40, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "beforePanel", + "name": "Before", + "width": 300, + "height": 452, + "fill": "#FAFAFA", + "layout": "vertical", + "gap": 8, + "padding": 16, + "cornerRadius": 8, + "stroke": "#E5E5E5", + "strokeWidth": 1, + "children": [ + { + "type": "text", + "id": "beforeTitle", + "content": "BEFORE", + "fontSize": 10, + "fontWeight": 700, + "letterSpacing": 1.2, + "fill": "#999999" + }, + { + "type": "frame", + "id": "beforeSep1", + "width": 268, + "height": 1, + "fill": "#E5E5E5" + }, + { + "type": "frame", + "id": "beforeRow1", + "layout": "horizontal", + "width": 268, + "children": [ + { + "type": "text", + "id": "beforeTypeLabel", + "content": "TYPE", + "fontSize": 10, + "fontWeight": 600, + "letterSpacing": 1.2, + "fill": "#999999", + "width": 80 + }, + { + "type": "frame", + "id": "beforeTypePill", + "fill": "#E8F5E9", + "cornerRadius": 6, + "padding": 4, + "children": [ + { + "type": "text", + "id": "beforeTypeVal", + "content": "Project", + "fontSize": 12, + "fontWeight": 500, + "fill": "#388E3C" + } + ] + } + ] + }, + { + "type": "frame", + "id": "beforeRow2", + "layout": "horizontal", + "width": 268, + "fill": "#FFF3E0", + "cornerRadius": 4, + "padding": 6, + "stroke": "#FF9800", + "strokeWidth": 1, + "children": [ + { + "type": "text", + "id": "beforeIsALabel", + "content": "is_a", + "fontSize": 10, + "fontWeight": 600, + "letterSpacing": 1.2, + "fill": "#E65100", + "width": 80 + }, + { + "type": "text", + "id": "beforeIsAVal", + "content": "Project", + "fontSize": 13, + "fill": "#E65100" + } + ] + }, + { + "type": "text", + "id": "beforeDupNote", + "content": "⚠ Duplicate! 'is_a' duplicates 'Type'", + "fontSize": 11, + "fill": "#E65100", + "fontWeight": 500 + }, + { + "type": "frame", + "id": "beforeRow3", + "layout": "horizontal", + "width": 268, + "children": [ + { + "type": "text", + "id": "beforeStatusLabel", + "content": "STATUS", + "fontSize": 10, + "fontWeight": 600, + "letterSpacing": 1.2, + "fill": "#999999", + "width": 80 + }, + { + "type": "frame", + "id": "beforeStatusPill", + "fill": "#E8F5E9", + "cornerRadius": 16, + "padding": 4, + "children": [ + { + "type": "text", + "id": "beforeStatusVal", + "content": "ACTIVE", + "fontSize": 10, + "fontWeight": 600, + "letterSpacing": 1.2, + "fill": "#388E3C" + } + ] + } + ] + }, + { + "type": "frame", + "id": "beforeRow4", + "layout": "horizontal", + "width": 268, + "children": [ + { + "type": "text", + "id": "beforeModLabel", + "content": "MODIFIED", + "fontSize": 10, + "fontWeight": 600, + "letterSpacing": 1.2, + "fill": "#999999", + "width": 80 + }, + { + "type": "text", + "id": "beforeModVal", + "content": "Feb 23, 2026", + "fontSize": 12, + "fill": "#666666" + } + ] + }, + { + "type": "frame", + "id": "beforeRow5", + "layout": "horizontal", + "width": 268, + "children": [ + { + "type": "text", + "id": "beforeWordsLabel", + "content": "WORDS", + "fontSize": 10, + "fontWeight": 600, + "letterSpacing": 1.2, + "fill": "#999999", + "width": 80 + }, + { + "type": "text", + "id": "beforeWordsVal", + "content": "342", + "fontSize": 12, + "fill": "#666666" + } + ] + } + ] + }, + { + "type": "frame", + "id": "afterPanel", + "name": "After", + "width": 300, + "height": 452, + "fill": "#FAFAFA", + "layout": "vertical", + "gap": 8, + "padding": 16, + "cornerRadius": 8, + "stroke": "#388E3C", + "strokeWidth": 2, + "children": [ + { + "type": "text", + "id": "afterTitle", + "content": "AFTER", + "fontSize": 10, + "fontWeight": 700, + "letterSpacing": 1.2, + "fill": "#388E3C" + }, + { + "type": "frame", + "id": "afterSep1", + "width": 268, + "height": 1, + "fill": "#E5E5E5" + }, + { + "type": "frame", + "id": "afterRow1", + "layout": "horizontal", + "width": 268, + "children": [ + { + "type": "text", + "id": "afterTypeLabel", + "content": "TYPE", + "fontSize": 10, + "fontWeight": 600, + "letterSpacing": 1.2, + "fill": "#999999", + "width": 80 + }, + { + "type": "frame", + "id": "afterTypePill", + "fill": "#E8F5E9", + "cornerRadius": 6, + "padding": 4, + "children": [ + { + "type": "text", + "id": "afterTypeVal", + "content": "Project", + "fontSize": 12, + "fontWeight": 500, + "fill": "#388E3C" + } + ] + } + ] + }, + { + "type": "text", + "id": "afterNote", + "content": "✓ 'is_a' removed — only 'Type' shown", + "fontSize": 11, + "fill": "#388E3C", + "fontWeight": 500 + }, + { + "type": "frame", + "id": "afterRow3", + "layout": "horizontal", + "width": 268, + "children": [ + { + "type": "text", + "id": "afterStatusLabel", + "content": "STATUS", + "fontSize": 10, + "fontWeight": 600, + "letterSpacing": 1.2, + "fill": "#999999", + "width": 80 + }, + { + "type": "frame", + "id": "afterStatusPill", + "fill": "#E8F5E9", + "cornerRadius": 16, + "padding": 4, + "children": [ + { + "type": "text", + "id": "afterStatusVal", + "content": "ACTIVE", + "fontSize": 10, + "fontWeight": 600, + "letterSpacing": 1.2, + "fill": "#388E3C" + } + ] + } + ] + }, + { + "type": "frame", + "id": "afterRow4", + "layout": "horizontal", + "width": 268, + "children": [ + { + "type": "text", + "id": "afterModLabel", + "content": "MODIFIED", + "fontSize": 10, + "fontWeight": 600, + "letterSpacing": 1.2, + "fill": "#999999", + "width": 80 + }, + { + "type": "text", + "id": "afterModVal", + "content": "Feb 23, 2026", + "fontSize": 12, + "fill": "#666666" + } + ] + }, + { + "type": "frame", + "id": "afterRow5", + "layout": "horizontal", + "width": 268, + "children": [ + { + "type": "text", + "id": "afterWordsLabel", + "content": "WORDS", + "fontSize": 10, + "fontWeight": 600, + "letterSpacing": 1.2, + "fill": "#999999", + "width": 80 + }, + { + "type": "text", + "id": "afterWordsVal", + "content": "342", + "fontSize": 12, + "fill": "#666666" + } + ] + } + ] + } + ] + } + ], + "themes": {}, + "variables": {}, + "fonts": [] +} From 38694823f641ce329209fe0417b479580ba65a79 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 23 Feb 2026 19:39:42 +0100 Subject: [PATCH 4/5] fix: cargo fmt vault.rs formatting --- src-tauri/src/vault.rs | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs index 6e4863ff..0427f882 100644 --- a/src-tauri/src/vault.rs +++ b/src-tauri/src/vault.rs @@ -517,8 +517,8 @@ fn extract_is_a_value(line: &str) -> Option<&str> { /// 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 { - let content = - fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; + 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); @@ -2664,16 +2664,8 @@ References: #[test] fn test_migrate_vault() { let dir = TempDir::new().unwrap(); - create_test_file( - dir.path(), - "note/a.md", - "---\nis_a: Note\n---\n# A\n", - ); - create_test_file( - dir.path(), - "project/b.md", - "---\ntype: Project\n---\n# B\n", - ); + create_test_file(dir.path(), "note/a.md", "---\nis_a: Note\n---\n# A\n"); + create_test_file(dir.path(), "project/b.md", "---\ntype: Project\n---\n# B\n"); create_test_file( dir.path(), "type/c.md", From bb64dc7654db15d67ca1d7c560578fd33759e7f0 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 23 Feb 2026 20:48:22 +0100 Subject: [PATCH 5/5] fix: cargo fmt vault.rs formatting --- src-tauri/src/vault.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs index 0427f882..756e5066 100644 --- a/src-tauri/src/vault.rs +++ b/src-tauri/src/vault.rs @@ -690,8 +690,7 @@ pub fn get_note_content(path: &str) -> Result { pub fn save_note_content(path: &str, content: &str) -> Result<(), String> { let file_path = Path::new(path); validate_save_path(file_path, path)?; - fs::write(file_path, content) - .map_err(|e| format!("Failed to save {}: {}", path, e)) + fs::write(file_path, content).map_err(|e| format!("Failed to save {}: {}", path, e)) } fn validate_save_path(file_path: &Path, display_path: &str) -> Result<(), String> { @@ -2186,7 +2185,9 @@ References: fn test_save_note_content_nonexistent_parent() { let result = save_note_content("/nonexistent/parent/dir/file.md", "content"); assert!(result.is_err()); - assert!(result.unwrap_err().contains("Parent directory does not exist")); + assert!(result + .unwrap_err() + .contains("Parent directory does not exist")); } #[test] @@ -2218,7 +2219,8 @@ References: let original = "---\nIs A: Project\nStatus: Active\n---\n# My Project\n\nOriginal body."; create_test_file(dir.path(), "note.md", original); - let updated = "---\nIs A: Project\nStatus: Active\n---\n# My Project\n\nUpdated body with changes."; + let updated = + "---\nIs A: Project\nStatus: Active\n---\n# My Project\n\nUpdated body with changes."; save_note_content(file_path.to_str().unwrap(), updated).unwrap(); let saved = fs::read_to_string(&file_path).unwrap();