feat: standardize canonical wikilink targets

This commit is contained in:
lucaronin
2026-04-10 19:57:21 +02:00
parent a44dd41f95
commit 5ebffc447b
19 changed files with 792 additions and 348 deletions

View File

@@ -1,5 +1,6 @@
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use walkdir::WalkDir;
@@ -48,13 +49,13 @@ fn is_replaceable_md_file(path: &Path, exclude: &Path) -> bool {
}
/// Replace wikilink references in a single file's content. Returns updated content if changed.
fn replace_wikilinks_in_content(content: &str, re: &Regex, new_title: &str) -> Option<String> {
fn replace_wikilinks_in_content(content: &str, re: &Regex, new_target: &str) -> Option<String> {
if !re.is_match(content) {
return None;
}
let replaced = re.replace_all(content, |caps: &regex::Captures| match caps.get(1) {
Some(pipe) => format!("[[{}{}]]", new_title, pipe.as_str()),
None => format!("[[{}]]", new_title),
Some(pipe) => format!("[[{}{}]]", new_target, pipe.as_str()),
None => format!("[[{}]]", new_target),
});
if replaced != content {
Some(replaced.into_owned())
@@ -63,15 +64,6 @@ fn replace_wikilinks_in_content(content: &str, re: &Regex, new_title: &str) -> O
}
}
/// Parameters for a vault-wide wikilink replacement.
struct WikilinkReplacement<'a> {
vault_path: &'a Path,
old_title: &'a str,
new_title: &'a str,
old_path_stem: &'a str,
exclude_path: &'a Path,
}
/// Collect all .md file paths in vault eligible for wikilink replacement.
fn collect_md_files(vault_path: &Path, exclude: &Path) -> Vec<std::path::PathBuf> {
WalkDir::new(vault_path)
@@ -83,16 +75,35 @@ fn collect_md_files(vault_path: &Path, exclude: &Path) -> Vec<std::path::PathBuf
.collect()
}
fn unique_wikilink_targets<'a>(targets: Vec<&'a str>) -> Vec<&'a str> {
let mut seen = HashSet::new();
targets
.into_iter()
.filter(|target| !target.is_empty())
.filter(|target| seen.insert(*target))
.collect()
}
fn collect_legacy_wikilink_targets<'a>(old_title: &'a str, old_path_stem: &'a str) -> Vec<&'a str> {
let old_filename_stem = old_path_stem.rsplit('/').next().unwrap_or(old_path_stem);
unique_wikilink_targets(vec![old_title, old_path_stem, old_filename_stem])
}
/// Replace wiki link references across all vault markdown files.
fn update_wikilinks_in_vault(params: &WikilinkReplacement) -> usize {
let re = match build_wikilink_pattern(&[params.old_title, params.old_path_stem]) {
fn update_wikilinks_in_vault(
vault_path: &Path,
old_targets: &[&str],
new_target: &str,
exclude_path: &Path,
) -> usize {
let re = match build_wikilink_pattern(old_targets) {
Some(r) => r,
None => return 0,
};
replace_wikilinks_in_files(
collect_md_files(params.vault_path, params.exclude_path),
collect_md_files(vault_path, exclude_path),
&re,
params.new_title,
new_target,
)
}
@@ -119,23 +130,6 @@ fn rewrite_wikilinks_in_file(path: &Path, re: &Regex, replacement: &str) -> bool
fs::write(path, &new_content).is_ok()
}
fn update_path_wikilinks_in_vault(
vault_path: &Path,
old_path_stem: &str,
new_path_stem: &str,
exclude_path: &Path,
) -> usize {
let re = match build_wikilink_pattern(&[old_path_stem]) {
Some(r) => r,
None => return 0,
};
replace_wikilinks_in_files(
collect_md_files(vault_path, exclude_path),
&re,
new_path_stem,
)
}
/// Extract the value of the `title:` frontmatter field from raw content.
fn extract_fm_title_value(content: &str) -> Option<String> {
if !content.starts_with("---\n") {
@@ -288,13 +282,9 @@ pub fn rename_note(
// Update wikilinks across the vault
let vault_prefix = format!("{}/", vault.to_string_lossy());
let old_path_stem = to_path_stem(old_path, &vault_prefix);
let updated_files = update_wikilinks_in_vault(&WikilinkReplacement {
vault_path: vault,
old_title,
new_title,
old_path_stem,
exclude_path: &new_file,
});
let new_path_stem = to_path_stem(&new_path_str, &vault_prefix).to_string();
let old_targets = collect_legacy_wikilink_targets(old_title, old_path_stem);
let updated_files = update_wikilinks_in_vault(vault, &old_targets, &new_path_stem, &new_file);
Ok(RenameResult {
new_path: new_path_str,
@@ -320,6 +310,10 @@ pub fn rename_note_filename(
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let content =
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
let fm_title = extract_fm_title_value(&content);
let old_title = super::extract_title(fm_title.as_deref(), &content, &old_filename);
let new_filename = format!("{}.md", normalized_stem);
if old_filename == new_filename {
@@ -350,8 +344,8 @@ pub fn rename_note_filename(
let old_path_stem = to_path_stem(old_path, &vault_prefix);
let new_path = new_file.to_string_lossy().to_string();
let new_path_stem = to_path_stem(&new_path, &vault_prefix).to_string();
let updated_files =
update_path_wikilinks_in_vault(vault, old_path_stem, &new_path_stem, &new_file);
let old_targets = collect_legacy_wikilink_targets(&old_title, old_path_stem);
let updated_files = update_wikilinks_in_vault(vault, &old_targets, &new_path_stem, &new_file);
Ok(RenameResult {
new_path,
@@ -460,22 +454,12 @@ pub fn update_wikilinks_for_renames(
.strip_suffix(".md")
.unwrap_or(&rename.new_path);
let old_filename_stem = old_stem.split('/').next_back().unwrap_or(old_stem);
let new_filename_stem = new_stem.split('/').next_back().unwrap_or(new_stem);
// Build title from filename stem (kebab-case → Title Case)
let old_title = super::parsing::slug_to_title(old_filename_stem);
let new_title = super::parsing::slug_to_title(new_filename_stem);
// The new file is the exclude target (don't rewrite wikilinks inside the renamed file itself)
let new_file = vault.join(&rename.new_path);
let updated = update_wikilinks_in_vault(&WikilinkReplacement {
vault_path: vault,
old_title: &old_title,
new_title: &new_title,
old_path_stem: old_filename_stem,
exclude_path: &new_file,
});
let old_targets = collect_legacy_wikilink_targets(&old_title, old_stem);
let updated = update_wikilinks_in_vault(vault, &old_targets, new_stem, &new_file);
total_updated += updated;
}
@@ -565,11 +549,11 @@ mod tests {
assert_eq!(result.updated_files, 2);
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
assert!(other_content.contains("[[Sprint Retrospective]]"));
assert!(other_content.contains("[[note/sprint-retrospective]]"));
assert!(!other_content.contains("[[Weekly Review]]"));
let project_content = fs::read_to_string(vault.join("project/my-project.md")).unwrap();
assert!(project_content.contains("[[Sprint Retrospective]]"));
assert!(project_content.contains("[[note/sprint-retrospective]]"));
}
#[test]
@@ -629,7 +613,29 @@ mod tests {
assert_eq!(result.updated_files, 1);
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
assert!(ref_content.contains("[[Sprint Retro|my review]]"));
assert!(ref_content.contains("[[note/sprint-retro|my review]]"));
}
#[test]
fn test_rename_note_updates_filename_only_wikilinks_to_canonical_path() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "note/weekly-review.md", "# Weekly Review\n");
create_test_file(vault, "note/ref.md", "# Ref\n\nSee [[weekly-review]] for info.\n");
let old_path = vault.join("note/weekly-review.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Sprint Retro",
None,
)
.unwrap();
assert_eq!(result.updated_files, 1);
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
assert!(ref_content.contains("[[note/sprint-retro]]"));
assert!(!ref_content.contains("[[weekly-review]]"));
}
#[test]
@@ -838,7 +844,7 @@ mod tests {
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
assert!(ref_content.contains("[[note/manual-name]]"));
assert!(ref_content.contains("[[Project Kickoff]]"));
assert!(!ref_content.contains("[[Project Kickoff]]"));
assert!(!ref_content.contains("[[note/project-kickoff]]"));
}
@@ -896,11 +902,11 @@ mod tests {
assert!(!vault.join("note/weekly-review.md").exists());
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
assert!(other_content.contains("[[Sprint Retrospective]]"));
assert!(other_content.contains("[[note/sprint-retrospective]]"));
assert!(!other_content.contains("[[Weekly Review]]"));
let project_content = fs::read_to_string(vault.join("project/my-project.md")).unwrap();
assert!(project_content.contains("[[Sprint Retrospective]]"));
assert!(project_content.contains("[[note/sprint-retrospective]]"));
}
#[test]
@@ -930,7 +936,7 @@ mod tests {
assert_eq!(result.updated_files, 1);
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
assert!(other_content.contains("[[Sprint Retrospective]]"));
assert!(other_content.contains("[[note/sprint-retrospective]]"));
}
#[test]

View File

@@ -413,7 +413,7 @@ describe('wikilink autocomplete', () => {
expect(items.length).toBeGreaterThan(0)
items[0].onItemClick()
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
{ type: 'wikilink', props: { target: 'vault/project/test|Alpha Project' } },
{ type: 'wikilink', props: { target: 'vault/project/test' } },
' ',
])
})
@@ -566,7 +566,7 @@ describe('person @mention autocomplete', () => {
expect(items.length).toBeGreaterThan(0)
items[0].onItemClick()
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
{ type: 'wikilink', props: { target: 'vault/person/matteo-cellini|Matteo Cellini' } },
{ type: 'wikilink', props: { target: 'vault/person/matteo-cellini' } },
' ',
])
})

View File

@@ -75,6 +75,7 @@ export function EditorRightPanel({
content={inspectorContent}
entries={entries}
gitHistory={gitHistory}
vaultPath={vaultPath}
onNavigate={onNavigateWikilink}
onViewCommitDiff={onViewCommitDiff}
onUpdateFrontmatter={onUpdateFrontmatter}

View File

@@ -24,6 +24,7 @@ interface InspectorProps {
content: string | null
entries: VaultEntry[]
gitHistory: GitCommit[]
vaultPath?: string
onNavigate: (target: string) => void
onViewCommitDiff?: (commitHash: string) => void
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
@@ -41,6 +42,7 @@ export function Inspector({
content,
entries,
gitHistory,
vaultPath,
onNavigate,
onViewCommitDiff,
onUpdateFrontmatter,
@@ -96,6 +98,7 @@ export function Inspector({
frontmatter={frontmatter}
entries={entries}
typeEntryMap={typeEntryMap}
vaultPath={vaultPath}
onNavigate={onNavigate}
onAddProperty={onAddProperty ? handleAddProperty : undefined}
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}

View File

@@ -179,7 +179,7 @@ describe('DynamicRelationshipsPanel', () => {
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Related to' } })
fireEvent.change(screen.getByPlaceholderText('Note title'), { target: { value: 'AI' } })
fireEvent.click(screen.getByTestId('submit-add-relationship'))
expect(onAddProperty).toHaveBeenCalledWith('Related to', '[[AI]]')
expect(onAddProperty).toHaveBeenCalledWith('Related to', '[[topic/ai]]')
})
it('cancels add relationship form', () => {
@@ -427,14 +427,14 @@ describe('DynamicRelationshipsPanel', () => {
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.change(input, { target: { value: 'AI' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[AI]]'])
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[topic/ai]]'])
})
it('does not add duplicate refs', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ 'Belongs to': ['[[AI]]'] }}
frontmatter={{ 'Belongs to': ['[[topic/ai]]'] }}
entries={entries}
onNavigate={onNavigate}
onUpdateProperty={onUpdateProperty}
@@ -533,7 +533,7 @@ describe('DynamicRelationshipsPanel', () => {
fireEvent.click(screen.getByTestId('create-and-open-option'))
expect(onCreateAndOpenNote).toHaveBeenCalledWith('Brand New Note')
await vi.waitFor(() => {
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[Brand New Note]]'])
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[brand-new-note]]'])
})
})
@@ -647,7 +647,7 @@ describe('DynamicRelationshipsPanel', () => {
fireEvent.click(screen.getByTestId('create-and-open-option'))
expect(onCreateAndOpenNote).toHaveBeenCalledWith('New Person')
await vi.waitFor(() => {
expect(onAddProperty).toHaveBeenCalledWith('Mentions', '[[New Person]]')
expect(onAddProperty).toHaveBeenCalledWith('Mentions', '[[new-person]]')
})
})
})

View File

@@ -1,7 +1,6 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, act } from '@testing-library/react'
import { RawEditorView } from './RawEditorView'
import { extractWikilinkQuery, detectYamlError } from '../utils/rawEditorUtils'
function entry(title: string, path = `/vault/note/${title}.md`) {
return {
@@ -23,61 +22,6 @@ const defaultProps = {
onSave: vi.fn(),
}
describe('extractWikilinkQuery', () => {
it('returns null when no [[ trigger', () => {
expect(extractWikilinkQuery('hello world', 5)).toBeNull()
})
it('returns empty string immediately after [[', () => {
const text = 'see [['
expect(extractWikilinkQuery(text, text.length)).toBe('')
})
it('returns query after [[', () => {
const text = 'see [[Proj'
expect(extractWikilinkQuery(text, text.length)).toBe('Proj')
})
it('returns null when ]] closes the link', () => {
const text = '[[Proj]]'
expect(extractWikilinkQuery(text, text.length)).toBeNull()
})
it('returns null when newline is in query', () => {
const text = '[[Proj\ncontinued'
expect(extractWikilinkQuery(text, text.length)).toBeNull()
})
it('handles cursor before end of text', () => {
const text = '[[Proj after'
expect(extractWikilinkQuery(text, 6)).toBe('Proj')
})
})
describe('detectYamlError', () => {
it('returns null for content without frontmatter', () => {
expect(detectYamlError('# Title\n\nSome content.')).toBeNull()
})
it('returns null for valid frontmatter', () => {
expect(detectYamlError('---\ntitle: My Note\n---\n\n# Title')).toBeNull()
})
it('returns error for unclosed frontmatter', () => {
const error = detectYamlError('---\ntitle: My Note\n\n# Title')
expect(error).toContain('Unclosed frontmatter')
})
it('returns error for tab indentation in frontmatter', () => {
const error = detectYamlError('---\n\ttitle: My Note\n---\n')
expect(error).toContain('tab indentation')
})
it('returns null for content not starting with ---', () => {
expect(detectYamlError('Not frontmatter')).toBeNull()
})
})
describe('RawEditorView', () => {
it('renders CodeMirror container', () => {
render(<RawEditorView {...defaultProps} />)

View File

@@ -1,22 +1,21 @@
import { useRef, useState, useCallback, useEffect, useMemo } from 'react'
import { trackEvent } from '../lib/telemetry'
import type { EditorView } from '@codemirror/view'
import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
import { attachClickHandlers, enrichSuggestionItems } from '../utils/suggestionEnrichment'
import { MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
import { buildTypeEntryMap } from '../utils/typeColors'
import { NoteSearchList } from './NoteSearchList'
import { extractWikilinkQuery, detectYamlError } from '../utils/rawEditorUtils'
import {
buildRawEditorAutocompleteState,
buildRawEditorBaseItems,
detectYamlError,
extractWikilinkQuery,
getRawEditorDropdownPosition,
replaceActiveWikilinkQuery,
type RawEditorAutocompleteState,
} from '../utils/rawEditorUtils'
import { useCodeMirror } from '../hooks/useCodeMirror'
import type { WikilinkSuggestionItem } from './WikilinkSuggestionMenu'
import type { VaultEntry } from '../types'
interface AutocompleteState {
caretTop: number
caretLeft: number
selectedIndex: number
items: WikilinkSuggestionItem[]
}
export interface RawEditorViewProps {
content: string
path: string
@@ -32,13 +31,6 @@ export interface RawEditorViewProps {
const DEBOUNCE_MS = 500
const DROPDOWN_MAX_HEIGHT = 200
function getCursorCoords(view: EditorView): { top: number; left: number } | null {
const pos = view.state.selection.main.head
const coords = view.coordsAtPos(pos)
if (!coords) return null
return { top: coords.bottom, left: coords.left }
}
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath }: RawEditorViewProps) {
const containerRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -52,23 +44,14 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
useEffect(() => { onContentChangeRef.current = onContentChange }, [onContentChange])
useEffect(() => { onSaveRef.current = onSave }, [onSave])
const [autocomplete, setAutocomplete] = useState<AutocompleteState | null>(null)
const [autocomplete, setAutocomplete] = useState<RawEditorAutocompleteState | null>(null)
const [yamlError, setYamlError] = useState<string | null>(() => detectYamlError(content))
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const baseItems = useMemo(
() => deduplicateByPath(entries.map(entry => ({
title: entry.title,
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
group: entry.isA || 'Note',
entryTitle: entry.title,
path: entry.path,
}))),
[entries],
)
const baseItems = useMemo(() => buildRawEditorBaseItems(entries), [entries])
const insertWikilinkRef = useRef<(entryTitle: string) => void>(() => {})
const insertWikilinkRef = useRef<(target: string) => void>(() => {})
const latestContentRefStable = useRef(latestContentRef)
useEffect(() => { latestContentRefStable.current = latestContentRef }, [latestContentRef])
@@ -91,12 +74,15 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
setAutocomplete(null)
return
}
const coords = getCursorCoords(view)
if (!coords) { setAutocomplete(null); return }
const candidates = preFilterWikilinks(baseItems, query)
const withHandlers = attachClickHandlers(candidates, (title: string) => insertWikilinkRef.current(title), vaultPath ?? '')
const items = enrichSuggestionItems(withHandlers, query, typeEntryMap)
setAutocomplete({ caretTop: coords.top, caretLeft: coords.left, selectedIndex: 0, items })
const nextAutocomplete = buildRawEditorAutocompleteState({
view,
baseItems,
query,
typeEntryMap,
onInsertTarget: (target: string) => insertWikilinkRef.current(target),
vaultPath: vaultPath ?? '',
})
setAutocomplete(nextAutocomplete)
}, [baseItems, typeEntryMap, vaultPath])
const handleSave = useCallback(() => {
@@ -120,30 +106,25 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
onEscape: handleEscape,
})
const insertWikilink = useCallback((entryTitle: string) => {
const insertWikilink = useCallback((target: string) => {
const view = viewRef.current
if (!view) return
const cursor = view.state.selection.main.head
const doc = view.state.doc.toString()
const before = doc.slice(0, cursor)
const triggerIdx = before.lastIndexOf('[[')
if (triggerIdx === -1) return
const after = doc.slice(cursor)
const newText = `${doc.slice(0, triggerIdx)}[[${entryTitle}]]${after}`
const newCursor = triggerIdx + entryTitle.length + 4
const replacement = replaceActiveWikilinkQuery(doc, cursor, target)
if (!replacement) return
view.dispatch({
changes: { from: 0, to: doc.length, insert: newText },
selection: { anchor: newCursor },
changes: { from: 0, to: doc.length, insert: replacement.text },
selection: { anchor: replacement.cursor },
})
trackEvent('wikilink_inserted')
setAutocomplete(null)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = null
latestDocRef.current = newText
onContentChangeRef.current(pathRef.current, newText)
latestDocRef.current = replacement.text
onContentChangeRef.current(pathRef.current, replacement.text)
view.focus()
}, [viewRef])
@@ -165,9 +146,9 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
} else if (e.key === 'Enter') {
e.preventDefault()
const item = autocomplete.items[autocomplete.selectedIndex]
if (item) insertWikilink(item.entryTitle ?? item.title)
if (item) item.onItemClick()
}
}, [autocomplete, insertWikilink])
}, [autocomplete])
// Flush pending debounce on unmount
useEffect(() => {
@@ -179,15 +160,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
}
}, [])
const dropdownBelow = autocomplete
? autocomplete.caretTop + 20 + DROPDOWN_MAX_HEIGHT <= window.innerHeight
: true
const dropdownTop = autocomplete
? (dropdownBelow ? autocomplete.caretTop + 4 : autocomplete.caretTop - DROPDOWN_MAX_HEIGHT - 24)
: 0
const dropdownLeft = autocomplete
? Math.min(autocomplete.caretLeft, window.innerWidth - 260)
: 0
const dropdownPosition = getRawEditorDropdownPosition(autocomplete, DROPDOWN_MAX_HEIGHT, window)
return (
<div className="flex flex-1 flex-col min-h-0 relative" style={{ background: 'var(--background)' }} onKeyDown={handleAutocompleteKey} role="presentation">
@@ -212,8 +185,8 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
<div
className="fixed z-50 min-w-64 max-w-xs rounded-md border shadow-lg overflow-auto"
style={{
top: dropdownTop,
left: dropdownLeft,
top: dropdownPosition.top,
left: dropdownPosition.left,
maxHeight: DROPDOWN_MAX_HEIGHT,
background: 'var(--popover)',
borderColor: 'var(--border)',
@@ -224,7 +197,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
items={autocomplete.items}
selectedIndex={autocomplete.selectedIndex}
getItemKey={(item, i) => `${item.title}-${item.path ?? i}`}
onItemClick={(item) => insertWikilink(item.entryTitle ?? item.title)}
onItemClick={(item) => item.onItemClick()}
onItemHover={(i) => setAutocomplete(prev => prev ? { ...prev, selectedIndex: i } : null)}
/>
</div>

View File

@@ -6,7 +6,12 @@ import { containsWikilinks } from '../DynamicPropertiesPanel'
import type { FrontmatterValue } from '../Inspector'
import { NoteSearchList } from '../NoteSearchList'
import { useNoteSearch } from '../../hooks/useNoteSearch'
import { resolveEntry } from '../../utils/wikilink'
import {
resolveEntry,
canonicalWikilinkTargetForEntry,
canonicalWikilinkTargetForTitle,
formatWikilinkRef,
} from '../../utils/wikilink'
import { isWikilink, resolveRefProps } from './shared'
import { LinkButton } from './LinkButton'
@@ -15,6 +20,61 @@ function hasExactTitleMatch(entries: VaultEntry[], title: string): boolean {
return resolveEntry(entries, title) !== undefined
}
function inferVaultPath(entries: VaultEntry[]): string {
if (entries.length === 0) return ''
const segments = entries.map((entry) => entry.path.split('/').slice(0, -1))
const prefix: string[] = []
const maxDepth = Math.min(...segments.map((parts) => parts.length))
for (let i = 0; i < maxDepth; i += 1) {
const segment = segments[0][i]
if (segments.every((parts) => parts[i] === segment)) prefix.push(segment)
else break
}
return prefix.join('/')
}
function canonicalRefForEntry(entry: VaultEntry, vaultPath: string): string {
return formatWikilinkRef(canonicalWikilinkTargetForEntry(entry, vaultPath))
}
function canonicalRefForTitle(title: string, entries: VaultEntry[], vaultPath: string): string {
return formatWikilinkRef(canonicalWikilinkTargetForTitle(title, entries, vaultPath))
}
function shouldShowSearchDropdown(focused: boolean, trimmed: string, resultCount: number, showCreate: boolean): boolean {
return focused && trimmed.length > 0 && (resultCount > 0 || showCreate)
}
function confirmRelationshipSelection({
showCreate,
selectedIndex,
createIndex,
trimmed,
selectedEntry,
onCreate,
onSelectEntry,
onFallback,
}: {
showCreate: boolean
selectedIndex: number
createIndex: number
trimmed: string
selectedEntry?: VaultEntry
onCreate?: (title: string) => void
onSelectEntry?: (entry: VaultEntry) => void
onFallback?: () => void
}): void {
if (showCreate && selectedIndex === createIndex && trimmed) {
onCreate?.(trimmed)
return
}
if (selectedEntry) {
onSelectEntry?.(selectedEntry)
return
}
onFallback?.()
}
/** Shared keyboard navigation for search dropdowns with an optional "create" item. */
function useSearchKeyboard(
search: ReturnType<typeof useNoteSearch>,
@@ -90,7 +150,7 @@ function CreateAndOpenOption({ title, selected, onClick, onHover }: {
function SearchDropdownWithCreate({ search, onSelect, query, entries, onCreateAndOpen }: {
search: ReturnType<typeof useNoteSearch>
onSelect: (title: string) => void
onSelect: (entry: VaultEntry) => void
query: string
entries: VaultEntry[]
onCreateAndOpen?: (title: string) => void
@@ -108,7 +168,7 @@ function SearchDropdownWithCreate({ search, onSelect, query, entries, onCreateAn
items={search.results}
selectedIndex={search.selectedIndex}
getItemKey={(item) => item.entry.path}
onItemClick={(item) => onSelect(item.entry.title)}
onItemClick={(item) => onSelect(item.entry)}
onItemHover={(i) => search.setSelectedIndex(i)}
className="max-h-[160px] overflow-y-auto"
/>
@@ -125,11 +185,12 @@ function SearchDropdownWithCreate({ search, onSelect, query, entries, onCreateAn
)
}
function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: {
entries: VaultEntry[]
onAdd: (noteTitle: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
}) {
function useInlineAddNoteState(
entries: VaultEntry[],
vaultPath: string,
onAdd: (ref: string) => void,
onCreateAndOpenNote?: (title: string) => Promise<boolean>,
) {
const [active, setActive] = useState(false)
const [query, setQuery] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
@@ -138,25 +199,82 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: {
const trimmed = query.trim()
const { showCreate, createIndex, totalItems } = useCreateOption(entries, trimmed, search.results.length, !!onCreateAndOpenNote)
const dismiss = useCallback(() => { setQuery(''); setActive(false) }, [])
const dismiss = useCallback(() => {
setQuery('')
setActive(false)
}, [])
const selectAndClose = useCallback((title: string) => {
onAdd(title)
const selectAndClose = useCallback((ref: string) => {
onAdd(ref)
dismiss()
}, [onAdd, dismiss])
const handleCreateAndOpen = useCreateAndOpen(onCreateAndOpenNote, onAdd, dismiss)
const selectEntryAndClose = useCallback((entry: VaultEntry) => {
selectAndClose(canonicalRefForEntry(entry, vaultPath))
}, [selectAndClose, vaultPath])
const handleCreateAndOpen = useCreateAndOpen(
onCreateAndOpenNote,
(title) => onAdd(canonicalRefForTitle(title, entries, vaultPath)),
dismiss,
)
const handleFallback = useCallback(() => {
if (!trimmed) return
selectAndClose(canonicalRefForTitle(trimmed, entries, vaultPath))
}, [trimmed, selectAndClose, entries, vaultPath])
const handleConfirm = useCallback(() => {
if (showCreate && search.selectedIndex === createIndex) {
handleCreateAndOpen(trimmed)
return
}
const title = search.selectedEntry?.title ?? trimmed
if (title) selectAndClose(title)
}, [search.selectedEntry, search.selectedIndex, trimmed, selectAndClose, showCreate, createIndex, handleCreateAndOpen])
confirmRelationshipSelection({
showCreate,
selectedIndex: search.selectedIndex,
createIndex,
trimmed,
selectedEntry: search.selectedEntry,
onCreate: handleCreateAndOpen,
onSelectEntry: selectEntryAndClose,
onFallback: handleFallback,
})
}, [showCreate, search.selectedIndex, search.selectedEntry, createIndex, trimmed, handleCreateAndOpen, selectEntryAndClose, handleFallback])
const handleKeyDown = useSearchKeyboard(search, totalItems, handleConfirm, dismiss)
const showDropdown = shouldShowSearchDropdown(active, trimmed, search.results.length, showCreate)
return {
active,
setActive,
query,
setQuery,
inputRef,
search,
dismiss,
handleKeyDown,
showDropdown,
selectEntryAndClose,
showCreate,
handleCreateAndOpen,
}
}
function InlineAddNote({ entries, vaultPath, onAdd, onCreateAndOpenNote }: {
entries: VaultEntry[]
vaultPath: string
onAdd: (ref: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
}) {
const {
active,
setActive,
query,
setQuery,
inputRef,
search,
dismiss,
handleKeyDown,
showDropdown,
selectEntryAndClose,
handleCreateAndOpen,
} = useInlineAddNoteState(entries, vaultPath, onAdd, onCreateAndOpenNote)
if (!active) {
return (
@@ -171,8 +289,6 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: {
)
}
const showDropdown = trimmed.length > 0 && (search.results.length > 0 || showCreate)
return (
<div className="relative mt-1">
<div className="group/add relative flex items-center">
@@ -197,7 +313,7 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: {
{showDropdown && (
<SearchDropdownWithCreate
search={search}
onSelect={selectAndClose}
onSelect={selectEntryAndClose}
query={query}
entries={entries}
onCreateAndOpen={onCreateAndOpenNote ? (title) => { handleCreateAndOpen(title) } : undefined}
@@ -207,11 +323,11 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: {
)
}
function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onRemoveRef, onAddRef, onCreateAndOpenNote }: {
label: string; refs: string[]; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>
function RelationshipGroup({ label, refs, entries, typeEntryMap, vaultPath, onNavigate, onRemoveRef, onAddRef, onCreateAndOpenNote }: {
label: string; refs: string[]; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>; vaultPath: string
onNavigate: (target: string) => void
onRemoveRef?: (ref: string) => void
onAddRef?: (noteTitle: string) => void
onAddRef?: (ref: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
}) {
if (refs.length === 0) return null
@@ -234,6 +350,7 @@ function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onR
{onAddRef && (
<InlineAddNote
entries={entries}
vaultPath={vaultPath}
onAdd={onAddRef}
onCreateAndOpenNote={onCreateAndOpenNote}
/>
@@ -269,22 +386,30 @@ function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreat
const trimmed = value.trim()
const { showCreate, createIndex, totalItems } = useCreateOption(entries, trimmed, search.results.length, !!onCreateAndOpenNote)
const handleConfirm = useCallback(() => {
if (showCreate && search.selectedIndex === createIndex) {
onSubmitWithCreate?.(trimmed)
} else if (search.selectedEntry) {
onChange(search.selectedEntry.title)
setFocused(false)
} else {
onSubmit?.()
}
}, [showCreate, search.selectedIndex, search.selectedEntry, createIndex, trimmed, onChange, onSubmit, onSubmitWithCreate])
const selectEntry = useCallback((entry: VaultEntry) => {
onChange(entry.title)
setFocused(false)
}, [onChange])
const handleEscape = useCallback(() => { onCancel?.() }, [onCancel])
const handleConfirm = useCallback(() => {
confirmRelationshipSelection({
showCreate,
selectedIndex: search.selectedIndex,
createIndex,
trimmed,
selectedEntry: search.selectedEntry,
onCreate: onSubmitWithCreate,
onSelectEntry: selectEntry,
onFallback: onSubmit,
})
}, [showCreate, search.selectedIndex, search.selectedEntry, createIndex, trimmed, onSubmitWithCreate, selectEntry, onSubmit])
const handleEscape = useCallback(() => {
onCancel?.()
}, [onCancel])
const handleKeyDown = useSearchKeyboard(search, totalItems, handleConfirm, handleEscape)
const showDropdown = focused && trimmed.length > 0 && (search.results.length > 0 || showCreate)
const showDropdown = shouldShowSearchDropdown(focused, trimmed, search.results.length, showCreate)
return (
<div className="relative">
@@ -301,7 +426,7 @@ function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreat
{showDropdown && (
<SearchDropdownWithCreate
search={search}
onSelect={(title) => { onChange(title); setFocused(false) }}
onSelect={selectEntry}
query={value}
entries={entries}
onCreateAndOpen={onCreateAndOpenNote ? (title) => onSubmitWithCreate?.(title) : undefined}
@@ -311,8 +436,56 @@ function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreat
)
}
function AddRelationshipForm({ entries, onAddProperty, onCreateAndOpenNote }: {
function useRelationshipPanelState(
frontmatter: ParsedFrontmatter,
entries: VaultEntry[],
vaultPath: string | undefined,
onAddProperty?: (key: string, value: FrontmatterValue) => void,
onUpdateProperty?: (key: string, value: FrontmatterValue) => void,
onDeleteProperty?: (key: string) => void,
) {
const relationshipEntries = useMemo(() => extractRelationshipRefs(frontmatter), [frontmatter])
const resolvedVaultPath = useMemo(() => vaultPath ?? inferVaultPath(entries), [vaultPath, entries])
const handleRemoveRef = useCallback((key: string, refToRemove: string) => {
if (!onUpdateProperty || !onDeleteProperty) return
const group = relationshipEntries.find(g => g.key === key)
if (!group) return
const result = updateRefsForRemoval(group.refs, refToRemove)
if (result === null) onDeleteProperty(key)
else onUpdateProperty(key, result)
}, [relationshipEntries, onUpdateProperty, onDeleteProperty])
const handleAddRef = useCallback((key: string, ref: string) => {
if (!onUpdateProperty) return
const existing = relationshipEntries.find(g => g.key === key)?.refs ?? []
const result = updateRefsForAddition(existing, ref)
if (result !== false) onUpdateProperty(key, result)
}, [relationshipEntries, onUpdateProperty])
const canEdit = !!onUpdateProperty && !!onDeleteProperty
const existingRelKeys = useMemo(
() => new Set(relationshipEntries.map(g => g.key.toLowerCase())),
[relationshipEntries],
)
const missingSuggestedRels = useMemo(
() => (onAddProperty ? SUGGESTED_RELATIONSHIPS.filter(r => !existingRelKeys.has(r.toLowerCase())) : []),
[onAddProperty, existingRelKeys],
)
return {
relationshipEntries,
resolvedVaultPath,
handleRemoveRef,
handleAddRef,
canEdit,
missingSuggestedRels,
}
}
function AddRelationshipForm({ entries, vaultPath, onAddProperty, onCreateAndOpenNote }: {
entries: VaultEntry[]
vaultPath: string
onAddProperty: (key: string, value: FrontmatterValue) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
}) {
@@ -327,16 +500,16 @@ function AddRelationshipForm({ entries, onAddProperty, onCreateAndOpenNote }: {
const submitForm = useCallback((targetOverride?: string) => {
const key = relKey.trim()
const target = (targetOverride ?? relTarget).trim()
if (!key || !target) return
onAddProperty(key, `[[${target}]]`)
const rawTarget = (targetOverride ?? relTarget).trim()
if (!key || !rawTarget) return
onAddProperty(key, canonicalRefForTitle(rawTarget, entries, vaultPath))
resetForm()
}, [relKey, relTarget, onAddProperty, resetForm])
}, [relKey, relTarget, entries, vaultPath, onAddProperty, resetForm])
const addPropertyForKey = useCallback((title: string) => {
const key = relKey.trim()
if (key) onAddProperty(key, `[[${title}]]`)
}, [relKey, onAddProperty])
if (key) onAddProperty(key, canonicalRefForTitle(title, entries, vaultPath))
}, [relKey, entries, vaultPath, onAddProperty])
const handleCreateAndSubmit = useCreateAndOpen(onCreateAndOpenNote, addPropertyForKey, resetForm)
@@ -381,10 +554,9 @@ function updateRefsForRemoval(refs: string[], refToRemove: string): FrontmatterV
return remaining.length === 1 ? remaining[0] : remaining
}
function updateRefsForAddition(refs: string[], noteTitle: string): FrontmatterValue | false {
const newRef = `[[${noteTitle}]]`
if (refs.includes(newRef)) return false
const updated = [...refs, newRef]
function updateRefsForAddition(refs: string[], refToAdd: string): FrontmatterValue | false {
if (refs.includes(refToAdd)) return false
const updated = [...refs, refToAdd]
return updated.length === 1 ? updated[0] : updated
}
@@ -396,10 +568,11 @@ function DisabledLinkButton() {
const SUGGESTED_RELATIONSHIPS = ['Belongs to', 'Related to', 'Has'] as const
function SuggestedRelationshipSlot({ label, entries, onAdd, onCreateAndOpenNote }: {
function SuggestedRelationshipSlot({ label, entries, vaultPath, onAdd, onCreateAndOpenNote }: {
label: string
entries: VaultEntry[]
onAdd: (noteTitle: string) => void
vaultPath: string
onAdd: (ref: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
}) {
return (
@@ -407,6 +580,7 @@ function SuggestedRelationshipSlot({ label, entries, onAdd, onCreateAndOpenNote
<span className="mb-1 block text-[12px] text-muted-foreground/50">{label}</span>
<InlineAddNote
entries={entries}
vaultPath={vaultPath}
onAdd={onAdd}
onCreateAndOpenNote={onCreateAndOpenNote}
/>
@@ -414,50 +588,30 @@ function SuggestedRelationshipSlot({ label, entries, onAdd, onCreateAndOpenNote
)
}
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote }: {
frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, vaultPath, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote }: {
frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>; vaultPath?: string
onNavigate: (target: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onDeleteProperty?: (key: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
}) {
const relationshipEntries = useMemo(() => extractRelationshipRefs(frontmatter), [frontmatter])
const handleRemoveRef = useCallback((key: string, refToRemove: string) => {
if (!onUpdateProperty || !onDeleteProperty) return
const group = relationshipEntries.find(g => g.key === key)
if (!group) return
const result = updateRefsForRemoval(group.refs, refToRemove)
if (result === null) onDeleteProperty(key)
else onUpdateProperty(key, result)
}, [relationshipEntries, onUpdateProperty, onDeleteProperty])
const handleAddRef = useCallback((key: string, noteTitle: string) => {
if (!onUpdateProperty) return
const existing = relationshipEntries.find(g => g.key === key)?.refs ?? []
const result = updateRefsForAddition(existing, noteTitle)
if (result !== false) onUpdateProperty(key, result)
}, [relationshipEntries, onUpdateProperty])
const canEdit = !!onUpdateProperty && !!onDeleteProperty
const existingRelKeys = useMemo(
() => new Set(relationshipEntries.map(g => g.key.toLowerCase())),
[relationshipEntries],
)
const missingSuggestedRels = onAddProperty
? SUGGESTED_RELATIONSHIPS.filter(r => !existingRelKeys.has(r.toLowerCase()))
: []
const {
relationshipEntries,
resolvedVaultPath,
handleRemoveRef,
handleAddRef,
canEdit,
missingSuggestedRels,
} = useRelationshipPanelState(frontmatter, entries, vaultPath, onAddProperty, onUpdateProperty, onDeleteProperty)
return (
<div>
{relationshipEntries.map(({ key, refs }) => (
<RelationshipGroup
key={key} label={key} refs={refs} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate}
key={key} label={key} refs={refs} entries={entries} typeEntryMap={typeEntryMap} vaultPath={resolvedVaultPath} onNavigate={onNavigate}
onRemoveRef={canEdit ? (ref) => handleRemoveRef(key, ref) : undefined}
onAddRef={canEdit ? (noteTitle) => handleAddRef(key, noteTitle) : undefined}
onAddRef={canEdit ? (ref) => handleAddRef(key, ref) : undefined}
onCreateAndOpenNote={canEdit ? onCreateAndOpenNote : undefined}
/>
))}
@@ -466,12 +620,13 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
key={label}
label={label}
entries={entries}
onAdd={(noteTitle) => onAddProperty!(label, `[[${noteTitle}]]`)}
vaultPath={resolvedVaultPath}
onAdd={(ref) => onAddProperty!(label, ref)}
onCreateAndOpenNote={onCreateAndOpenNote}
/>
))}
{onAddProperty
? <AddRelationshipForm entries={entries} onAddProperty={onAddProperty} onCreateAndOpenNote={onCreateAndOpenNote} />
? <AddRelationshipForm entries={entries} vaultPath={resolvedVaultPath} onAddProperty={onAddProperty} onCreateAndOpenNote={onCreateAndOpenNote} />
: <DisabledLinkButton />
}
</div>

View File

@@ -11,10 +11,10 @@ function targetMatchesEntry(target: string, entryPath: string, matchTargets: Set
return false
}
function refsMatchTargets(refs: string[], targets: Set<string>): boolean {
function refsMatchTargets(refs: string[], entryPath: string, targets: Set<string>): boolean {
return refs.some((ref) => {
const target = wikilinkTarget(ref)
return targets.has(target) || targets.has(target.split('/').pop() ?? '')
return targetMatchesEntry(target, entryPath, targets)
})
}
@@ -29,7 +29,7 @@ export function useReferencedBy(entry: VaultEntry | null, entries: VaultEntry[])
for (const other of entries) {
if (other.path === entry.path) continue
for (const [key, refs] of Object.entries(other.relationships)) {
if (key !== 'Type' && refsMatchTargets(refs, matchTargets)) {
if (key !== 'Type' && refsMatchTargets(refs, entry.path, matchTargets)) {
results.push({ entry: other, viaKey: key })
}
}

View File

@@ -566,7 +566,7 @@ describe('useNoteActions hook', () => {
new_title: 'Sprint Retro',
old_title: 'Weekly Review',
}))
expect(setToastMessage).toHaveBeenCalledWith('Renamed — updated 2 wiki links')
expect(setToastMessage).toHaveBeenCalledWith('Updated 2 notes')
})
it('handleRenameNote passes null old_title when entry not found', async () => {

View File

@@ -66,11 +66,11 @@ describe('renameToastMessage', () => {
})
it('returns singular when 1 file updated', () => {
expect(renameToastMessage(1)).toBe('Renamed — updated 1 wiki link')
expect(renameToastMessage(1)).toBe('Updated 1 note')
})
it('returns plural when multiple files updated', () => {
expect(renameToastMessage(3)).toBe('Renamed — updated 3 wiki links')
expect(renameToastMessage(3)).toBe('Updated 3 notes')
})
})
@@ -110,7 +110,7 @@ describe('useNoteRename hook', () => {
new_title: 'New',
old_title: 'Old',
}))
expect(setToastMessage).toHaveBeenCalledWith('Renamed — updated 2 wiki links')
expect(setToastMessage).toHaveBeenCalledWith('Updated 2 notes')
expect(onEntryRenamed).toHaveBeenCalled()
})
@@ -199,7 +199,7 @@ describe('useNoteRename hook', () => {
}),
'# Project Kickoff\n',
)
expect(setToastMessage).toHaveBeenCalledWith('Renamed — updated 1 wiki link')
expect(setToastMessage).toHaveBeenCalledWith('Updated 1 note')
})
it('handleRenameFilename surfaces backend conflict errors', async () => {

View File

@@ -65,7 +65,7 @@ export async function loadNoteContent(path: string): Promise<string> {
export function renameToastMessage(updatedFiles: number): string {
if (updatedFiles === 0) return 'Renamed'
return `Renamed — updated ${updatedFiles} wiki link${updatedFiles > 1 ? 's' : ''}`
return `Updated ${updatedFiles} note${updatedFiles > 1 ? 's' : ''}`
}
/** Reload content for open tabs whose wikilinks may have changed after a rename. */

View File

@@ -110,35 +110,81 @@ let mockVaultList: { vaults: Array<{ label: string; path: string }>; active_vaul
let mockDeviceFlowPollCount = 0
function escapeRegex({ text }: { text: string }) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function relativePathStem({ path, vaultPath }: { path: string; vaultPath: string }) {
const prefix = vaultPath.endsWith('/') ? vaultPath : `${vaultPath}/`
if (path.startsWith(prefix)) return path.slice(prefix.length).replace(/\.md$/, '')
return (path.split('/').pop() ?? path).replace(/\.md$/, '')
}
function canonicalRenameTargets({ oldTitle, oldPathStem }: { oldTitle: string; oldPathStem: string }) {
const oldFilenameStem = oldPathStem.split('/').pop() ?? oldPathStem
return [...new Set([oldTitle, oldPathStem, oldFilenameStem].filter(Boolean))]
}
function slugifyMockTitle({ title }: { title: string }) {
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
}
function buildRenamedMockPath({ oldPath, newTitle }: { oldPath: string; newTitle: string }) {
const parentDir = oldPath.replace(/\/[^/]+$/, '')
return `${parentDir}/${slugifyMockTitle({ title: newTitle })}.md`
}
function replaceMockTitleFrontmatter({ content, newTitle }: { content: string; newTitle: string }) {
return /^title:\s*/m.test(content)
? content.replace(/^title:\s*.*$/m, `title: ${newTitle}`)
: content
}
function replaceRenamedWikilinks({ content, oldTargets, newPathStem }: {
content: string
oldTargets: string[]
newPathStem: string
}) {
if (oldTargets.length === 0) return content
const pattern = new RegExp(`\\[\\[(?:${oldTargets.map((target) => escapeRegex({ text: target })).join('|')})(\\|[^\\]]*?)?\\]\\]`, 'g')
return content.replace(pattern, (_match: string, pipe: string | undefined) =>
pipe ? `[[${newPathStem}${pipe}]]` : `[[${newPathStem}]]`
)
}
function updateMockRenameReferences({ newPath, newPathStem, oldTargets }: {
newPath: string
newPathStem: string
oldTargets: string[]
}) {
let updatedFiles = 0
for (const [path, content] of Object.entries(MOCK_CONTENT)) {
if (path === newPath) continue
const replaced = replaceRenamedWikilinks({ content, oldTargets, newPathStem })
if (replaced === content) continue
MOCK_CONTENT[path] = replaced
updatedFiles += 1
}
return updatedFiles
}
function handleRenameNote(args: { vault_path: string; old_path: string; new_title: string; old_title?: string | null }) {
const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path)
const oldTitle = args.old_title ?? oldEntry?.title ?? ''
if (oldTitle === args.new_title) {
const oldContent = MOCK_CONTENT[args.old_path] ?? ''
const newPath = buildRenamedMockPath({ oldPath: args.old_path, newTitle: args.new_title })
const oldPathStem = relativePathStem({ path: args.old_path, vaultPath: args.vault_path })
const newPathStem = relativePathStem({ path: newPath, vaultPath: args.vault_path })
if (oldTitle === args.new_title && newPath === args.old_path) {
return { new_path: args.old_path, updated_files: 0 }
}
const oldContent = MOCK_CONTENT[args.old_path] ?? ''
const slug = args.new_title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
const parentDir = args.old_path.replace(/\/[^/]+$/, '')
const newPath = `${parentDir}/${slug}.md`
const newContent = oldContent.replace(/^# .+$/m, `# ${args.new_title}`)
const newContent = replaceMockTitleFrontmatter({ content: oldContent, newTitle: args.new_title })
delete MOCK_CONTENT[args.old_path]
MOCK_CONTENT[newPath] = newContent
let updatedFiles = 0
if (oldTitle) {
const pattern = new RegExp(`\\[\\[${oldTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\|[^\\]]*?)?\\]\\]`, 'g')
for (const [path, content] of Object.entries(MOCK_CONTENT)) {
if (path === newPath) continue
const replaced = content.replace(pattern, (_m: string, pipe: string | undefined) =>
pipe ? `[[${args.new_title}${pipe}]]` : `[[${args.new_title}]]`
)
if (replaced !== content) {
MOCK_CONTENT[path] = replaced
updatedFiles++
}
}
}
const oldTargets = canonicalRenameTargets({ oldTitle, oldPathStem })
const updatedFiles = updateMockRenameReferences({ newPath, newPathStem, oldTargets })
syncWindowContent()
return { new_path: newPath, updated_files: updatedFiles }

View File

@@ -0,0 +1,117 @@
import { describe, it, expect } from 'vitest'
import type { VaultEntry } from '../types'
import { buildRawEditorBaseItems, detectYamlError, extractWikilinkQuery, getRawEditorDropdownPosition, replaceActiveWikilinkQuery } from './rawEditorUtils'
describe('extractWikilinkQuery', () => {
it('returns null when no [[ trigger', () => {
expect(extractWikilinkQuery('hello world', 5)).toBeNull()
})
it('returns empty string immediately after [[', () => {
const text = 'see [['
expect(extractWikilinkQuery(text, text.length)).toBe('')
})
it('returns query after [[', () => {
const text = 'see [[Proj'
expect(extractWikilinkQuery(text, text.length)).toBe('Proj')
})
it('returns null when ]] closes the link', () => {
const text = '[[Proj]]'
expect(extractWikilinkQuery(text, text.length)).toBeNull()
})
it('returns null when newline is in query', () => {
const text = '[[Proj\ncontinued'
expect(extractWikilinkQuery(text, text.length)).toBeNull()
})
it('handles cursor before end of text', () => {
const text = '[[Proj after'
expect(extractWikilinkQuery(text, 6)).toBe('Proj')
})
})
describe('replaceActiveWikilinkQuery', () => {
it('replaces the active wikilink query with the canonical target', () => {
expect(replaceActiveWikilinkQuery('See [[Proj', 10, 'projects/alpha')).toEqual({
text: 'See [[projects/alpha]]',
cursor: 22,
})
})
it('preserves text after the cursor', () => {
expect(replaceActiveWikilinkQuery('See [[Proj today', 10, 'projects/alpha')).toEqual({
text: 'See [[projects/alpha]] today',
cursor: 22,
})
})
it('returns null when no active wikilink trigger exists', () => {
expect(replaceActiveWikilinkQuery('See Proj', 8, 'projects/alpha')).toBeNull()
})
})
describe('detectYamlError', () => {
it('returns null for content without frontmatter', () => {
expect(detectYamlError('# Title\n\nSome content.')).toBeNull()
})
it('returns null for valid frontmatter', () => {
expect(detectYamlError('---\ntitle: My Note\n---\n\n# Title')).toBeNull()
})
it('returns error for unclosed frontmatter', () => {
const error = detectYamlError('---\ntitle: My Note\n\n# Title')
expect(error).toContain('Unclosed frontmatter')
})
it('returns error for tab indentation in frontmatter', () => {
const error = detectYamlError('---\n\ttitle: My Note\n---\n')
expect(error).toContain('tab indentation')
})
it('returns null for content not starting with ---', () => {
expect(detectYamlError('Not frontmatter')).toBeNull()
})
})
describe('buildRawEditorBaseItems', () => {
it('includes filename aliases and deduplicates entries by path', () => {
expect(buildRawEditorBaseItems([
{
title: 'Project Alpha',
aliases: ['Alpha'],
filename: 'project-alpha.md',
isA: 'Project',
path: 'projects/project-alpha.md',
},
{
title: 'Project Alpha',
aliases: ['Ignored'],
filename: 'project-alpha.md',
isA: 'Project',
path: 'projects/project-alpha.md',
},
] as VaultEntry[])).toEqual([
{
title: 'Project Alpha',
aliases: ['project-alpha', 'Alpha'],
group: 'Project',
entryTitle: 'Project Alpha',
path: 'projects/project-alpha.md',
},
])
})
})
describe('getRawEditorDropdownPosition', () => {
it('renders above the cursor when there is not enough space below', () => {
expect(getRawEditorDropdownPosition(
{ caretTop: 500, caretLeft: 900, selectedIndex: 0, items: [] },
200,
{ innerHeight: 640, innerWidth: 1000 },
)).toEqual({ top: 276, left: 740 })
})
})

View File

@@ -1,3 +1,33 @@
import type { EditorView } from '@codemirror/view'
import { attachClickHandlers, enrichSuggestionItems } from './suggestionEnrichment'
import { deduplicateByPath, preFilterWikilinks } from './wikilinkSuggestions'
import type { VaultEntry } from '../types'
import type { WikilinkSuggestionItem } from '../components/WikilinkSuggestionMenu'
export interface RawEditorBaseItem {
title: string
aliases: string[]
group: string
entryTitle: string
path: string
}
export interface RawEditorAutocompleteState {
caretTop: number
caretLeft: number
selectedIndex: number
items: WikilinkSuggestionItem[]
}
interface RawEditorAutocompleteParams {
view: EditorView
baseItems: RawEditorBaseItem[]
query: string
typeEntryMap: Record<string, VaultEntry>
onInsertTarget: (target: string) => void
vaultPath: string
}
/** Extract the wikilink query that the user is currently typing after [[ */
export function extractWikilinkQuery(text: string, cursor: number): string | null {
const before = text.slice(0, cursor)
@@ -9,6 +39,75 @@ export function extractWikilinkQuery(text: string, cursor: number): string | nul
return afterTrigger
}
export function replaceActiveWikilinkQuery(
text: string,
cursor: number,
target: string,
): { text: string; cursor: number } | null {
const before = text.slice(0, cursor)
const triggerIdx = before.lastIndexOf('[[')
if (triggerIdx === -1) return null
const after = text.slice(cursor)
return {
text: `${text.slice(0, triggerIdx)}[[${target}]]${after}`,
cursor: triggerIdx + target.length + 4,
}
}
export function buildRawEditorBaseItems(entries: VaultEntry[]): RawEditorBaseItem[] {
return deduplicateByPath(entries.map(entry => ({
title: entry.title,
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
group: entry.isA || 'Note',
entryTitle: entry.title,
path: entry.path,
})))
}
export function getRawEditorCursorCoords(view: EditorView): { top: number; left: number } | null {
const pos = view.state.selection.main.head
const coords = view.coordsAtPos(pos)
if (!coords) return null
return { top: coords.bottom, left: coords.left }
}
export function buildRawEditorAutocompleteState({
view,
baseItems,
query,
typeEntryMap,
onInsertTarget,
vaultPath,
}: RawEditorAutocompleteParams): RawEditorAutocompleteState | null {
const coords = getRawEditorCursorCoords(view)
if (!coords) return null
const candidates = preFilterWikilinks(baseItems, query)
const withHandlers = attachClickHandlers(candidates, onInsertTarget, vaultPath)
const items = enrichSuggestionItems(withHandlers, query, typeEntryMap)
return {
caretTop: coords.top,
caretLeft: coords.left,
selectedIndex: 0,
items,
}
}
export function getRawEditorDropdownPosition(
autocomplete: RawEditorAutocompleteState | null,
maxHeight: number,
viewport: { innerHeight: number; innerWidth: number },
): { top: number; left: number } {
if (!autocomplete) return { top: 0, left: 0 }
const dropdownBelow = autocomplete.caretTop + 20 + maxHeight <= viewport.innerHeight
return {
top: dropdownBelow ? autocomplete.caretTop + 4 : autocomplete.caretTop - maxHeight - 24,
left: Math.min(autocomplete.caretLeft, viewport.innerWidth - 260),
}
}
/** Basic YAML frontmatter structural checks. */
export function detectYamlError(content: string): string | null {
if (!content.startsWith('---')) return null

View File

@@ -32,9 +32,9 @@ describe('attachClickHandlers', () => {
expect(result).toHaveLength(2)
result[0].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('a|Note A')
expect(insertWikilink).toHaveBeenCalledWith('a')
result[1].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('b|Note B')
expect(insertWikilink).toHaveBeenCalledWith('b')
})
it('preserves all original properties', () => {
@@ -55,13 +55,13 @@ describe('attachClickHandlers', () => {
const result = attachClickHandlers(candidates, insertWikilink, vaultPath)
result[0].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('docs/adr/0001-tauri-stack|ADR 001')
expect(insertWikilink).toHaveBeenCalledWith('docs/adr/0001-tauri-stack')
})
it('omits pipe display when title matches path stem', () => {
it('omits any default alias even when the title differs from the path stem', () => {
const insertWikilink = vi.fn()
const candidates = [
{ title: 'roadmap', aliases: [], group: 'Note', entryTitle: 'roadmap', path: '/vault/roadmap.md' },
{ title: 'Roadmap', aliases: [], group: 'Note', entryTitle: 'Roadmap', path: '/vault/roadmap.md' },
]
const result = attachClickHandlers(candidates, insertWikilink, vaultPath)

View File

@@ -17,17 +17,14 @@ interface BaseSuggestionItem {
path: string
}
/** Build the wikilink target: relative path stem with pipe display for the title.
* e.g. "docs/adr/0001-tauri-stack|Tauri Stack" for subfolders,
* "roadmap|Roadmap" for root files. */
/** Build the canonical wikilink target: vault-relative path stem without a default alias. */
function buildTarget(item: BaseSuggestionItem, vaultPath: string): string {
const stem = relativePathStem(item.path, vaultPath)
return stem === item.entryTitle ? stem : `${stem}|${item.entryTitle}`
return relativePathStem(item.path, vaultPath)
}
/** Add onItemClick to raw suggestion candidates.
* Always inserts the vault-relative path as the wikilink target
* so links are unambiguous and work across subfolders. */
* Always inserts the canonical vault-relative path target so links are
* unambiguous and remain stable across renames. */
export function attachClickHandlers(
candidates: BaseSuggestionItem[],
insertWikilink: (target: string) => void,

View File

@@ -1,6 +1,15 @@
import { describe, it, expect } from 'vitest'
import type { VaultEntry } from '../types'
import { wikilinkTarget, wikilinkDisplay, resolveEntry, relativePathStem } from './wikilink'
import {
wikilinkTarget,
wikilinkDisplay,
resolveEntry,
relativePathStem,
slugifyWikilinkTarget,
canonicalWikilinkTargetForEntry,
canonicalWikilinkTargetForTitle,
formatWikilinkRef,
} from './wikilink'
function makeEntry(overrides: Partial<VaultEntry>): VaultEntry {
return {
@@ -124,3 +133,42 @@ describe('relativePathStem', () => {
expect(relativePathStem('/other/path/note.md', '/Users/luca/Vault')).toBe('note')
})
})
describe('slugifyWikilinkTarget', () => {
it('slugifies a human title to a canonical target', () => {
expect(slugifyWikilinkTarget('Weekly Review')).toBe('weekly-review')
})
it('falls back to untitled when the title has no slug characters', () => {
expect(slugifyWikilinkTarget('+++')).toBe('untitled')
})
})
describe('canonicalWikilinkTargetForEntry', () => {
it('returns a vault-relative path stem', () => {
const entry = makeEntry({ path: '/vault/projects/alpha.md', filename: 'alpha.md', title: 'Alpha' })
expect(canonicalWikilinkTargetForEntry(entry, '/vault')).toBe('projects/alpha')
})
})
describe('canonicalWikilinkTargetForTitle', () => {
const project = makeEntry({ path: '/vault/projects/alpha.md', filename: 'alpha.md', title: 'Alpha Project' })
it('resolves an existing entry to its canonical path target', () => {
expect(canonicalWikilinkTargetForTitle('Alpha Project', [project], '/vault')).toBe('projects/alpha')
})
it('keeps a canonical path input canonical', () => {
expect(canonicalWikilinkTargetForTitle('projects/alpha', [project], '/vault')).toBe('projects/alpha')
})
it('falls back to a slug for a newly created note title', () => {
expect(canonicalWikilinkTargetForTitle('Brand New Note', [], '/vault')).toBe('brand-new-note')
})
})
describe('formatWikilinkRef', () => {
it('wraps a canonical target in wikilink syntax', () => {
expect(formatWikilinkRef('projects/alpha')).toBe('[[projects/alpha]]')
})
})

View File

@@ -27,6 +27,86 @@ export function relativePathStem(absolutePath: string, vaultPath: string): strin
return filename.replace(/\.md$/, '')
}
/** Slugify a human-readable title into the canonical wikilink filename stem. */
export function slugifyWikilinkTarget(title: string): string {
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
return slug || 'untitled'
}
/** Build the canonical wikilink target for a vault entry. */
export function canonicalWikilinkTargetForEntry(entry: VaultEntry, vaultPath: string): string {
return relativePathStem(entry.path, vaultPath)
}
/** Resolve a user-facing title/path input to the canonical wikilink target. */
export function canonicalWikilinkTargetForTitle(
titleOrTarget: string,
entries: VaultEntry[],
vaultPath: string,
): string {
const trimmed = titleOrTarget.trim()
const resolved = resolveEntry(entries, trimmed)
return resolved
? canonicalWikilinkTargetForEntry(resolved, vaultPath)
: trimmed.includes('/')
? trimmed.replace(/^\/+/, '').replace(/\.md$/, '')
: slugifyWikilinkTarget(trimmed)
}
/** Wrap a target in wikilink syntax. */
export function formatWikilinkRef(target: string): string {
return `[[${target}]]`
}
interface ResolutionKey {
exactTarget: string
lastSegment: string
pathSuffix: string | null
humanizedTarget: string | null
}
function buildResolutionKey(rawTarget: string): ResolutionKey {
const exactTarget = rawTarget.includes('|') ? rawTarget.split('|')[0] : rawTarget
const normalizedTarget = exactTarget.toLowerCase()
const lastSegment = exactTarget.includes('/') ? (exactTarget.split('/').pop() ?? exactTarget).toLowerCase() : normalizedTarget
const humanizedTarget = lastSegment.replace(/-/g, ' ')
return {
exactTarget: normalizedTarget,
lastSegment,
pathSuffix: exactTarget.includes('/') ? `/${normalizedTarget}.md` : null,
humanizedTarget: humanizedTarget === normalizedTarget ? null : humanizedTarget,
}
}
function findEntryByPathSuffix(entries: VaultEntry[], pathSuffix: string | null): VaultEntry | undefined {
if (!pathSuffix) return undefined
return entries.find(entry => entry.path.toLowerCase().endsWith(pathSuffix))
}
function findEntryByFilename(entries: VaultEntry[], { exactTarget, lastSegment }: ResolutionKey): VaultEntry | undefined {
return entries.find((entry) => {
const stem = entry.filename.replace(/\.md$/, '').toLowerCase()
return stem === exactTarget || stem === lastSegment
})
}
function findEntryByAlias(entries: VaultEntry[], exactTarget: string): VaultEntry | undefined {
return entries.find(entry => entry.aliases.some(alias => alias.toLowerCase() === exactTarget))
}
function findEntryByTitle(entries: VaultEntry[], exactTarget: string, lastSegment: string): VaultEntry | undefined {
return entries.find((entry) => {
const lowerTitle = entry.title.toLowerCase()
return lowerTitle === exactTarget || lowerTitle === lastSegment
})
}
function findEntryByHumanizedTitle(entries: VaultEntry[], humanizedTarget: string | null): VaultEntry | undefined {
if (!humanizedTarget) return undefined
return entries.find(entry => entry.title.toLowerCase() === humanizedTarget)
}
/**
* Unified wikilink resolution: find the VaultEntry matching a wikilink target.
* Handles pipe syntax, case-insensitive matching.
@@ -38,37 +118,12 @@ export function relativePathStem(absolutePath: string, vaultPath: string): strin
* 5. Humanized title match (kebab-case → words)
*/
export function resolveEntry(entries: VaultEntry[], rawTarget: string): VaultEntry | undefined {
const key = rawTarget.includes('|') ? rawTarget.split('|')[0] : rawTarget
const keyLower = key.toLowerCase()
const lastSegment = key.includes('/') ? (key.split('/').pop() ?? key) : key
const lastSegmentLower = lastSegment.toLowerCase()
const asWords = lastSegmentLower.replace(/-/g, ' ')
const pathSuffix = key.includes('/') ? '/' + key.toLowerCase() + '.md' : null
// Pass 1: path-suffix match (for subfolder targets like "docs/adr/0031-foo")
if (pathSuffix) {
for (const e of entries) {
if (e.path.toLowerCase().endsWith(pathSuffix)) return e
}
}
// Pass 2: filename stem (strongest for flat vault)
for (const e of entries) {
const stem = e.filename.replace(/\.md$/, '').toLowerCase()
if (stem === keyLower || stem === lastSegmentLower) return e
}
// Pass 3: alias
for (const e of entries) {
if (e.aliases.some(a => a.toLowerCase() === keyLower)) return e
}
// Pass 4: exact title
for (const e of entries) {
if (e.title.toLowerCase() === keyLower || e.title.toLowerCase() === lastSegmentLower) return e
}
// Pass 5: humanized title (kebab-case → words)
if (asWords !== keyLower) {
for (const e of entries) {
if (e.title.toLowerCase() === asWords) return e
}
}
return undefined
const resolutionKey = buildResolutionKey(rawTarget)
return (
findEntryByPathSuffix(entries, resolutionKey.pathSuffix)
?? findEntryByFilename(entries, resolutionKey)
?? findEntryByAlias(entries, resolutionKey.exactTarget)
?? findEntryByTitle(entries, resolutionKey.exactTarget, resolutionKey.lastSegment)
?? findEntryByHumanizedTitle(entries, resolutionKey.humanizedTarget)
)
}