diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index a141b907..d2135fac 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -68,6 +68,9 @@ pub struct VaultEntry { /// Markdown template for notes of this Type. When a new note is created /// with this type, the template body is pre-filled after the frontmatter. pub template: Option, + /// Default sort preference for the note list when viewing instances of this Type. + /// Stored as "option:direction" (e.g. "modified:desc", "title:asc", "property:Priority:asc"). + pub sort: Option, /// Word count of the note body (excludes frontmatter and H1 title). #[serde(rename = "wordCount")] pub word_count: u32, @@ -118,6 +121,8 @@ struct Frontmatter { sidebar_label: Option, #[serde(default)] template: Option, + #[serde(default)] + sort: Option, } /// Handles YAML fields that can be either a single string or a list of strings. @@ -163,6 +168,7 @@ const SKIP_KEYS: &[&str] = &[ "order", "sidebar label", "template", + "sort", ]; /// Extract all wikilink-containing fields from raw YAML frontmatter. @@ -387,6 +393,7 @@ pub fn parse_md_file(path: &Path) -> Result { order: frontmatter.order, sidebar_label: frontmatter.sidebar_label, template: frontmatter.template, + sort: frontmatter.sort, word_count, outgoing_links, properties, @@ -1183,6 +1190,40 @@ References: assert!(entry.relationships.get("template").is_none()); } + // --- sort field tests --- + + #[test] + fn test_parse_sort_from_type_entry() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Type\nsort: \"modified:desc\"\n---\n# Project\n"; + let entry = parse_test_entry(&dir, "type/project.md", content); + assert_eq!(entry.sort, Some("modified:desc".to_string())); + } + + #[test] + fn test_parse_sort_missing_defaults_to_none() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Type\n---\n# Project\n"; + let entry = parse_test_entry(&dir, "type/project.md", content); + assert_eq!(entry.sort, None); + } + + #[test] + fn test_sort_not_in_relationships() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Type\nsort: \"title:asc\"\n---\n# Project\n"; + let entry = parse_test_entry(&dir, "type/project.md", content); + assert!(entry.relationships.get("sort").is_none()); + } + + #[test] + fn test_sort_not_in_properties() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Type\nsort: \"title:asc\"\n---\n# Project\n"; + let entry = parse_test_entry(&dir, "type/project.md", content); + assert!(entry.properties.get("sort").is_none()); + } + // --- custom properties tests --- #[test] diff --git a/src/App.test.tsx b/src/App.test.tsx index bff1eaf9..cee562bd 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -34,7 +34,7 @@ const mockEntries = [ modifiedAt: 1700000000, createdAt: null, fileSize: 1024, - template: null, + template: null, sort: null, outgoingLinks: [], }, { @@ -51,7 +51,7 @@ const mockEntries = [ modifiedAt: 1700000000, createdAt: null, fileSize: 256, - template: null, + template: null, sort: null, outgoingLinks: [], }, ] diff --git a/src/App.tsx b/src/App.tsx index 2daaa929..fc3c8016 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -386,7 +386,7 @@ function App() { {noteListVisible && ( <>
- +
diff --git a/src/components/DynamicPropertiesPanel.test.tsx b/src/components/DynamicPropertiesPanel.test.tsx index a874ef28..269170b6 100644 --- a/src/components/DynamicPropertiesPanel.test.tsx +++ b/src/components/DynamicPropertiesPanel.test.tsx @@ -51,7 +51,7 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], ...overrides, }) diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index 34ca31b5..aefdbf09 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -75,7 +75,7 @@ const mockEntry: VaultEntry = { icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], } diff --git a/src/components/Inspector.test.tsx b/src/components/Inspector.test.tsx index 0f380638..4aa1f125 100644 --- a/src/components/Inspector.test.tsx +++ b/src/components/Inspector.test.tsx @@ -26,7 +26,7 @@ const mockEntry: VaultEntry = { icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], } @@ -71,7 +71,7 @@ const referrerEntry: VaultEntry = { icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: ['Test Project'], } @@ -376,7 +376,7 @@ This is a test note with some words to count. icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], } @@ -403,7 +403,7 @@ This is a test note with some words to count. icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], } @@ -430,7 +430,7 @@ This is a test note with some words to count. icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], } @@ -457,7 +457,7 @@ This is a test note with some words to count. icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], } @@ -600,7 +600,7 @@ Status: Active icon: null, color: null, order: null, - template: null, + template: null, sort: null, // Body text also links to grow-newsletter outgoingLinks: ['responsibility/grow-newsletter'], } diff --git a/src/components/InspectorPanels.test.tsx b/src/components/InspectorPanels.test.tsx index fb55201a..f23e04bd 100644 --- a/src/components/InspectorPanels.test.tsx +++ b/src/components/InspectorPanels.test.tsx @@ -30,7 +30,7 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], ...overrides, }) diff --git a/src/components/NoteList.test.tsx b/src/components/NoteList.test.tsx index b296827f..062b5964 100644 --- a/src/components/NoteList.test.tsx +++ b/src/components/NoteList.test.tsx @@ -34,7 +34,7 @@ const mockEntries: VaultEntry[] = [ icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -64,7 +64,7 @@ const mockEntries: VaultEntry[] = [ icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -91,7 +91,7 @@ const mockEntries: VaultEntry[] = [ icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -118,7 +118,7 @@ const mockEntries: VaultEntry[] = [ icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -145,7 +145,7 @@ const mockEntries: VaultEntry[] = [ icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -367,7 +367,7 @@ describe('getSortComparator', () => { icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, ...overrides, @@ -481,7 +481,7 @@ describe('NoteList sort controls', () => { icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, ...overrides, @@ -739,7 +739,7 @@ const trashedEntry: VaultEntry = { icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, } @@ -767,7 +767,7 @@ const expiredTrashedEntry: VaultEntry = { icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, } @@ -924,7 +924,7 @@ describe('NoteList — virtual list with large datasets', () => { icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, ...overrides, @@ -1254,7 +1254,7 @@ const typeEntry: VaultEntry = { icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, } diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index a644934b..f9b0fdbe 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback, useEffect, memo } from 'react' +import { useState, useMemo, useCallback, useEffect, useRef, memo } from 'react' import { useDragRegion } from '../hooks/useDragRegion' import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../types' @@ -18,6 +18,7 @@ import { buildRelationshipGroups, filterEntries, relativeDate, getDisplayDate, loadSortPreferences, saveSortPreferences, + parseSortConfig, serializeSortConfig, clearListSortFromLocalStorage, } from '../utils/noteListHelpers' interface NoteListProps { @@ -34,6 +35,8 @@ interface NoteListProps { onCreateNote: () => void onBulkArchive?: (paths: string[]) => void onBulkTrash?: (paths: string[]) => void + onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void + updateEntry?: (path: string, patch: Partial) => void } function PinnedCard({ entry, typeEntryMap, onClickNote, showDate }: { @@ -256,11 +259,6 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list const isEntityView = selection.kind === 'entity' const isTrashView = selection.kind === 'filter' && selection.filter === 'trash' - 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(() => { @@ -279,14 +277,26 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list [isTrashView, searched], ) - return { isEntityView, isTrashView, typeDocument, searched, searchedGroups, expiredTrashCount } + return { isEntityView, isTrashView, searched, searchedGroups, expiredTrashCount } +} + +// --- Pure helpers --- + +const DEFAULT_LIST_CONFIG: SortConfig = { option: 'modified', direction: 'desc' } + +function resolveListSortConfig(typeDocument: VaultEntry | null, sortPrefs: Record): SortConfig { + if (typeDocument?.sort) { + const parsed = parseSortConfig(typeDocument.sort) + if (parsed) return parsed + } + return sortPrefs['__list__'] ?? DEFAULT_LIST_CONFIG } // --- Main component --- const defaultGetNoteStatus = (): NoteStatus => 'clean' -function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash }: NoteListProps) { +function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onUpdateTypeSort, updateEntry }: NoteListProps) { const [search, setSearch] = useState('') const [searchVisible, setSearchVisible] = useState(false) const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) @@ -311,9 +321,43 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF [getNoteStatus, modifiedFiles, modifiedPathSet], ) + // Resolve the type document for sectionGroup selections (needs to be above sort logic) + const typeDocument = useMemo(() => { + if (selection.kind !== 'sectionGroup') return null + return entries.find((e) => e.isA === 'Type' && e.title === selection.type) ?? null + }, [selection, entries]) + + // Resolve list sort config: read from type frontmatter for sectionGroup, else localStorage + const listConfig = resolveListSortConfig(typeDocument, sortPrefs) + + // Silent migration: if type has no sort in frontmatter but localStorage has __list__, migrate it + const migrationDoneRef = useRef>(new Set()) + useEffect(() => { + if (!typeDocument || typeDocument.sort || !onUpdateTypeSort || !updateEntry) return + if (migrationDoneRef.current.has(typeDocument.path)) return + const lsConfig = sortPrefs['__list__'] + if (!lsConfig) return + migrationDoneRef.current.add(typeDocument.path) + const serialized = serializeSortConfig(lsConfig) + onUpdateTypeSort(typeDocument.path, 'sort', serialized) + updateEntry(typeDocument.path, { sort: serialized }) + clearListSortFromLocalStorage() + }, [typeDocument, sortPrefs, onUpdateTypeSort, updateEntry]) + const handleSortChange = useCallback((groupLabel: string, option: SortOption, direction: SortDirection) => { - setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next }) - }, []) + if (groupLabel === '__list__' && typeDocument && onUpdateTypeSort && updateEntry) { + // Persist sort to type file frontmatter + const config: SortConfig = { option, direction } + const serialized = serializeSortConfig(config) + onUpdateTypeSort(typeDocument.path, 'sort', serialized) + updateEntry(typeDocument.path, { sort: serialized }) + // Clear old localStorage __list__ entry if present (migration cleanup) + clearListSortFromLocalStorage() + } else { + // Relationship group sorts still use localStorage + setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next }) + } + }, [typeDocument, onUpdateTypeSort, updateEntry]) const toggleGroup = useCallback((label: string) => { setCollapsedGroups((prev) => toggleSetMember(prev, label)) @@ -321,7 +365,6 @@ 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 } // Compute custom properties and derive effective sort before sorting entries const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes) @@ -332,7 +375,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF 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 { isEntityView, isTrashView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }) const isChangesView = selection.kind === 'filter' && selection.filter === 'changes' const noteListKeyboard = useNoteListKeyboard({ diff --git a/src/components/QuickOpenPalette.test.tsx b/src/components/QuickOpenPalette.test.tsx index bd914078..ee486bf2 100644 --- a/src/components/QuickOpenPalette.test.tsx +++ b/src/components/QuickOpenPalette.test.tsx @@ -26,7 +26,7 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], ...overrides, }) diff --git a/src/components/RawEditorView.test.tsx b/src/components/RawEditorView.test.tsx index a2f0926e..aeb5824d 100644 --- a/src/components/RawEditorView.test.tsx +++ b/src/components/RawEditorView.test.tsx @@ -11,7 +11,7 @@ function entry(title: string, path = `/vault/note/${title}.md`) { cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, - sidebarLabel: null, template: null, outgoingLinks: [], + sidebarLabel: null, template: null, sort: null, outgoingLinks: [], properties: {}, } } diff --git a/src/components/SearchPanel.test.tsx b/src/components/SearchPanel.test.tsx index de31c181..2cdf8994 100644 --- a/src/components/SearchPanel.test.tsx +++ b/src/components/SearchPanel.test.tsx @@ -38,7 +38,7 @@ const MOCK_ENTRIES: VaultEntry[] = [ icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: ['topic/ai', 'topic/api-design', 'person/luca'], properties: {}, }, @@ -65,7 +65,7 @@ const MOCK_ENTRIES: VaultEntry[] = [ icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: ['person/bob'], properties: {}, }, diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx index 63d78779..1ffb9327 100644 --- a/src/components/Sidebar.test.tsx +++ b/src/components/Sidebar.test.tsx @@ -40,7 +40,7 @@ const mockEntries: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -68,7 +68,7 @@ const mockEntries: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -96,7 +96,7 @@ const mockEntries: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -124,7 +124,7 @@ const mockEntries: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -152,7 +152,7 @@ const mockEntries: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -180,7 +180,7 @@ const mockEntries: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -208,7 +208,7 @@ const mockEntries: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -236,7 +236,7 @@ const mockEntries: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -457,7 +457,7 @@ describe('Sidebar', () => { color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -485,7 +485,7 @@ describe('Sidebar', () => { color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -512,7 +512,7 @@ describe('Sidebar', () => { icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -539,7 +539,7 @@ describe('Sidebar', () => { icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -627,7 +627,7 @@ describe('Sidebar', () => { color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, } diff --git a/src/components/TabBar.test.tsx b/src/components/TabBar.test.tsx index 6bca6518..7b162536 100644 --- a/src/components/TabBar.test.tsx +++ b/src/components/TabBar.test.tsx @@ -11,7 +11,7 @@ function makeEntry(path: string, title: string): VaultEntry { status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: null, createdAt: null, fileSize: 0, - snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, outgoingLinks: [], + snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [], } } diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index a420b01f..7f0a1a78 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -26,7 +26,7 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], ...overrides, }) diff --git a/src/hooks/useEntryActions.test.ts b/src/hooks/useEntryActions.test.ts index d8634c61..3f0abc13 100644 --- a/src/hooks/useEntryActions.test.ts +++ b/src/hooks/useEntryActions.test.ts @@ -26,7 +26,7 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, ...overrides, diff --git a/src/hooks/useKeyboardNavigation.test.ts b/src/hooks/useKeyboardNavigation.test.ts index 1f06e00d..5b39562d 100644 --- a/src/hooks/useKeyboardNavigation.test.ts +++ b/src/hooks/useKeyboardNavigation.test.ts @@ -30,7 +30,7 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, ...overrides, diff --git a/src/hooks/useNoteActions.test.ts b/src/hooks/useNoteActions.test.ts index ecb32332..203d6851 100644 --- a/src/hooks/useNoteActions.test.ts +++ b/src/hooks/useNoteActions.test.ts @@ -59,7 +59,7 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ color: null, order: null, outgoingLinks: [], - template: null, + template: null, sort: null, ...overrides, }) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index b892ed0a..ee2a17ad 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, properties: {}, + snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, properties: {}, } } @@ -148,7 +148,7 @@ const ENTRY_DELETE_MAP: Record> = { icon: { icon: null }, owner: { owner: null }, cadence: { cadence: null }, aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] }, archived: { archived: false }, trashed: { trashed: false }, order: { order: null }, - template: { template: null }, + template: { template: null }, sort: { sort: null }, } /** Map a frontmatter key+value to the corresponding VaultEntry field(s). */ @@ -166,6 +166,7 @@ export function frontmatterToEntryPatch( archived: { archived: Boolean(value) }, trashed: { trashed: Boolean(value) }, order: { order: typeof value === 'number' ? value : null }, template: { template: str }, + sort: { sort: str }, } return updates[k] ?? {} } diff --git a/src/hooks/useNoteListKeyboard.test.ts b/src/hooks/useNoteListKeyboard.test.ts index 6d3ff316..cc2247cc 100644 --- a/src/hooks/useNoteListKeyboard.test.ts +++ b/src/hooks/useNoteListKeyboard.test.ts @@ -22,7 +22,7 @@ function makeEntry(path: string, title: string): VaultEntry { fileSize: 100, color: null, icon: null, - template: null, + template: null, sort: null, outgoingLinks: [], relationships: {}, } diff --git a/src/hooks/useTabManagement.test.ts b/src/hooks/useTabManagement.test.ts index a5615ab0..2de20cc3 100644 --- a/src/hooks/useTabManagement.test.ts +++ b/src/hooks/useTabManagement.test.ts @@ -32,7 +32,7 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], ...overrides, }) diff --git a/src/hooks/useThemeManager.test.ts b/src/hooks/useThemeManager.test.ts index bed54136..5073e853 100644 --- a/src/hooks/useThemeManager.test.ts +++ b/src/hooks/useThemeManager.test.ts @@ -56,7 +56,7 @@ function makeThemeEntry(path: string, title: string): VaultEntry { color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, } diff --git a/src/hooks/useVaultLoader.test.ts b/src/hooks/useVaultLoader.test.ts index 323a725f..8e1d0e51 100644 --- a/src/hooks/useVaultLoader.test.ts +++ b/src/hooks/useVaultLoader.test.ts @@ -10,7 +10,7 @@ const mockEntries: VaultEntry[] = [ status: 'Active', owner: null, cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: 1700000000, fileSize: 100, - snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, outgoingLinks: [], + snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [], }, ] diff --git a/src/mock-tauri/mock-entries.ts b/src/mock-tauri/mock-entries.ts index 5bce91e0..fa41635d 100644 --- a/src/mock-tauri/mock-entries.ts +++ b/src/mock-tauri/mock-entries.ts @@ -36,7 +36,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: 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' }, }, @@ -73,7 +73,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: 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' }, }, @@ -104,7 +104,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: ['person/matteo-cellini'], properties: {}, }, @@ -135,7 +135,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: ['responsibility/grow-newsletter'], properties: {}, }, @@ -166,7 +166,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: ['responsibility/manage-sponsorships'], properties: {}, }, @@ -198,7 +198,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: ['topic/trading', 'topic/algorithmic-trading', 'data/ema200-backtest-results'], properties: { Priority: 'Low', 'Due date': '2026-03-01' }, }, @@ -230,7 +230,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: ['project/26q1-laputa-app', 'topic/growth', 'topic/ads'], properties: { Priority: 'Medium', Rating: 4 }, }, @@ -261,7 +261,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: ['project/26q1-laputa-app'], properties: {}, }, @@ -291,7 +291,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: { Company: 'Acme Corp', Role: 'Engineering Lead' }, }, @@ -321,7 +321,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: { Company: 'TechStart', Role: 'Product Manager' }, }, @@ -351,7 +351,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -381,7 +381,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -412,7 +412,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: ['project/26q1-laputa-app', 'person/matteo-cellini'], properties: {}, }, @@ -443,7 +443,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -474,7 +474,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -505,7 +505,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: ['responsibility/grow-newsletter'], properties: {}, }, @@ -537,7 +537,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: ['responsibility/grow-newsletter', 'topic/software-development'], properties: {}, }, @@ -568,7 +568,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: ['responsibility/grow-newsletter'], properties: {}, }, @@ -597,7 +597,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: 0, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -625,7 +625,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: 1, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -653,7 +653,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: 2, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -681,7 +681,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: 3, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -709,7 +709,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: 4, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -737,7 +737,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: 5, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -765,7 +765,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: 6, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -793,7 +793,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: 7, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -821,7 +821,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: 8, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -850,7 +850,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: 'orange', order: 9, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -878,7 +878,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: 'green', order: 10, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -909,7 +909,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: { Difficulty: 'Easy', 'Prep time': '30 min', Servings: 4 }, }, @@ -939,7 +939,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: { Author: 'Martin Kleppmann', Rating: 5, 'Year published': 2017 }, }, @@ -971,7 +971,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -1001,7 +1001,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -1032,7 +1032,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -1055,7 +1055,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, modifiedAt: now - 86400 * 120, @@ -1086,7 +1086,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, modifiedAt: now - 86400 * 90, @@ -1151,7 +1151,7 @@ function generateBulkEntries(count: number): VaultEntry[] { order: null, outgoingLinks: Array.from({ length: i % 8 }, (_j, j) => `note/link-target-${(i + j) % 50}`), sidebarLabel: null, - template: null, + template: null, sort: null, properties: {}, }) } @@ -1184,7 +1184,7 @@ MOCK_ENTRIES.push( color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -1212,7 +1212,7 @@ MOCK_ENTRIES.push( color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, @@ -1240,7 +1240,7 @@ MOCK_ENTRIES.push( color: null, order: null, sidebarLabel: null, - template: null, + template: null, sort: null, outgoingLinks: [], properties: {}, }, diff --git a/src/types.ts b/src/types.ts index 292491af..89cba39d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,6 +29,8 @@ export interface VaultEntry { sidebarLabel: string | null /** Markdown template for Type entries. Pre-fills new notes created with this type. */ template: string | null + /** Default sort preference for the note list of this Type. Format: "option:direction". */ + sort: string | null /** All wikilink targets found in the note content. Extracted from [[target]] patterns. */ outgoingLinks: string[] /** Custom scalar frontmatter properties (non-relationship, non-structural). */ diff --git a/src/utils/noteListHelpers.test.ts b/src/utils/noteListHelpers.test.ts index ec6b9cf4..ea916d5f 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, getSortComparator, extractSortableProperties, getSortOptionLabel, getDefaultDirection } from './noteListHelpers' +import { formatSubtitle, formatSearchSubtitle, relativeDate, buildRelationshipGroups, getSortComparator, extractSortableProperties, getSortOptionLabel, getDefaultDirection, parseSortConfig, serializeSortConfig } from './noteListHelpers' import type { VaultEntry } from '../types' function makeEntry(overrides: Partial = {}): VaultEntry { @@ -10,7 +10,7 @@ function makeEntry(overrides: Partial = {}): VaultEntry { trashed: false, trashedAt: null, modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', wordCount: 0, relationships: {}, - icon: null, color: null, order: null, template: null, outgoingLinks: [], + icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [], ...overrides, } } @@ -435,3 +435,53 @@ describe('getDefaultDirection', () => { expect(getDefaultDirection('property:Priority')).toBe('asc') }) }) + +describe('serializeSortConfig', () => { + it('serializes a built-in sort config', () => { + expect(serializeSortConfig({ option: 'modified', direction: 'desc' })).toBe('modified:desc') + expect(serializeSortConfig({ option: 'title', direction: 'asc' })).toBe('title:asc') + }) + + it('serializes a custom property sort config', () => { + expect(serializeSortConfig({ option: 'property:Priority', direction: 'asc' })).toBe('property:Priority:asc') + }) +}) + +describe('parseSortConfig', () => { + it('parses a built-in sort config', () => { + expect(parseSortConfig('modified:desc')).toEqual({ option: 'modified', direction: 'desc' }) + expect(parseSortConfig('title:asc')).toEqual({ option: 'title', direction: 'asc' }) + }) + + it('parses a custom property sort config with colon in option', () => { + expect(parseSortConfig('property:Priority:asc')).toEqual({ option: 'property:Priority', direction: 'asc' }) + }) + + it('returns null for null/undefined input', () => { + expect(parseSortConfig(null)).toBeNull() + expect(parseSortConfig(undefined)).toBeNull() + }) + + it('returns null for empty string', () => { + expect(parseSortConfig('')).toBeNull() + }) + + it('returns null for invalid direction', () => { + expect(parseSortConfig('modified:up')).toBeNull() + }) + + it('returns null for string without colon', () => { + expect(parseSortConfig('modified')).toBeNull() + }) + + it('roundtrips correctly', () => { + const configs = [ + { option: 'modified' as const, direction: 'desc' as const }, + { option: 'title' as const, direction: 'asc' as const }, + { option: 'property:Due date' as const, direction: 'desc' as const }, + ] + for (const config of configs) { + expect(parseSortConfig(serializeSortConfig(config))).toEqual(config) + } + }) +}) diff --git a/src/utils/noteListHelpers.ts b/src/utils/noteListHelpers.ts index 07c0a175..cead2294 100644 --- a/src/utils/noteListHelpers.ts +++ b/src/utils/noteListHelpers.ts @@ -183,6 +183,23 @@ export function getSortComparator(option: SortOption, direction?: SortDirection) const SORT_STORAGE_KEY = 'laputa-sort-preferences' +/** Serialize a SortConfig to the string format stored in type frontmatter: "option:direction". */ +export function serializeSortConfig(config: SortConfig): string { + return `${config.option}:${config.direction}` +} + +/** Parse a frontmatter sort string ("option:direction") back to SortConfig. */ +export function parseSortConfig(raw: string | null | undefined): SortConfig | null { + if (!raw) return null + // Format: "option:direction" where option itself can contain ":" (e.g. "property:Priority:asc") + const lastColon = raw.lastIndexOf(':') + if (lastColon <= 0) return null + const dir = raw.slice(lastColon + 1) + if (dir !== 'asc' && dir !== 'desc') return null + const option = raw.slice(0, lastColon) as SortOption + return { option, direction: dir } +} + export function loadSortPreferences(): Record { try { const raw = localStorage.getItem(SORT_STORAGE_KEY) @@ -210,6 +227,21 @@ export function saveSortPreferences(prefs: Record) { } catch { /* ignore */ } } +/** Remove the `__list__` key from localStorage sort preferences (used during migration). */ +export function clearListSortFromLocalStorage(): void { + try { + const raw = localStorage.getItem(SORT_STORAGE_KEY) + if (!raw) return + const parsed = JSON.parse(raw) + delete parsed['__list__'] + if (Object.keys(parsed).length === 0) { + localStorage.removeItem(SORT_STORAGE_KEY) + } else { + localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(parsed)) + } + } catch { /* ignore */ } +} + function findBacklinks(entity: VaultEntry, allEntries: VaultEntry[], allContent: Record): VaultEntry[] { const stem = entity.filename.replace(/\.md$/, '') const pathStem = entity.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '') diff --git a/src/utils/suggestionEnrichment.test.ts b/src/utils/suggestionEnrichment.test.ts index 318cb33f..c0c51d54 100644 --- a/src/utils/suggestionEnrichment.test.ts +++ b/src/utils/suggestionEnrichment.test.ts @@ -13,7 +13,7 @@ function makeEntry(overrides: Partial = {}): VaultEntry { aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, - order: null, template: null, outgoingLinks: [], + order: null, template: null, sort: null, outgoingLinks: [], ...overrides, } } diff --git a/src/utils/typeColors.test.ts b/src/utils/typeColors.test.ts index 3c17bdf9..b4ebb70e 100644 --- a/src/utils/typeColors.test.ts +++ b/src/utils/typeColors.test.ts @@ -54,7 +54,7 @@ const baseEntry: VaultEntry = { status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', relationships: {}, wordCount: 0, - icon: null, color: null, order: null, template: null, outgoingLinks: [], + icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [], } describe('buildTypeEntryMap', () => { diff --git a/src/utils/wikilinkColors.test.ts b/src/utils/wikilinkColors.test.ts index e8e93931..63d4c565 100644 --- a/src/utils/wikilinkColors.test.ts +++ b/src/utils/wikilinkColors.test.ts @@ -26,7 +26,7 @@ function makeEntry(overrides: Partial): VaultEntry { icon: null, color: null, order: null, - template: null, + template: null, sort: null, outgoingLinks: [], ...overrides, }