mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-19 17:37:29 +02:00
Add note duplication (#24)
This commit is contained in:
@@ -596,6 +596,20 @@ pub fn save_note(
|
||||
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]
|
||||
pub fn create_note(
|
||||
state: State<'_, AppState>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(©_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();
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,10 @@ export async function createNote(
|
||||
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> {
|
||||
return invoke("create_daily_note", { date: date ?? null });
|
||||
}
|
||||
|
||||
@@ -970,7 +970,7 @@
|
||||
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
|
||||
</div>
|
||||
<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 class="mobile-panel" class:active={$mobileView === 'editor'}>
|
||||
<Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
|
||||
@@ -1031,7 +1031,7 @@
|
||||
|
||||
{#if !$notelistCollapsed}
|
||||
<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>
|
||||
|
||||
<ResizeHandle onResize={handleNotelistResize} />
|
||||
|
||||
@@ -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<boolean>;
|
||||
onNoteCreated?: () => void;
|
||||
onToggleTask?: (t: TaskItem) => 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) {
|
||||
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 @@
|
||||
</div>
|
||||
|
||||
{#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)}
|
||||
<!-- Batch context menu -->
|
||||
{#if $viewMode === 'trash'}
|
||||
@@ -1549,6 +1572,12 @@
|
||||
</svg>
|
||||
Rename
|
||||
</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; }}>
|
||||
<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"/>
|
||||
@@ -2291,13 +2320,15 @@
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.note-list.mobile .context-menu {
|
||||
.context-menu.mobile {
|
||||
min-width: 220px;
|
||||
border-radius: 12px;
|
||||
padding: 6px;
|
||||
max-height: calc(100dvh - 16px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.note-list.mobile .context-menu button {
|
||||
.context-menu.mobile button {
|
||||
padding: 12px 16px;
|
||||
font-size: 15px;
|
||||
min-height: 44px;
|
||||
|
||||
Reference in New Issue
Block a user