feat: note templates per type (💡 Note templates per tipo) (#170)

* feat: add template field to type entries and template-aware note creation

- Rust: add template field to Frontmatter, VaultEntry, SKIP_KEYS
- Rust: support YAML block scalar (|) for multi-line strings in frontmatter
- Rust: fix value continuation detection to handle block scalars properly
- TypeScript: add template to VaultEntry, buildNewEntry, frontmatterToEntryPatch
- Add DEFAULT_TEMPLATES for Project, Person, Responsibility, Experiment
- resolveNewNote/buildNoteContent accept optional template parameter
- handleCreateNote and handleCreateNoteImmediate look up type template
- Tests for all new behavior (Rust + TypeScript)

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

* feat: wire template UI through Sidebar and App, add TypeCustomizePopover template section

- Add template textarea with debounced save to TypeCustomizePopover
- Wire handleUpdateTypeTemplate through useEntryActions → Sidebar → App
- Add useDebouncedCallback hook for 500ms template save debounce
- Add tests for handleUpdateTypeTemplate and TypeCustomizePopover template UI
- Create design/note-templates.pen placeholder

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

* fix: useRef type arg + template field in bulk mock entries

* style: rustfmt

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Luca Rossi
2026-03-02 08:37:06 +01:00
committed by GitHub
parent 7d563b28fd
commit 986965a28f
34 changed files with 536 additions and 143 deletions

View File

@@ -1,42 +0,0 @@
# Drag & Drop Images — Implementation Summary
## Problem
Dragging image files from Finder into the BlockNote editor didn't work. Tauri v2's
`dragDropEnabled` defaults to `true`, which intercepts OS-level file drops before they
reach the webview's HTML5 DnD API — BlockNote never received the dropped files.
## Solution
Instead of disabling Tauri's drag interception, we use Tauri's `onDragDropEvent` API
to listen for native file drops and handle them directly:
1. **Rust backend** (`src-tauri/src/vault/image.rs`):
- Added `copy_image_to_vault` command that copies a file by OS path into
`vault/attachments/` with a timestamp-prefixed filename
- Refactored shared path logic into `prepare_attachment_path` helper
- Validates file exists and has an image extension before copying
2. **Frontend** (`src/hooks/useImageDrop.ts`):
- Added Tauri `onDragDropEvent` listener that fires on native OS drag-drop
- On `drop`: filters for image paths, copies each to vault, calls `onImageUrl`
- On `over`: shows drag overlay (paths aren't available until drop)
- `copyImageToVault` invokes the new Rust command and returns an asset URL
3. **Editor integration** (`src/components/SingleEditorView.tsx`):
- `useInsertImageCallback` inserts a BlockNote image block at cursor position
- Wired into `useImageDrop` via the `onImageUrl` callback
- `vaultPath` threaded through Editor → EditorContent → SingleEditorView
4. **Existing `/image` slash command** continues to work unchanged (uses `uploadFile`
on `useCreateBlockNote` → `uploadImageFile` → `save_image` Tauri command).
## Commits
- `d45f2e2` feat: add copy_image_to_vault Rust command for native drag-drop
- `b047d0c` feat: handle Tauri native drag-drop for filesystem images
- `2e04f5a` fix: remove nonexistent drag-drop permission from capabilities
- `23ad982` fix: correct DragDropEvent type handling for 'over' events
## Tests
- 4 new Rust tests for `copy_image_to_vault` (success, missing file, non-image, all extensions)
- 1 new frontend test for `useImageDrop` with onImageUrl + vaultPath params
- All 300 Rust tests pass, 1167 frontend tests pass
- Coverage: Rust 85.47%, Frontend 78.64%

1
.claude-pid Normal file
View File

@@ -0,0 +1 @@
81859

View File

@@ -0,0 +1 @@
{"children":[],"variables":{}}

View File

@@ -15,7 +15,7 @@ pub enum FrontmatterValue {
/// Characters that require a YAML string value to be quoted.
fn has_yaml_special_chars(s: &str) -> bool {
s.contains(':') || s.contains('#') || s.contains('\n')
s.contains(':') || s.contains('#')
}
/// Check if a string starts with a YAML collection indicator (array or map).
@@ -41,6 +41,23 @@ fn format_list_item(item: &str) -> String {
format!(" - {}", quote_yaml_string(item))
}
/// Format a multi-line string as a YAML block scalar (`|`).
/// Each line is indented by 2 spaces; empty lines are preserved as blank.
fn format_block_scalar(s: &str) -> String {
let indented = s
.lines()
.map(|l| {
if l.is_empty() {
String::new()
} else {
format!(" {}", l)
}
})
.collect::<Vec<_>>()
.join("\n");
format!("|\n{}", indented)
}
/// Format a number for YAML (integers without decimal, floats with).
fn format_yaml_number(n: f64) -> String {
if n.fract() == 0.0 {
@@ -54,7 +71,9 @@ impl FrontmatterValue {
pub fn to_yaml_value(&self) -> String {
match self {
FrontmatterValue::String(s) => {
if needs_yaml_quoting(s) {
if s.contains('\n') {
format_block_scalar(s)
} else if needs_yaml_quoting(s) {
quote_yaml_string(s)
} else {
s.clone()
@@ -113,16 +132,20 @@ fn line_is_key(line: &str, key: &str) -> bool {
fn format_yaml_field(key: &str, value: &FrontmatterValue) -> Vec<String> {
let yaml_key = format_yaml_key(key);
let yaml_value = value.to_yaml_value();
if yaml_value.contains('\n') {
if yaml_value.starts_with("|\n") {
// Block scalar: key and indicator on the same line, content follows
vec![format!("{}: {}", yaml_key, yaml_value)]
} else if yaml_value.contains('\n') {
vec![format!("{}:", yaml_key), yaml_value]
} else {
vec![format!("{}: {}", yaml_key, yaml_value)]
}
}
/// Check if a line is a YAML list continuation (` - ...`) rather than a new key.
fn is_list_continuation(line: &str) -> bool {
line.starts_with(" - ") || line.starts_with(" -\t")
/// Check if a line continues the previous key's value (indented list item,
/// block scalar content, or blank line inside a block scalar).
fn is_value_continuation(line: &str) -> bool {
line.is_empty() || line.starts_with(" ") || line.starts_with('\t')
}
/// Split content into frontmatter body and the rest after the closing `---`.
@@ -163,8 +186,8 @@ fn apply_field_update(lines: &[&str], key: &str, value: Option<&FrontmatterValue
found_key = true;
i += 1;
// Skip list continuation lines belonging to this key
while i < lines.len() && is_list_continuation(lines[i]) {
// Skip continuation lines belonging to this key (lists, block scalars)
while i < lines.len() && is_value_continuation(lines[i]) {
i += 1;
}
// Insert replacement value (if any)
@@ -699,6 +722,94 @@ mod tests {
assert!(updated.contains("title: New Title"));
}
// --- block scalar (multi-line string) tests ---
#[test]
fn test_to_yaml_value_multiline_uses_block_scalar() {
let v = FrontmatterValue::String("line 1\nline 2\nline 3".to_string());
let yaml = v.to_yaml_value();
assert!(yaml.starts_with("|\n"));
assert!(yaml.contains(" line 1"));
assert!(yaml.contains(" line 2"));
}
#[test]
fn test_format_yaml_field_block_scalar() {
let v = FrontmatterValue::String("## Objective\n\n## Timeline".to_string());
let lines = format_yaml_field("template", &v);
assert_eq!(lines.len(), 1);
assert!(lines[0].starts_with("template: |\n"));
assert!(lines[0].contains(" ## Objective"));
assert!(lines[0].contains(" ## Timeline"));
}
#[test]
fn test_update_frontmatter_block_scalar_add() {
let content = "---\ntype: Type\n---\n# Project\n";
let template = "## Objective\n\n## Timeline";
let updated = update_frontmatter_content(
content,
"template",
Some(FrontmatterValue::String(template.to_string())),
)
.unwrap();
assert!(updated.contains("template: |"));
assert!(updated.contains(" ## Objective"));
assert!(updated.contains(" ## Timeline"));
assert!(updated.contains("type: Type"));
}
#[test]
fn test_update_frontmatter_block_scalar_replace() {
let content = "---\ntype: Type\ntemplate: |\n ## Old\n \n ## Stuff\ncolor: green\n---\n# Project\n";
let new_template = "## New\n\n## Content";
let updated = update_frontmatter_content(
content,
"template",
Some(FrontmatterValue::String(new_template.to_string())),
)
.unwrap();
assert!(updated.contains(" ## New"));
assert!(updated.contains(" ## Content"));
assert!(!updated.contains("## Old"));
assert!(!updated.contains("## Stuff"));
assert!(updated.contains("color: green"));
}
#[test]
fn test_delete_frontmatter_block_scalar() {
let content =
"---\ntype: Type\ntemplate: |\n ## Heading\n \n ## Body\ncolor: green\n---\n# Project\n";
let updated = update_frontmatter_content(content, "template", None).unwrap();
assert!(!updated.contains("template"));
assert!(!updated.contains("## Heading"));
assert!(updated.contains("color: green"));
}
#[test]
fn test_roundtrip_block_scalar() {
let content = "---\ntype: Type\n---\n# Project\n";
let template = "## Objective\n\nDescribe the goal.\n\n## Timeline\n\nKey dates.";
let updated = update_frontmatter_content(
content,
"template",
Some(FrontmatterValue::String(template.to_string())),
)
.unwrap();
// Parse back with gray_matter
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
let parsed = matter.parse(&updated);
let data = parsed.data.unwrap();
if let gray_matter::Pod::Hash(map) = data {
let roundtripped = map.get("template").unwrap().as_string().unwrap();
assert!(roundtripped.contains("## Objective"));
assert!(roundtripped.contains("## Timeline"));
assert!(roundtripped.contains("Describe the goal."));
} else {
panic!("Expected hash");
}
}
#[test]
fn test_update_frontmatter_no_body_after_closing() {
// Frontmatter with title, no body after closing ---

View File

@@ -65,6 +65,9 @@ pub struct VaultEntry {
/// Custom sidebar section label for Type entries, overriding auto-pluralization.
#[serde(rename = "sidebarLabel")]
pub sidebar_label: Option<String>,
/// Markdown template for notes of this Type. When a new note is created
/// with this type, the template body is pre-filled after the frontmatter.
pub template: Option<String>,
/// Word count of the note body (excludes frontmatter and H1 title).
#[serde(rename = "wordCount")]
pub word_count: u32,
@@ -109,6 +112,8 @@ struct Frontmatter {
order: Option<i64>,
#[serde(rename = "sidebar label", default)]
sidebar_label: Option<String>,
#[serde(default)]
template: Option<String>,
}
/// Handles YAML fields that can be either a single string or a list of strings.
@@ -153,6 +158,7 @@ const SKIP_KEYS: &[&str] = &[
"color",
"order",
"sidebar label",
"template",
];
/// Extract all wikilink-containing fields from raw YAML frontmatter.
@@ -332,6 +338,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
color: frontmatter.color,
order: frontmatter.order,
sidebar_label: frontmatter.sidebar_label,
template: frontmatter.template,
word_count,
outgoing_links,
})
@@ -1088,6 +1095,45 @@ References:
assert!(entry.relationships.get("sidebar label").is_none());
}
// --- template field tests ---
#[test]
fn test_parse_template_from_type_entry() {
let dir = TempDir::new().unwrap();
let content =
"---\ntype: Type\ntemplate: \"## Objective\\n\\n## Timeline\"\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert!(entry.template.is_some());
}
#[test]
fn test_parse_template_block_scalar() {
let dir = TempDir::new().unwrap();
let content =
"---\ntype: Type\ntemplate: |\n ## Objective\n \n ## Timeline\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert!(entry.template.is_some());
let tmpl = entry.template.unwrap();
assert!(tmpl.contains("## Objective"));
assert!(tmpl.contains("## Timeline"));
}
#[test]
fn test_parse_template_missing_defaults_to_none() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\n---\n# Note\n";
let entry = parse_test_entry(&dir, "type/note.md", content);
assert_eq!(entry.template, None);
}
#[test]
fn test_template_not_in_relationships() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\ntemplate: \"## Heading\"\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert!(entry.relationships.get("template").is_none());
}
// Frontmatter update/delete tests are in frontmatter.rs
// save_image tests are in vault/image.rs
// purge_trash tests are in vault/trash.rs

View File

@@ -34,6 +34,7 @@ const mockEntries = [
modifiedAt: 1700000000,
createdAt: null,
fileSize: 1024,
template: null,
outgoingLinks: [],
},
{
@@ -50,6 +51,7 @@ const mockEntries = [
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
template: null,
outgoingLinks: [],
},
]

View File

@@ -332,7 +332,7 @@ function App() {
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onReorderSections={entryActions.handleReorderSections} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} />
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>

View File

@@ -51,6 +51,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
...overrides,
})

View File

@@ -75,6 +75,7 @@ const mockEntry: VaultEntry = {
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
}

View File

@@ -26,6 +26,7 @@ const mockEntry: VaultEntry = {
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
}
@@ -70,6 +71,7 @@ const referrerEntry: VaultEntry = {
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: ['Test Project'],
}
@@ -374,6 +376,7 @@ This is a test note with some words to count.
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
}
@@ -400,6 +403,7 @@ This is a test note with some words to count.
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
}
@@ -426,6 +430,7 @@ This is a test note with some words to count.
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
}
@@ -452,6 +457,7 @@ This is a test note with some words to count.
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
}
@@ -594,6 +600,7 @@ Status: Active
icon: null,
color: null,
order: null,
template: null,
// Body text also links to grow-newsletter
outgoingLinks: ['responsibility/grow-newsletter'],
}

View File

@@ -30,6 +30,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
...overrides,
})

View File

@@ -34,6 +34,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
},
{
@@ -62,6 +63,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
},
{
@@ -87,6 +89,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
},
{
@@ -112,6 +115,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
},
{
@@ -137,6 +141,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
},
]
@@ -357,6 +362,7 @@ describe('getSortComparator', () => {
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
...overrides,
})
@@ -469,6 +475,7 @@ describe('NoteList sort controls', () => {
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
...overrides,
})
@@ -663,6 +670,7 @@ const trashedEntry: VaultEntry = {
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
}
@@ -689,6 +697,7 @@ const expiredTrashedEntry: VaultEntry = {
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
}
@@ -844,6 +853,7 @@ describe('NoteList — virtual list with large datasets', () => {
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
...overrides,
})
@@ -1145,6 +1155,7 @@ const typeEntry: VaultEntry = {
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
}

View File

@@ -26,6 +26,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
...overrides,
})

