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,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