Compare commits
4 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cdc534c01 | ||
|
|
1a93acdce7 | ||
|
|
9c8f7907b8 | ||
|
|
8d9862ffef |
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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> = {}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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]].')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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?/, '')
|
||||
|
||||
Reference in New Issue
Block a user