diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 5d79beca..1e50fe06 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -36,6 +36,7 @@ const GO_ALL_NOTES: &str = "go-all-notes"; const GO_ARCHIVED: &str = "go-archived"; const GO_TRASH: &str = "go-trash"; const GO_CHANGES: &str = "go-changes"; +const GO_INBOX: &str = "go-inbox"; const NOTE_ARCHIVE: &str = "note-archive"; const NOTE_TRASH: &str = "note-trash"; @@ -267,6 +268,7 @@ fn build_go_menu(app: &App) -> MenuResult { .build(app)?; let trash = MenuItemBuilder::new("Trash").id(GO_TRASH).build(app)?; let changes = MenuItemBuilder::new("Changes").id(GO_CHANGES).build(app)?; + let inbox = MenuItemBuilder::new("Inbox").id(GO_INBOX).build(app)?; let go_back = MenuItemBuilder::new("Go Back") .id(VIEW_GO_BACK) .accelerator("CmdOrCtrl+[") @@ -281,6 +283,7 @@ fn build_go_menu(app: &App) -> MenuResult { .item(&archived) .item(&trash) .item(&changes) + .item(&inbox) .separator() .item(&go_back) .item(&go_forward) diff --git a/src/App.tsx b/src/App.tsx index aed03f9f..b127a349 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -49,9 +49,9 @@ import { FlatVaultMigrationBanner } from './components/FlatVaultMigrationBanner' import { useFlatVaultMigration } from './hooks/useFlatVaultMigration' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from './mock-tauri' -import type { SidebarSelection, VaultEntry } from './types' +import type { SidebarSelection, VaultEntry, InboxPeriod } from './types' import type { NoteListItem } from './utils/ai-context' -import { filterEntries, type NoteListFilter } from './utils/noteListHelpers' +import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers' import { openLocalFile } from './utils/url' import { flushEditorContent } from './utils/autoSave' import './App.css' @@ -71,6 +71,7 @@ const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' } function App() { const [selection, setSelection] = useState(DEFAULT_SELECTION) const [noteListFilter, setNoteListFilter] = useState('open') + const [inboxPeriod, setInboxPeriod] = useState('month') const handleSetSelection = useCallback((sel: SidebarSelection) => { setSelection(sel) setNoteListFilter('open') @@ -513,11 +514,15 @@ function App() { const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null + const inboxCount = useMemo(() => filterInboxEntries(vault.entries, inboxPeriod).length, [vault.entries, inboxPeriod]) + const aiNoteList = useMemo(() => { - return filterEntries(vault.entries, selection).map(e => ({ + const isInbox = selection.kind === 'filter' && selection.filter === 'inbox' + const filtered = isInbox ? filterInboxEntries(vault.entries, inboxPeriod) : filterEntries(vault.entries, selection) + return filtered.map(e => ({ path: e.path, title: e.title, type: e.isA ?? 'Note', })) - }, [vault.entries, selection]) + }, [vault.entries, selection, inboxPeriod]) const aiNoteListFilter = useMemo(() => { if (selection.kind === 'sectionGroup') return { type: selection.type, query: '' } @@ -541,7 +546,7 @@ function App() { {sidebarVisible && ( <>
- +
@@ -552,7 +557,7 @@ function App() { {selection.kind === 'filter' && selection.filter === 'pulse' ? ( setViewMode('all')} /> ) : ( - + )} diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index bb5993c1..68946d1c 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -1,7 +1,7 @@ import { useState, useMemo, useCallback, useEffect, memo } from 'react' -import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../types' +import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, InboxPeriod } from '../types' import type { NoteListFilter } from '../utils/noteListHelpers' -import { countByFilter } from '../utils/noteListHelpers' +import { countByFilter, countInboxByPeriod } from '../utils/noteListHelpers' import { NoteItem } from './NoteItem' import { prefetchNoteContent } from '../hooks/useTabManagement' import { BulkActionBar } from './BulkActionBar' @@ -9,6 +9,7 @@ import { useMultiSelect } from '../hooks/useMultiSelect' import { useNoteListKeyboard } from '../hooks/useNoteListKeyboard' import { NoteListHeader } from './note-list/NoteListHeader' import { FilterPills } from './note-list/FilterPills' +import { InboxFilterPills } from './note-list/InboxFilterPills' import { EntityView, ListView } from './note-list/NoteListViews' import { DeletedNotesBanner } from './note-list/TrashWarningBanner' import { routeNoteClick, toggleSetMember, resolveHeaderTitle } from './note-list/noteListUtils' @@ -23,6 +24,8 @@ interface NoteListProps { selectedNote: VaultEntry | null noteListFilter: NoteListFilter onNoteListFilterChange: (filter: NoteListFilter) => void + inboxPeriod?: InboxPeriod + onInboxPeriodChange?: (period: InboxPeriod) => void modifiedFiles?: ModifiedFile[] modifiedFilesError?: string | null getNoteStatus?: (path: string) => NoteStatus @@ -39,10 +42,11 @@ interface NoteListProps { updateEntry?: (path: string, patch: Partial) => void } -function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry }: NoteListProps) { +function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'month', onInboxPeriodChange, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry }: NoteListProps) { const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus) const isSectionGroup = selection.kind === 'sectionGroup' + const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox' const subFilter = isSectionGroup ? noteListFilter : undefined const filterCounts = useMemo( @@ -50,12 +54,17 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot [entries, isSectionGroup, selection], ) - const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, onUpdateTypeSort, updateEntry }) + const inboxCounts = useMemo( + () => isInboxView ? countInboxByPeriod(entries) : { week: 0, month: 0, quarter: 0, all: 0 }, + [entries, isInboxView], + ) + + const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, onUpdateTypeSort, updateEntry }) const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch() const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) const typeEntryMap = useTypeEntryMap(entries) - const { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter }) + const { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined }) const isChangesView = selection.kind === 'filter' && selection.filter === 'changes' const deletedCount = useMemo( () => isChangesView ? (modifiedFiles ?? []).filter((f) => f.status === 'deleted').length : 0, @@ -91,12 +100,13 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
{isSectionGroup && } + {isInboxView && onInboxPeriodChange && }
{entitySelection ? ( ) : ( - + )}
{isChangesView && deletedCount > 0 && } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 6c5bfc0e..80cb94bd 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -12,7 +12,7 @@ import { } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { - FileText, Trash, Archive, CaretLeft, GitDiff, Pulse, + FileText, Trash, Archive, CaretLeft, GitDiff, Pulse, Tray, } from '@phosphor-icons/react' import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react' import { @@ -34,6 +34,7 @@ interface SidebarProps { onRenameSection?: (typeName: string, label: string) => void onToggleTypeVisibility?: (typeName: string) => void modifiedCount?: number + inboxCount?: number onCommitPush?: () => void onCollapse?: () => void isGitVault?: boolean @@ -222,7 +223,7 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType, onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection, onToggleTypeVisibility, - modifiedCount = 0, onCommitPush, onCollapse, isGitVault = false, + modifiedCount = 0, inboxCount = 0, onCommitPush, onCollapse, isGitVault = false, }: SidebarProps) { const [collapsed, setCollapsed] = useState>({}) const [customizeTarget, setCustomizeTarget] = useState(null) @@ -312,6 +313,7 @@ export const Sidebar = memo(function Sidebar({ onSelect({ kind: 'filter', filter: 'changes' })} /> )} onSelect({ kind: 'filter', filter: 'pulse' }) : undefined} /> + onSelect({ kind: 'filter', filter: 'inbox' })} />
{/* Sections header + visibility popover */} diff --git a/src/components/note-list/InboxFilterPills.tsx b/src/components/note-list/InboxFilterPills.tsx new file mode 100644 index 00000000..c9532c59 --- /dev/null +++ b/src/components/note-list/InboxFilterPills.tsx @@ -0,0 +1,44 @@ +import { memo } from 'react' +import type { InboxPeriod } from '../../types' + +interface InboxFilterPillsProps { + active: InboxPeriod + counts: Record + onChange: (period: InboxPeriod) => void +} + +const PILLS: { value: InboxPeriod; label: string }[] = [ + { value: 'week', label: 'This week' }, + { value: 'month', label: 'This month' }, + { value: 'quarter', label: 'This quarter' }, + { value: 'all', label: 'All time' }, +] + +function InboxFilterPillsInner({ active, counts, onChange }: InboxFilterPillsProps) { + return ( +
+ {PILLS.map(({ value, label }) => ( + + ))} +
+ ) +} + +export const InboxFilterPills = memo(InboxFilterPillsInner) diff --git a/src/components/note-list/NoteListViews.tsx b/src/components/note-list/NoteListViews.tsx index 9e09c5a7..fe851c74 100644 --- a/src/components/note-list/NoteListViews.tsx +++ b/src/components/note-list/NoteListViews.tsx @@ -11,11 +11,12 @@ function ListViewHeader({ isTrashView, expiredTrashCount }: { return } -function resolveEmptyText(isChangesView: boolean, changesError: string | null | undefined, isTrashView: boolean, isArchivedView: boolean, query: string): string { +function resolveEmptyText(isChangesView: boolean, changesError: string | null | undefined, isTrashView: boolean, isArchivedView: boolean, isInboxView: boolean, query: string): string { if (isChangesView && changesError) return `Failed to load changes: ${changesError}` if (isChangesView) return 'No pending changes' if (isTrashView) return 'Trash is empty' if (isArchivedView) return 'No archived notes' + if (isInboxView) return query ? 'No matching notes' : 'All notes are organized' return query ? 'No matching notes' : 'No notes found' } @@ -39,13 +40,13 @@ export function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, ) } -export function ListView({ isTrashView, isArchivedView, isChangesView, changesError, expiredTrashCount, deletedCount = 0, searched, query, renderItem, virtuosoRef }: { - isTrashView: boolean; isArchivedView?: boolean; isChangesView?: boolean; changesError?: string | null; expiredTrashCount: number +export function ListView({ isTrashView, isArchivedView, isChangesView, isInboxView, changesError, expiredTrashCount, deletedCount = 0, searched, query, renderItem, virtuosoRef }: { + isTrashView: boolean; isArchivedView?: boolean; isChangesView?: boolean; isInboxView?: boolean; changesError?: string | null; expiredTrashCount: number deletedCount?: number; searched: VaultEntry[]; query: string renderItem: (entry: VaultEntry) => React.ReactNode virtuosoRef?: React.RefObject }) { - const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, isTrashView, !!isArchivedView, query) + const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, isTrashView, !!isArchivedView, !!isInboxView, query) const hasHeader = isTrashView && expiredTrashCount > 0 const hasDeletedOnly = !!isChangesView && deletedCount > 0 && searched.length === 0 diff --git a/src/components/note-list/noteListHooks.ts b/src/components/note-list/noteListHooks.ts index a4a82d25..87d7c013 100644 --- a/src/components/note-list/noteListHooks.ts +++ b/src/components/note-list/noteListHooks.ts @@ -3,10 +3,11 @@ import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../ import { type SortOption, type SortDirection, type SortConfig, type NoteListFilter, getSortComparator, extractSortableProperties, - buildRelationshipGroups, filterEntries, + buildRelationshipGroups, filterEntries, filterInboxEntries, loadSortPreferences, saveSortPreferences, parseSortConfig, serializeSortConfig, clearListSortFromLocalStorage, } from '../../utils/noteListHelpers' +import type { InboxPeriod } from '../../types' import { buildTypeEntryMap } from '../../utils/typeColors' import { filterByQuery, filterGroupsByQuery, countExpiredTrash, createNoteStatusResolver, isModifiedEntry } from './noteListUtils' import type { MultiSelectState } from '../../hooks/useMultiSelect' @@ -19,14 +20,16 @@ export function useTypeEntryMap(entries: VaultEntry[]) { // --- useFilteredEntries --- -export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set, modifiedSuffixes: string[], subFilter?: NoteListFilter) { +export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set, modifiedSuffixes: string[], subFilter?: NoteListFilter, inboxPeriod?: InboxPeriod) { const isEntityView = selection.kind === 'entity' const isChangesView = selection.kind === 'filter' && selection.filter === 'changes' + const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox' return useMemo(() => { if (isEntityView) return [] if (isChangesView) return entries.filter((e) => isModifiedEntry(e.path, modifiedPathSet, modifiedSuffixes)) + if (isInboxView) return filterInboxEntries(entries, inboxPeriod ?? 'month') return filterEntries(entries, selection, subFilter) - }, [entries, selection, isEntityView, isChangesView, modifiedPathSet, modifiedSuffixes, subFilter]) + }, [entries, selection, isEntityView, isChangesView, isInboxView, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod]) } // --- useNoteListData --- @@ -36,14 +39,15 @@ interface NoteListDataParams { query: string; listSort: SortOption; listDirection: SortDirection modifiedPathSet: Set; modifiedSuffixes: string[] subFilter?: NoteListFilter + inboxPeriod?: InboxPeriod } -export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter }: NoteListDataParams) { +export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod }: NoteListDataParams) { const isEntityView = selection.kind === 'entity' const isTrashView = (selection.kind === 'filter' && selection.filter === 'trash') || subFilter === 'trashed' const isArchivedView = (selection.kind === 'filter' && selection.filter === 'archived') || subFilter === 'archived' - const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter) + const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod) const searched = useMemo(() => { const sorted = [...filteredEntries].sort(getSortComparator(listSort, listDirection)) @@ -125,11 +129,12 @@ export interface UseNoteListSortParams { modifiedPathSet: Set modifiedSuffixes: string[] subFilter?: NoteListFilter + inboxPeriod?: InboxPeriod onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void updateEntry?: (path: string, patch: Partial) => void } -export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, onUpdateTypeSort, updateEntry }: UseNoteListSortParams) { +export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, onUpdateTypeSort, updateEntry }: UseNoteListSortParams) { const [sortPrefs, setSortPrefs] = useState>(loadSortPreferences) const typeDocument = useMemo(() => { @@ -157,7 +162,7 @@ export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedS } }, [typeDocument, persistence]) - const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter) + const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod) const customProperties = useMemo(() => extractSortableProperties(filteredEntries), [filteredEntries]) const listSort = useMemo(() => deriveEffectiveSort(listConfig.option, customProperties), [listConfig.option, customProperties]) const listDirection = listSort === listConfig.option ? listConfig.direction : 'desc' diff --git a/src/components/note-list/noteListUtils.ts b/src/components/note-list/noteListUtils.ts index 1fc73c2d..9e75fbee 100644 --- a/src/components/note-list/noteListUtils.ts +++ b/src/components/note-list/noteListUtils.ts @@ -7,6 +7,7 @@ export function resolveHeaderTitle(selection: SidebarSelection, typeDocument: Va if (selection.kind === 'filter' && selection.filter === 'archived') return 'Archive' if (selection.kind === 'filter' && selection.filter === 'trash') return 'Trash' if (selection.kind === 'filter' && selection.filter === 'changes') return 'Changes' + if (selection.kind === 'filter' && selection.filter === 'inbox') return 'Inbox' return 'Notes' } diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index 7a069749..15ceb2de 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -4,7 +4,7 @@ import { useCommandRegistry } from './useCommandRegistry' import type { CommandAction } from './useCommandRegistry' import { useKeyboardNavigation } from './useKeyboardNavigation' import { useMenuEvents } from './useMenuEvents' -import type { SidebarSelection, ThemeFile, VaultEntry } from '../types' +import type { SidebarSelection, SidebarFilter, ThemeFile, VaultEntry } from '../types' import type { NoteListFilter } from '../utils/noteListHelpers' import type { ViewMode } from './useViewMode' @@ -97,7 +97,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { const { onSelect } = config - const selectFilter = useCallback((filter: 'all' | 'archived' | 'trash' | 'changes' | 'pulse') => { + const selectFilter = useCallback((filter: SidebarFilter) => { onSelect({ kind: 'filter', filter }) }, [onSelect]) diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index 7c64f2e3..5a15e512 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -242,6 +242,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction { id: 'empty-trash', label: 'Empty Trash', group: 'Note', keywords: ['delete', 'permanently', 'purge', 'clear', 'trash'], enabled: (trashedCount ?? 0) > 0, execute: () => onEmptyTrash?.() }, { id: 'go-changes', label: 'Go to Changes', group: 'Navigation', keywords: ['git', 'modified', 'pending'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) }, { id: 'go-pulse', label: 'Go to Pulse', group: 'Navigation', keywords: ['activity', 'history', 'commits', 'git', 'feed'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'pulse' }) }, + { id: 'go-inbox', label: 'Go to Inbox', group: 'Navigation', keywords: ['inbox', 'unlinked', 'orphan', 'unorganized', 'triage'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'inbox' }) }, { id: 'go-back', label: 'Go Back', group: 'Navigation', shortcut: '⌘[', keywords: ['previous', 'history', 'back'], enabled: !!canGoBack, execute: () => onGoBack?.() }, { id: 'go-forward', label: 'Go Forward', group: 'Navigation', shortcut: '⌘]', keywords: ['next', 'history', 'forward'], enabled: !!canGoForward, execute: () => onGoForward?.() }, diff --git a/src/hooks/useMenuEvents.ts b/src/hooks/useMenuEvents.ts index f68a08d9..5eacff10 100644 --- a/src/hooks/useMenuEvents.ts +++ b/src/hooks/useMenuEvents.ts @@ -75,6 +75,7 @@ const FILTER_MAP: Record = { 'go-archived': 'archived', 'go-trash': 'trash', 'go-changes': 'changes', + 'go-inbox': 'inbox', } type OptionalHandler = diff --git a/src/types.ts b/src/types.ts index 988c2309..d7c9df80 100644 --- a/src/types.ts +++ b/src/types.ts @@ -176,7 +176,9 @@ export interface PulseCommit { deleted: number } -export type SidebarFilter = 'all' | 'archived' | 'trash' | 'changes' | 'pulse' +export type SidebarFilter = 'all' | 'archived' | 'trash' | 'changes' | 'pulse' | 'inbox' + +export type InboxPeriod = 'week' | 'month' | 'quarter' | 'all' export type SidebarSelection = | { kind: 'filter'; filter: SidebarFilter } diff --git a/src/utils/noteListHelpers.test.ts b/src/utils/noteListHelpers.test.ts index dd74f866..617a0b76 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, parseSortConfig, serializeSortConfig } from './noteListHelpers' +import { formatSubtitle, formatSearchSubtitle, relativeDate, buildRelationshipGroups, getSortComparator, extractSortableProperties, getSortOptionLabel, getDefaultDirection, parseSortConfig, serializeSortConfig, buildValidLinkTargets, isInboxEntry, filterInboxEntries } from './noteListHelpers' import type { VaultEntry } from '../types' function makeEntry(overrides: Partial = {}): VaultEntry { @@ -485,3 +485,135 @@ describe('parseSortConfig', () => { } }) }) + +// --- Inbox --- + +describe('buildValidLinkTargets', () => { + it('builds a set of titles, filename stems, and path stems', () => { + const entries = [ + makeEntry({ path: '/vault/project/my-project.md', filename: 'my-project.md', title: 'My Project', aliases: ['MP'] }), + makeEntry({ path: '/vault/note/test.md', filename: 'test.md', title: 'Test Note' }), + ] + const targets = buildValidLinkTargets(entries) + expect(targets.has('My Project')).toBe(true) + expect(targets.has('Test Note')).toBe(true) + expect(targets.has('my-project')).toBe(true) + expect(targets.has('test')).toBe(true) + expect(targets.has('MP')).toBe(true) + // path stems (last 2 segments without .md) + expect(targets.has('project/my-project')).toBe(true) + expect(targets.has('note/test')).toBe(true) + }) +}) + +describe('isInboxEntry', () => { + const allEntries = [ + makeEntry({ path: '/vault/topic/ai.md', filename: 'ai.md', title: 'AI', aliases: [] }), + makeEntry({ path: '/vault/project/laputa.md', filename: 'laputa.md', title: 'Laputa' }), + ] + const validTargets = buildValidLinkTargets(allEntries) + + it('returns true for a note with no outgoing links and no relationships', () => { + const note = makeEntry({ outgoingLinks: [], relationships: {}, belongsTo: [], relatedTo: [] }) + expect(isInboxEntry(note, validTargets)).toBe(true) + }) + + it('returns false for a trashed note', () => { + const note = makeEntry({ trashed: true, outgoingLinks: [], relationships: {} }) + expect(isInboxEntry(note, validTargets)).toBe(false) + }) + + it('returns false for an archived note', () => { + const note = makeEntry({ archived: true, outgoingLinks: [], relationships: {} }) + expect(isInboxEntry(note, validTargets)).toBe(false) + }) + + it('returns false for a note with valid outgoing links', () => { + const note = makeEntry({ outgoingLinks: ['AI'] }) + expect(isInboxEntry(note, validTargets)).toBe(false) + }) + + it('returns true for a note with only broken outgoing links (non-existent targets)', () => { + const note = makeEntry({ outgoingLinks: ['NonExistent Page', 'Another Missing'] }) + expect(isInboxEntry(note, validTargets)).toBe(true) + }) + + it('returns false for a note with valid frontmatter relationships', () => { + const note = makeEntry({ outgoingLinks: [], relationships: { 'Related to': ['[[AI]]'] } }) + expect(isInboxEntry(note, validTargets)).toBe(false) + }) + + it('returns false for a note with belongsTo pointing to real note', () => { + const note = makeEntry({ outgoingLinks: [], belongsTo: ['[[Laputa]]'] }) + expect(isInboxEntry(note, validTargets)).toBe(false) + }) + + it('returns false for a note with relatedTo pointing to real note', () => { + const note = makeEntry({ outgoingLinks: [], relatedTo: ['[[AI]]'] }) + expect(isInboxEntry(note, validTargets)).toBe(false) + }) + + it('returns true for a note with only broken relationship refs', () => { + const note = makeEntry({ outgoingLinks: [], relationships: { 'Relates': ['[[Ghost]]'] }, belongsTo: ['[[Missing]]'] }) + expect(isInboxEntry(note, validTargets)).toBe(true) + }) + + it('excludes Type entries from inbox', () => { + const note = makeEntry({ isA: 'Type', outgoingLinks: [], relationships: {} }) + expect(isInboxEntry(note, validTargets)).toBe(false) + }) +}) + +describe('filterInboxEntries', () => { + const now = Math.floor(Date.now() / 1000) + const DAY = 86400 + + const allEntries = [ + makeEntry({ path: '/vault/a.md', filename: 'a.md', title: 'A', createdAt: now - 2 * DAY, outgoingLinks: [] }), + makeEntry({ path: '/vault/b.md', filename: 'b.md', title: 'B', createdAt: now - 15 * DAY, outgoingLinks: [] }), + makeEntry({ path: '/vault/c.md', filename: 'c.md', title: 'C', createdAt: now - 60 * DAY, outgoingLinks: [] }), + makeEntry({ path: '/vault/d.md', filename: 'd.md', title: 'D', createdAt: now - 120 * DAY, outgoingLinks: [] }), + makeEntry({ path: '/vault/linked.md', filename: 'linked.md', title: 'Linked', createdAt: now - 1 * DAY, outgoingLinks: ['A'] }), + ] + + it('filters by "week" period (last 7 days)', () => { + const result = filterInboxEntries(allEntries, 'week') + expect(result.map(e => e.title)).toEqual(['A']) + }) + + it('filters by "month" period (last 30 days)', () => { + const result = filterInboxEntries(allEntries, 'month') + expect(result.map(e => e.title)).toEqual(['A', 'B']) + }) + + it('filters by "quarter" period (last 90 days)', () => { + const result = filterInboxEntries(allEntries, 'quarter') + expect(result.map(e => e.title)).toEqual(['A', 'B', 'C']) + }) + + it('filters by "all" period', () => { + const result = filterInboxEntries(allEntries, 'all') + expect(result.map(e => e.title)).toEqual(['A', 'B', 'C', 'D']) + }) + + it('sorts by createdAt descending', () => { + const result = filterInboxEntries(allEntries, 'all') + for (let i = 1; i < result.length; i++) { + expect((result[i - 1].createdAt ?? 0)).toBeGreaterThanOrEqual((result[i].createdAt ?? 0)) + } + }) + + it('excludes linked notes', () => { + const result = filterInboxEntries(allEntries, 'all') + expect(result.find(e => e.title === 'Linked')).toBeUndefined() + }) + + it('returns empty array when all notes have valid outgoing links', () => { + const linked = [ + makeEntry({ path: '/vault/x.md', title: 'X', outgoingLinks: ['Y'] }), + makeEntry({ path: '/vault/y.md', title: 'Y', outgoingLinks: ['X'] }), + ] + const result = filterInboxEntries(linked, 'all') + expect(result).toEqual([]) + }) +}) diff --git a/src/utils/noteListHelpers.ts b/src/utils/noteListHelpers.ts index 2a130b68..8ebf5360 100644 --- a/src/utils/noteListHelpers.ts +++ b/src/utils/noteListHelpers.ts @@ -1,4 +1,4 @@ -import type { VaultEntry, SidebarSelection } from '../types' +import type { VaultEntry, SidebarSelection, InboxPeriod } from '../types' export type NoteListFilter = 'open' | 'archived' | 'trashed' @@ -353,3 +353,88 @@ export function countByFilter(entries: VaultEntry[], type: string): Record { + const targets = new Set() + for (const e of entries) { + targets.add(e.title) + const fileStem = e.filename.replace(/\.md$/, '') + targets.add(fileStem) + // path stem: everything after vault root, minus .md + // E.g. /Users/luca/Laputa/project/foo.md → project/foo + const parts = e.path.replace(/\.md$/, '').split('/') + // Try from index that gives "folder/name" pattern — skip first segments + if (parts.length >= 2) { + const last2 = parts.slice(-2).join('/') + if (last2 !== fileStem) targets.add(last2) + } + for (const alias of e.aliases) targets.add(alias) + } + return targets +} + +function extractRef(raw: string): string { + return raw.replace(/^\[\[/, '').replace(/\]\]$/, '').split('|')[0] +} + +function hasValidRef(refs: string[], validTargets: Set): boolean { + return refs.some((raw) => { + const inner = extractRef(raw) + return validTargets.has(inner) || validTargets.has(inner.split('/').pop() ?? '') + }) +} + +/** Check if entry has any valid outgoing link (body or frontmatter) that resolves to a real note. */ +export function isInboxEntry(entry: VaultEntry, validTargets: Set): boolean { + if (entry.trashed || entry.archived) return false + if (entry.isA === 'Type') return false + + // Check body outgoing links + if (entry.outgoingLinks.some((link) => validTargets.has(link) || validTargets.has(link.split('/').pop() ?? ''))) return false + + // Check frontmatter relationship refs + if (entry.belongsTo?.length && hasValidRef(entry.belongsTo, validTargets)) return false + if (entry.relatedTo?.length && hasValidRef(entry.relatedTo, validTargets)) return false + if (entry.relationships) { + for (const refs of Object.values(entry.relationships)) { + if (hasValidRef(refs, validTargets)) return false + } + } + + return true +} + +const INBOX_PERIOD_DAYS: Record = { + week: 7, month: 30, quarter: 90, all: Infinity, +} + +/** Filter entries for the Inbox view: no valid relationships, within the given time period, sorted by createdAt desc. */ +export function filterInboxEntries(entries: VaultEntry[], period: InboxPeriod): VaultEntry[] { + const validTargets = buildValidLinkTargets(entries) + const now = Math.floor(Date.now() / 1000) + const cutoff = period === 'all' ? 0 : now - INBOX_PERIOD_DAYS[period] * 86400 + + return entries + .filter((e) => isInboxEntry(e, validTargets) && (e.createdAt ?? 0) >= cutoff) + .sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)) +} + +/** Count inbox entries per period. */ +export function countInboxByPeriod(entries: VaultEntry[]): Record { + const validTargets = buildValidLinkTargets(entries) + const inbox = entries.filter((e) => isInboxEntry(e, validTargets)) + const now = Math.floor(Date.now() / 1000) + + let week = 0, month = 0, quarter = 0 + for (const e of inbox) { + const age = now - (e.createdAt ?? 0) + if (age <= 7 * 86400) week++ + if (age <= 30 * 86400) month++ + if (age <= 90 * 86400) quarter++ + } + + return { week, month, quarter, all: inbox.length } +}