Compare commits

...

4 Commits

Author SHA1 Message Date
Luca Rossi
4cdc534c01 feat: add daily note command — Cmd+J creates or opens today's journal note (#168)
* feat: add daily note command — Cmd+J creates or opens today's journal note

Adds "Open Today's Note" command accessible via:
- Keyboard shortcut: Cmd+J
- Command Palette (Cmd+K → "Open Today's Note")
- File menu bar entry

If journal/YYYY-MM-DD.md exists, opens it. Otherwise creates it with
Journal type frontmatter and Intentions/Reflections template sections.

Also refactors useNoteActions to extract a shared persistNew callback,
reducing code duplication and keeping CodeScene complexity thresholds green.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: trigger CI for PR #168

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 03:08:15 +01:00
Luca Rossi
1a93acdce7 fix: show new (untracked) notes in Changes view (#161)
* fix: show new (untracked) notes in Changes view

Two fixes:
1. Use `git status --porcelain --untracked-files=all` so individual
   untracked files in subdirectories are listed (instead of just the
   directory name, which gets filtered out by the .md extension check).
2. Call loadModifiedFiles after persistOptimistic completes, so notes
   created via handleCreateNote/handleCreateType appear in Changes
   immediately without requiring a manual Cmd+S.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: trigger CI for PR #161

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-02 03:07:02 +01:00
Luca Rossi
9c8f7907b8 feat: enhance backlinks panel with collapsible section and paragraph context (#167)
The backlinks section in the Inspector is now collapsed by default with a
"Backlinks (N)" toggle header. Expanding reveals each backlink entry with
a paragraph preview showing the surrounding context of the [[wikilink]].

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 02:01:24 +01:00
Luca Rossi
8d9862ffef feat: allow custom sidebar label for type sections (#165)
* feat: allow custom sidebar label for type sections

- Adds sidebarLabel field to vault type config (Rust + frontend)
- Overrides auto-pluralization with user-defined label in sidebar
- Tests updated to cover custom label display

* fix: add missing sidebarLabel field to buildNewEntry and bulk mock generator

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-02 02:01:21 +01:00
24 changed files with 661 additions and 69 deletions

View File

@@ -128,7 +128,7 @@ pub fn get_modified_files(vault_path: &str) -> Result<Vec<ModifiedFile>, String>
let vault = Path::new(vault_path);
let output = Command::new("git")
.args(["status", "--porcelain"])
.args(["status", "--porcelain", "--untracked-files=all"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git status: {}", e))?;
@@ -740,7 +740,42 @@ mod tests {
assert!(modified.len() >= 2);
let statuses: Vec<&str> = modified.iter().map(|f| f.status.as_str()).collect();
assert!(statuses.contains(&"modified") || statuses.contains(&"untracked"));
assert!(statuses.contains(&"modified"));
assert!(statuses.contains(&"untracked"));
}
#[test]
fn test_get_modified_files_untracked_in_subdirectory() {
let dir = setup_git_repo();
let vault = dir.path();
// Create initial commit so git is initialized
fs::write(vault.join("init.md"), "# Init\n").unwrap();
Command::new("git")
.args(["add", "init.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Initial"])
.current_dir(vault)
.output()
.unwrap();
// Create a new untracked file in a subdirectory (simulates new note creation)
fs::create_dir_all(vault.join("note")).unwrap();
fs::write(vault.join("note/brand-new.md"), "# Brand New\n").unwrap();
let modified = get_modified_files(vault.to_str().unwrap()).unwrap();
assert_eq!(modified.len(), 1);
assert_eq!(modified[0].status, "untracked");
assert_eq!(modified[0].relative_path, "note/brand-new.md");
assert!(
modified[0].path.ends_with("/note/brand-new.md"),
"Full path should end with relative path: {}",
modified[0].path
);
}
#[test]

View File

@@ -6,6 +6,7 @@ use tauri::{
// Custom menu item IDs that emit events to the frontend.
const APP_SETTINGS: &str = "app-settings";
const FILE_NEW_NOTE: &str = "file-new-note";
const FILE_DAILY_NOTE: &str = "file-daily-note";
const FILE_QUICK_OPEN: &str = "file-quick-open";
const FILE_SAVE: &str = "file-save";
const FILE_CLOSE_TAB: &str = "file-close-tab";
@@ -26,6 +27,7 @@ const VIEW_GO_FORWARD: &str = "view-go-forward";
const CUSTOM_IDS: &[&str] = &[
APP_SETTINGS,
FILE_NEW_NOTE,
FILE_DAILY_NOTE,
FILE_QUICK_OPEN,
FILE_SAVE,
FILE_CLOSE_TAB,
@@ -75,6 +77,10 @@ fn build_file_menu(app: &App) -> MenuResult {
.id(FILE_NEW_NOTE)
.accelerator("CmdOrCtrl+N")
.build(app)?;
let daily_note = MenuItemBuilder::new("Open Today's Note")
.id(FILE_DAILY_NOTE)
.accelerator("CmdOrCtrl+J")
.build(app)?;
let quick_open = MenuItemBuilder::new("Quick Open")
.id(FILE_QUICK_OPEN)
.accelerator("CmdOrCtrl+P")
@@ -98,6 +104,7 @@ fn build_file_menu(app: &App) -> MenuResult {
Ok(SubmenuBuilder::new(app, "File")
.item(&new_note)
.item(&daily_note)
.item(&quick_open)
.separator()
.item(&save)
@@ -245,6 +252,7 @@ mod tests {
let expected = [
"app-settings",
"file-new-note",
"file-daily-note",
"file-quick-open",
"file-save",
"file-close-tab",

View File

@@ -62,6 +62,9 @@ pub struct VaultEntry {
pub color: Option<String>,
/// Display order for Type entries in sidebar (lower = higher). None = use default order.
pub order: Option<i64>,
/// Custom sidebar section label for Type entries, overriding auto-pluralization.
#[serde(rename = "sidebarLabel")]
pub sidebar_label: Option<String>,
/// Word count of the note body (excludes frontmatter and H1 title).
#[serde(rename = "wordCount")]
pub word_count: u32,
@@ -104,6 +107,8 @@ struct Frontmatter {
color: Option<String>,
#[serde(default)]
order: Option<i64>,
#[serde(rename = "sidebar label", default)]
sidebar_label: Option<String>,
}
/// Handles YAML fields that can be either a single string or a list of strings.
@@ -147,6 +152,7 @@ const SKIP_KEYS: &[&str] = &[
"icon",
"color",
"order",
"sidebar label",
];
/// Extract all wikilink-containing fields from raw YAML frontmatter.
@@ -325,6 +331,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
icon: frontmatter.icon,
color: frontmatter.color,
order: frontmatter.order,
sidebar_label: frontmatter.sidebar_label,
word_count,
outgoing_links,
})
@@ -1055,6 +1062,32 @@ References:
assert_eq!(fs::read_to_string(&path).unwrap(), content);
}
// --- sidebar_label tests ---
#[test]
fn test_parse_sidebar_label_from_type_entry() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nsidebar label: News\n---\n# News\n";
let entry = parse_test_entry(&dir, "type/news.md", content);
assert_eq!(entry.sidebar_label, Some("News".to_string()));
}
#[test]
fn test_parse_sidebar_label_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.sidebar_label, None);
}
#[test]
fn test_sidebar_label_not_in_relationships() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nsidebar label: My Series\n---\n# Series\n";
let entry = parse_test_entry(&dir, "type/series.md", content);
assert!(entry.relationships.get("sidebar label").is_none());
}
// Frontmatter update/delete tests are in frontmatter.rs
// save_image tests are in vault/image.rs
// purge_trash tests are in vault/trash.rs

View File

@@ -142,7 +142,7 @@ function App() {
// Read at callback time, so it's always current when user presses Cmd+N.
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content) })
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles })
const navHistory = useNavigationHistory()
@@ -271,6 +271,7 @@ function App() {
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
onSearch: dialogs.openSearch,
onCreateNote: notes.handleCreateNoteImmediate,
onOpenDailyNote: notes.handleOpenDailyNote,
onCreateNoteOfType: notes.handleCreateNoteImmediate,
onSave: handleSave,
onOpenSettings: dialogs.openSettings,

View File

@@ -186,8 +186,11 @@ This is a test note with some words to count.
entries={[mockEntry, referrerEntry]}
/>
)
// Backlinks section is collapsed by default, but header with count is visible
expect(screen.getByText('Backlinks (1)')).toBeInTheDocument()
// Expand to see the backlink entry
fireEvent.click(screen.getByTestId('backlinks-toggle'))
expect(screen.getByText('Referrer Note')).toBeInTheDocument()
expect(screen.getByText('1')).toBeInTheDocument() // count badge
})
it('updates backlinks reactively when outgoingLinks changes', () => {
@@ -200,7 +203,7 @@ This is a test note with some words to count.
/>
)
// Initially no backlinks — section is hidden entirely
expect(screen.queryByText('Backlinks')).not.toBeInTheDocument()
expect(screen.queryByTestId('backlinks-toggle')).not.toBeInTheDocument()
// Rerender with updated outgoingLinks (simulates adding [[Test Project]] to referrer)
rerender(
@@ -211,6 +214,8 @@ This is a test note with some words to count.
entries={[mockEntry, { ...referrerEntry, outgoingLinks: ['Test Project'] }]}
/>
)
expect(screen.getByText('Backlinks (1)')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('backlinks-toggle'))
expect(screen.getByText('Referrer Note')).toBeInTheDocument()
})
@@ -223,8 +228,7 @@ This is a test note with some words to count.
entries={[mockEntry]}
/>
)
expect(screen.queryByText('No backlinks')).not.toBeInTheDocument()
expect(screen.queryByText('Backlinks')).not.toBeInTheDocument()
expect(screen.queryByTestId('backlinks-toggle')).not.toBeInTheDocument()
})
it('navigates when a backlink is clicked', () => {
@@ -235,10 +239,10 @@ This is a test note with some words to count.
entry={mockEntry}
content={mockContent}
entries={[mockEntry, referrerEntry]}
onNavigate={onNavigate}
/>
)
fireEvent.click(screen.getByTestId('backlinks-toggle'))
fireEvent.click(screen.getByText('Referrer Note'))
expect(onNavigate).toHaveBeenCalledWith('Referrer Note')
})
@@ -605,7 +609,7 @@ Status: Active
expect(screen.getByText(/← Belongs to/i)).toBeInTheDocument()
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
// But NOT in Backlinks (even though outgoingLinks matches) — section hidden
expect(screen.queryByText('Backlinks')).not.toBeInTheDocument()
expect(screen.queryByTestId('backlinks-toggle')).not.toBeInTheDocument()
})
it('does not show self-references', () => {

View File

@@ -7,7 +7,8 @@ import { parseFrontmatter } from '../utils/frontmatter'
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel } from './InspectorPanels'
import { wikilinkTarget } from '../utils/wikilink'
import type { ReferencedByItem } from './InspectorPanels'
import { extractBacklinkContext } from '../utils/wikilinks'
import type { ReferencedByItem, BacklinkItem } from './InspectorPanels'
export type FrontmatterValue = string | number | boolean | string[] | null
@@ -26,7 +27,12 @@ interface InspectorProps {
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
}
function useBacklinks(entry: VaultEntry | null, entries: VaultEntry[], referencedBy: ReferencedByItem[]): VaultEntry[] {
function useBacklinks(
entry: VaultEntry | null,
entries: VaultEntry[],
referencedBy: ReferencedByItem[],
allContent?: Record<string, string>,
): BacklinkItem[] {
return useMemo(() => {
if (!entry) return []
const matchTargets = new Set([
@@ -37,14 +43,21 @@ function useBacklinks(entry: VaultEntry | null, entries: VaultEntry[], reference
const referencedByPaths = new Set(referencedBy.map((item) => item.entry.path))
return entries.filter((e) => {
if (e.path === entry.path) return false
if (referencedByPaths.has(e.path)) return false
return e.outgoingLinks.some((target) =>
matchTargets.has(target) || matchTargets.has(target.split('/').pop() ?? '')
)
})
}, [entry, entries, referencedBy])
return entries
.filter((e) => {
if (e.path === entry.path) return false
if (referencedByPaths.has(e.path)) return false
return e.outgoingLinks.some((target) =>
matchTargets.has(target) || matchTargets.has(target.split('/').pop() ?? '')
)
})
.map((e) => ({
entry: e,
context: allContent?.[e.path]
? extractBacklinkContext(allContent[e.path], matchTargets)
: null,
}))
}, [entry, entries, referencedBy, allContent])
}
function refsMatchTargets(refs: string[], targets: Set<string>): boolean {
@@ -109,7 +122,7 @@ export function Inspector({
onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty,
}: InspectorProps) {
const referencedBy = useReferencedBy(entry, entries)
const backlinks = useBacklinks(entry, entries, referencedBy)
const backlinks = useBacklinks(entry, entries, referencedBy, allContent)
const frontmatter = useMemo(() => parseFrontmatter(content), [content])
const typeEntryMap = useMemo(() => {
const map: Record<string, VaultEntry> = {}

View File

@@ -420,27 +420,48 @@ describe('BacklinksPanel', () => {
expect(container.innerHTML).toBe('')
})
it('renders backlink entries', () => {
const backlinks = [
makeEntry({ title: 'Referencing Note', isA: 'Note' }),
makeEntry({ title: 'Another Note', isA: 'Project', path: '/vault/project/another.md' }),
]
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
const twoBacklinks = [
{ entry: makeEntry({ title: 'Referencing Note', isA: 'Note' }), context: null },
{ entry: makeEntry({ title: 'Another Note', isA: 'Project', path: '/vault/project/another.md' }), context: null },
]
it('renders collapsed by default with count badge', () => {
render(<BacklinksPanel typeEntryMap={{}} backlinks={twoBacklinks} onNavigate={onNavigate} />)
expect(screen.getByText('Backlinks (2)')).toBeInTheDocument()
expect(screen.queryByText('Referencing Note')).not.toBeInTheDocument()
})
it('expands to show backlink entries when toggle clicked', () => {
render(<BacklinksPanel typeEntryMap={{}} backlinks={twoBacklinks} onNavigate={onNavigate} />)
fireEvent.click(screen.getByTestId('backlinks-toggle'))
expect(screen.getByText('Referencing Note')).toBeInTheDocument()
expect(screen.getByText('Another Note')).toBeInTheDocument()
})
it('navigates when clicking backlink', () => {
const backlinks = [makeEntry({ title: 'Reference' })]
const backlinks = [{ entry: makeEntry({ title: 'Reference' }), context: null }]
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
fireEvent.click(screen.getByTestId('backlinks-toggle'))
fireEvent.click(screen.getByText('Reference'))
expect(onNavigate).toHaveBeenCalledWith('Reference')
})
it('shows count when backlinks exist', () => {
const backlinks = [makeEntry(), makeEntry({ path: '/vault/b.md', title: 'B' })]
it('shows paragraph context preview when available', () => {
const backlinks = [
{ entry: makeEntry({ title: 'Referencing Note' }), context: 'This references [[My Note]] in context.' },
]
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
expect(screen.getByText('2')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('backlinks-toggle'))
expect(screen.getByText('This references [[My Note]] in context.')).toBeInTheDocument()
})
it('collapses when toggle clicked twice', () => {
const backlinks = [{ entry: makeEntry({ title: 'Note A' }), context: null }]
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
fireEvent.click(screen.getByTestId('backlinks-toggle'))
expect(screen.getByText('Note A')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('backlinks-toggle'))
expect(screen.queryByText('Note A')).not.toBeInTheDocument()
})
})

View File

@@ -2,7 +2,7 @@ import { useMemo, useCallback, useState, useRef } from 'react'
import type { ComponentType, SVGAttributes } from 'react'
import { wikilinkTarget, wikilinkDisplay } from '../utils/wikilink'
import type { VaultEntry, GitCommit } from '../types'
import { Trash, X } from '@phosphor-icons/react'
import { CaretRight, Trash, X } from '@phosphor-icons/react'
import type { ParsedFrontmatter } from '../utils/frontmatter'
import { RELATIONSHIP_KEYS, containsWikilinks } from './DynamicPropertiesPanel'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
@@ -372,21 +372,78 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
)
}
export function BacklinksPanel({ backlinks, typeEntryMap, onNavigate }: { backlinks: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>; onNavigate: (target: string) => void }) {
export interface BacklinkItem {
entry: VaultEntry
context: string | null
}
function BacklinkEntry({ entry, context, typeEntryMap, onNavigate }: {
entry: VaultEntry
context: string | null
typeEntryMap: Record<string, VaultEntry>
onNavigate: (target: string) => void
}) {
const te = typeEntryMap[entry.isA ?? '']
const isDimmed = entry.archived || entry.trashed
return (
<button
className="flex w-full cursor-pointer flex-col items-start gap-0.5 border-none bg-transparent p-0 text-left hover:opacity-80"
onClick={() => onNavigate(entry.title)}
title={entryStatusTitle(entry)}
>
<span
className="flex items-center gap-1 text-xs font-medium"
style={{ color: isDimmed ? 'var(--muted-foreground)' : getTypeColor(entry.isA, te?.color) }}
>
{entry.trashed && <Trash size={12} className="shrink-0" />}
{entry.title}
<StatusSuffix isArchived={entry.archived} isTrashed={entry.trashed} />
</span>
{context && (
<span className="line-clamp-2 text-[11px] leading-snug text-muted-foreground">
{context}
</span>
)}
</button>
)
}
export function BacklinksPanel({ backlinks, typeEntryMap, onNavigate }: {
backlinks: BacklinkItem[]
typeEntryMap: Record<string, VaultEntry>
onNavigate: (target: string) => void
}) {
const [expanded, setExpanded] = useState(false)
if (backlinks.length === 0) return null
return (
<div>
<h4 className="font-mono-overline mb-2 text-muted-foreground">
Backlinks <span className="ml-1" style={{ fontWeight: 400 }}>{backlinks.length}</span>
</h4>
<div className="flex flex-col gap-0.5">
{backlinks.map((e) => {
const te = typeEntryMap[e.isA ?? '']
return (
<LinkButton key={e.path} label={e.title} typeColor={getTypeColor(e.isA, te?.color)} isArchived={e.archived} isTrashed={e.trashed} onClick={() => onNavigate(e.title)} title={e.trashed ? 'Trashed' : e.archived ? 'Archived' : undefined} TypeIcon={getTypeIcon(e.isA, te?.icon)} />
)
})}
</div>
<button
className="font-mono-overline mb-2 flex w-full cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left text-muted-foreground hover:text-foreground"
onClick={() => setExpanded((v) => !v)}
data-testid="backlinks-toggle"
>
<CaretRight
size={12}
className="shrink-0 transition-transform"
style={{ transform: expanded ? 'rotate(90deg)' : undefined }}
/>
Backlinks ({backlinks.length})
</button>
{expanded && (
<div className="flex flex-col gap-1.5" data-testid="backlinks-list">
{backlinks.map(({ entry, context }) => (
<BacklinkEntry
key={entry.path}
entry={entry}
context={context}
typeEntryMap={typeEntryMap}
onNavigate={onNavigate}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -985,6 +985,19 @@ describe('NoteList — virtual list with large datasets', () => {
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
})
it('shows untracked (new) notes alongside modified notes in changes view', () => {
const mixedFiles = [
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
{ path: mockEntries[2].path, relativePath: 'person/matteo-cellini.md', status: 'untracked' as const },
]
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={mixedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument()
})
})
})

View File

@@ -39,6 +39,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -64,6 +65,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -89,6 +91,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -114,6 +117,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -139,6 +143,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -164,6 +169,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -189,6 +195,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -214,6 +221,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
]
@@ -432,6 +440,7 @@ describe('Sidebar', () => {
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -457,6 +466,7 @@ describe('Sidebar', () => {
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -553,7 +563,7 @@ describe('Sidebar', () => {
isA: 'Event', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
cadence: null, archived: false, trashed: true, trashedAt: 1700000000,
modifiedAt: 1700000000, createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
relationships: {}, icon: null, color: null, order: null, outgoingLinks: [],
relationships: {}, icon: null, color: null, order: null, sidebarLabel: null, outgoingLinks: [],
},
]
render(<Sidebar entries={entriesWithTrashedOnly} selection={defaultSelection} onSelect={() => {}} />)
@@ -591,6 +601,7 @@ describe('Sidebar', () => {
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
}
render(<Sidebar entries={[...mockEntries, projectTypeEntry]} selection={defaultSelection} onSelect={() => {}} />)
@@ -598,6 +609,52 @@ describe('Sidebar', () => {
const projectLabels = screen.getAllByText('Projects')
expect(projectLabels.length).toBe(1)
})
it('uses sidebarLabel from Type entry instead of auto-pluralization', () => {
const entriesWithLabel: VaultEntry[] = [
...mockEntries,
{
path: '/vault/type/news.md', filename: 'news.md', title: 'News', isA: 'Type',
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
fileSize: 200, snippet: '', wordCount: 0, relationships: {},
icon: null, color: null, order: null, sidebarLabel: 'News', outgoingLinks: [],
},
{
path: '/vault/news/breaking.md', filename: 'breaking.md', title: 'Breaking Story', isA: 'News',
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
fileSize: 300, snippet: '', wordCount: 0, relationships: {},
icon: null, color: null, order: null, sidebarLabel: null, outgoingLinks: [],
},
]
render(<Sidebar entries={entriesWithLabel} selection={defaultSelection} onSelect={() => {}} />)
// Should show "News" (custom label), not "Newses" (auto-pluralized)
expect(screen.getByText('News')).toBeInTheDocument()
expect(screen.queryByText('Newses')).not.toBeInTheDocument()
})
it('uses sidebarLabel to override built-in type label', () => {
const entriesWithBuiltInOverride: VaultEntry[] = [
...mockEntries,
{
path: '/vault/type/person.md', filename: 'person.md', title: 'Person', isA: 'Type',
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
fileSize: 200, snippet: '', wordCount: 0, relationships: {},
icon: null, color: null, order: null, sidebarLabel: 'Contacts', outgoingLinks: [],
},
]
render(<Sidebar entries={entriesWithBuiltInOverride} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('Contacts')).toBeInTheDocument()
expect(screen.queryByText('People')).not.toBeInTheDocument()
})
it('falls back to auto-pluralization when sidebarLabel is null', () => {
render(<Sidebar entries={entriesWithCustomTypes} selection={defaultSelection} onSelect={() => {}} />)
// Recipe has no sidebarLabel → should auto-pluralize to "Recipes"
expect(screen.getByText('Recipes')).toBeInTheDocument()
})
})
describe('customize section visibility', () => {
@@ -705,21 +762,21 @@ describe('Sidebar', () => {
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
wordCount: 0,
relationships: {}, icon: null, color: null, order: 5, outgoingLinks: [],
relationships: {}, icon: null, color: null, order: 5, sidebarLabel: null, outgoingLinks: [],
},
{
path: '/vault/type/topic.md', filename: 'topic.md', title: 'Topic', isA: 'Type',
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
wordCount: 0,
relationships: {}, icon: null, color: null, order: 0, outgoingLinks: [],
relationships: {}, icon: null, color: null, order: 0, sidebarLabel: null, outgoingLinks: [],
},
{
path: '/vault/type/person.md', filename: 'person.md', title: 'Person', isA: 'Type',
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
wordCount: 0,
relationships: {}, icon: null, color: null, order: 1, outgoingLinks: [],
relationships: {}, icon: null, color: null, order: 1, sidebarLabel: null, outgoingLinks: [],
},
]

View File

@@ -79,11 +79,12 @@ function buildSectionGroup(type: string, typeEntryMap: Record<string, VaultEntry
const builtIn = BUILT_IN_TYPE_MAP.get(type)
const typeEntry = typeEntryMap[type]
const customColor = typeEntry?.color ?? null
const label = typeEntry?.sidebarLabel || (builtIn?.label ?? pluralizeType(type))
if (builtIn) {
const Icon = typeEntry?.icon ? resolveIcon(typeEntry.icon) : builtIn.Icon
return { ...builtIn, Icon, customColor }
return { ...builtIn, label, Icon, customColor }
}
return { label: pluralizeType(type), type, Icon: resolveIcon(typeEntry?.icon ?? null), customColor }
return { label, type, Icon: resolveIcon(typeEntry?.icon ?? null), customColor }
}
/** Build sections dynamically from actual vault entries — only types with ≥1 active note appear */

View File

@@ -21,6 +21,7 @@ interface AppCommandsConfig {
onCommandPalette: () => void
onSearch: () => void
onCreateNote: () => void
onOpenDailyNote: () => void
onCreateNoteOfType: (type: string) => void
onSave: () => void
onOpenSettings: () => void
@@ -57,6 +58,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onCommandPalette: config.onCommandPalette,
onSearch: config.onSearch,
onCreateNote: config.onCreateNote,
onOpenDailyNote: config.onOpenDailyNote,
onSave: config.onSave,
onOpenSettings: config.onOpenSettings,
onTrashNote: config.onTrashNote,
@@ -74,6 +76,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
useMenuEvents({
onSetViewMode: config.onSetViewMode,
onCreateNote: config.onCreateNote,
onOpenDailyNote: config.onOpenDailyNote,
onQuickOpen: config.onQuickOpen,
onSave: config.onSave,
onOpenSettings: config.onOpenSettings,
@@ -112,6 +115,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onZoomReset: config.onZoomReset,
zoomLevel: config.zoomLevel,
onSelect: config.onSelect,
onOpenDailyNote: config.onOpenDailyNote,
onCloseTab: config.onCloseTab,
onGoBack: config.onGoBack,
onGoForward: config.onGoForward,

View File

@@ -21,6 +21,7 @@ function makeActions() {
onCommandPalette: vi.fn(),
onSearch: vi.fn(),
onCreateNote: vi.fn(),
onOpenDailyNote: vi.fn(),
onSave: vi.fn(),
onOpenSettings: vi.fn(),
onTrashNote: vi.fn(),
@@ -79,6 +80,13 @@ describe('useAppKeyboard', () => {
expect(actions.onCreateNote).toHaveBeenCalled()
})
it('Cmd+J triggers open daily note', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('j', { metaKey: true })
expect(actions.onOpenDailyNote).toHaveBeenCalled()
})
it('Cmd+W closes the active tab', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))

View File

@@ -6,6 +6,7 @@ interface KeyboardActions {
onCommandPalette: () => void
onSearch: () => void
onCreateNote: () => void
onOpenDailyNote: () => void
onSave: () => void
onOpenSettings: () => void
onTrashNote: (path: string) => void
@@ -60,7 +61,7 @@ function handleCmdKey(e: KeyboardEvent, keyMap: Record<string, ShortcutHandler>)
}
export function useAppKeyboard({
onQuickOpen, onCommandPalette, onSearch, onCreateNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, activeTabPathRef, handleCloseTabRef,
}: KeyboardActions) {
useEffect(() => {
@@ -73,6 +74,7 @@ export function useAppKeyboard({
k: onCommandPalette,
p: onQuickOpen,
n: onCreateNote,
j: onOpenDailyNote,
s: onSave,
',': onOpenSettings,
e: withActiveTab(onArchiveNote),
@@ -100,5 +102,5 @@ export function useAppKeyboard({
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward])
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward])
}

View File

@@ -176,6 +176,23 @@ describe('useCommandRegistry', () => {
expect(result.current.find(c => c.id === 'zoom-in')!.label).toContain('120%')
})
it('has open-daily-note command with shortcut', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const cmd = result.current.find(c => c.id === 'open-daily-note')
expect(cmd).toBeDefined()
expect(cmd!.label).toBe("Open Today's Note")
expect(cmd!.shortcut).toBe('⌘J')
expect(cmd!.group).toBe('Note')
expect(cmd!.enabled).toBe(true)
})
it('calls onOpenDailyNote when open-daily-note executes', () => {
const onOpenDailyNote = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onOpenDailyNote })))
result.current.find(c => c.id === 'open-daily-note')!.execute()
expect(onOpenDailyNote).toHaveBeenCalled()
})
describe('type-aware commands', () => {
it('generates "New [Type]" commands from vault entries', () => {
const entries = [

View File

@@ -36,6 +36,7 @@ interface CommandRegistryConfig {
onZoomReset: () => void
zoomLevel: number
onSelect: (sel: SidebarSelection) => void
onOpenDailyNote: () => void
onCloseTab: (path: string) => void
onGoBack?: () => void
onGoForward?: () => void
@@ -129,7 +130,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
onTrashNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector, onOpenVault,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onCloseTab,
onSelect, onOpenDailyNote, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,
themes, activeThemeId, onSwitchTheme, onCreateTheme,
} = config
@@ -158,6 +159,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
// Note actions (contextual)
{ id: 'create-note', label: 'Create New Note', group: 'Note', shortcut: '⌘N', keywords: ['new', 'add'], enabled: true, execute: onCreateNote },
{ id: 'open-daily-note', label: "Open Today's Note", group: 'Note', shortcut: '⌘J', keywords: ['daily', 'journal', 'today'], enabled: true, execute: onOpenDailyNote },
{ id: 'save-note', label: 'Save Note', group: 'Note', shortcut: '⌘S', keywords: ['write'], enabled: hasActiveNote, execute: onSave },
{ id: 'close-tab', label: 'Close Tab', group: 'Note', shortcut: '⌘W', keywords: [], enabled: hasActiveNote, execute: () => { if (activeTabPath) onCloseTab(activeTabPath) } },
{ id: 'trash-note', label: 'Trash Note', group: 'Note', shortcut: '⌘⌫', keywords: ['delete', 'remove'], enabled: hasActiveNote, execute: () => { if (activeTabPath) onTrashNote(activeTabPath) } },
@@ -198,7 +200,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
onTrashNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector, onOpenVault,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onCloseTab,
onSelect, onOpenDailyNote, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenVault,
])

View File

@@ -5,6 +5,7 @@ function makeHandlers(): MenuEventHandlers {
return {
onSetViewMode: vi.fn(),
onCreateNote: vi.fn(),
onOpenDailyNote: vi.fn(),
onQuickOpen: vi.fn(),
onSave: vi.fn(),
onOpenSettings: vi.fn(),
@@ -49,6 +50,12 @@ describe('dispatchMenuEvent', () => {
expect(h.onCreateNote).toHaveBeenCalled()
})
it('file-daily-note triggers open daily note', () => {
const h = makeHandlers()
dispatchMenuEvent('file-daily-note', h)
expect(h.onOpenDailyNote).toHaveBeenCalled()
})
it('file-quick-open triggers quick open', () => {
const h = makeHandlers()
dispatchMenuEvent('file-quick-open', h)

View File

@@ -5,6 +5,7 @@ import type { ViewMode } from './useViewMode'
export interface MenuEventHandlers {
onSetViewMode: (mode: ViewMode) => void
onCreateNote: () => void
onOpenDailyNote: () => void
onQuickOpen: () => void
onSave: () => void
onOpenSettings: () => void
@@ -29,10 +30,11 @@ const VIEW_MODE_MAP: Record<string, ViewMode> = {
'view-all': 'all',
}
type SimpleHandler = 'onCreateNote' | 'onQuickOpen' | 'onSave' | 'onOpenSettings' | 'onToggleInspector' | 'onCommandPalette' | 'onZoomIn' | 'onZoomOut' | 'onZoomReset' | 'onSearch'
type SimpleHandler = 'onCreateNote' | 'onOpenDailyNote' | 'onQuickOpen' | 'onSave' | 'onOpenSettings' | 'onToggleInspector' | 'onCommandPalette' | 'onZoomIn' | 'onZoomOut' | 'onZoomReset' | 'onSearch'
const SIMPLE_EVENT_MAP: Record<string, SimpleHandler> = {
'file-new-note': 'onCreateNote',
'file-daily-note': 'onOpenDailyNote',
'file-quick-open': 'onQuickOpen',
'file-save': 'onSave',
'app-settings': 'onOpenSettings',

View File

@@ -12,6 +12,10 @@ import {
resolveNewNote,
resolveNewType,
frontmatterToEntryPatch,
todayDateString,
buildDailyNoteContent,
resolveDailyNote,
findDailyNote,
useNoteActions,
} from './useNoteActions'
import type { NoteActionsConfig } from './useNoteActions'
@@ -282,6 +286,75 @@ describe('frontmatterToEntryPatch', () => {
})
})
describe('todayDateString', () => {
it('returns date in YYYY-MM-DD format', () => {
const result = todayDateString()
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/)
})
})
describe('buildDailyNoteContent', () => {
it('generates frontmatter with Journal type and date', () => {
const content = buildDailyNoteContent('2026-03-02')
expect(content).toContain('type: Journal')
expect(content).toContain('date: 2026-03-02')
expect(content).toContain('title: 2026-03-02')
})
it('includes Intentions and Reflections sections', () => {
const content = buildDailyNoteContent('2026-03-02')
expect(content).toContain('## Intentions')
expect(content).toContain('## Reflections')
})
it('includes H1 heading with the date', () => {
const content = buildDailyNoteContent('2026-03-02')
expect(content).toContain('# 2026-03-02')
})
})
describe('resolveDailyNote', () => {
it('creates entry in journal folder with date as filename', () => {
const { entry } = resolveDailyNote('2026-03-02')
expect(entry.path).toBe('/Users/luca/Laputa/journal/2026-03-02.md')
expect(entry.filename).toBe('2026-03-02.md')
expect(entry.title).toBe('2026-03-02')
expect(entry.isA).toBe('Journal')
expect(entry.status).toBeNull()
})
it('returns content with daily note template', () => {
const { content } = resolveDailyNote('2026-03-02')
expect(content).toContain('type: Journal')
expect(content).toContain('## Intentions')
})
})
describe('findDailyNote', () => {
it('finds entry by journal path suffix', () => {
const entries = [
makeEntry({ path: '/Users/luca/Laputa/journal/2026-03-02.md' }),
makeEntry({ path: '/Users/luca/Laputa/note/other.md' }),
]
const found = findDailyNote(entries, '2026-03-02')
expect(found).toBeDefined()
expect(found!.path).toBe('/Users/luca/Laputa/journal/2026-03-02.md')
})
it('returns undefined when no matching entry exists', () => {
const entries = [makeEntry({ path: '/Users/luca/Laputa/note/other.md' })]
expect(findDailyNote(entries, '2026-03-02')).toBeUndefined()
})
it('works with different vault paths', () => {
const entries = [
makeEntry({ path: '/other/vault/journal/2026-03-02.md' }),
]
const found = findDailyNote(entries, '2026-03-02')
expect(found).toBeDefined()
})
})
describe('useNoteActions hook', () => {
const addEntry = vi.fn()
const removeEntry = vi.fn()
@@ -461,6 +534,35 @@ describe('useNoteActions hook', () => {
expect(setToastMessage).toHaveBeenCalledWith('Property updated')
})
it('handleOpenDailyNote creates a new daily note when none exists', () => {
const { result } = renderHook(() => useNoteActions(makeConfig()))
act(() => {
result.current.handleOpenDailyNote()
})
expect(addEntry).toHaveBeenCalledTimes(1)
const [createdEntry] = addEntry.mock.calls[0]
expect(createdEntry.isA).toBe('Journal')
expect(createdEntry.path).toContain('journal/')
expect(createdEntry.path).toMatch(/journal\/\d{4}-\d{2}-\d{2}\.md$/)
})
it('handleOpenDailyNote opens existing daily note instead of creating', async () => {
const today = todayDateString()
const existing = makeEntry({ path: `/Users/luca/Laputa/journal/${today}.md`, title: today })
const { result } = renderHook(() => useNoteActions(makeConfig([existing])))
await act(async () => {
result.current.handleOpenDailyNote()
await new Promise((r) => setTimeout(r, 0))
})
// Should open existing note, not create a new one
expect(addEntry).not.toHaveBeenCalled()
expect(result.current.activeTabPath).toBe(`/Users/luca/Laputa/journal/${today}.md`)
})
describe('pending save lifecycle', () => {
it('createAndPersist calls addPendingSave on start (non-Tauri)', async () => {
const addPendingSave = vi.fn()
@@ -535,6 +637,38 @@ describe('useNoteActions hook', () => {
expect(trackUnsaved).toHaveBeenCalledWith(expect.stringContaining('note/untitled-note.md'))
expect(markContentPending).toHaveBeenCalledWith(expect.stringContaining('note/untitled-note.md'), expect.stringContaining('Untitled note'))
})
it('calls onNewNotePersisted after successful disk write (non-Tauri)', async () => {
const onNewNotePersisted = vi.fn()
const config = makeConfig()
config.onNewNotePersisted = onNewNotePersisted
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
result.current.handleCreateNote('Persist Callback', 'Note')
await new Promise((r) => setTimeout(r, 0))
})
expect(onNewNotePersisted).toHaveBeenCalledTimes(1)
})
it('does not call onNewNotePersisted when disk write fails (Tauri)', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(invoke).mockRejectedValueOnce(new Error('disk full'))
const onNewNotePersisted = vi.fn()
const config = makeConfig()
config.onNewNotePersisted = onNewNotePersisted
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
result.current.handleCreateNote('Fail Persist', 'Note')
await new Promise((r) => setTimeout(r, 0))
})
expect(onNewNotePersisted).not.toHaveBeenCalled()
})
})
describe('optimistic error recovery (Tauri mode)', () => {

View File

@@ -33,6 +33,8 @@ export interface NoteActionsConfig {
unsavedPaths?: Set<string>
/** Called when an unsaved note is created so the save system can buffer its initial content. */
markContentPending?: (path: string, content: string) => void
/** Called after a new note is persisted to disk (e.g. to refresh git status for Changes view). */
onNewNotePersisted?: () => void
}
async function performRename(
@@ -70,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: [],
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null,
}
}
@@ -122,9 +124,10 @@ const TYPE_FOLDER_MAP: Record<string, string> = {
Note: 'note', Project: 'project', Experiment: 'experiment',
Responsibility: 'responsibility', Procedure: 'procedure',
Person: 'person', Event: 'event', Topic: 'topic',
Journal: 'journal',
}
const NO_STATUS_TYPES = new Set(['Topic', 'Person'])
const NO_STATUS_TYPES = new Set(['Topic', 'Person', 'Journal'])
const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
type: { isA: null }, is_a: { isA: null }, status: { status: null }, color: { color: null },
@@ -177,6 +180,36 @@ export function resolveNewType(typeName: string): { entry: VaultEntry; content:
return { entry, content: `---\ntype: Type\n---\n\n# ${typeName}\n\n` }
}
export function todayDateString(): string {
return new Date().toISOString().split('T')[0]
}
export function buildDailyNoteContent(date: string): string {
const lines = ['---', `title: ${date}`, 'type: Journal', `date: ${date}`, '---']
return `${lines.join('\n')}\n\n# ${date}\n\n## Intentions\n\n\n\n## Reflections\n\n`
}
export function resolveDailyNote(date: string): { entry: VaultEntry; content: string } {
const entry = buildNewEntry({ path: `/Users/luca/Laputa/journal/${date}.md`, slug: date, title: date, type: 'Journal', status: null })
return { entry, content: buildDailyNoteContent(date) }
}
export function findDailyNote(entries: VaultEntry[], date: string): VaultEntry | undefined {
const suffix = `journal/${date}.md`
return entries.find(e => e.path.endsWith(suffix))
}
type PersistFn = (resolved: { entry: VaultEntry; content: string }) => void
/** Open today's daily note: navigate to it if it exists, or create + persist a new one. */
function openDailyNote(entries: VaultEntry[], selectNote: (e: VaultEntry) => void, persist: PersistFn): void {
const date = todayDateString()
const existing = findDailyNote(entries, date)
if (existing) selectNote(existing)
else persist(resolveDailyNote(date))
signalFocusEditor()
}
function findWikilinkTarget(entries: VaultEntry[], target: string): VaultEntry | undefined {
const targetLower = target.toLowerCase()
const targetAsWords = target.split('/').pop()?.replace(/-/g, ' ').toLowerCase() ?? targetLower
@@ -199,13 +232,14 @@ interface PersistCallbacks {
onFail: (p: string) => void
onStart?: (p: string) => void
onEnd?: (p: string) => void
onPersisted?: () => void
}
/** Persist to disk; track pending state via onStart/onEnd; revert on failure. */
function persistOptimistic(path: string, content: string, cbs: PersistCallbacks): void {
cbs.onStart?.(path)
persistNewNote(path, content)
.then(() => cbs.onEnd?.(path))
.then(() => { cbs.onEnd?.(path); cbs.onPersisted?.() })
.catch(() => { cbs.onEnd?.(path); cbs.onFail(path) })
}
@@ -296,13 +330,17 @@ export function useNoteActions(config: NoteActionsConfig) {
onFail: revertOptimisticNote,
onStart: addPendingSave,
onEnd: removePendingSave,
onPersisted: config.onNewNotePersisted,
}
const pendingNamesRef = useRef<Set<string>>(new Set())
const handleCreateNote = useCallback((title: string, type: string) => {
createAndPersist(resolveNewNote(title, type), addEntry, openTabWithContent, persistCbs)
}, [openTabWithContent, addEntry, revertOptimisticNote, addPendingSave, removePendingSave]) // eslint-disable-line react-hooks/exhaustive-deps -- persistCbs is stable when deps are
const persistNew: PersistFn = useCallback(
(resolved) => createAndPersist(resolved, addEntry, openTabWithContent, persistCbs),
[openTabWithContent, addEntry, revertOptimisticNote, addPendingSave, removePendingSave], // eslint-disable-line react-hooks/exhaustive-deps -- persistCbs is stable when deps are
)
const handleCreateNote = useCallback((title: string, type: string) => persistNew(resolveNewNote(title, type)), [persistNew])
const handleCreateNoteImmediate = useCallback((type?: string) => {
const noteType = type || 'Note'
@@ -319,19 +357,16 @@ export function useNoteActions(config: NoteActionsConfig) {
/** Close tab and discard entry+unsaved state if the note was never persisted. */
const handleCloseTabWithCleanup = useCallback((path: string) => {
if (unsavedPathsRef.current?.has(path)) {
removeEntry(path)
config.clearUnsaved?.(path)
}
if (unsavedPathsRef.current?.has(path)) { removeEntry(path); config.clearUnsaved?.(path) }
handleCloseTab(path)
}, [handleCloseTab, removeEntry, config.clearUnsaved]) // eslint-disable-line react-hooks/exhaustive-deps -- ref access is stable
// Keep handleCloseTabRef in sync so Cmd+W and menu events also clean up unsaved notes.
useEffect(() => { handleCloseTabRef.current = handleCloseTabWithCleanup })
const handleCreateType = useCallback((typeName: string) => {
createAndPersist(resolveNewType(typeName), addEntry, openTabWithContent, persistCbs)
}, [openTabWithContent, addEntry, revertOptimisticNote, addPendingSave, removePendingSave]) // eslint-disable-line react-hooks/exhaustive-deps -- persistCbs is stable when deps are
const handleOpenDailyNote = useCallback(() => openDailyNote(entries, handleSelectNote, persistNew), [entries, handleSelectNote, persistNew])
const handleCreateType = useCallback((typeName: string) => persistNew(resolveNewType(typeName)), [persistNew])
const fmCallbacks = { updateTab: updateTabContent, updateEntry, toast: setToastMessage }
@@ -368,6 +403,7 @@ export function useNoteActions(config: NoteActionsConfig) {
handleNavigateWikilink,
handleCreateNote,
handleCreateNoteImmediate,
handleOpenDailyNote,
handleCreateType,
handleUpdateFrontmatter: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
handleDeleteProperty: useCallback((path: string, key: string) => runFrontmatterOp('delete', path, key), [runFrontmatterOp]),

View File

@@ -35,6 +35,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['quarter/q1-2026', 'topic/software-development', 'person/matteo-cellini', 'person/maria-bianchi', 'person/marco-verdi'],
},
{
@@ -69,6 +70,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['essay/on-writing-well', 'essay/engineering-leadership-101', 'essay/ai-agents-primer', 'topic/growth', 'topic/writing'],
},
{
@@ -97,6 +99,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['person/matteo-cellini'],
},
{
@@ -125,6 +128,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['responsibility/grow-newsletter'],
},
{
@@ -153,6 +157,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['responsibility/manage-sponsorships'],
},
{
@@ -182,6 +187,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['topic/trading', 'topic/algorithmic-trading', 'data/ema200-backtest-results'],
},
{
@@ -211,6 +217,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['project/26q1-laputa-app', 'topic/growth', 'topic/ads'],
},
{
@@ -239,6 +246,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['project/26q1-laputa-app'],
},
{
@@ -266,6 +274,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -293,6 +302,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -320,6 +330,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -347,6 +358,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -375,6 +387,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['project/26q1-laputa-app', 'person/matteo-cellini'],
},
{
@@ -403,6 +416,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -431,6 +445,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -459,6 +474,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['responsibility/grow-newsletter'],
},
{
@@ -488,6 +504,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['responsibility/grow-newsletter', 'topic/software-development'],
},
{
@@ -516,6 +533,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['responsibility/grow-newsletter'],
},
// --- Type documents ---
@@ -542,6 +560,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 0,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -567,6 +586,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 1,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -592,6 +612,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 2,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -617,6 +638,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 3,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -642,6 +664,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 4,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -667,6 +690,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 5,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -692,6 +716,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 6,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -717,6 +742,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 7,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -742,6 +768,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 8,
sidebarLabel: null,
outgoingLinks: [],
},
// --- Custom type documents ---
@@ -768,6 +795,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: 'cooking-pot',
color: 'orange',
order: 9,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -793,6 +821,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: 'book-open',
color: 'green',
order: 10,
sidebarLabel: null,
outgoingLinks: [],
},
// --- Instances of custom types ---
@@ -821,6 +850,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -848,6 +878,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
// --- Trashed entries ---
@@ -877,6 +908,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -904,6 +936,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -932,6 +965,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
// --- Archived entries ---
@@ -952,6 +986,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
modifiedAt: now - 86400 * 120,
createdAt: now - 86400 * 200,
@@ -980,6 +1015,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
modifiedAt: now - 86400 * 90,
createdAt: now - 86400 * 150,
@@ -1041,7 +1077,8 @@ function generateBulkEntries(count: number): VaultEntry[] {
icon: null,
color: null,
order: null,
outgoingLinks: Array.from({ length: i % 8 }, (_, j) => `note/link-target-${(i + j) % 50}`),
outgoingLinks: Array.from({ length: i % 8 }, (_j, j) => `note/link-target-${(i + j) % 50}`),
sidebarLabel: null,
})
}
return entries

View File

@@ -25,6 +25,8 @@ export interface VaultEntry {
color: string | null
/** Display order for Type entries in sidebar (lower = higher). null = use default order. */
order: number | null
/** Custom sidebar section label for Type entries, overriding auto-pluralization. */
sidebarLabel: string | null
/** All wikilink targets found in the note content. Extracted from [[target]] patterns. */
outgoingLinks: string[]
}

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks, splitFrontmatter, countWords, extractOutgoingLinks } from './wikilinks'
import { preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks, splitFrontmatter, countWords, extractOutgoingLinks, extractBacklinkContext } from './wikilinks'
interface TestBlock {
type?: string
@@ -421,3 +421,67 @@ describe('extractOutgoingLinks', () => {
expect(extractOutgoingLinks(content)).toEqual(['Valid'])
})
})
describe('extractBacklinkContext', () => {
const targets = new Set(['My Note'])
it('extracts the paragraph containing a matching wikilink', () => {
const content = '---\ntitle: Test\n---\n\n# Test\n\nFirst paragraph.\n\nThis references [[My Note]] in context.\n\nThird paragraph.'
const result = extractBacklinkContext(content, targets)
expect(result).toBe('This references [[My Note]] in context.')
})
it('returns null when no matching wikilink found', () => {
const content = '---\ntitle: Test\n---\n\n# Test\n\nNo links here.'
expect(extractBacklinkContext(content, targets)).toBeNull()
})
it('returns null for empty content', () => {
expect(extractBacklinkContext('', targets)).toBeNull()
})
it('truncates long paragraphs with ellipsis', () => {
const longPara = 'A'.repeat(100) + ' [[My Note]] ' + 'B'.repeat(100)
const content = `---\ntitle: X\n---\n\n# X\n\n${longPara}`
const result = extractBacklinkContext(content, targets, 50)
expect(result).not.toBeNull()
expect(result!.length).toBe(50)
expect(result!.endsWith('\u2026')).toBe(true)
})
it('matches path-based wikilinks via last segment', () => {
const content = '---\ntitle: Test\n---\n\n# Test\n\nSee [[project/My Note]] for details.'
const result = extractBacklinkContext(content, targets)
expect(result).toBe('See [[project/My Note]] for details.')
})
it('matches aliased wikilinks [[target|display]]', () => {
const content = '---\ntitle: Test\n---\n\n# Test\n\nCheck [[My Note|the note]] here.'
const result = extractBacklinkContext(content, targets)
expect(result).toBe('Check [[My Note|the note]] here.')
})
it('skips frontmatter and title heading', () => {
const content = '---\ntitle: My Note\n---\n\n# My Note\n\nBody text with [[My Note]] link.'
const result = extractBacklinkContext(content, targets)
expect(result).toBe('Body text with [[My Note]] link.')
})
it('collapses internal whitespace', () => {
const content = '---\ntitle: X\n---\n\n# X\n\nMultiple spaces\nand newline with [[My Note]] link.'
const result = extractBacklinkContext(content, targets)
expect(result).toBe('Multiple spaces and newline with [[My Note]] link.')
})
it('returns first matching paragraph when multiple match', () => {
const content = '---\ntitle: X\n---\n\n# X\n\nFirst [[My Note]] mention.\n\nSecond [[My Note]] mention.'
const result = extractBacklinkContext(content, targets)
expect(result).toBe('First [[My Note]] mention.')
})
it('does not return paragraph when maxLength is respected', () => {
const content = '---\ntitle: X\n---\n\n# X\n\nShort [[My Note]].'
const result = extractBacklinkContext(content, targets, 200)
expect(result).toBe('Short [[My Note]].')
})
})

View File

@@ -120,6 +120,40 @@ export function extractOutgoingLinks(content: string): string[] {
return [...new Set(links)].sort()
}
/** Extract the paragraph surrounding a [[target]] wikilink match from note content.
* Searches for any target in the set, returns the first matching paragraph trimmed
* to a max length. Returns null if no match found. */
export function extractBacklinkContext(
content: string,
matchTargets: Set<string>,
maxLength = 120,
): string | null {
const [, body] = splitFrontmatter(content)
// Remove the H1 title line
const withoutTitle = body.replace(/^\s*# [^\n]+\n?/, '')
const paragraphs = withoutTitle.split(/\n{2,}/)
for (const para of paragraphs) {
const trimmed = para.trim()
if (!trimmed) continue
// Check if this paragraph contains a wikilink matching any target
const re = /\[\[([^\]]+)\]\]/g
let match
while ((match = re.exec(trimmed)) !== null) {
const inner = match[1]
const pipeIdx = inner.indexOf('|')
const target = pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner
if (matchTargets.has(target) || matchTargets.has(target.split('/').pop() ?? '')) {
// Collapse whitespace and truncate
const flat = trimmed.replace(/\s+/g, ' ')
if (flat.length <= maxLength) return flat
return flat.slice(0, maxLength - 1) + '\u2026'
}
}
}
return null
}
export function countWords(content: string): number {
const [, body] = splitFrontmatter(content)
const withoutTitle = body.replace(/^\s*# [^\n]+\n?/, '')