Add note duplication (#24)

This commit is contained in:
Yuri Karamian
2026-08-03 13:23:18 +02:00
parent 1a880a4f96
commit e6e1ed1ed6
6 changed files with 158 additions and 7 deletions
+14
View File
@@ -596,6 +596,20 @@ pub fn save_note(
Ok(()) Ok(())
} }
#[tauri::command]
pub fn duplicate_note(
state: State<'_, AppState>,
path: String,
) -> Result<crate::types::NoteEntry, String> {
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] #[tauri::command]
pub fn create_note( pub fn create_note(
state: State<'_, AppState>, state: State<'_, AppState>,
+1
View File
@@ -150,6 +150,7 @@ pub fn run() {
commands::read_note, commands::read_note,
commands::save_note, commands::save_note,
commands::create_note, commands::create_note,
commands::duplicate_note,
commands::create_daily_note, commands::create_daily_note,
commands::rename_note, commands::rename_note,
commands::delete_note, commands::delete_note,
+102 -1
View File
@@ -476,6 +476,75 @@ pub fn create_note(
}) })
} }
pub fn duplicate_note(path: &str, vault_path: &str) -> Result<NoteEntry, String> {
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(&copy_title)));
while copy_path.exists() {
copy_title = format!("{} {}", base_title, copy_number);
copy_number += 1;
copy_path = parent.join(format!("{}.md", sanitize_filename(&copy_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(&copy_path, copy_raw).map_err(|e| e.to_string())?;
read_note_entry(&copy_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 { fn get_system_locale() -> Locale {
let sys = sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string()); let sys = sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string());
let lang = sys.split(&['-', '_', '.'][..]).next().unwrap_or("en"); let lang = sys.split(&['-', '_', '.'][..]).next().unwrap_or("en");
@@ -1240,7 +1309,7 @@ pub fn sanitize_filename(name: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { 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 std::fs;
use uuid::Uuid; use uuid::Uuid;
@@ -1263,4 +1332,36 @@ mod tests {
fs::remove_dir_all(vault).unwrap(); 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(&notebook).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();
}
} }
+4
View File
@@ -141,6 +141,10 @@ export async function createNote(
return invoke("create_note", { notebookRelative, title }); return invoke("create_note", { notebookRelative, title });
} }
export async function duplicateNote(path: string): Promise<NoteEntry> {
return invoke("duplicate_note", { path });
}
export async function createDailyNote(date?: string): Promise<NoteEntry> { export async function createDailyNote(date?: string): Promise<NoteEntry> {
return invoke("create_daily_note", { date: date ?? null }); return invoke("create_daily_note", { date: date ?? null });
} }
+2 -2
View File
@@ -970,7 +970,7 @@
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} /> <Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
</div> </div>
<div class="mobile-panel" class:active={$mobileView === 'notelist'}> <div class="mobile-panel" class:active={$mobileView === 'notelist'}>
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} /> <NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onBeforeNoteDuplicate={() => editor?.forceSave() ?? Promise.resolve(true)} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
</div> </div>
<div class="mobile-panel" class:active={$mobileView === 'editor'}> <div class="mobile-panel" class:active={$mobileView === 'editor'}>
<Editor bind:this={editor} onMoveToTrash={trashOpenNote} /> <Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
@@ -1031,7 +1031,7 @@
{#if !$notelistCollapsed} {#if !$notelistCollapsed}
<div class="notelist-panel" style="width: {$notelistWidth}px"> <div class="notelist-panel" style="width: {$notelistWidth}px">
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} /> <NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onBeforeNoteDuplicate={() => editor?.forceSave() ?? Promise.resolve(true)} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
</div> </div>
<ResizeHandle onResize={handleNotelistResize} /> <ResizeHandle onResize={handleNotelistResize} />
+35 -4
View File
@@ -23,6 +23,7 @@
getNotes, getNotes,
readNote, readNote,
createNote, createNote,
duplicateNote,
deleteNote, deleteNote,
renameNote, renameNote,
saveNote, saveNote,
@@ -47,10 +48,11 @@
import TagSuggestInput from './TagSuggestInput.svelte'; import TagSuggestInput from './TagSuggestInput.svelte';
import { isMobile, isAndroid } from '$lib/platform'; 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; onNoteSelected?: (path: string, content: string, task?: TaskItem) => void;
onNoteMoved?: () => void; onNoteMoved?: () => void;
onBeforeNoteSwitch?: () => void; onBeforeNoteSwitch?: () => void;
onBeforeNoteDuplicate?: () => Promise<boolean>;
onNoteCreated?: () => void; onNoteCreated?: () => void;
onToggleTask?: (t: TaskItem) => Promise<void>; onToggleTask?: (t: TaskItem) => Promise<void>;
onSetTaskPriority?: (t: TaskItem, p: string | null) => Promise<void>; onSetTaskPriority?: (t: TaskItem, p: string | null) => Promise<void>;
@@ -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) { function handleListDoubleClick(e: MouseEvent) {
const target = e.target as HTMLElement | null; const target = e.target as HTMLElement | null;
if (target?.closest('button, input, .note-item, .context-menu, .sort-menu, .batch-move-picker, .cal')) return; if (target?.closest('button, input, .note-item, .context-menu, .sort-menu, .batch-move-picker, .cal')) return;
@@ -1385,7 +1408,7 @@
</div> </div>
{#if contextMenu} {#if contextMenu}
<div class="context-menu" style="left: {contextMenu.x}px; top: {contextMenu.y}px" onclick={(e) => e.stopPropagation()} role="menu"> <div class="context-menu" class:mobile={isMobile} style="left: {contextMenu.x}px; top: {contextMenu.y}px" onclick={(e) => e.stopPropagation()} role="menu">
{#if selectedPaths.size > 1 && selectedPaths.has(contextMenu.note.path)} {#if selectedPaths.size > 1 && selectedPaths.has(contextMenu.note.path)}
<!-- Batch context menu --> <!-- Batch context menu -->
{#if $viewMode === 'trash'} {#if $viewMode === 'trash'}
@@ -1549,6 +1572,12 @@
</svg> </svg>
Rename Rename
</button> </button>
<button onclick={() => handleDuplicate(contextMenu!.note)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="8" y="8" width="13" height="13" rx="2"/><path d="M16 8V5a2 2 0 00-2-2H5a2 2 0 00-2 2v9a2 2 0 002 2h3"/>
</svg>
Duplicate Note
</button>
<button onclick={() => { movePickerNote = contextMenu!.note; }}> <button onclick={() => { movePickerNote = contextMenu!.note; }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/> <path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
@@ -2291,13 +2320,15 @@
border-radius: 10px; border-radius: 10px;
} }
.note-list.mobile .context-menu { .context-menu.mobile {
min-width: 220px; min-width: 220px;
border-radius: 12px; border-radius: 12px;
padding: 6px; padding: 6px;
max-height: calc(100dvh - 16px);
overflow-y: auto;
} }
.note-list.mobile .context-menu button { .context-menu.mobile button {
padding: 12px 16px; padding: 12px 16px;
font-size: 15px; font-size: 15px;
min-height: 44px; min-height: 44px;