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 }