2026-02-22 10:11:52 +01:00
|
|
|
import { useCallback } from 'react'
|
2026-02-17 12:10:21 +01:00
|
|
|
import { invoke } from '@tauri-apps/api/core'
|
|
|
|
|
import { isTauri, mockInvoke, addMockEntry, updateMockContent } from '../mock-tauri'
|
|
|
|
|
import type { VaultEntry } from '../types'
|
|
|
|
|
import type { FrontmatterValue } from '../components/Inspector'
|
2026-02-22 10:11:52 +01:00
|
|
|
import { useTabManagement } from './useTabManagement'
|
|
|
|
|
import { updateMockFrontmatter, deleteMockFrontmatterProperty } from './mockFrontmatterHelpers'
|
|
|
|
|
|
|
|
|
|
interface NewEntryParams {
|
|
|
|
|
path: string
|
|
|
|
|
slug: string
|
|
|
|
|
title: string
|
|
|
|
|
type: string
|
|
|
|
|
status: string | null
|
2026-02-17 12:10:21 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-21 19:22:44 +01:00
|
|
|
interface RenameResult {
|
|
|
|
|
new_path: string
|
|
|
|
|
updated_files: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function performRename(
|
|
|
|
|
path: string,
|
|
|
|
|
newTitle: string,
|
|
|
|
|
vaultPath: string,
|
|
|
|
|
): Promise<RenameResult> {
|
|
|
|
|
if (isTauri()) {
|
|
|
|
|
return invoke<RenameResult>('rename_note', { vaultPath, oldPath: path, newTitle })
|
|
|
|
|
}
|
|
|
|
|
return mockInvoke<RenameResult>('rename_note', { vault_path: vaultPath, old_path: path, new_title: newTitle })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildRenamedEntry(entry: VaultEntry, newTitle: string, newPath: string): VaultEntry {
|
|
|
|
|
const slug = newTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
|
|
|
|
return { ...entry, path: newPath, filename: `${slug}.md`, title: newTitle }
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 12:10:17 +01:00
|
|
|
async function loadNoteContent(path: string): Promise<string> {
|
|
|
|
|
return isTauri()
|
|
|
|
|
? invoke<string>('get_note_content', { path })
|
|
|
|
|
: mockInvoke<string>('get_note_content', { path })
|
2026-02-17 12:10:21 +01:00
|
|
|
}
|
|
|
|
|
|
refactor: export pure functions from useNoteActions + update coverage config
Export buildNewEntry, slugify, entryMatchesTarget, buildNoteContent,
resolveNewNote, resolveNewType as public functions for direct unit testing.
Add coverage exclusions for untestable files: shadcn/ui primitives,
AI modules (useAIChat, useMcpBridge, ai-chat, AIChatPanel), and types.ts.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:02:07 +01:00
|
|
|
export function buildNewEntry({ path, slug, title, type, status }: NewEntryParams): VaultEntry {
|
2026-02-22 10:11:52 +01:00
|
|
|
const now = Math.floor(Date.now() / 1000)
|
|
|
|
|
return {
|
|
|
|
|
path, filename: `${slug}.md`, title, isA: type,
|
|
|
|
|
aliases: [], belongsTo: [], relatedTo: [],
|
|
|
|
|
status, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
|
|
|
|
|
modifiedAt: now, createdAt: now, fileSize: 0,
|
|
|
|
|
snippet: '', relationships: {}, icon: null, color: null, order: null,
|
2026-02-17 12:10:21 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
refactor: export pure functions from useNoteActions + update coverage config
Export buildNewEntry, slugify, entryMatchesTarget, buildNoteContent,
resolveNewNote, resolveNewType as public functions for direct unit testing.
Add coverage exclusions for untestable files: shadcn/ui primitives,
AI modules (useAIChat, useMcpBridge, ai-chat, AIChatPanel), and types.ts.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:02:07 +01:00
|
|
|
export function slugify(text: string): string {
|
2026-02-22 10:11:52 +01:00
|
|
|
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
|
|
|
|
}
|
2026-02-17 12:10:21 +01:00
|
|
|
|
2026-02-21 19:15:39 +01:00
|
|
|
/** Generate a unique "Untitled <type>" name by checking existing entries. */
|
|
|
|
|
export function generateUntitledName(entries: VaultEntry[], type: string): string {
|
|
|
|
|
const baseName = `Untitled ${type.toLowerCase()}`
|
|
|
|
|
const existingTitles = new Set(entries.map(e => e.title))
|
|
|
|
|
let title = baseName
|
|
|
|
|
let counter = 2
|
|
|
|
|
while (existingTitles.has(title)) {
|
|
|
|
|
title = `${baseName} ${counter}`
|
|
|
|
|
counter++
|
|
|
|
|
}
|
|
|
|
|
return title
|
|
|
|
|
}
|
|
|
|
|
|
refactor: export pure functions from useNoteActions + update coverage config
Export buildNewEntry, slugify, entryMatchesTarget, buildNoteContent,
resolveNewNote, resolveNewType as public functions for direct unit testing.
Add coverage exclusions for untestable files: shadcn/ui primitives,
AI modules (useAIChat, useMcpBridge, ai-chat, AIChatPanel), and types.ts.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:02:07 +01:00
|
|
|
export function entryMatchesTarget(e: VaultEntry, targetLower: string, targetAsWords: string): boolean {
|
2026-02-22 10:11:52 +01:00
|
|
|
if (e.title.toLowerCase() === targetLower) return true
|
|
|
|
|
if (e.aliases.some((a) => a.toLowerCase() === targetLower)) return true
|
|
|
|
|
const pathStem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
|
|
|
|
|
if (pathStem.toLowerCase() === targetLower) return true
|
|
|
|
|
const fileStem = e.filename.replace(/\.md$/, '')
|
|
|
|
|
if (fileStem.toLowerCase() === targetLower.split('/').pop()) return true
|
|
|
|
|
return e.title.toLowerCase() === targetAsWords
|
2026-02-17 12:10:21 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 10:11:52 +01:00
|
|
|
async function invokeFrontmatter(command: string, args: Record<string, unknown>): Promise<string> {
|
|
|
|
|
return invoke<string>(command, args)
|
|
|
|
|
}
|
2026-02-21 17:24:01 +01:00
|
|
|
|
2026-02-22 10:11:52 +01:00
|
|
|
function applyMockFrontmatterUpdate(path: string, key: string, value: FrontmatterValue): string {
|
|
|
|
|
const content = updateMockFrontmatter(path, key, value)
|
|
|
|
|
updateMockContent(path, content)
|
|
|
|
|
return content
|
2026-02-21 17:24:01 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 10:11:52 +01:00
|
|
|
function applyMockFrontmatterDelete(path: string, key: string): string {
|
|
|
|
|
const content = deleteMockFrontmatterProperty(path, key)
|
|
|
|
|
updateMockContent(path, content)
|
|
|
|
|
return content
|
2026-02-21 17:24:01 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 10:11:52 +01:00
|
|
|
const TYPE_FOLDER_MAP: Record<string, string> = {
|
|
|
|
|
Note: 'note', Project: 'project', Experiment: 'experiment',
|
|
|
|
|
Responsibility: 'responsibility', Procedure: 'procedure',
|
|
|
|
|
Person: 'person', Event: 'event', Topic: 'topic',
|
2026-02-21 10:50:50 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 10:11:52 +01:00
|
|
|
const NO_STATUS_TYPES = new Set(['Topic', 'Person'])
|
|
|
|
|
|
|
|
|
|
function addEntryWithMock(entry: VaultEntry, content: string, addEntry: (e: VaultEntry, c: string) => void) {
|
|
|
|
|
if (!isTauri()) addMockEntry(entry, content)
|
|
|
|
|
addEntry(entry, content)
|
2026-02-21 10:50:50 +01:00
|
|
|
}
|
|
|
|
|
|
refactor: export pure functions from useNoteActions + update coverage config
Export buildNewEntry, slugify, entryMatchesTarget, buildNoteContent,
resolveNewNote, resolveNewType as public functions for direct unit testing.
Add coverage exclusions for untestable files: shadcn/ui primitives,
AI modules (useAIChat, useMcpBridge, ai-chat, AIChatPanel), and types.ts.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:02:07 +01:00
|
|
|
export function buildNoteContent(title: string, type: string, status: string | null): string {
|
2026-02-22 10:55:45 +01:00
|
|
|
const lines = ['---', `title: ${title}`, `is_a: ${type}`]
|
|
|
|
|
if (status) lines.push(`status: ${status}`)
|
|
|
|
|
lines.push('---')
|
|
|
|
|
return `${lines.join('\n')}\n\n# ${title}\n\n`
|
|
|
|
|
}
|
|
|
|
|
|
refactor: export pure functions from useNoteActions + update coverage config
Export buildNewEntry, slugify, entryMatchesTarget, buildNoteContent,
resolveNewNote, resolveNewType as public functions for direct unit testing.
Add coverage exclusions for untestable files: shadcn/ui primitives,
AI modules (useAIChat, useMcpBridge, ai-chat, AIChatPanel), and types.ts.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:02:07 +01:00
|
|
|
export function resolveNewNote(title: string, type: string): { entry: VaultEntry; content: string } {
|
2026-02-22 10:55:45 +01:00
|
|
|
const folder = TYPE_FOLDER_MAP[type] || slugify(type)
|
|
|
|
|
const slug = slugify(title)
|
|
|
|
|
const status = NO_STATUS_TYPES.has(type) ? null : 'Active'
|
|
|
|
|
const entry = buildNewEntry({ path: `/Users/luca/Laputa/${folder}/${slug}.md`, slug, title, type, status })
|
|
|
|
|
return { entry, content: buildNoteContent(title, type, status) }
|
|
|
|
|
}
|
|
|
|
|
|
refactor: export pure functions from useNoteActions + update coverage config
Export buildNewEntry, slugify, entryMatchesTarget, buildNoteContent,
resolveNewNote, resolveNewType as public functions for direct unit testing.
Add coverage exclusions for untestable files: shadcn/ui primitives,
AI modules (useAIChat, useMcpBridge, ai-chat, AIChatPanel), and types.ts.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:02:07 +01:00
|
|
|
export function resolveNewType(typeName: string): { entry: VaultEntry; content: string } {
|
2026-02-22 10:55:45 +01:00
|
|
|
const slug = slugify(typeName)
|
|
|
|
|
const entry = buildNewEntry({ path: `/Users/luca/Laputa/type/${slug}.md`, slug, title: typeName, type: 'Type', status: null })
|
|
|
|
|
return { entry, content: `---\nIs A: Type\n---\n\n# ${typeName}\n\n` }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function executeFrontmatterOp(op: 'update' | 'delete', path: string, key: string, value?: FrontmatterValue): Promise<string> {
|
|
|
|
|
if (op === 'update') {
|
|
|
|
|
return isTauri() ? invokeFrontmatter('update_frontmatter', { path, key, value }) : applyMockFrontmatterUpdate(path, key, value!)
|
|
|
|
|
}
|
|
|
|
|
return isTauri() ? invokeFrontmatter('delete_frontmatter_property', { path, key }) : applyMockFrontmatterDelete(path, key)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 12:10:21 +01:00
|
|
|
export function useNoteActions(
|
|
|
|
|
addEntry: (entry: VaultEntry, content: string) => void,
|
|
|
|
|
updateContent: (path: string, content: string) => void,
|
|
|
|
|
entries: VaultEntry[],
|
|
|
|
|
setToastMessage: (msg: string | null) => void,
|
|
|
|
|
) {
|
2026-02-22 10:11:52 +01:00
|
|
|
const tabMgmt = useTabManagement()
|
2026-02-22 12:10:17 +01:00
|
|
|
const { setTabs, handleSelectNote, activeTabPathRef, handleSwitchTab } = tabMgmt
|
2026-02-17 12:10:21 +01:00
|
|
|
|
2026-02-22 10:11:52 +01:00
|
|
|
const updateTabContent = useCallback((path: string, newContent: string) => {
|
2026-02-22 10:55:45 +01:00
|
|
|
setTabs((prev) => prev.map((t) => t.entry.path === path ? { ...t, content: newContent } : t))
|
2026-02-22 10:11:52 +01:00
|
|
|
updateContent(path, newContent)
|
|
|
|
|
}, [setTabs, updateContent])
|
2026-02-17 12:10:21 +01:00
|
|
|
|
|
|
|
|
const handleNavigateWikilink = useCallback((target: string) => {
|
|
|
|
|
const targetLower = target.toLowerCase()
|
2026-02-22 10:11:52 +01:00
|
|
|
const targetAsWords = target.split('/').pop()?.replace(/-/g, ' ').toLowerCase() ?? targetLower
|
|
|
|
|
const found = entries.find((e) => entryMatchesTarget(e, targetLower, targetAsWords))
|
|
|
|
|
if (found) handleSelectNote(found)
|
|
|
|
|
else console.warn(`Navigation target not found: ${target}`)
|
2026-02-17 12:10:21 +01:00
|
|
|
}, [entries, handleSelectNote])
|
|
|
|
|
|
2026-02-22 10:55:45 +01:00
|
|
|
const handleCreateNote = useCallback((title: string, type: string) => {
|
|
|
|
|
const { entry, content } = resolveNewNote(title, type)
|
|
|
|
|
addEntryWithMock(entry, content, addEntry)
|
|
|
|
|
handleSelectNote(entry)
|
2026-02-17 12:10:21 +01:00
|
|
|
}, [handleSelectNote, addEntry])
|
|
|
|
|
|
2026-02-22 10:55:45 +01:00
|
|
|
const handleCreateType = useCallback((typeName: string) => {
|
|
|
|
|
const { entry, content } = resolveNewType(typeName)
|
|
|
|
|
addEntryWithMock(entry, content, addEntry)
|
|
|
|
|
handleSelectNote(entry)
|
2026-02-21 09:41:46 +01:00
|
|
|
}, [handleSelectNote, addEntry])
|
|
|
|
|
|
2026-02-22 10:55:45 +01:00
|
|
|
const runFrontmatterOp = useCallback(async (op: 'update' | 'delete', path: string, key: string, value?: FrontmatterValue) => {
|
2026-02-17 12:10:21 +01:00
|
|
|
try {
|
2026-02-22 10:55:45 +01:00
|
|
|
updateTabContent(path, await executeFrontmatterOp(op, path, key, value))
|
|
|
|
|
setToastMessage(op === 'update' ? 'Property updated' : 'Property deleted')
|
2026-02-17 12:10:21 +01:00
|
|
|
} catch (err) {
|
2026-02-22 10:55:45 +01:00
|
|
|
console.error(`Failed to ${op} frontmatter:`, err)
|
|
|
|
|
setToastMessage(`Failed to ${op} property`)
|
2026-02-17 12:10:21 +01:00
|
|
|
}
|
2026-02-22 10:11:52 +01:00
|
|
|
}, [updateTabContent, setToastMessage])
|
2026-02-17 12:10:21 +01:00
|
|
|
|
2026-02-21 19:22:44 +01:00
|
|
|
const handleRenameNote = useCallback(async (
|
|
|
|
|
path: string,
|
|
|
|
|
newTitle: string,
|
|
|
|
|
vaultPath: string,
|
|
|
|
|
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
|
|
|
|
|
) => {
|
|
|
|
|
try {
|
|
|
|
|
const result = await performRename(path, newTitle, vaultPath)
|
|
|
|
|
const newContent = await loadNoteContent(result.new_path)
|
2026-02-22 12:10:17 +01:00
|
|
|
const entry = entries.find((e) => e.path === path)
|
|
|
|
|
const newEntry = buildRenamedEntry(entry ?? {} as VaultEntry, newTitle, result.new_path)
|
2026-02-21 19:22:44 +01:00
|
|
|
|
|
|
|
|
setTabs((prev) => prev.map((t) => t.entry.path === path ? { entry: newEntry, content: newContent } : t))
|
2026-02-22 12:10:17 +01:00
|
|
|
if (activeTabPathRef.current === path) handleSwitchTab(result.new_path)
|
2026-02-21 19:22:44 +01:00
|
|
|
onEntryRenamed(path, newEntry, newContent)
|
|
|
|
|
|
|
|
|
|
const n = result.updated_files
|
|
|
|
|
setToastMessage(n > 0 ? `Renamed — updated ${n} wiki link${n > 1 ? 's' : ''}` : 'Renamed')
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Failed to rename note:', err)
|
|
|
|
|
setToastMessage('Failed to rename note')
|
|
|
|
|
}
|
2026-02-22 12:10:17 +01:00
|
|
|
}, [entries, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage])
|
2026-02-21 19:22:44 +01:00
|
|
|
|
2026-02-17 12:10:21 +01:00
|
|
|
return {
|
2026-02-22 10:55:45 +01:00
|
|
|
...tabMgmt,
|
2026-02-17 12:10:21 +01:00
|
|
|
handleNavigateWikilink,
|
|
|
|
|
handleCreateNote,
|
2026-02-21 09:41:46 +01:00
|
|
|
handleCreateType,
|
2026-02-22 10:55:45 +01:00
|
|
|
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]),
|
|
|
|
|
handleAddProperty: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
|
2026-02-21 19:22:44 +01:00
|
|
|
handleRenameNote,
|
2026-02-17 12:10:21 +01:00
|
|
|
}
|
|
|
|
|
}
|