From 851e482ab7553823c875f91e847ce483b9c3f693 Mon Sep 17 00:00:00 2001 From: Luca Rossi Date: Tue, 3 Mar 2026 02:31:18 +0100 Subject: [PATCH] feat: sort picker shows custom frontmatter properties (#185) The sort dropdown now discovers all scalar properties (string, number, boolean, date) across notes in the current list and shows them below a separator after the built-in options. Properties that no longer exist in the current list are gracefully handled by falling back to Modified. Rust backend extracts custom properties during vault scan so they are available on every VaultEntry without loading file content on demand. Co-authored-by: Test Co-authored-by: Claude Opus 4.6 --- design/sort-picker-properties.pen | 1 + src-tauri/src/vault/cache.rs | 2 +- src-tauri/src/vault/mod.rs | 145 +++++++++++++++++++++++- src/components/NoteList.test.tsx | 73 ++++++++++++ src/components/NoteList.tsx | 46 +++++--- src/components/RawEditorView.test.tsx | 1 + src/components/SearchPanel.test.tsx | 2 + src/components/Sidebar.test.tsx | 20 ++++ src/components/SortDropdown.tsx | 106 ++++++++++------- src/hooks/useEntryActions.test.ts | 1 + src/hooks/useKeyboardNavigation.test.ts | 1 + src/hooks/useNoteActions.ts | 2 +- src/hooks/useThemeManager.test.ts | 1 + src/mock-tauri/mock-entries.ts | 40 +++++++ src/types.ts | 2 + src/utils/noteListHelpers.test.ts | 112 +++++++++++++++++- src/utils/noteListHelpers.ts | 99 ++++++++++++---- 17 files changed, 566 insertions(+), 88 deletions(-) create mode 100644 design/sort-picker-properties.pen diff --git a/design/sort-picker-properties.pen b/design/sort-picker-properties.pen new file mode 100644 index 00000000..ebcc2d64 --- /dev/null +++ b/design/sort-picker-properties.pen @@ -0,0 +1 @@ +{"children":[],"variables":{}} \ No newline at end of file diff --git a/src-tauri/src/vault/cache.rs b/src-tauri/src/vault/cache.rs index 86f4d018..8f0d3bc9 100644 --- a/src-tauri/src/vault/cache.rs +++ b/src-tauri/src/vault/cache.rs @@ -7,7 +7,7 @@ use super::{parse_md_file, scan_vault, VaultEntry}; // --- Vault Cache --- /// Bump this when VaultEntry fields change to force a full rescan. -const CACHE_VERSION: u32 = 3; +const CACHE_VERSION: u32 = 4; #[derive(Debug, Serialize, Deserialize)] struct VaultCache { diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index 6f580a55..a141b907 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -75,6 +75,10 @@ pub struct VaultEntry { /// Extracted from `[[target]]` and `[[target|display]]` patterns. #[serde(rename = "outgoingLinks", default)] pub outgoing_links: Vec, + /// Custom scalar frontmatter properties (non-relationship, non-structural). + /// Only includes strings, numbers, and booleans — arrays/objects are excluded. + #[serde(default)] + pub properties: HashMap, } /// Intermediate struct to capture YAML frontmatter fields. @@ -200,6 +204,45 @@ fn extract_relationships( relationships } +/// Additional keys to skip when extracting custom properties. +/// These are already first-class fields on VaultEntry, so including them +/// in `properties` would duplicate information. +const PROPERTY_EXTRA_SKIP: &[&str] = &["belongs to", "related to", "owner"]; + +/// Extract custom scalar properties from raw YAML frontmatter. +/// Captures string, number, and boolean values that are not structural fields +/// and do not contain wikilinks. Arrays and objects are excluded. +fn extract_properties( + data: &HashMap, +) -> HashMap { + let mut properties = HashMap::new(); + + for (key, value) in data { + let lower = key.to_ascii_lowercase(); + if SKIP_KEYS.iter().any(|k| k.eq_ignore_ascii_case(&lower)) + || PROPERTY_EXTRA_SKIP + .iter() + .any(|k| k.eq_ignore_ascii_case(&lower)) + { + continue; + } + + match value { + serde_json::Value::String(s) => { + if !contains_wikilink(s) { + properties.insert(key.clone(), value.clone()); + } + } + serde_json::Value::Number(_) | serde_json::Value::Bool(_) => { + properties.insert(key.clone(), value.clone()); + } + _ => {} + } + } + + properties +} + /// Infer entity type from a parent folder name. fn infer_type_from_folder(folder: &str) -> String { match folder { @@ -243,19 +286,24 @@ fn parse_created_at(fm: &Frontmatter) -> Option { .or_else(|| fm.created_time.as_ref().and_then(|s| parse_iso_date(s))) } -/// Extract frontmatter and relationships from parsed gray_matter data. +/// Extract frontmatter, relationships, and custom properties from parsed gray_matter data. fn extract_fm_and_rels( data: Option, -) -> (Frontmatter, HashMap>) { +) -> ( + Frontmatter, + HashMap>, + HashMap, +) { let hash = match data { Some(gray_matter::Pod::Hash(map)) => map, - _ => return (Frontmatter::default(), HashMap::new()), + _ => return (Frontmatter::default(), HashMap::new(), HashMap::new()), }; let json_map: HashMap = hash.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect(); ( parse_frontmatter(&json_map), extract_relationships(&json_map), + extract_properties(&json_map), ) } @@ -282,7 +330,7 @@ pub fn parse_md_file(path: &Path) -> Result { let matter = Matter::::new(); let parsed = matter.parse(&content); - let (frontmatter, mut relationships) = extract_fm_and_rels(parsed.data); + let (frontmatter, mut relationships, properties) = extract_fm_and_rels(parsed.data); let title = extract_title(&parsed.content, &filename); let snippet = extract_snippet(&content); @@ -341,6 +389,7 @@ pub fn parse_md_file(path: &Path) -> Result { template: frontmatter.template, word_count, outgoing_links, + properties, }) } @@ -1134,6 +1183,94 @@ References: assert!(entry.relationships.get("template").is_none()); } + // --- custom properties tests --- + + #[test] + fn test_extract_properties_scalar_values() { + let dir = TempDir::new().unwrap(); + let content = r#"--- +Is A: Project +Status: Active +Priority: High +Rating: 5 +Due date: 2026-06-15 +Reviewed: true +--- +# Test +"#; + let entry = parse_test_entry(&dir, "project/test.md", content); + let expected: HashMap = [ + ("Priority".into(), serde_json::Value::String("High".into())), + ("Rating".into(), serde_json::json!(5)), + ( + "Due date".into(), + serde_json::Value::String("2026-06-15".into()), + ), + ("Reviewed".into(), serde_json::Value::Bool(true)), + ] + .into_iter() + .collect(); + assert_eq!(entry.properties, expected); + } + + #[test] + fn test_extract_properties_skips_structural_fields() { + let dir = TempDir::new().unwrap(); + let content = r#"--- +Is A: Project +Status: Active +Owner: Luca +Cadence: Weekly +Archived: false +Priority: High +--- +# Test +"#; + let entry = parse_test_entry(&dir, "project/test.md", content); + // Only Priority should survive — all others are structural + assert_eq!(entry.properties.len(), 1); + assert_eq!( + entry.properties.get("Priority").and_then(|v| v.as_str()), + Some("High") + ); + } + + #[test] + fn test_extract_properties_skips_wikilinks() { + let dir = TempDir::new().unwrap(); + let content = r#"--- +Mentor: "[[person/alice]]" +Company: Acme Corp +--- +# Test +"#; + let entry = parse_test_entry(&dir, "test.md", content); + assert!(entry.properties.get("Mentor").is_none()); + assert_eq!( + entry.properties.get("Company").and_then(|v| v.as_str()), + Some("Acme Corp") + ); + } + + #[test] + fn test_extract_properties_skips_arrays() { + let dir = TempDir::new().unwrap(); + let content = r#"--- +Tags: + - productivity + - writing +Company: Acme Corp +--- +# Test +"#; + let entry = parse_test_entry(&dir, "test.md", content); + assert!(entry.properties.get("Tags").is_none()); + assert_eq!( + entry.properties.get("Company").and_then(|v| v.as_str()), + Some("Acme Corp") + ); + } + // 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/components/NoteList.test.tsx b/src/components/NoteList.test.tsx index 2b4e9727..b296827f 100644 --- a/src/components/NoteList.test.tsx +++ b/src/components/NoteList.test.tsx @@ -36,6 +36,7 @@ const mockEntries: VaultEntry[] = [ order: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/Users/luca/Laputa/note/facebook-ads-strategy.md', @@ -65,6 +66,7 @@ const mockEntries: VaultEntry[] = [ order: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/Users/luca/Laputa/person/matteo-cellini.md', @@ -91,6 +93,7 @@ const mockEntries: VaultEntry[] = [ order: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/Users/luca/Laputa/event/2026-02-14-kickoff.md', @@ -117,6 +120,7 @@ const mockEntries: VaultEntry[] = [ order: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/Users/luca/Laputa/topic/software-development.md', @@ -143,6 +147,7 @@ const mockEntries: VaultEntry[] = [ order: null, template: null, outgoingLinks: [], + properties: {}, }, ] @@ -364,6 +369,7 @@ describe('getSortComparator', () => { order: null, template: null, outgoingLinks: [], + properties: {}, ...overrides, }) @@ -477,6 +483,7 @@ describe('NoteList sort controls', () => { order: null, template: null, outgoingLinks: [], + properties: {}, ...overrides, }) @@ -643,6 +650,68 @@ describe('NoteList sort controls', () => { titles = screen.getAllByText(/Zebra Note|Alpha Note/).map((el) => el.textContent) expect(titles).toEqual(['Alpha Note', 'Zebra Note']) }) + + it('shows custom properties with separator in sort dropdown', () => { + const entries = [ + makeEntry({ path: '/a.md', title: 'A', properties: { Priority: 'High', Rating: 5 } }), + makeEntry({ path: '/b.md', title: 'B', properties: { Priority: 'Low', Company: 'Acme' } }), + ] + render( + + ) + fireEvent.click(screen.getByTestId('sort-button-__list__')) + expect(screen.getByTestId('sort-separator')).toBeInTheDocument() + expect(screen.getByTestId('sort-option-property:Company')).toBeInTheDocument() + expect(screen.getByTestId('sort-option-property:Priority')).toBeInTheDocument() + expect(screen.getByTestId('sort-option-property:Rating')).toBeInTheDocument() + }) + + it('omits separator when no custom properties exist', () => { + render( + + ) + fireEvent.click(screen.getByTestId('sort-button-__list__')) + expect(screen.queryByTestId('sort-separator')).not.toBeInTheDocument() + }) + + it('sorts entries by custom property when selected', () => { + const entries = [ + makeEntry({ path: '/a.md', title: 'A', modifiedAt: 3000, properties: { Rating: 3 } }), + makeEntry({ path: '/b.md', title: 'B', modifiedAt: 2000, properties: { Rating: 1 } }), + makeEntry({ path: '/c.md', title: 'C', modifiedAt: 1000, properties: { Rating: 5 } }), + ] + render( + + ) + // Default: modified desc → A, B, C + let titles = screen.getAllByText(/^[ABC]$/).map((el) => el.textContent) + expect(titles).toEqual(['A', 'B', 'C']) + + // Switch to Rating sort (asc by default for properties) + fireEvent.click(screen.getByTestId('sort-button-__list__')) + fireEvent.click(screen.getByTestId('sort-option-property:Rating')) + + // Rating asc: B(1), A(3), C(5) + titles = screen.getAllByText(/^[ABC]$/).map((el) => el.textContent) + expect(titles).toEqual(['B', 'A', 'C']) + }) + + it('pushes entries without the property to end when sorting by custom property', () => { + const entries = [ + makeEntry({ path: '/a.md', title: 'A', modifiedAt: 3000, properties: { Priority: 'High' } }), + makeEntry({ path: '/b.md', title: 'B', modifiedAt: 2000, properties: {} }), + makeEntry({ path: '/c.md', title: 'C', modifiedAt: 1000, properties: { Priority: 'Low' } }), + ] + render( + + ) + fireEvent.click(screen.getByTestId('sort-button-__list__')) + fireEvent.click(screen.getByTestId('sort-option-property:Priority')) + + // Asc: A(High), C(Low), B(null → end) + const titles = screen.getAllByText(/^[ABC]$/).map((el) => el.textContent) + expect(titles).toEqual(['A', 'C', 'B']) + }) }) // --- Trash feature tests --- @@ -672,6 +741,7 @@ const trashedEntry: VaultEntry = { order: null, template: null, outgoingLinks: [], + properties: {}, } const expiredTrashedEntry: VaultEntry = { @@ -699,6 +769,7 @@ const expiredTrashedEntry: VaultEntry = { order: null, template: null, outgoingLinks: [], + properties: {}, } const entriesWithTrashed = [...mockEntries, trashedEntry, expiredTrashedEntry] @@ -855,6 +926,7 @@ describe('NoteList — virtual list with large datasets', () => { order: null, template: null, outgoingLinks: [], + properties: {}, ...overrides, }) @@ -1184,6 +1256,7 @@ const typeEntry: VaultEntry = { order: null, template: null, outgoingLinks: [], + properties: {}, } const entriesWithType = [...mockEntries, typeEntry] diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index 471df6cc..a644934b 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -14,7 +14,7 @@ import { useMultiSelect } from '../hooks/useMultiSelect' import { useNoteListKeyboard } from '../hooks/useNoteListKeyboard' import { type SortOption, type SortDirection, type SortConfig, type RelationshipGroup, - getSortComparator, + getSortComparator, extractSortableProperties, buildRelationshipGroups, filterEntries, relativeDate, getDisplayDate, loadSortPreferences, saveSortPreferences, @@ -67,6 +67,7 @@ function RelationshipGroupSection({ group, isCollapsed, sortPrefs, onToggle, han }) { const groupConfig = sortPrefs[group.label] ?? { option: 'modified' as SortOption, direction: 'desc' as SortDirection } const sortedEntries = [...group.entries].sort(getSortComparator(groupConfig.option, groupConfig.direction)) + const customProperties = useMemo(() => extractSortableProperties(group.entries), [group.entries]) return (
@@ -75,7 +76,7 @@ function RelationshipGroupSection({ group, isCollapsed, sortPrefs, onToggle, han {group.entries.length} - + @@ -241,25 +242,31 @@ function isModifiedEntry(path: string, pathSet: Set, suffixes: string[]) return suffixes.some((suffix) => path.endsWith(suffix)) } +function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set, modifiedSuffixes: string[]) { + const isEntityView = selection.kind === 'entity' + const isChangesView = selection.kind === 'filter' && selection.filter === 'changes' + return useMemo(() => { + if (isEntityView) return [] + if (isChangesView) return entries.filter((e) => isModifiedEntry(e.path, modifiedPathSet, modifiedSuffixes)) + return filterEntries(entries, selection) + }, [entries, selection, isEntityView, isChangesView, modifiedPathSet, modifiedSuffixes]) +} + function useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }: NoteListDataParams) { const isEntityView = selection.kind === 'entity' const isTrashView = selection.kind === 'filter' && selection.filter === 'trash' - const isChangesView = selection.kind === 'filter' && selection.filter === 'changes' const typeDocument = useMemo(() => { if (selection.kind !== 'sectionGroup') return null return entries.find((e) => e.isA === 'Type' && e.title === selection.type) ?? null }, [selection, entries]) + const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes) + const searched = useMemo(() => { - if (isEntityView) return [] - if (isChangesView) { - const sorted = [...entries.filter((e) => isModifiedEntry(e.path, modifiedPathSet, modifiedSuffixes))].sort(getSortComparator(listSort, listDirection)) - return filterByQuery(sorted, query) - } - const sorted = [...filterEntries(entries, selection)].sort(getSortComparator(listSort, listDirection)) + const sorted = [...filteredEntries].sort(getSortComparator(listSort, listDirection)) return filterByQuery(sorted, query) - }, [entries, selection, isEntityView, isChangesView, listSort, listDirection, query, modifiedPathSet, modifiedSuffixes]) + }, [filteredEntries, listSort, listDirection, query]) const searchedGroups = useMemo(() => { if (!isEntityView) return [] @@ -272,7 +279,7 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list [isTrashView, searched], ) - return { isEntityView, isTrashView, isChangesView, typeDocument, searched, searchedGroups, expiredTrashCount } + return { isEntityView, isTrashView, typeDocument, searched, searchedGroups, expiredTrashCount } } // --- Main component --- @@ -315,9 +322,18 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF const typeEntryMap = useTypeEntryMap(entries) const query = search.trim().toLowerCase() const listConfig = sortPrefs['__list__'] ?? { option: 'modified' as SortOption, direction: 'desc' as SortDirection } - const listSort = listConfig.option - const listDirection = listConfig.direction - const { isEntityView, isTrashView, isChangesView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }) + + // Compute custom properties and derive effective sort before sorting entries + const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes) + const customProperties = useMemo(() => extractSortableProperties(filteredEntries), [filteredEntries]) + const listSort = useMemo(() => { + const opt = listConfig.option + if (!opt.startsWith('property:')) return opt + return customProperties.includes(opt.slice('property:'.length)) ? opt : 'modified' + }, [listConfig.option, customProperties]) + const listDirection = listSort === listConfig.option ? listConfig.direction : 'desc' + const { isEntityView, isTrashView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }) + const isChangesView = selection.kind === 'filter' && selection.filter === 'changes' const noteListKeyboard = useNoteListKeyboard({ items: searched, @@ -395,7 +411,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF {resolveHeaderTitle(selection, typeDocument)}
- {!isEntityView && } + {!isEntityView && } diff --git a/src/components/RawEditorView.test.tsx b/src/components/RawEditorView.test.tsx index 4d805d8d..a2f0926e 100644 --- a/src/components/RawEditorView.test.tsx +++ b/src/components/RawEditorView.test.tsx @@ -12,6 +12,7 @@ function entry(title: string, path = `/vault/note/${title}.md`) { modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, } } diff --git a/src/components/SearchPanel.test.tsx b/src/components/SearchPanel.test.tsx index 45ff25d8..de31c181 100644 --- a/src/components/SearchPanel.test.tsx +++ b/src/components/SearchPanel.test.tsx @@ -40,6 +40,7 @@ const MOCK_ENTRIES: VaultEntry[] = [ order: null, template: null, outgoingLinks: ['topic/ai', 'topic/api-design', 'person/luca'], + properties: {}, }, { path: '/vault/event/retreat.md', @@ -66,6 +67,7 @@ const MOCK_ENTRIES: VaultEntry[] = [ order: null, template: null, outgoingLinks: ['person/bob'], + properties: {}, }, ] diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx index be9f000f..63d78779 100644 --- a/src/components/Sidebar.test.tsx +++ b/src/components/Sidebar.test.tsx @@ -42,6 +42,7 @@ const mockEntries: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/vault/responsibility/grow-newsletter.md', @@ -69,6 +70,7 @@ const mockEntries: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/vault/experiment/stock-screener.md', @@ -96,6 +98,7 @@ const mockEntries: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/vault/procedure/weekly-essays.md', @@ -123,6 +126,7 @@ const mockEntries: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/vault/topic/software-development.md', @@ -150,6 +154,7 @@ const mockEntries: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/vault/topic/trading.md', @@ -177,6 +182,7 @@ const mockEntries: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/vault/person/alice.md', @@ -204,6 +210,7 @@ const mockEntries: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/vault/event/kickoff.md', @@ -231,6 +238,7 @@ const mockEntries: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, ] @@ -451,6 +459,7 @@ describe('Sidebar', () => { sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/vault/type/book.md', @@ -478,6 +487,7 @@ describe('Sidebar', () => { sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/vault/recipe/pasta.md', @@ -504,6 +514,7 @@ describe('Sidebar', () => { order: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/vault/book/ddia.md', @@ -530,6 +541,7 @@ describe('Sidebar', () => { order: null, template: null, outgoingLinks: [], + properties: {}, }, ] @@ -576,6 +588,7 @@ describe('Sidebar', () => { cadence: null, archived: false, trashed: true, trashedAt: 1700000000, modifiedAt: 1700000000, createdAt: null, fileSize: 100, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, sidebarLabel: null, outgoingLinks: [], + properties: {}, }, ] render( {}} />) @@ -616,6 +629,7 @@ describe('Sidebar', () => { sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, } render( {}} />) // "Projects" should appear once (the built-in section), not twice @@ -632,6 +646,7 @@ describe('Sidebar', () => { archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, sidebarLabel: 'News', outgoingLinks: [], + properties: {}, }, { path: '/vault/news/breaking.md', filename: 'breaking.md', title: 'Breaking Story', isA: 'News', @@ -639,6 +654,7 @@ describe('Sidebar', () => { archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 300, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, sidebarLabel: null, outgoingLinks: [], + properties: {}, }, ] render( {}} />) @@ -656,6 +672,7 @@ describe('Sidebar', () => { archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, sidebarLabel: 'Contacts', outgoingLinks: [], + properties: {}, }, ] render( {}} />) @@ -776,6 +793,7 @@ describe('Sidebar', () => { archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: 5, sidebarLabel: null, outgoingLinks: [], + properties: {}, }, { path: '/vault/type/topic.md', filename: 'topic.md', title: 'Topic', isA: 'Type', @@ -783,6 +801,7 @@ describe('Sidebar', () => { archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: 0, sidebarLabel: null, outgoingLinks: [], + properties: {}, }, { path: '/vault/type/person.md', filename: 'person.md', title: 'Person', isA: 'Type', @@ -790,6 +809,7 @@ describe('Sidebar', () => { archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: 1, sidebarLabel: null, outgoingLinks: [], + properties: {}, }, ] diff --git a/src/components/SortDropdown.tsx b/src/components/SortDropdown.tsx index 13e37133..455543b1 100644 --- a/src/components/SortDropdown.tsx +++ b/src/components/SortDropdown.tsx @@ -1,12 +1,13 @@ import { useState, useEffect, useRef } from 'react' import { cn } from '@/lib/utils' import { ArrowUp, ArrowDown } from '@phosphor-icons/react' -import { type SortOption, type SortDirection, DEFAULT_DIRECTIONS, SORT_OPTIONS } from '../utils/noteListHelpers' +import { type SortOption, type SortDirection, getDefaultDirection, SORT_OPTIONS, getSortOptionLabel } from '../utils/noteListHelpers' -export function SortDropdown({ groupLabel, current, direction, onChange }: { +export function SortDropdown({ groupLabel, current, direction, customProperties, onChange }: { groupLabel: string current: SortOption direction: SortDirection + customProperties?: string[] onChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void }) { const [open, setOpen] = useState(false) @@ -27,58 +28,81 @@ export function SortDropdown({ groupLabel, current, direction, onChange }: { } const DirectionIcon = direction === 'asc' ? ArrowUp : ArrowDown + const hasCustom = customProperties && customProperties.length > 0 return (
{open && ( -
- {SORT_OPTIONS.map((opt) => { - const isActive = opt.value === current - return ( -
{ e.stopPropagation(); handleSelect(opt.value, isActive ? direction : DEFAULT_DIRECTIONS[opt.value]) }} - > - - {opt.label} - - - - - -
- ) - })} +
+ {SORT_OPTIONS.map((opt) => ( + + ))} + {hasCustom && ( + <> +
+ {customProperties.map((key) => { + const value: SortOption = `property:${key}` + return + })} + + )}
)}
) } + +function SortRow({ value, label, current, direction, onSelect }: { + value: SortOption + label: string + current: SortOption + direction: SortDirection + onSelect: (opt: SortOption, dir: SortDirection) => void +}) { + const isActive = value === current + return ( +
{ e.stopPropagation(); onSelect(value, isActive ? direction : getDefaultDirection(value)) }} + > + + {label} + + + + + +
+ ) +} diff --git a/src/hooks/useEntryActions.test.ts b/src/hooks/useEntryActions.test.ts index a6681c5c..d8634c61 100644 --- a/src/hooks/useEntryActions.test.ts +++ b/src/hooks/useEntryActions.test.ts @@ -28,6 +28,7 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ order: null, template: null, outgoingLinks: [], + properties: {}, ...overrides, }) diff --git a/src/hooks/useKeyboardNavigation.test.ts b/src/hooks/useKeyboardNavigation.test.ts index ad26f840..1f06e00d 100644 --- a/src/hooks/useKeyboardNavigation.test.ts +++ b/src/hooks/useKeyboardNavigation.test.ts @@ -32,6 +32,7 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ order: null, template: null, outgoingLinks: [], + properties: {}, ...overrides, }) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index e9bc5d40..b892ed0a 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -72,7 +72,7 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam aliases: [], belongsTo: [], relatedTo: [], status, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: now, createdAt: now, fileSize: 0, - snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, + snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, properties: {}, } } diff --git a/src/hooks/useThemeManager.test.ts b/src/hooks/useThemeManager.test.ts index 595b1c24..7f792099 100644 --- a/src/hooks/useThemeManager.test.ts +++ b/src/hooks/useThemeManager.test.ts @@ -58,6 +58,7 @@ function makeThemeEntry(path: string, title: string): VaultEntry { sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, } } diff --git a/src/mock-tauri/mock-entries.ts b/src/mock-tauri/mock-entries.ts index 9372e35a..5bce91e0 100644 --- a/src/mock-tauri/mock-entries.ts +++ b/src/mock-tauri/mock-entries.ts @@ -38,6 +38,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: ['quarter/q1-2026', 'topic/software-development', 'person/matteo-cellini', 'person/maria-bianchi', 'person/marco-verdi'], + properties: { Priority: 'High', 'Due date': '2026-06-15' }, }, { path: '/Users/luca/Laputa/responsibility/grow-newsletter.md', @@ -74,6 +75,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: ['essay/on-writing-well', 'essay/engineering-leadership-101', 'essay/ai-agents-primer', 'topic/growth', 'topic/writing'], + properties: { Priority: 'High', Rating: 5, Cadence: 'Weekly' }, }, { path: '/Users/luca/Laputa/responsibility/manage-sponsorships.md', @@ -104,6 +106,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: ['person/matteo-cellini'], + properties: {}, }, { path: '/Users/luca/Laputa/procedure/write-weekly-essays.md', @@ -134,6 +137,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: ['responsibility/grow-newsletter'], + properties: {}, }, { path: '/Users/luca/Laputa/procedure/run-sponsorships.md', @@ -164,6 +168,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: ['responsibility/manage-sponsorships'], + properties: {}, }, { path: '/Users/luca/Laputa/experiment/stock-screener.md', @@ -195,6 +200,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: ['topic/trading', 'topic/algorithmic-trading', 'data/ema200-backtest-results'], + properties: { Priority: 'Low', 'Due date': '2026-03-01' }, }, { path: '/Users/luca/Laputa/note/facebook-ads-strategy.md', @@ -226,6 +232,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: ['project/26q1-laputa-app', 'topic/growth', 'topic/ads'], + properties: { Priority: 'Medium', Rating: 4 }, }, { path: '/Users/luca/Laputa/note/budget-allocation.md', @@ -256,6 +263,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: ['project/26q1-laputa-app'], + properties: {}, }, { path: '/Users/luca/Laputa/person/matteo-cellini.md', @@ -285,6 +293,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: { Company: 'Acme Corp', Role: 'Engineering Lead' }, }, { path: '/Users/luca/Laputa/person/maria-bianchi.md', @@ -314,6 +323,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: { Company: 'TechStart', Role: 'Product Manager' }, }, { path: '/Users/luca/Laputa/person/marco-verdi.md', @@ -343,6 +353,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/Users/luca/Laputa/person/elena-russo.md', @@ -372,6 +383,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/Users/luca/Laputa/event/2026-02-14-laputa-app-kickoff.md', @@ -402,6 +414,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: ['project/26q1-laputa-app', 'person/matteo-cellini'], + properties: {}, }, { path: '/Users/luca/Laputa/topic/software-development.md', @@ -432,6 +445,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/Users/luca/Laputa/topic/trading.md', @@ -462,6 +476,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/Users/luca/Laputa/essay/on-writing-well.md', @@ -492,6 +507,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: ['responsibility/grow-newsletter'], + properties: {}, }, { path: '/Users/luca/Laputa/essay/engineering-leadership-101.md', @@ -523,6 +539,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: ['responsibility/grow-newsletter', 'topic/software-development'], + properties: {}, }, { path: '/Users/luca/Laputa/essay/ai-agents-primer.md', @@ -553,6 +570,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: ['responsibility/grow-newsletter'], + properties: {}, }, // --- Type documents --- { @@ -581,6 +599,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/Users/luca/Laputa/type/responsibility.md', @@ -608,6 +627,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/Users/luca/Laputa/type/procedure.md', @@ -635,6 +655,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/Users/luca/Laputa/type/experiment.md', @@ -662,6 +683,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/Users/luca/Laputa/type/person.md', @@ -689,6 +711,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/Users/luca/Laputa/type/event.md', @@ -716,6 +739,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/Users/luca/Laputa/type/topic.md', @@ -743,6 +767,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/Users/luca/Laputa/type/essay.md', @@ -770,6 +795,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/Users/luca/Laputa/type/note.md', @@ -797,6 +823,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, // --- Custom type documents --- { @@ -825,6 +852,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/Users/luca/Laputa/type/book.md', @@ -852,6 +880,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, // --- Instances of custom types --- { @@ -882,6 +911,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: { Difficulty: 'Easy', 'Prep time': '30 min', Servings: 4 }, }, { path: '/Users/luca/Laputa/book/designing-data-intensive-applications.md', @@ -911,6 +941,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: { Author: 'Martin Kleppmann', Rating: 5, 'Year published': 2017 }, }, // --- Trashed entries --- { @@ -942,6 +973,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/Users/luca/Laputa/note/deprecated-api-notes.md', @@ -971,6 +1003,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/Users/luca/Laputa/experiment/failed-seo-experiment.md', @@ -1001,6 +1034,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, // --- Archived entries --- { @@ -1023,6 +1057,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, modifiedAt: now - 86400 * 120, createdAt: now - 86400 * 200, fileSize: 680, @@ -1053,6 +1088,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, modifiedAt: now - 86400 * 90, createdAt: now - 86400 * 150, fileSize: 520, @@ -1116,6 +1152,7 @@ function generateBulkEntries(count: number): VaultEntry[] { outgoingLinks: Array.from({ length: i % 8 }, (_j, j) => `note/link-target-${(i + j) % 50}`), sidebarLabel: null, template: null, + properties: {}, }) } return entries @@ -1149,6 +1186,7 @@ MOCK_ENTRIES.push( sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/Users/luca/Laputa/theme/dark.md', @@ -1176,6 +1214,7 @@ MOCK_ENTRIES.push( sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, { path: '/Users/luca/Laputa/theme/minimal.md', @@ -1203,6 +1242,7 @@ MOCK_ENTRIES.push( sidebarLabel: null, template: null, outgoingLinks: [], + properties: {}, }, ) diff --git a/src/types.ts b/src/types.ts index 9d1c4c39..292491af 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,6 +31,8 @@ export interface VaultEntry { template: string | null /** All wikilink targets found in the note content. Extracted from [[target]] patterns. */ outgoingLinks: string[] + /** Custom scalar frontmatter properties (non-relationship, non-structural). */ + properties: Record } export type NoteStatus = 'new' | 'modified' | 'clean' | 'pendingSave' | 'unsaved' diff --git a/src/utils/noteListHelpers.test.ts b/src/utils/noteListHelpers.test.ts index 17e9d613..ec6b9cf4 100644 --- a/src/utils/noteListHelpers.test.ts +++ b/src/utils/noteListHelpers.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from 'vitest' -import { formatSubtitle, formatSearchSubtitle, relativeDate, buildRelationshipGroups } from './noteListHelpers' +import { formatSubtitle, formatSearchSubtitle, relativeDate, buildRelationshipGroups, getSortComparator, extractSortableProperties, getSortOptionLabel, getDefaultDirection } from './noteListHelpers' import type { VaultEntry } from '../types' function makeEntry(overrides: Partial = {}): VaultEntry { @@ -325,3 +325,113 @@ describe('buildRelationshipGroups', () => { expect(groups.find((g) => g.label === 'Backlinks')!.entries[0].title).toBe('Linker') }) }) + +describe('getSortComparator — custom properties', () => { + it('sorts by string property alphabetically', () => { + const a = makeEntry({ title: 'A', properties: { Priority: 'High' } }) + const b = makeEntry({ title: 'B', properties: { Priority: 'Low' } }) + const c = makeEntry({ title: 'C', properties: { Priority: 'Medium' } }) + const sorted = [a, b, c].sort(getSortComparator('property:Priority')) + expect(sorted.map((e) => e.title)).toEqual(['A', 'B', 'C']) + }) + + it('sorts by numeric property', () => { + const a = makeEntry({ title: 'A', properties: { Rating: 3 } }) + const b = makeEntry({ title: 'B', properties: { Rating: 5 } }) + const c = makeEntry({ title: 'C', properties: { Rating: 1 } }) + const sorted = [a, b, c].sort(getSortComparator('property:Rating')) + expect(sorted.map((e) => e.title)).toEqual(['C', 'A', 'B']) + }) + + it('sorts by date property chronologically', () => { + const a = makeEntry({ title: 'A', properties: { 'Due date': '2026-06-15' } }) + const b = makeEntry({ title: 'B', properties: { 'Due date': '2026-01-01' } }) + const c = makeEntry({ title: 'C', properties: { 'Due date': '2026-03-10' } }) + const sorted = [a, b, c].sort(getSortComparator('property:Due date')) + expect(sorted.map((e) => e.title)).toEqual(['B', 'C', 'A']) + }) + + it('pushes null values to end regardless of direction', () => { + const a = makeEntry({ title: 'A', properties: { Priority: 'High' } }) + const b = makeEntry({ title: 'B', properties: {} }) + const c = makeEntry({ title: 'C', properties: { Priority: 'Low' } }) + const ascSorted = [a, b, c].sort(getSortComparator('property:Priority', 'asc')) + expect(ascSorted.map((e) => e.title)).toEqual(['A', 'C', 'B']) + const descSorted = [a, b, c].sort(getSortComparator('property:Priority', 'desc')) + expect(descSorted.map((e) => e.title)).toEqual(['C', 'A', 'B']) + }) + + it('sorts descending when direction is desc', () => { + const a = makeEntry({ title: 'A', properties: { Rating: 3 } }) + const b = makeEntry({ title: 'B', properties: { Rating: 5 } }) + const c = makeEntry({ title: 'C', properties: { Rating: 1 } }) + const sorted = [a, b, c].sort(getSortComparator('property:Rating', 'desc')) + expect(sorted.map((e) => e.title)).toEqual(['B', 'A', 'C']) + }) + + it('handles entries with no properties field gracefully', () => { + const a = makeEntry({ title: 'A', properties: { Priority: 'High' } }) + const b = makeEntry({ title: 'B', properties: {} }) + const sorted = [a, b].sort(getSortComparator('property:Priority')) + expect(sorted.map((e) => e.title)).toEqual(['A', 'B']) + }) + + it('handles boolean property sorting', () => { + const a = makeEntry({ title: 'A', properties: { Reviewed: true } }) + const b = makeEntry({ title: 'B', properties: { Reviewed: false } }) + const sorted = [a, b].sort(getSortComparator('property:Reviewed')) + expect(sorted.map((e) => e.title)).toEqual(['B', 'A']) + }) +}) + +describe('extractSortableProperties', () => { + it('returns union of all property keys across entries', () => { + const entries = [ + makeEntry({ properties: { Priority: 'High', Rating: 5 } }), + makeEntry({ properties: { Priority: 'Low', Company: 'Acme' } }), + ] + expect(extractSortableProperties(entries)).toEqual(['Company', 'Priority', 'Rating']) + }) + + it('returns empty array for entries without properties', () => { + const entries = [makeEntry(), makeEntry()] + expect(extractSortableProperties(entries)).toEqual([]) + }) + + it('returns empty array for empty entry list', () => { + expect(extractSortableProperties([])).toEqual([]) + }) + + it('deduplicates property keys', () => { + const entries = [ + makeEntry({ properties: { Priority: 'High' } }), + makeEntry({ properties: { Priority: 'Low' } }), + ] + expect(extractSortableProperties(entries)).toEqual(['Priority']) + }) +}) + +describe('getSortOptionLabel', () => { + it('returns label for built-in options', () => { + expect(getSortOptionLabel('modified')).toBe('Modified') + expect(getSortOptionLabel('title')).toBe('Title') + }) + + it('returns property key for custom properties', () => { + expect(getSortOptionLabel('property:Priority')).toBe('Priority') + expect(getSortOptionLabel('property:Due date')).toBe('Due date') + }) +}) + +describe('getDefaultDirection', () => { + it('returns desc for time-based sorts', () => { + expect(getDefaultDirection('modified')).toBe('desc') + expect(getDefaultDirection('created')).toBe('desc') + }) + + it('returns asc for other sorts', () => { + expect(getDefaultDirection('title')).toBe('asc') + expect(getDefaultDirection('status')).toBe('asc') + expect(getDefaultDirection('property:Priority')).toBe('asc') + }) +}) diff --git a/src/utils/noteListHelpers.ts b/src/utils/noteListHelpers.ts index c4e84a2d..07c0a175 100644 --- a/src/utils/noteListHelpers.ts +++ b/src/utils/noteListHelpers.ts @@ -85,7 +85,7 @@ export function sortByModified(a: VaultEntry, b: VaultEntry): number { return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0) } -export type SortOption = 'modified' | 'created' | 'title' | 'status' +export type SortOption = 'modified' | 'created' | 'title' | 'status' | `property:${string}` export type SortDirection = 'asc' | 'desc' export interface SortConfig { @@ -93,11 +93,11 @@ export interface SortConfig { direction: SortDirection } -export const DEFAULT_DIRECTIONS: Record = { - modified: 'desc', - created: 'desc', - title: 'asc', - status: 'asc', +export const DEFAULT_SORT_OPTIONS: SortOption[] = ['modified', 'created', 'title', 'status'] + +export function getDefaultDirection(option: SortOption): SortDirection { + if (option === 'modified' || option === 'created') return 'desc' + return 'asc' } export const SORT_OPTIONS: { value: SortOption; label: string }[] = [ @@ -107,31 +107,80 @@ export const SORT_OPTIONS: { value: SortOption; label: string }[] = [ { value: 'status', label: 'Status' }, ] +export function getSortOptionLabel(option: SortOption): string { + if (option.startsWith('property:')) return option.slice('property:'.length) + return SORT_OPTIONS.find((o) => o.value === option)?.label ?? option +} + +/** Extract sortable custom property keys from a list of entries. */ +export function extractSortableProperties(entries: VaultEntry[]): string[] { + const keys = new Set() + for (const entry of entries) { + if (entry.properties) { + for (const key of Object.keys(entry.properties)) keys.add(key) + } + } + return [...keys].sort((a, b) => a.localeCompare(b)) +} + const STATUS_ORDER: Record = { Active: 0, Paused: 1, Done: 2, Finished: 3, } -export function getSortComparator(option: SortOption, direction?: SortDirection): (a: VaultEntry, b: VaultEntry) => number { - const dir = direction ?? DEFAULT_DIRECTIONS[option] - const flip = dir === 'asc' ? 1 : -1 - switch (option) { - case 'modified': - return (a, b) => flip * ((getDisplayDate(a) ?? 0) - (getDisplayDate(b) ?? 0)) - case 'created': - return (a, b) => flip * ((a.createdAt ?? a.modifiedAt ?? 0) - (b.createdAt ?? b.modifiedAt ?? 0)) - case 'title': - return (a, b) => flip * a.title.localeCompare(b.title) - case 'status': - return (a, b) => { - const sa = STATUS_ORDER[a.status ?? ''] ?? 999 - const sb = STATUS_ORDER[b.status ?? ''] ?? 999 - if (sa !== sb) return flip * (sa - sb) - // Tiebreaker: always newest first regardless of direction - return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0) - } +const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}/ + +function tryParseDate(s: string): number | null { + if (!ISO_DATE_RE.test(s)) return null + const d = new Date(s) + return isNaN(d.getTime()) ? null : d.getTime() +} + +function compareNumericPair(a: unknown, b: unknown): number | null { + if (typeof a === 'number' && typeof b === 'number') return a - b + if (typeof a === 'boolean' && typeof b === 'boolean') return (a ? 1 : 0) - (b ? 1 : 0) + return null +} + +function comparePropertyValues(a: unknown, b: unknown): number { + const numeric = compareNumericPair(a, b) + if (numeric !== null) return numeric + const sa = String(a) + const sb = String(b) + const da = tryParseDate(sa) + const db = tryParseDate(sb) + if (da !== null && db !== null) return da - db + return sa.localeCompare(sb) +} + +function makePropertyComparator(key: string, flip: number): (a: VaultEntry, b: VaultEntry) => number { + return (a, b) => { + const va = a.properties?.[key] ?? null + const vb = b.properties?.[key] ?? null + if (va == null && vb == null) return 0 + if (va == null) return 1 + if (vb == null) return -1 + return flip * comparePropertyValues(va, vb) } } +function makeBuiltinComparator(option: string, flip: number): (a: VaultEntry, b: VaultEntry) => number { + if (option === 'title') return (a, b) => flip * a.title.localeCompare(b.title) + if (option === 'created') return (a, b) => flip * ((a.createdAt ?? a.modifiedAt ?? 0) - (b.createdAt ?? b.modifiedAt ?? 0)) + if (option === 'status') return (a, b) => { + const sa = STATUS_ORDER[a.status ?? ''] ?? 999 + const sb = STATUS_ORDER[b.status ?? ''] ?? 999 + if (sa !== sb) return flip * (sa - sb) + return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0) + } + return (a, b) => flip * ((getDisplayDate(a) ?? 0) - (getDisplayDate(b) ?? 0)) +} + +export function getSortComparator(option: SortOption, direction?: SortDirection): (a: VaultEntry, b: VaultEntry) => number { + const flip = (direction ?? getDefaultDirection(option)) === 'asc' ? 1 : -1 + if (option.startsWith('property:')) return makePropertyComparator(option.slice('property:'.length), flip) + return makeBuiltinComparator(option, flip) +} + const SORT_STORAGE_KEY = 'laputa-sort-preferences' export function loadSortPreferences(): Record { @@ -144,7 +193,7 @@ export function loadSortPreferences(): Record { if (typeof value === 'string') { // Migrate old format: bare SortOption string → SortConfig const opt = value as SortOption - result[key] = { option: opt, direction: DEFAULT_DIRECTIONS[opt] } + result[key] = { option: opt, direction: getDefaultDirection(opt) } } else { result[key] = value as SortConfig }