From 55e479be135af5612e514aaec957c2f087ff1e9d Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 8 Mar 2026 23:38:17 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20flip=20canonical=20type=20field=20?= =?UTF-8?q?=E2=80=94=20make=20type:=20primary,=20Is=20A:=20the=20alias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rust parser now treats `type:` as the canonical frontmatter field for entity type, with `Is A:` and `is_a:` accepted as legacy aliases. Previously it was the other way around, creating an asymmetric read/write cycle since the frontend and all 8800+ vault notes already use `type:`. - Flip serde attribute: rename="type", alias="Is A", alias="is_a" - Update theme defaults, getting-started vault, and type definitions - Add round-trip tests for both type: and Is A: parsing - Update mock data and TypeScript tests to use canonical form Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/theme/defaults.rs | 8 +-- src-tauri/src/theme/seed.rs | 14 +++--- src-tauri/src/vault/getting_started.rs | 34 ++++++------- src-tauri/src/vault/mod.rs | 28 ++++++++++- src-tauri/src/vault/trash.rs | 2 +- src/components/Inspector.test.tsx | 2 +- src/components/ThemePropertyEditor.test.tsx | 2 +- src/hooks/useThemeManager.test.ts | 6 +-- src/mock-tauri/mock-content.ts | 6 +-- src/mock-tauri/mock-handlers.ts | 2 +- tests/smoke/flip-canonical-type-field.spec.ts | 50 +++++++++++++++++++ 11 files changed, 115 insertions(+), 39 deletions(-) create mode 100644 tests/smoke/flip-canonical-type-field.spec.ts diff --git a/src-tauri/src/theme/defaults.rs b/src-tauri/src/theme/defaults.rs index 8ece8d76..4b465842 100644 --- a/src-tauri/src/theme/defaults.rs +++ b/src-tauri/src/theme/defaults.rs @@ -164,7 +164,7 @@ pub const DEFAULT_VAULT_THEME_VARS: [(&str, &str); 46] = [ /// Vault-based theme note for the built-in Default theme. pub const DEFAULT_VAULT_THEME: &str = "---\n\ -Is A: Theme\n\ +type: Theme\n\ Description: Light theme with warm, paper-like tones\n\ background: \"#FFFFFF\"\n\ foreground: \"#37352F\"\n\ @@ -220,7 +220,7 @@ The default light theme for Laputa. Clean and warm, inspired by Notion.\n"; /// Vault-based theme note for the built-in Dark theme. pub const DARK_VAULT_THEME: &str = "---\n\ -Is A: Theme\n\ +type: Theme\n\ Description: Dark variant with deep navy tones\n\ background: \"#0f0f1a\"\n\ foreground: \"#e0e0e0\"\n\ @@ -276,7 +276,7 @@ A dark theme with deep navy tones for comfortable night-time reading.\n"; /// Vault-based theme note for the built-in Minimal theme. pub const MINIMAL_VAULT_THEME: &str = "---\n\ -Is A: Theme\n\ +type: Theme\n\ Description: High contrast, minimal chrome\n\ background: \"#FAFAFA\"\n\ foreground: \"#111111\"\n\ @@ -332,7 +332,7 @@ High contrast, minimal chrome. Monospace typography throughout.\n"; /// Type definition for the Theme note type. pub const THEME_TYPE_DEFINITION: &str = "---\n\ -Is A: Type\n\ +type: Type\n\ icon: palette\n\ color: purple\n\ order: 50\n\ diff --git a/src-tauri/src/theme/seed.rs b/src-tauri/src/theme/seed.rs index fb3842a8..624286b4 100644 --- a/src-tauri/src/theme/seed.rs +++ b/src-tauri/src/theme/seed.rs @@ -181,7 +181,7 @@ mod tests { seed_vault_themes(vp); let content = fs::read_to_string(theme_dir.join("default.md")).unwrap(); - assert!(content.contains("Is A: Theme")); + assert!(content.contains("type: Theme")); } #[test] @@ -190,7 +190,7 @@ mod tests { let vault = dir.path().join("vault"); let theme_dir = vault.join("theme"); fs::create_dir_all(&theme_dir).unwrap(); - let custom = "---\nIs A: Theme\nbackground: \"#FF0000\"\n---\n# Custom\n"; + let custom = "---\ntype: Theme\nbackground: \"#FF0000\"\n---\n# Custom\n"; fs::write(theme_dir.join("default.md"), custom).unwrap(); let vp = vault.to_str().unwrap(); @@ -227,7 +227,7 @@ mod tests { ensure_vault_themes(vp).unwrap(); let content = fs::read_to_string(theme_dir.join("default.md")).unwrap(); - assert!(content.contains("Is A: Theme")); + assert!(content.contains("type: Theme")); } #[test] @@ -236,7 +236,7 @@ mod tests { let vault = dir.path().join("vault"); let theme_dir = vault.join("theme"); fs::create_dir_all(&theme_dir).unwrap(); - let custom = "---\nIs A: Theme\nbackground: \"#123456\"\n---\n"; + let custom = "---\ntype: Theme\nbackground: \"#123456\"\n---\n"; fs::write(theme_dir.join("default.md"), custom).unwrap(); let vp = vault.to_str().unwrap(); @@ -265,7 +265,7 @@ mod tests { "restore must create type/theme.md" ); let type_content = fs::read_to_string(vault.join("type").join("theme.md")).unwrap(); - assert!(type_content.contains("Is A: Type")); + assert!(type_content.contains("type: Type")); assert!(type_content.contains("icon: palette")); } @@ -280,7 +280,7 @@ mod tests { let path = vault.join("type").join("theme.md"); assert!(path.exists()); let content = fs::read_to_string(&path).unwrap(); - assert!(content.contains("Is A: Type")); + assert!(content.contains("type: Type")); assert!(content.contains("icon: palette")); } @@ -290,7 +290,7 @@ mod tests { let vault = dir.path().join("vault"); let type_dir = vault.join("type"); fs::create_dir_all(&type_dir).unwrap(); - let custom = "---\nIs A: Type\nicon: swatches\ncolor: green\n---\n# Theme\n"; + let custom = "---\ntype: Type\nicon: swatches\ncolor: green\n---\n# Theme\n"; fs::write(type_dir.join("theme.md"), custom).unwrap(); let vp = vault.to_str().unwrap(); diff --git a/src-tauri/src/vault/getting_started.rs b/src-tauri/src/vault/getting_started.rs index a30ac982..d2d0217f 100644 --- a/src-tauri/src/vault/getting_started.rs +++ b/src-tauri/src/vault/getting_started.rs @@ -51,7 +51,7 @@ YAML frontmatter between `---` delimiters defines metadata: ```yaml --- -Is A: Project +type: Project Status: Active Owner: "[[person/jane-doe]]" Belongs to: "[[quarter/24q1]]" @@ -65,7 +65,7 @@ Related to: | Field | Purpose | |-------|---------| -| `Is A` | Entity type (usually inferred from folder) | +| `type` | Entity type (usually inferred from folder) | | `Status` | Active, Done, Paused, Archived, Dropped | | `Owner` | Person responsible (wikilink) | | `Belongs to` | Parent relationship(s) | @@ -98,7 +98,7 @@ Files in `type/` define entity types and control how they appear in the sidebar: ```yaml --- -Is A: Type +type: Type icon: rocket-launch color: purple order: 1 @@ -119,32 +119,32 @@ Available colors: red, purple, blue, green, yellow, orange. Icons are Phosphor n const SAMPLE_FILES: &[SampleFile] = &[ SampleFile { rel_path: "type/project.md", - content: "---\nIs A: Type\nicon: rocket-launch\ncolor: purple\norder: 1\n---\n\n# Project\n\nA Project is a time-bounded effort with a clear goal and an eventual completion date. Projects belong to a quarter or area and advance specific goals.\n", + content: "---\ntype: Type\nicon: rocket-launch\ncolor: purple\norder: 1\n---\n\n# Project\n\nA Project is a time-bounded effort with a clear goal and an eventual completion date. Projects belong to a quarter or area and advance specific goals.\n", }, SampleFile { rel_path: "type/note.md", - content: "---\nIs A: Type\nicon: note\ncolor: blue\norder: 2\n---\n\n# Note\n\nA Note is a general-purpose document — research notes, meeting notes, strategy docs, or anything that doesn't fit a more specific type.\n", + content: "---\ntype: Type\nicon: note\ncolor: blue\norder: 2\n---\n\n# Note\n\nA Note is a general-purpose document — research notes, meeting notes, strategy docs, or anything that doesn't fit a more specific type.\n", }, SampleFile { rel_path: "type/person.md", - content: "---\nIs A: Type\nicon: user\ncolor: green\norder: 3\n---\n\n# Person\n\nA Person represents someone you interact with — a colleague, friend, mentor, or collaborator.\n", + content: "---\ntype: Type\nicon: user\ncolor: green\norder: 3\n---\n\n# Person\n\nA Person represents someone you interact with — a colleague, friend, mentor, or collaborator.\n", }, SampleFile { rel_path: "type/topic.md", - content: "---\nIs A: Type\nicon: tag\ncolor: yellow\norder: 4\n---\n\n# Topic\n\nA Topic is a subject area or interest category that groups related notes, projects, and people.\n", + content: "---\ntype: Type\nicon: tag\ncolor: yellow\norder: 4\n---\n\n# Topic\n\nA Topic is a subject area or interest category that groups related notes, projects, and people.\n", }, SampleFile { rel_path: "type/theme.md", - content: "---\nIs A: Type\nicon: palette\ncolor: purple\norder: 50\n---\n\n# Theme\n\nA visual theme for Laputa. Each theme defines CSS custom properties that control colors, typography, and spacing.\n", + content: "---\ntype: Type\nicon: palette\ncolor: purple\norder: 50\n---\n\n# Theme\n\nA visual theme for Laputa. Each theme defines CSS custom properties that control colors, typography, and spacing.\n", }, SampleFile { rel_path: "type/config.md", - content: "---\nIs A: Type\nicon: gear-six\ncolor: gray\norder: 90\nsidebar label: Config\n---\n\n# Config\n\nVault configuration files. These control how AI agents, tools, and other integrations interact with this vault.\n", + content: "---\ntype: Type\nicon: gear-six\ncolor: gray\norder: 90\nsidebar label: Config\n---\n\n# Config\n\nVault configuration files. These control how AI agents, tools, and other integrations interact with this vault.\n", }, SampleFile { rel_path: "note/welcome-to-laputa.md", content: r#"--- -Is A: Note +type: Note Related to: - "[[note/editor-basics]]" - "[[note/using-properties]]" @@ -179,7 +179,7 @@ Every note is a markdown file with optional YAML frontmatter at the top. Notes l SampleFile { rel_path: "note/editor-basics.md", content: r#"--- -Is A: Note +type: Note Related to: "[[note/welcome-to-laputa]]" --- @@ -227,7 +227,7 @@ function hello() { SampleFile { rel_path: "note/using-properties.md", content: r#"--- -Is A: Note +type: Note Status: Active Related to: - "[[note/welcome-to-laputa]]" @@ -240,7 +240,7 @@ Every note can have **properties** defined in the YAML frontmatter at the top of ## Common properties -- **Is A** — The note's type (Project, Note, Person, etc.) +- **type** — The note's type (Project, Note, Person, etc.) - **Status** — Current state: Active, Done, Paused, Archived, Dropped - **Belongs to** — Parent relationship (e.g., a project belongs to a quarter) - **Related to** — Lateral connections to other notes @@ -261,7 +261,7 @@ You can add any custom property. If the value contains `[[wiki-links]]`, Laputa SampleFile { rel_path: "note/wiki-links-and-relationships.md", content: r#"--- -Is A: Note +type: Note Related to: - "[[note/welcome-to-laputa]]" - "[[note/using-properties]]" @@ -300,7 +300,7 @@ Over time, your wiki-links form a rich web of connections. Use the **Referenced SampleFile { rel_path: "project/sample-project.md", content: r#"--- -Is A: Project +type: Project Status: Active Owner: "[[person/sample-collaborator]]" Related to: "[[topic/getting-started]]" @@ -329,7 +329,7 @@ This project is owned by [[person/sample-collaborator]] and relates to [[topic/g SampleFile { rel_path: "person/sample-collaborator.md", content: r#"--- -Is A: Person +type: Person --- # Sample Collaborator @@ -350,7 +350,7 @@ This person is the owner of [[project/sample-project]]. Check the **Referenced B SampleFile { rel_path: "topic/getting-started.md", content: r#"--- -Is A: Topic +type: Topic --- # Getting Started diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index bba5a281..75f2c347 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -94,7 +94,7 @@ pub struct VaultEntry { /// Intermediate struct to capture YAML frontmatter fields. #[derive(Debug, Deserialize, Default)] struct Frontmatter { - #[serde(rename = "Is A", alias = "type")] + #[serde(rename = "type", alias = "Is A", alias = "is_a")] is_a: Option, #[serde(default)] aliases: Option, @@ -1444,6 +1444,32 @@ Company: Acme Corp assert!(entry.properties.get("visible").is_none()); } + // --- round-trip: canonical `type:` field and `Is A:` alias --- + + #[test] + fn test_roundtrip_type_key_parses_correctly() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Quarter\n---\n# Q1 2026\n"; + let entry = parse_test_entry(&dir, "quarter/q1.md", content); + assert_eq!(entry.is_a, Some("Quarter".to_string())); + } + + #[test] + fn test_roundtrip_is_a_alias_still_works() { + let dir = TempDir::new().unwrap(); + let content = "---\nIs A: Quarter\n---\n# Q1 2026\n"; + let entry = parse_test_entry(&dir, "quarter/q1.md", content); + assert_eq!(entry.is_a, Some("Quarter".to_string())); + } + + #[test] + fn test_roundtrip_is_a_snake_case_alias_still_works() { + let dir = TempDir::new().unwrap(); + let content = "---\nis_a: Quarter\n---\n# Q1 2026\n"; + let entry = parse_test_entry(&dir, "quarter/q1.md", content); + assert_eq!(entry.is_a, Some("Quarter".to_string())); + } + // Frontmatter update/delete tests are in frontmatter.rs // save_image tests are in vault/image.rs // purge_trash tests are in vault/trash.rs diff --git a/src-tauri/src/vault/trash.rs b/src-tauri/src/vault/trash.rs index 289a720d..5842a9e0 100644 --- a/src-tauri/src/vault/trash.rs +++ b/src-tauri/src/vault/trash.rs @@ -155,7 +155,7 @@ mod tests { create_test_file( dir.path(), "normal.md", - "---\nIs A: Note\n---\n# Normal Note\n", + "---\ntype: Note\n---\n# Normal Note\n", ); let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap(); diff --git a/src/components/Inspector.test.tsx b/src/components/Inspector.test.tsx index 4aa1f125..e4f5f71f 100644 --- a/src/components/Inspector.test.tsx +++ b/src/components/Inspector.test.tsx @@ -543,7 +543,7 @@ Status: Active diff --git a/src/components/ThemePropertyEditor.test.tsx b/src/components/ThemePropertyEditor.test.tsx index ec67b92c..3119d90e 100644 --- a/src/components/ThemePropertyEditor.test.tsx +++ b/src/components/ThemePropertyEditor.test.tsx @@ -8,7 +8,7 @@ function makeThemeManager(overrides: Partial = {}): ThemeManager { themes: [], activeThemeId: '/vault/_themes/My Theme.md', activeTheme: { id: '/vault/_themes/My Theme.md', name: 'My Theme', description: '', colors: {}, typography: {}, spacing: {} }, - activeThemeContent: '---\nIs A: Theme\nName: My Theme\neditor-font-size: 18px\nlists-bullet-color: "#ff0000"\n---\n', + activeThemeContent: '---\ntype: Theme\nName: My Theme\neditor-font-size: 18px\nlists-bullet-color: "#ff0000"\n---\n', isDark: false, switchTheme: vi.fn(), createTheme: vi.fn().mockResolvedValue(''), diff --git a/src/hooks/useThemeManager.test.ts b/src/hooks/useThemeManager.test.ts index 5ad52bea..cf0be02a 100644 --- a/src/hooks/useThemeManager.test.ts +++ b/src/hooks/useThemeManager.test.ts @@ -6,7 +6,7 @@ const THEME_PATH_DEFAULT = '/vault/theme/default.md' const THEME_PATH_DARK = '/vault/theme/dark.md' const DEFAULT_THEME_CONTENT = `--- -Is A: Theme +type: Theme Description: Light theme background: "#FFFFFF" foreground: "#37352F" @@ -19,7 +19,7 @@ text-primary: "#37352F" ` const DARK_THEME_CONTENT = `--- -Is A: Theme +type: Theme Description: Dark theme background: "#0f0f1a" foreground: "#e0e0e0" @@ -342,7 +342,7 @@ describe('useThemeManager', () => { }) const newContent = { - [THEME_PATH_DEFAULT]: `---\nIs A: Theme\nbackground: "#FF0000"\n---\n# Default Theme\n`, + [THEME_PATH_DEFAULT]: `---\ntype: Theme\nbackground: "#FF0000"\n---\n# Default Theme\n`, } rerender({ content: newContent }) diff --git a/src/mock-tauri/mock-content.ts b/src/mock-tauri/mock-content.ts index 31a49eef..5754b6a7 100644 --- a/src/mock-tauri/mock-content.ts +++ b/src/mock-tauri/mock-content.ts @@ -734,7 +734,7 @@ rating: 5 Essential reading for anyone building distributed systems. Covers replication, partitioning, transactions, and stream processing. `, '/Users/luca/Laputa/theme/default.md': `--- -Is A: Theme +type: Theme title: Default primary: "#155DFF" background: "#FFFFFF" @@ -755,7 +755,7 @@ line-height-base: 1.6 Light theme with warm, paper-like tones. `, '/Users/luca/Laputa/theme/dark.md': `--- -Is A: Theme +type: Theme title: Dark primary: "#155DFF" background: "#0f0f1a" @@ -776,7 +776,7 @@ line-height-base: 1.6 Dark variant with deep navy tones. `, '/Users/luca/Laputa/theme/minimal.md': `--- -Is A: Theme +type: Theme title: Minimal primary: "#000000" background: "#FAFAFA" diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 20d8bd47..63dfbaba 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -314,7 +314,7 @@ export const mockHandlers: Record any> = { const slug = displayName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || 'untitled-theme' const path = `${args.vaultPath}/theme/${slug}.md` MOCK_CONTENT[path] = `--- -Is A: Theme +type: Theme title: ${displayName} primary: "#155DFF" background: "#FFFFFF" diff --git a/tests/smoke/flip-canonical-type-field.spec.ts b/tests/smoke/flip-canonical-type-field.spec.ts new file mode 100644 index 00000000..3b184792 --- /dev/null +++ b/tests/smoke/flip-canonical-type-field.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from '@playwright/test' +import { sendShortcut } from './helpers' + +async function openNoteViaQuickOpen(page: import('@playwright/test').Page, query: string) { + await page.locator('body').click() + await sendShortcut(page, 'p', ['Control']) + const searchInput = page.locator('input[placeholder="Search notes..."]') + await expect(searchInput).toBeVisible() + await searchInput.fill(query) + await page.waitForTimeout(500) + await page.keyboard.press('Enter') + await page.waitForTimeout(500) +} + +test.describe('Canonical type field', () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 1600, height: 900 }) + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + test('sidebar shows type sections parsed from type: field', async ({ page }) => { + // The sidebar groups notes by type — this confirms the type: field is parsed correctly + // by the mock layer (which mirrors what the Rust parser does with serde rename) + const projectSection = page.getByText('Project').first() + await expect(projectSection).toBeVisible({ timeout: 5000 }) + + // Also verify Note and Person sections exist (these use type: in mock data) + const noteSection = page.getByText('Note').first() + await expect(noteSection).toBeVisible({ timeout: 5000 }) + }) + + test('theme notes display correctly with type: Theme', async ({ page }) => { + // Navigate to a theme note — themes now use type: Theme in their frontmatter + await openNoteViaQuickOpen(page, 'Default Theme') + + // Verify the note loaded and the editor/title area shows the theme title + const heading = page.getByText('Default Theme').first() + await expect(heading).toBeVisible({ timeout: 5000 }) + }) + + test('type definition notes display correctly with type: Type', async ({ page }) => { + // Open a Type definition note (e.g. "Project" type definition) + await openNoteViaQuickOpen(page, 'Project') + + // Type definitions should still show the Instances section + const instancesLabel = page.getByText(/Instances \(\d+\)/) + await expect(instancesLabel).toBeVisible({ timeout: 5000 }) + }) +})