View File

@@ -38,6 +38,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: ['topic/ai', 'topic/api-design', 'person/luca'],
},
{
@@ -63,6 +64,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: ['person/bob'],
},
]

View File

@@ -40,6 +40,7 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -66,6 +67,7 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -92,6 +94,7 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -118,6 +121,7 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -144,6 +148,7 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -170,6 +175,7 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -196,6 +202,7 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -222,6 +229,7 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
]
@@ -441,6 +449,7 @@ describe('Sidebar', () => {
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -467,6 +476,7 @@ describe('Sidebar', () => {
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -492,6 +502,7 @@ describe('Sidebar', () => {
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
},
{
@@ -517,6 +528,7 @@ describe('Sidebar', () => {
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
},
]
@@ -602,6 +614,7 @@ describe('Sidebar', () => {
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
}
render(<Sidebar entries={[...mockEntries, projectTypeEntry]} selection={defaultSelection} onSelect={() => {}} />)

View File

@@ -32,6 +32,7 @@ interface SidebarProps {
onCreateType?: (type: string) => void
onCreateNewType?: () => void
onCustomizeType?: (typeName: string, icon: string, color: string) => void
onUpdateTypeTemplate?: (typeName: string, template: string) => void
onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void
modifiedCount?: number
onCommitPush?: () => void
@@ -229,10 +230,11 @@ function ContextMenuOverlay({ pos, type, innerRef, onOpenCustomize }: {
)
}
function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onClose }: {
function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onChangeTemplate, onClose }: {
target: string | null; typeEntryMap: Record<string, VaultEntry>
innerRef: React.Ref<HTMLDivElement>
onCustomize: (prop: 'icon' | 'color', value: string) => void
onChangeTemplate: (template: string) => void
onClose: () => void
}) {
if (!target) return null
@@ -241,8 +243,10 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onClose
<TypeCustomizePopover
currentIcon={typeEntryMap[target]?.icon ?? null}
currentColor={typeEntryMap[target]?.color ?? null}
currentTemplate={typeEntryMap[target]?.template ?? null}
onChangeIcon={(icon) => onCustomize('icon', icon)}
onChangeColor={(color) => onCustomize('color', color)}
onChangeTemplate={onChangeTemplate}
onClose={onClose}
/>
</div>
@@ -253,7 +257,7 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onClose
export const Sidebar = memo(function Sidebar({
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
onCustomizeType, onReorderSections, modifiedCount = 0, onCommitPush, onCollapse,
onCustomizeType, onUpdateTypeTemplate, onReorderSections, modifiedCount = 0, onCommitPush, onCollapse,
}: SidebarProps) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
@@ -302,6 +306,10 @@ export const Sidebar = memo(function Sidebar({
applyCustomization(customizeTarget, typeEntryMap, onCustomizeType, prop, value)
}, [customizeTarget, typeEntryMap, onCustomizeType])
const handleChangeTemplate = useCallback((template: string) => {
if (customizeTarget) onUpdateTypeTemplate?.(customizeTarget, template)
}, [customizeTarget, onUpdateTypeTemplate])
const sectionProps = {
entries, collapsed, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
onContextMenu: handleContextMenu, onToggle: toggleSection,
@@ -346,7 +354,7 @@ export const Sidebar = memo(function Sidebar({
<CommitButton modifiedCount={modifiedCount} onClick={onCommitPush} />
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { closeContextMenu(); setCustomizeTarget(type) }} />
<CustomizeOverlay target={customizeTarget} typeEntryMap={typeEntryMap} innerRef={popoverRef} onCustomize={handleCustomize} onClose={closeCustomizeTarget} />
<CustomizeOverlay target={customizeTarget} typeEntryMap={typeEntryMap} innerRef={popoverRef} onCustomize={handleCustomize} onChangeTemplate={handleChangeTemplate} onClose={closeCustomizeTarget} />
</aside>
)
})

View File

@@ -10,7 +10,7 @@ function makeEntry(path: string, title: string): VaultEntry {
status: null, owner: null, cadence: null, archived: false,
trashed: false, trashedAt: null,
modifiedAt: null, createdAt: null, fileSize: 0,
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [],
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, outgoingLinks: [],
}
}

View File

@@ -43,50 +43,42 @@ describe('ICON_OPTIONS', () => {
describe('TypeCustomizePopover', () => {
const onChangeIcon = vi.fn()
const onChangeColor = vi.fn()
const onChangeTemplate = vi.fn()
const onClose = vi.fn()
const renderPopover = (overrides: Partial<Parameters<typeof TypeCustomizePopover>[0]> = {}) =>
render(
<TypeCustomizePopover
currentIcon={null}
currentColor={null}
currentTemplate={null}
onChangeIcon={onChangeIcon}
onChangeColor={onChangeColor}
onChangeTemplate={onChangeTemplate}
onClose={onClose}
{...overrides}
/>
)
beforeEach(() => {
vi.clearAllMocks()
})
it('renders color section and icon section', () => {
render(
<TypeCustomizePopover
currentIcon="wrench"
currentColor="blue"
onChangeIcon={onChangeIcon}
onChangeColor={onChangeColor}
onClose={onClose}
/>
)
it('renders color, icon, and template sections', () => {
renderPopover()
expect(screen.getByText('COLOR')).toBeInTheDocument()
expect(screen.getByText('ICON')).toBeInTheDocument()
expect(screen.getByText('TEMPLATE')).toBeInTheDocument()
expect(screen.getByText('Done')).toBeInTheDocument()
})
it('renders search input', () => {
render(
<TypeCustomizePopover
currentIcon={null}
currentColor={null}
onChangeIcon={onChangeIcon}
onChangeColor={onChangeColor}
onClose={onClose}
/>
)
renderPopover()
expect(screen.getByPlaceholderText('Search icons…')).toBeInTheDocument()
})
it('filters icons by search query', () => {
render(
<TypeCustomizePopover
currentIcon={null}
currentColor={null}
onChangeIcon={onChangeIcon}
onChangeColor={onChangeColor}
onClose={onClose}
/>
)
renderPopover()
const searchInput = screen.getByPlaceholderText('Search icons…')
fireEvent.change(searchInput, { target: { value: 'book' } })
@@ -99,15 +91,7 @@ describe('TypeCustomizePopover', () => {
})
it('shows empty state when no icons match search', () => {
render(
<TypeCustomizePopover
currentIcon={null}
currentColor={null}
onChangeIcon={onChangeIcon}
onChangeColor={onChangeColor}
onClose={onClose}
/>
)
renderPopover()
const searchInput = screen.getByPlaceholderText('Search icons…')
fireEvent.change(searchInput, { target: { value: 'zzzznonexistent' } })
@@ -116,15 +100,7 @@ describe('TypeCustomizePopover', () => {
})
it('calls onChangeColor when a color is clicked', () => {
render(
<TypeCustomizePopover
currentIcon={null}
currentColor={null}
onChangeIcon={onChangeIcon}
onChangeColor={onChangeColor}
onClose={onClose}
/>
)
renderPopover()
const colorButtons = screen.getAllByTitle(/red|blue|green|purple|yellow|orange|teal|pink/i)
fireEvent.click(colorButtons[0])
@@ -133,47 +109,70 @@ describe('TypeCustomizePopover', () => {
})
it('calls onChangeIcon when an icon is clicked', () => {
render(
<TypeCustomizePopover
currentIcon={null}
currentColor={null}
onChangeIcon={onChangeIcon}
onChangeColor={onChangeColor}
onClose={onClose}
/>
)
renderPopover()
fireEvent.click(screen.getByTitle('wrench'))
expect(onChangeIcon).toHaveBeenCalledWith('wrench')
})
it('calls onClose when Done is clicked', () => {
render(
<TypeCustomizePopover
currentIcon={null}
currentColor={null}
onChangeIcon={onChangeIcon}
onChangeColor={onChangeColor}
onClose={onClose}
/>
)
renderPopover()
fireEvent.click(screen.getByText('Done'))
expect(onClose).toHaveBeenCalled()
})
it('renders all color options including teal and pink', () => {
render(
<TypeCustomizePopover
currentIcon={null}
currentColor={null}
onChangeIcon={onChangeIcon}
onChangeColor={onChangeColor}
onClose={onClose}
/>
)
renderPopover()
expect(screen.getByTitle('Teal')).toBeInTheDocument()
expect(screen.getByTitle('Pink')).toBeInTheDocument()
})
// --- Template tests ---
it('renders template textarea', () => {
renderPopover()
expect(screen.getByTestId('template-textarea')).toBeInTheDocument()
})
it('shows placeholder when template is empty', () => {
renderPopover()
expect(screen.getByPlaceholderText('Markdown template for new notes of this type…')).toBeInTheDocument()
})
it('displays current template value', () => {
renderPopover({ currentTemplate: '## Objective\n\n## Notes' })
const textarea = screen.getByTestId('template-textarea') as HTMLTextAreaElement
expect(textarea.value).toBe('## Objective\n\n## Notes')
})
it('updates template text on user input', () => {
renderPopover()
const textarea = screen.getByTestId('template-textarea')
fireEvent.change(textarea, { target: { value: '## New Template' } })
expect((textarea as HTMLTextAreaElement).value).toBe('## New Template')
})
it('calls onChangeTemplate after debounce', async () => {
vi.useFakeTimers()
renderPopover()
const textarea = screen.getByTestId('template-textarea')
fireEvent.change(textarea, { target: { value: '## Debounced' } })
// Should not be called immediately
expect(onChangeTemplate).not.toHaveBeenCalled()
// Fast-forward past debounce
vi.advanceTimersByTime(600)
expect(onChangeTemplate).toHaveBeenCalledWith('## Debounced')
vi.useRealTimers()
})
it('treats null template as empty string', () => {
renderPopover({ currentTemplate: null })
const textarea = screen.getByTestId('template-textarea') as HTMLTextAreaElement
expect(textarea.value).toBe('')
})
})

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react'
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
import { MagnifyingGlass } from '@phosphor-icons/react'
import { ICON_OPTIONS, type IconEntry } from '../utils/iconRegistry'
import { ACCENT_COLORS } from '../utils/typeColors'
@@ -13,21 +13,40 @@ function filterIcons(icons: IconEntry[], query: string): IconEntry[] {
interface TypeCustomizePopoverProps {
currentIcon: string | null
currentColor: string | null
currentTemplate: string | null
onChangeIcon: (icon: string) => void
onChangeColor: (color: string) => void
onChangeTemplate: (template: string) => void
onClose: () => void
}
/** Debounce a callback by `delay` ms. Returns a stable ref-based wrapper. */
function useDebouncedCallback(fn: (v: string) => void, delay: number): (v: string) => void {
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const fnRef = useRef(fn)
useEffect(() => { fnRef.current = fn })
useEffect(() => () => { clearTimeout(timerRef.current) }, [])
return useCallback((v: string) => {
clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => fnRef.current(v), delay)
}, [delay])
}
export function TypeCustomizePopover({
currentIcon,
currentColor,
currentTemplate,
onChangeIcon,
onChangeColor,
onChangeTemplate,
onClose,
}: TypeCustomizePopoverProps) {
const [selectedColor, setSelectedColor] = useState(currentColor)
const [selectedIcon, setSelectedIcon] = useState(currentIcon)
const [search, setSearch] = useState('')
const [templateText, setTemplateText] = useState(currentTemplate ?? '')
const filteredIcons = useMemo(() => filterIcons(ICON_OPTIONS, search), [search])
@@ -41,10 +60,17 @@ export function TypeCustomizePopover({
onChangeIcon(name)
}
const debouncedSaveTemplate = useDebouncedCallback(onChangeTemplate, 500)
const handleTemplateChange = (value: string) => {
setTemplateText(value)
debouncedSaveTemplate(value)
}
return (
<div
className="bg-popover text-popover-foreground z-50 rounded-lg border shadow-md"
style={{ width: 280, padding: 12 }}
style={{ width: 320, padding: 12 }}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.stopPropagation()}
>
@@ -84,7 +110,7 @@ export function TypeCustomizePopover({
</div>
{/* Icon grid */}
<div className="flex flex-wrap gap-1 overflow-y-auto" style={{ maxHeight: 240 }}>
<div className="flex flex-wrap gap-1 overflow-y-auto" style={{ maxHeight: 160 }}>
{filteredIcons.length === 0 ? (
<div className="w-full py-6 text-center text-[12px] text-muted-foreground">
No icons found
@@ -109,6 +135,17 @@ export function TypeCustomizePopover({
)}
</div>
{/* Template section */}
<div className="font-mono-overline mb-2 mt-3 text-muted-foreground">TEMPLATE</div>
<textarea
value={templateText}
onChange={(e) => handleTemplateChange(e.target.value)}
placeholder="Markdown template for new notes of this type…"
className="w-full rounded border border-border bg-background px-2 py-1.5 text-[12px] font-mono text-foreground placeholder:text-muted-foreground outline-none focus:border-primary resize-y"
style={{ minHeight: 80, maxHeight: 200 }}
data-testid="template-textarea"
/>
{/* Done button */}
<div className="mt-3 flex justify-end">
<button

View File

@@ -26,6 +26,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
...overrides,
})

View File

@@ -26,6 +26,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
...overrides,
})
@@ -142,6 +143,43 @@ describe('useEntryActions', () => {
})
})
describe('handleUpdateTypeTemplate', () => {
it('updates template on the type entry', () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
const { result } = setup([typeEntry])
act(() => {
result.current.handleUpdateTypeTemplate('Project', '## Objective\n\n## Notes')
})
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'template', '## Objective\n\n## Notes')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { template: '## Objective\n\n## Notes' })
})
it('sets template to null when empty string', () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
const { result } = setup([typeEntry])
act(() => {
result.current.handleUpdateTypeTemplate('Project', '')
})
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'template', '')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { template: null })
})
it('does nothing when type entry not found', () => {
const { result } = setup([])
act(() => {
result.current.handleUpdateTypeTemplate('NonExistent', '## Template')
})
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
expect(updateEntry).not.toHaveBeenCalled()
})
})
describe('handleReorderSections', () => {
it('updates order on multiple type entries', () => {
const typeA = makeEntry({ isA: 'Type', title: 'Note', path: '/vault/type/note.md' })

View File

@@ -60,5 +60,12 @@ export function useEntryActions({
}
}, [entries, handleUpdateFrontmatter, updateEntry])
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections }
const handleUpdateTypeTemplate = useCallback((typeName: string, template: string) => {
const typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) return
handleUpdateFrontmatter(typeEntry.path, 'template', template)
updateEntry(typeEntry.path, { template: template || null })
}, [entries, handleUpdateFrontmatter, updateEntry])
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate }
}

View File

@@ -30,6 +30,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
...overrides,
})

View File

@@ -17,6 +17,8 @@ import {
resolveDailyNote,
findDailyNote,
useNoteActions,
DEFAULT_TEMPLATES,
resolveTemplate,
} from './useNoteActions'
import type { NoteActionsConfig } from './useNoteActions'
@@ -57,6 +59,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
color: null,
order: null,
outgoingLinks: [],
template: null,
...overrides,
})
@@ -192,6 +195,47 @@ describe('buildNoteContent', () => {
const content = buildNoteContent('AI', 'Topic', null)
expect(content).toBe('---\ntitle: AI\ntype: Topic\n---\n\n# AI\n\n')
})
it('includes template body when provided', () => {
const content = buildNoteContent('My Project', 'Project', 'Active', '## Objective\n\n## Notes\n\n')
expect(content).toContain('# My Project')
expect(content).toContain('## Objective')
expect(content).toContain('## Notes')
})
it('ignores null template', () => {
const content = buildNoteContent('My Note', 'Note', 'Active', null)
expect(content).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n\n# My Note\n\n')
})
})
describe('resolveTemplate', () => {
it('returns template from type entry when set', () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', template: '## Ingredients\n\n## Steps\n\n' })
expect(resolveTemplate([typeEntry], 'Recipe')).toBe('## Ingredients\n\n## Steps\n\n')
})
it('falls back to DEFAULT_TEMPLATES for built-in types', () => {
expect(resolveTemplate([], 'Project')).toBe(DEFAULT_TEMPLATES.Project)
})
it('returns null when no template and no default', () => {
expect(resolveTemplate([], 'CustomType')).toBeNull()
})
it('type entry template overrides default', () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', template: '## Custom\n\n' })
expect(resolveTemplate([typeEntry], 'Project')).toBe('## Custom\n\n')
})
})
describe('DEFAULT_TEMPLATES', () => {
it('has templates for Project, Person, Responsibility, Experiment', () => {
expect(DEFAULT_TEMPLATES.Project).toBeDefined()
expect(DEFAULT_TEMPLATES.Person).toBeDefined()
expect(DEFAULT_TEMPLATES.Responsibility).toBeDefined()
expect(DEFAULT_TEMPLATES.Experiment).toBeDefined()
})
})
describe('resolveNewNote', () => {
@@ -244,6 +288,7 @@ describe('frontmatterToEntryPatch', () => {
['archived', true, { archived: true }],
['trashed', true, { trashed: true }],
['order', 5, { order: 5 }],
['template', '## Heading\n\n', { template: '## Heading\n\n' }],
] as [string, unknown, Partial<VaultEntry>][])(
'maps %s update to correct entry field',
(key, value, expected) => {
@@ -274,6 +319,7 @@ describe('frontmatterToEntryPatch', () => {
['aliases', { aliases: [] }],
['archived', { archived: false }],
['order', { order: null }],
['template', { template: null }],
] as [string, Partial<VaultEntry>][])(
'maps delete of %s to null/default',
(key, expected) => {
@@ -523,6 +569,43 @@ describe('useNoteActions hook', () => {
expect(createdEntry.isA).toBe('Project')
})
it('handleCreateNote uses default template for Project type', () => {
const { result } = renderHook(() => useNoteActions(makeConfig()))
act(() => {
result.current.handleCreateNote('My Project', 'Project')
})
const [, createdContent] = addEntry.mock.calls[0]
expect(createdContent).toContain('## Objective')
expect(createdContent).toContain('## Key Results')
})
it('handleCreateNote uses custom template from type entry', () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', template: '## Ingredients\n\n## Steps\n\n' })
const { result } = renderHook(() => useNoteActions(makeConfig([typeEntry])))
act(() => {
result.current.handleCreateNote('Pasta', 'Recipe')
})
const [, createdContent] = addEntry.mock.calls[0]
expect(createdContent).toContain('## Ingredients')
expect(createdContent).toContain('## Steps')
})
it('handleCreateNoteImmediate uses template for typed notes', () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', template: '## Custom Template\n\n' })
const { result } = renderHook(() => useNoteActions(makeConfig([typeEntry])))
act(() => {
result.current.handleCreateNoteImmediate('Project')
})
const [, createdContent] = addEntry.mock.calls[0]
expect(createdContent).toContain('## Custom Template')
})
it('handleUpdateFrontmatter does not call updateEntry for unknown keys', async () => {
const { result } = renderHook(() => useNoteActions(makeConfig()))

View File

@@ -72,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: [], sidebarLabel: null,
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null,
}
}
@@ -129,11 +129,26 @@ const TYPE_FOLDER_MAP: Record<string, string> = {
const NO_STATUS_TYPES = new Set(['Topic', 'Person', 'Journal'])
/** Default templates for built-in types. Used when the type entry has no custom template. */
export const DEFAULT_TEMPLATES: Record<string, string> = {
Project: '## Objective\n\n\n\n## Key Results\n\n\n\n## Notes\n\n',
Person: '## Role\n\n\n\n## Contact\n\n\n\n## Notes\n\n',
Responsibility: '## Description\n\n\n\n## Key Activities\n\n\n\n## Notes\n\n',
Experiment: '## Hypothesis\n\n\n\n## Method\n\n\n\n## Results\n\n\n\n## Conclusion\n\n',
}
/** Look up the template for a given type from the type entry or defaults. */
export function resolveTemplate(entries: VaultEntry[], typeName: string): string | null {
const typeEntry = entries.find(e => e.isA === 'Type' && e.title === typeName)
return typeEntry?.template ?? DEFAULT_TEMPLATES[typeName] ?? null
}
const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
type: { isA: null }, is_a: { isA: null }, status: { status: null }, color: { color: null },
icon: { icon: null }, owner: { owner: null }, cadence: { cadence: null },
aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] },
archived: { archived: false }, trashed: { trashed: false }, order: { order: null },
template: { template: null },
}
/** Map a frontmatter key+value to the corresponding VaultEntry field(s). */
@@ -150,6 +165,7 @@ export function frontmatterToEntryPatch(
aliases: { aliases: arr }, belongs_to: { belongsTo: arr }, related_to: { relatedTo: arr },
archived: { archived: Boolean(value) }, trashed: { trashed: Boolean(value) },
order: { order: typeof value === 'number' ? value : null },
template: { template: str },
}
return updates[k] ?? {}
}
@@ -159,19 +175,20 @@ function addEntryWithMock(entry: VaultEntry, content: string, addEntry: (e: Vaul
addEntry(entry, content)
}
export function buildNoteContent(title: string, type: string, status: string | null): string {
export function buildNoteContent(title: string, type: string, status: string | null, template?: string | null): string {
const lines = ['---', `title: ${title}`, `type: ${type}`]
if (status) lines.push(`status: ${status}`)
lines.push('---')
return `${lines.join('\n')}\n\n# ${title}\n\n`
const body = template ? `\n${template}` : '\n'
return `${lines.join('\n')}\n\n# ${title}\n${body}`
}
export function resolveNewNote(title: string, type: string): { entry: VaultEntry; content: string } {
export function resolveNewNote(title: string, type: string, template?: string | null): { entry: VaultEntry; content: string } {
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) }
return { entry, content: buildNoteContent(title, type, status, template) }
}
export function resolveNewType(typeName: string): { entry: VaultEntry; content: string } {
@@ -340,13 +357,17 @@ export function useNoteActions(config: NoteActionsConfig) {
[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 handleCreateNote = useCallback((title: string, type: string) => {
const template = resolveTemplate(entries, type)
persistNew(resolveNewNote(title, type, template))
}, [entries, persistNew])
const handleCreateNoteImmediate = useCallback((type?: string) => {
const noteType = type || 'Note'
const title = generateUntitledName(entries, noteType, pendingNamesRef.current)
pendingNamesRef.current.add(title)
const resolved = resolveNewNote(title, noteType)
const template = resolveTemplate(entries, noteType)
const resolved = resolveNewNote(title, noteType, template)
openTabWithContent(resolved.entry, resolved.content)
addEntryWithMock(resolved.entry, resolved.content, addEntry)
config.trackUnsaved?.(resolved.entry.path)

View File

@@ -22,6 +22,7 @@ function makeEntry(path: string, title: string): VaultEntry {
fileSize: 100,
color: null,
icon: null,
template: null,
outgoingLinks: [],
relationships: {},
}

View File

@@ -32,6 +32,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
...overrides,
})

View File

@@ -10,7 +10,7 @@ const mockEntries: VaultEntry[] = [
status: 'Active', owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null,
modifiedAt: 1700000000, createdAt: 1700000000, fileSize: 100,
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [],
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, outgoingLinks: [],
},
]

View File

@@ -36,6 +36,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: ['quarter/q1-2026', 'topic/software-development', 'person/matteo-cellini', 'person/maria-bianchi', 'person/marco-verdi'],
},
{
@@ -71,6 +72,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: ['essay/on-writing-well', 'essay/engineering-leadership-101', 'essay/ai-agents-primer', 'topic/growth', 'topic/writing'],
},
{
@@ -100,6 +102,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: ['person/matteo-cellini'],
},
{
@@ -129,6 +132,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: ['responsibility/grow-newsletter'],
},
{
@@ -158,6 +162,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: ['responsibility/manage-sponsorships'],
},
{
@@ -188,6 +193,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: ['topic/trading', 'topic/algorithmic-trading', 'data/ema200-backtest-results'],
},
{
@@ -218,6 +224,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: ['project/26q1-laputa-app', 'topic/growth', 'topic/ads'],
},
{
@@ -247,6 +254,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: ['project/26q1-laputa-app'],
},
{
@@ -275,6 +283,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -303,6 +312,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -331,6 +341,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -359,6 +370,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -388,6 +400,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: ['project/26q1-laputa-app', 'person/matteo-cellini'],
},
{
@@ -417,6 +430,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -446,6 +460,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -475,6 +490,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: ['responsibility/grow-newsletter'],
},
{
@@ -505,6 +521,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: ['responsibility/grow-newsletter', 'topic/software-development'],
},
{
@@ -534,6 +551,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: ['responsibility/grow-newsletter'],
},
// --- Type documents ---
@@ -561,6 +579,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 0,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -587,6 +606,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 1,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -613,6 +633,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 2,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -639,6 +660,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 3,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -665,6 +687,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 4,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -691,6 +714,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 5,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -717,6 +741,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 6,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -743,6 +768,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 7,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -769,6 +795,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 8,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
// --- Custom type documents ---
@@ -796,6 +823,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: 'orange',
order: 9,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -822,6 +850,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: 'green',
order: 10,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
// --- Instances of custom types ---
@@ -851,6 +880,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -879,6 +909,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
// --- Trashed entries ---
@@ -909,6 +940,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -937,6 +969,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
@@ -966,6 +999,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
// --- Archived entries ---
@@ -987,6 +1021,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
modifiedAt: now - 86400 * 120,
createdAt: now - 86400 * 200,
@@ -1016,6 +1051,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
modifiedAt: now - 86400 * 90,
createdAt: now - 86400 * 150,
@@ -1079,6 +1115,7 @@ function generateBulkEntries(count: number): VaultEntry[] {
order: null,
outgoingLinks: Array.from({ length: i % 8 }, (_j, j) => `note/link-target-${(i + j) % 50}`),
sidebarLabel: null,
template: null,
})
}
return entries

View File

@@ -27,6 +27,8 @@ export interface VaultEntry {
order: number | null
/** Custom sidebar section label for Type entries, overriding auto-pluralization. */
sidebarLabel: string | null
/** Markdown template for Type entries. Pre-fills new notes created with this type. */
template: string | null
/** All wikilink targets found in the note content. Extracted from [[target]] patterns. */
outgoingLinks: string[]
}

View File

@@ -10,7 +10,7 @@ function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
trashed: false, trashedAt: null,
modifiedAt: null, createdAt: null, fileSize: 0,
snippet: '', wordCount: 0, relationships: {},
icon: null, color: null, order: null, outgoingLinks: [],
icon: null, color: null, order: null, template: null, outgoingLinks: [],
...overrides,
}
}

View File

@@ -13,7 +13,7 @@ function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null, modifiedAt: null, createdAt: null,
fileSize: 0, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null,
order: null, outgoingLinks: [],
order: null, template: null, outgoingLinks: [],
...overrides,
}
}

View File

@@ -54,7 +54,7 @@ const baseEntry: VaultEntry = {
status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', relationships: {},
wordCount: 0,
icon: null, color: null, order: null, outgoingLinks: [],
icon: null, color: null, order: null, template: null, outgoingLinks: [],
}
describe('buildTypeEntryMap', () => {

View File

@@ -26,6 +26,7 @@ function makeEntry(overrides: Partial<VaultEntry>): VaultEntry {
icon: null,
color: null,
order: null,
template: null,
outgoingLinks: [],
...overrides,
}