diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 68a2ec4..05c350b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -596,6 +596,20 @@ pub fn save_note( Ok(()) } +#[tauri::command] +pub fn duplicate_note( + state: State<'_, AppState>, + path: String, +) -> Result { + let config = state.config.lock().map_err(|e| e.to_string())?; + let vault = config.active_vault.as_ref().ok_or("No active vault")?.clone(); + drop(config); + + let entry = operations::duplicate_note(&path, &vault)?; + index_note_bg(&state, &entry.path); + Ok(entry) +} + #[tauri::command] pub fn create_note( state: State<'_, AppState>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9f81867..bd5777a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -150,6 +150,7 @@ pub fn run() { commands::read_note, commands::save_note, commands::create_note, + commands::duplicate_note, commands::create_daily_note, commands::rename_note, commands::delete_note, diff --git a/src-tauri/src/vault/operations.rs b/src-tauri/src/vault/operations.rs index 66604d7..123e602 100644 --- a/src-tauri/src/vault/operations.rs +++ b/src-tauri/src/vault/operations.rs @@ -476,6 +476,75 @@ pub fn create_note( }) } +pub fn duplicate_note(path: &str, vault_path: &str) -> Result { + let src = Path::new(path); + if !src.is_file() { + return Err("Note does not exist".to_string()); + } + + let parent = src + .parent() + .ok_or_else(|| "Note has no parent directory".to_string())?; + let raw = fs::read_to_string(src).map_err(|e| e.to_string())?; + let filename = src + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let (mut meta, body) = frontmatter::parse_note(&raw, &filename); + let source_title = meta.title.clone(); + let base_title = format!("{} copy", source_title.trim_end()); + let mut copy_title = base_title.clone(); + let mut copy_number = 2; + let mut copy_path = parent.join(format!("{}.md", sanitize_filename(©_title))); + + while copy_path.exists() { + copy_title = format!("{} {}", base_title, copy_number); + copy_number += 1; + copy_path = parent.join(format!("{}.md", sanitize_filename(©_title))); + } + + let now = Utc::now(); + meta.id = Uuid::new_v4().to_string(); + meta.title = copy_title; + meta.pinned = false; + meta.created = now; + meta.modified = now; + + let body = retitle_leading_heading(&body, &source_title, &meta.title); + let copy_raw = frontmatter::merge_frontmatter(&raw, &meta, &body); + fs::write(©_path, copy_raw).map_err(|e| e.to_string())?; + + read_note_entry(©_path, Path::new(vault_path)) +} + +fn retitle_leading_heading(body: &str, old_title: &str, new_title: &str) -> String { + let trimmed = body.trim_start(); + let leading_len = body.len() - trimmed.len(); + let first_line_end = trimmed.find('\n').unwrap_or(trimmed.len()); + let first_line = &trimmed[..first_line_end]; + let heading_len = first_line.chars().take_while(|&c| c == '#').count(); + + if !(1..=6).contains(&heading_len) { + return body.to_string(); + } + + let Some(heading_title) = first_line[heading_len..].strip_prefix(' ') else { + return body.to_string(); + }; + if !heading_title.trim().eq_ignore_ascii_case(old_title.trim()) { + return body.to_string(); + } + + let mut retitled = String::with_capacity(body.len() + new_title.len()); + retitled.push_str(&body[..leading_len]); + retitled.push_str(&"#".repeat(heading_len)); + retitled.push(' '); + retitled.push_str(new_title); + retitled.push_str(&trimmed[first_line_end..]); + retitled +} + fn get_system_locale() -> Locale { let sys = sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string()); let lang = sys.split(&['-', '_', '.'][..]).next().unwrap_or("en"); @@ -1240,7 +1309,7 @@ pub fn sanitize_filename(name: &str) -> String { #[cfg(test)] mod tests { - use super::{helixnotes_dir, load_notebook_icons, set_notebook_icon}; + use super::{duplicate_note, helixnotes_dir, load_notebook_icons, set_notebook_icon}; use std::fs; use uuid::Uuid; @@ -1263,4 +1332,36 @@ mod tests { fs::remove_dir_all(vault).unwrap(); } + + #[test] + fn duplicates_note_content_and_assigns_unique_identity_and_name() { + let vault = + std::env::temp_dir().join(format!("helixnotes-duplicate-note-test-{}", Uuid::new_v4())); + let notebook = vault.join("Projects"); + fs::create_dir_all(¬ebook).unwrap(); + let source_path = notebook.join("Project.md"); + let source_raw = "---\nid: source-id\ntitle: Project\ntags:\n - work\npinned: true\ncreated: 2020-01-01T00:00:00Z\nmodified: 2020-01-02T00:00:00Z\naliases:\n - Plan\n---\n# Project\n\nOriginal body.\n"; + fs::write(&source_path, source_raw).unwrap(); + + let vault_path = vault.to_string_lossy(); + let first = duplicate_note(&source_path.to_string_lossy(), &vault_path).unwrap(); + let second = duplicate_note(&source_path.to_string_lossy(), &vault_path).unwrap(); + + assert_eq!(first.meta.title, "Project copy"); + assert_eq!(second.meta.title, "Project copy 2"); + assert_eq!(first.meta.tags, vec!["work"]); + assert!(!first.meta.pinned); + assert_ne!(first.meta.id, "source-id"); + assert_ne!(first.meta.id, second.meta.id); + assert_eq!(first.relative_path, "Projects/Project copy.md"); + assert_eq!(second.relative_path, "Projects/Project copy 2.md"); + + let first_raw = fs::read_to_string(&first.path).unwrap(); + assert!(first_raw.contains("aliases:\n- Plan")); + assert!(first_raw.contains("# Project copy\n\nOriginal body.")); + assert!(!first_raw.contains("\n# Project\n")); + assert_eq!(fs::read_to_string(&source_path).unwrap(), source_raw); + + fs::remove_dir_all(vault).unwrap(); + } } \ No newline at end of file diff --git a/src/lib/api.ts b/src/lib/api.ts index 8282556..f94c15b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -141,6 +141,10 @@ export async function createNote( return invoke("create_note", { notebookRelative, title }); } +export async function duplicateNote(path: string): Promise { + return invoke("duplicate_note", { path }); +} + export async function createDailyNote(date?: string): Promise { return invoke("create_daily_note", { date: date ?? null }); } diff --git a/src/lib/components/AppLayout.svelte b/src/lib/components/AppLayout.svelte index 3ed7adc..c84b7e2 100644 --- a/src/lib/components/AppLayout.svelte +++ b/src/lib/components/AppLayout.svelte @@ -970,7 +970,7 @@
- editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} /> + editor?.flushSave()} onBeforeNoteDuplicate={() => editor?.forceSave() ?? Promise.resolve(true)} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
@@ -1031,7 +1031,7 @@ {#if !$notelistCollapsed}
- editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} /> + editor?.flushSave()} onBeforeNoteDuplicate={() => editor?.forceSave() ?? Promise.resolve(true)} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
diff --git a/src/lib/components/NoteList.svelte b/src/lib/components/NoteList.svelte index b5de5cc..805223c 100644 --- a/src/lib/components/NoteList.svelte +++ b/src/lib/components/NoteList.svelte @@ -23,6 +23,7 @@ getNotes, readNote, createNote, + duplicateNote, deleteNote, renameNote, saveNote, @@ -47,10 +48,11 @@ import TagSuggestInput from './TagSuggestInput.svelte'; import { isMobile, isAndroid } from '$lib/platform'; - let { onNoteSelected = (_path: string, _content: string, _task?: TaskItem) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: { + let { onNoteSelected = (_path: string, _content: string, _task?: TaskItem) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onBeforeNoteDuplicate = async () => true, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: { onNoteSelected?: (path: string, content: string, task?: TaskItem) => void; onNoteMoved?: () => void; onBeforeNoteSwitch?: () => void; + onBeforeNoteDuplicate?: () => Promise; onNoteCreated?: () => void; onToggleTask?: (t: TaskItem) => Promise; onSetTaskPriority?: (t: TaskItem, p: string | null) => Promise; @@ -468,6 +470,27 @@ } } + async function handleDuplicate(note: NoteEntry) { + contextMenu = null; + try { + if ($activeNotePath === note.path && $editorDirty) { + const saved = await onBeforeNoteDuplicate(); + if (!saved) { + console.error('Failed to save note before duplicating'); + return; + } + } + + const entry = await duplicateNote(note.path); + if ($sortMode === 'custom') appendManualNoteOrder(entry.path); + noteCache.clear(); + if ($viewMode !== 'quickaccess') $notes = [entry, ...$notes]; + await selectNote(entry); + } catch (e) { + console.error('Failed to duplicate note:', e); + } + } + function handleListDoubleClick(e: MouseEvent) { const target = e.target as HTMLElement | null; if (target?.closest('button, input, .note-item, .context-menu, .sort-menu, .batch-move-picker, .cal')) return; @@ -1385,7 +1408,7 @@
{#if contextMenu} -
e.stopPropagation()} role="menu"> +
e.stopPropagation()} role="menu"> {#if selectedPaths.size > 1 && selectedPaths.has(contextMenu.note.path)} {#if $viewMode === 'trash'} @@ -1549,6 +1572,12 @@ Rename +