feat: add Inbox sidebar section showing unlinked notes
Inbox shows notes without valid outgoing relationships (body wikilinks or frontmatter refs), helping users find captured but unorganized notes. Includes time-period filter pills (This week/month/quarter/All time), Cmd+K command, and macOS menu bar entry. Broken wikilinks (targeting non-existent notes) are not counted as relationships. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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> = {}): 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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<NoteL
|
||||
}
|
||||
return { open, archived, trashed }
|
||||
}
|
||||
|
||||
// --- Inbox ---
|
||||
|
||||
/** Build a set of all valid link targets (titles, aliases, filename stems, path stems). */
|
||||
export function buildValidLinkTargets(entries: VaultEntry[]): Set<string> {
|
||||
const targets = new Set<string>()
|
||||
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<string>): 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<string>): 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<InboxPeriod, number> = {
|
||||
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<InboxPeriod, number> {
|
||||
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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user