From e9fadc0584bc0e902f7538542c12acb93605de73 Mon Sep 17 00:00:00 2001 From: j4rviscmd Date: Sat, 28 Mar 2026 09:28:15 +0900 Subject: [PATCH 1/2] feat: add duplicate note via sidebar context menu Add a "Duplicate" option to the note context menu that creates a copy of the selected note. The Rust-side `duplicate_note` command handles title suffixing, group inheritance and H1 heading rewrite in a single IPC round-trip. Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/db.rs | 115 ++++++++++++++++++++---- src-tauri/src/lib.rs | 7 +- src/App.tsx | 27 +++++- src/features/editor/api/notes.ts | 13 +++ src/features/editor/index.ts | 1 + src/features/sidebar/ui/NoteItem.tsx | 32 +++++-- src/features/sidebar/ui/NoteSidebar.tsx | 89 +++++++++++++++--- 7 files changed, 238 insertions(+), 46 deletions(-) diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 2c44331..413c085 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -112,26 +112,16 @@ pub fn init_db(app: &tauri::AppHandle) -> Result<(), String> { .map_err(|e| format!("failed to init schema: {e}"))?; // Migration: add is_pinned column if it does not exist (existing databases). - if conn - .execute( - "ALTER TABLE notes ADD COLUMN is_pinned INTEGER NOT NULL DEFAULT 0", - [], - ) - .is_err() - { - // Column already exists — ignore the error. - } + let _ = conn.execute( + "ALTER TABLE notes ADD COLUMN is_pinned INTEGER NOT NULL DEFAULT 0", + [], + ); // Migration: add group_id column if it does not exist. - if conn - .execute( - "ALTER TABLE notes ADD COLUMN group_id TEXT REFERENCES groups(id) ON DELETE SET NULL", - [], - ) - .is_err() - { - // Column already exists — ignore the error. - } + let _ = conn.execute( + "ALTER TABLE notes ADD COLUMN group_id TEXT REFERENCES groups(id) ON DELETE SET NULL", + [], + ); app.manage(DbState(Mutex::new(conn))); Ok(()) @@ -344,3 +334,92 @@ pub fn delete_note(state: tauri::State, id: String) -> Result<(), Strin .map_err(|e| e.to_string())?; Ok(()) } + +/// Duplicates an existing note, copying its content and group membership. +/// +/// The duplicated note receives a new UUID, a title suffixed with " (copy)", +/// `is_pinned = false`, and the same `group_id` as the original. +/// +/// # Arguments +/// +/// * `state` - Managed database state injected by Tauri. +/// * `id` - The UUID of the note to duplicate. +/// +/// # Returns +/// +/// The newly created [`Note`]. +/// +/// # Errors +/// +/// Returns a `String` if the database lock is poisoned, the source note is +/// not found, or the INSERT fails. +#[tauri::command] +pub fn duplicate_note(state: tauri::State, id: String) -> Result { + let conn = state.0.lock().map_err(|e| e.to_string())?; + + let source = conn + .query_row( + "SELECT id, title, content, created_at, updated_at, is_pinned, group_id FROM notes WHERE id = ?1", + rusqlite::params![id], + note_from_row, + ) + .map_err(|e| e.to_string())?; + + let new_id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + let title = format!("{} (copy)", source.title); + + // Update the first heading block's text in the content JSON so the H1 + // heading also carries the "(copy)" suffix, matching the sidebar label. + let content = rewrite_first_heading(&source.content, &title); + + conn.execute( + "INSERT INTO notes (id, title, content, created_at, updated_at, is_pinned, group_id) VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6)", + rusqlite::params![new_id, title, content, now, now, source.group_id], + ) + .map_err(|e| e.to_string())?; + + Ok(Note { + id: new_id, + title, + content, + created_at: now.clone(), + updated_at: now, + is_pinned: false, + group_id: source.group_id, + }) +} + +/// Rewrites the text of the first heading block in a BlockNote content JSON. +/// +/// BlockNote content is a JSON array of block objects. Each block may have a +/// `content` array of inline objects whose `text` field holds the visible +/// string. This function finds the first block whose `type` is `"heading"` +/// and replaces the **concatenated** text of its inline content with `new_title`. +/// +/// If the content cannot be parsed or no heading block is found, the original +/// content string is returned unchanged. +fn rewrite_first_heading(content_json: &str, new_title: &str) -> String { + let mut blocks: Vec = match serde_json::from_str(content_json) { + Ok(v) => v, + Err(_) => return content_json.to_owned(), + }; + + for block in blocks.iter_mut() { + let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or(""); + if block_type != "heading" { + continue; + } + if let Some(inline_content) = block.get_mut("content").and_then(|c| c.as_array_mut()) { + if let Some(first_inline) = inline_content.first_mut() { + first_inline["text"] = serde_json::Value::String(new_title.to_owned()); + } + // Truncate any additional inline nodes after the first so the + // title stays coherent (e.g. bold prefix + plain suffix). + inline_content.truncate(1); + break; + } + } + + serde_json::to_string(&blocks).unwrap_or_else(|_| content_json.to_owned()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7da0049..2638f36 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,8 @@ mod groups; mod link_preview; mod window; +use std::collections::HashMap; +use std::sync::Mutex; use tauri::Manager; /// Initializes and runs the Tauri application. @@ -41,8 +43,8 @@ pub fn run() { .plugin(tauri_plugin_fs::init()) .setup(|app| { db::init_db(app.handle())?; - app.manage(link_preview::LinkPreviewCache(std::sync::Mutex::new( - std::collections::HashMap::new(), + app.manage(link_preview::LinkPreviewCache(Mutex::new( + HashMap::new(), ))); window::create_main_window(app.handle())?; Ok(()) @@ -58,6 +60,7 @@ pub fn run() { db::create_note, db::update_note, db::delete_note, + db::duplicate_note, db::toggle_pin, groups::list_groups, groups::create_group, diff --git a/src/App.tsx b/src/App.tsx index 6a943df..361ac18 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ import { createNote, DEFAULT_CONTENT, deleteNote, + duplicateNote, Editor, exportToMarkdown, extractTitle, @@ -266,10 +267,7 @@ function AppContent() { * @param id - The ID of the note that was saved. */ const handleNoteSaved = useCallback((id: string) => { - setSelectedNoteId((current) => { - if (current === null || current === id) return id - return current - }) + setSelectedNoteId((current) => (current === null ? id : current)) setRefreshKey((v) => v + 1) }, []) @@ -334,6 +332,26 @@ function AppContent() { [] ) + /** + * Duplicates the specified note and selects the newly created copy. + * + * @param noteId - The ID of the note to duplicate. + * @throws Shows an error toast if duplication fails. + */ + const handleDuplicateNote = useCallback( + async (noteId: string) => { + try { + const duplicated = await duplicateNote(noteId) + selectNote(duplicated.id) + setRefreshKey((v) => v + 1) + toast.success('Note duplicated') + } catch { + toast.error('Failed to duplicate note') + } + }, + [selectNote] + ) + /** * Exports the given note as a Markdown file via a native save dialog. * @@ -411,6 +429,7 @@ function AppContent() { onNewNote={handleNewNote} onDeleteNote={handleDeleteNote} onTogglePin={handleTogglePin} + onDuplicateNote={handleDuplicateNote} onExportNote={handleExportNote} onImportNote={handleImportNote} refreshKey={refreshKey} diff --git a/src/features/editor/api/notes.ts b/src/features/editor/api/notes.ts index e944a82..1656765 100644 --- a/src/features/editor/api/notes.ts +++ b/src/features/editor/api/notes.ts @@ -89,6 +89,19 @@ export async function togglePinNote( return invoke('toggle_pin', { id, pinned }) } +/** + * Duplicates an existing note. + * + * Creates a new note with the same content and group as the original. + * The title is suffixed with " (copy)" and the pin state is not inherited. + * + * @param id - The UUID of the note to duplicate. + * @returns The newly created duplicate note. + */ +export async function duplicateNote(id: string): Promise { + return invoke('duplicate_note', { id }) +} + /** * Reads a text file and returns its content. * diff --git a/src/features/editor/index.ts b/src/features/editor/index.ts index ead3a5f..c00dd4c 100644 --- a/src/features/editor/index.ts +++ b/src/features/editor/index.ts @@ -9,6 +9,7 @@ export type { Note } from './api/notes' export { createNote, deleteNote, + duplicateNote, getNote, listNotes, readTextFile, diff --git a/src/features/sidebar/ui/NoteItem.tsx b/src/features/sidebar/ui/NoteItem.tsx index d50a8c6..a1f0504 100644 --- a/src/features/sidebar/ui/NoteItem.tsx +++ b/src/features/sidebar/ui/NoteItem.tsx @@ -1,6 +1,7 @@ import { useDraggable } from '@dnd-kit/core' import { Check, + Copy, Download, FileText, FolderInput, @@ -24,12 +25,27 @@ import type { Group } from '@/features/groups' import { formatRelativeDate } from '@/features/groups' import { cn } from '@/lib/utils' +/** + * Props for the {@link NoteItem} component. + * + * @property note - The note object to render. + * @property selectedNoteId - The ID of the currently selected note, or `null`. + * @property onSelectNote - Callback invoked when the user clicks this note. + * @property onTogglePin - Callback to pin or unpin the note. + * @property onDeleteNote - Callback invoked when the user confirms deletion. + * @property onDuplicateNote - Callback invoked to duplicate the note. + * @property onExportNote - Callback invoked to export the note as Markdown. + * @property onMoveToGroup - Callback invoked to move the note to a different group. + * @property groups - The full list of groups available for the "Move to group" submenu. + * @property justPinnedId - The ID of the note that was just pinned (for bounce animation), or `null`. + */ interface NoteItemProps { note: Note selectedNoteId: string | null onSelectNote: (id: string) => void onTogglePin: (id: string, pinned: boolean) => void onDeleteNote: () => void + onDuplicateNote: (noteId: string) => void onExportNote: (noteId: string) => void onMoveToGroup: (noteId: string, groupId: string | null) => void groups: Group[] @@ -47,6 +63,7 @@ export function NoteItem({ onSelectNote, onTogglePin, onDeleteNote, + onDuplicateNote, onExportNote, onMoveToGroup, groups, @@ -101,16 +118,11 @@ export function NoteItem({ onTogglePin(note.id, !note.isPinned)}> {note.isPinned ? ( - <> - - Unpin - + ) : ( - <> - - Pin to top - + )} + {note.isPinned ? 'Unpin' : 'Pin to top'} @@ -140,6 +152,10 @@ export function NoteItem({ Export as Markdown + onDuplicateNote(note.id)}> + + Duplicate + diff --git a/src/features/sidebar/ui/NoteSidebar.tsx b/src/features/sidebar/ui/NoteSidebar.tsx index 816eca6..37c5346 100644 --- a/src/features/sidebar/ui/NoteSidebar.tsx +++ b/src/features/sidebar/ui/NoteSidebar.tsx @@ -37,6 +37,17 @@ import { UncategorizedSection } from './UncategorizedSection' /** * Props for the {@link NoteSidebar} component. + * + * @property selectedNoteId - The ID of the currently selected note, or `null`. + * @property onSelectNote - Callback invoked when the user selects a note. + * @property onNewNote - Callback invoked to create a new note. + * @property onDeleteNote - Callback invoked after a note has been deleted. + * @property onTogglePin - Callback to pin or unpin a note. + * @property onExportNote - Callback invoked to export a note as Markdown. + * @property onDuplicateNote - Callback invoked to duplicate a note. + * @property onImportNote - Callback invoked to import a Markdown file. + * @property refreshKey - A monotonically increasing key used to trigger data refresh. + * @property onRefresh - Callback invoked to bump the refresh key after a mutation. */ interface NoteSidebarProps { selectedNoteId: string | null @@ -45,6 +56,7 @@ interface NoteSidebarProps { onDeleteNote: (noteId: string) => void onTogglePin: (noteId: string, pinned: boolean) => void onExportNote: (noteId: string) => void + onDuplicateNote: (noteId: string) => void onImportNote: () => void refreshKey: number onRefresh: () => void @@ -64,6 +76,7 @@ export function NoteSidebar({ onDeleteNote, onTogglePin, onExportNote, + onDuplicateNote, onImportNote, refreshKey, onRefresh, @@ -97,6 +110,13 @@ export function NoteSidebar({ }) ) + /** + * Handles the start of a drag operation. + * + * Tracks the dragged note ID so the {@link DragOverlay} can render a preview. + * + * @param event - The drag-start event from `@dnd-kit`. + */ const handleDragStart = useCallback((event: DragStartEvent) => { const data = event.active.data.current if (data?.type === 'note') { @@ -104,6 +124,15 @@ export function NoteSidebar({ } }, []) + /** + * Handles the end of a drag operation. + * + * Supports two drag scenarios: + * 1. **Note -> Group/Uncategorized**: Moves the note to the target group. + * 2. **Group -> Group**: Reorders the groups by swapping positions. + * + * @param event - The drag-end event from `@dnd-kit`. + */ const handleDragEnd = useCallback( async (event: DragEndEvent) => { setActiveDragNoteId(null) @@ -116,16 +145,15 @@ export function NoteSidebar({ // Note dropped on a group or uncategorized if (activeData?.type === 'note') { const noteId = activeData.noteId as string + let targetGroupId: string | null | undefined if (overData?.type === 'group') { - const groupId = overData.groupId as string - try { - await moveNote(noteId, groupId) - } catch { - toast.error('Failed to move note') - } + targetGroupId = overData.groupId as string } else if (overData?.type === 'uncategorized') { + targetGroupId = null + } + if (targetGroupId !== undefined) { try { - await moveNote(noteId, null) + await moveNote(noteId, targetGroupId) } catch { toast.error('Failed to move note') } @@ -150,6 +178,13 @@ export function NoteSidebar({ [groups, moveNote, reorder] ) + /** + * Toggles the pin state of a note and triggers a brief bounce animation + * when pinning. + * + * @param noteId - The ID of the note to pin or unpin. + * @param pinned - `true` to pin, `false` to unpin. + */ const handleTogglePin = useCallback( (noteId: string, pinned: boolean) => { onTogglePin(noteId, pinned) @@ -161,6 +196,13 @@ export function NoteSidebar({ [onTogglePin] ) + /** + * Moves a note to a specified group (or to uncategorized). + * + * @param noteId - The ID of the note to move. + * @param groupId - The target group ID, or `null` for uncategorized. + * @throws Shows an error toast if the move operation fails. + */ const handleMoveToGroup = useCallback( async (noteId: string, groupId: string | null) => { try { @@ -172,6 +214,15 @@ export function NoteSidebar({ [moveNote] ) + /** + * Renders a single {@link NoteItem} with the current sidebar state and + * callbacks wired up. + * + * Memoised to avoid unnecessary re-renders when unrelated state changes. + * + * @param note - The note to render. + * @returns The rendered {@link NoteItem} element. + */ const renderNoteItem = useCallback( (note: Note) => ( setDeleteTarget(note.id)} onExportNote={onExportNote} + onDuplicateNote={onDuplicateNote} onMoveToGroup={handleMoveToGroup} groups={groups} justPinnedId={justPinnedId} @@ -191,6 +243,7 @@ export function NoteSidebar({ selectedNoteId, onSelectNote, handleTogglePin, + onDuplicateNote, onExportNote, handleMoveToGroup, groups, @@ -198,6 +251,17 @@ export function NoteSidebar({ ] ) + /** + * Renders the main body of the sidebar based on the current state. + * + * Handles four visual states: + * - **noResults**: Shows a "no matches" message when a search yields nothing. + * - **isEmpty**: Shows a placeholder when there are no notes at all. + * - **isSearching**: Flattens all groups into date-bucketed results. + * - **default**: Renders pinned section, group sections, and uncategorized section. + * + * @returns The rendered sidebar body content. + */ function renderSidebarBody(): React.ReactNode { if (noResults) { return ( @@ -208,6 +272,10 @@ export function NoteSidebar({ ) } + if (isEmpty) { + return

No notes yet

+ } + // During search: bypass groups, show flat date-grouped results if (isSearching) { const searchBuckets = bucketByDate( @@ -215,9 +283,6 @@ export function NoteSidebar({ .flatMap((g) => g.dateBuckets.flatMap((b) => b.items)) .concat(uncategorized.flatMap((b) => b.items)) ) - if (searchBuckets.length === 0) { - return

No notes yet

- } return searchBuckets.map((bucket) => ( No notes yet

- } - const hasGroups = groups.length > 0 return ( From 5d8e3910890f357759bdef18b4e9a07f17cd7b9e Mon Sep 17 00:00:00 2001 From: j4rviscmd Date: Sat, 28 Mar 2026 09:32:06 +0900 Subject: [PATCH 2/2] style: fix cargo fmt for CI compliance Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/lib.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2638f36..589d76f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -43,9 +43,7 @@ pub fn run() { .plugin(tauri_plugin_fs::init()) .setup(|app| { db::init_db(app.handle())?; - app.manage(link_preview::LinkPreviewCache(Mutex::new( - HashMap::new(), - ))); + app.manage(link_preview::LinkPreviewCache(Mutex::new(HashMap::new()))); window::create_main_window(app.handle())?; Ok(()) })