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:
Test
2026-03-19 06:41:40 +01:00
parent 24da33e7cd
commit bc55231baa
14 changed files with 322 additions and 30 deletions

View File

@@ -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 }
}