Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 97 additions & 18 deletions src-tauri/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
Expand Down Expand Up @@ -344,3 +334,92 @@ pub fn delete_note(state: tauri::State<DbState>, 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<DbState>, id: String) -> Result<Note, String> {
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<serde_json::Value> = 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())
}
7 changes: 4 additions & 3 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -41,9 +43,7 @@ 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(())
})
Expand All @@ -58,6 +58,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,
Expand Down
27 changes: 23 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
createNote,
DEFAULT_CONTENT,
deleteNote,
duplicateNote,
Editor,
exportToMarkdown,
extractTitle,
Expand Down Expand Up @@ -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)
}, [])

Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -411,6 +429,7 @@ function AppContent() {
onNewNote={handleNewNote}
onDeleteNote={handleDeleteNote}
onTogglePin={handleTogglePin}
onDuplicateNote={handleDuplicateNote}
onExportNote={handleExportNote}
onImportNote={handleImportNote}
refreshKey={refreshKey}
Expand Down
13 changes: 13 additions & 0 deletions src/features/editor/api/notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,19 @@ export async function togglePinNote(
return invoke<Note>('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<Note> {
return invoke<Note>('duplicate_note', { id })
}

/**
* Reads a text file and returns its content.
*
Expand Down
1 change: 1 addition & 0 deletions src/features/editor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type { Note } from './api/notes'
export {
createNote,
deleteNote,
duplicateNote,
getNote,
listNotes,
readTextFile,
Expand Down
32 changes: 24 additions & 8 deletions src/features/sidebar/ui/NoteItem.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useDraggable } from '@dnd-kit/core'
import {
Check,
Copy,
Download,
FileText,
FolderInput,
Expand All @@ -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[]
Expand All @@ -47,6 +63,7 @@ export function NoteItem({
onSelectNote,
onTogglePin,
onDeleteNote,
onDuplicateNote,
onExportNote,
onMoveToGroup,
groups,
Expand Down Expand Up @@ -101,16 +118,11 @@ export function NoteItem({
<ContextMenuContent>
<ContextMenuItem onClick={() => onTogglePin(note.id, !note.isPinned)}>
{note.isPinned ? (
<>
<PinOff className="h-4 w-4" />
Unpin
</>
<PinOff className="h-4 w-4" />
) : (
<>
<Pin className="h-4 w-4" />
Pin to top
</>
<Pin className="h-4 w-4" />
)}
{note.isPinned ? 'Unpin' : 'Pin to top'}
</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>
Expand Down Expand Up @@ -140,6 +152,10 @@ export function NoteItem({
<Download className="h-4 w-4" />
Export as Markdown
</ContextMenuItem>
<ContextMenuItem onClick={() => onDuplicateNote(note.id)}>
<Copy className="h-4 w-4" />
Duplicate
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive" onClick={onDeleteNote}>
<Trash2 />
Expand Down
Loading
Loading