From 7bff1d21ebb7e36b2d2a1835f37feb0d8fee9ec8 Mon Sep 17 00:00:00 2001 From: careck Date: Fri, 12 Jun 2026 22:27:53 +1000 Subject: [PATCH 1/8] feat(draft): add note_drafts table migration Add note_drafts table to both schema.sql (new workspaces) and run_migrations_inner (existing workspaces) for auto-save recovery. --- krillnotes-core/src/core/schema.sql | 11 +++++++++++ krillnotes-core/src/core/storage.rs | 13 +++++++++++++ 2 files changed, 24 insertions(+) diff --git a/krillnotes-core/src/core/schema.sql b/krillnotes-core/src/core/schema.sql index 3fab1e52..213c7e47 100644 --- a/krillnotes-core/src/core/schema.sql +++ b/krillnotes-core/src/core/schema.sql @@ -119,3 +119,14 @@ CREATE TABLE IF NOT EXISTS sync_events ( event_type TEXT NOT NULL, detail TEXT ); + +-- Note drafts for auto-save recovery (local-only, not synced). +CREATE TABLE IF NOT EXISTS note_drafts ( + note_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + fields_json TEXT NOT NULL, + tags_json TEXT NOT NULL, + base_modified_at INTEGER NOT NULL, + saved_at INTEGER NOT NULL, + FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE +); diff --git a/krillnotes-core/src/core/storage.rs b/krillnotes-core/src/core/storage.rs index 70404416..e19f0240 100644 --- a/krillnotes-core/src/core/storage.rs +++ b/krillnotes-core/src/core/storage.rs @@ -499,6 +499,19 @@ impl Storage { )", )?; + // Migration: create note_drafts table for auto-save recovery. + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS note_drafts ( + note_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + fields_json TEXT NOT NULL, + tags_json TEXT NOT NULL, + base_modified_at INTEGER NOT NULL, + saved_at INTEGER NOT NULL, + FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE + )", + )?; + Ok(()) } From 712116cdbc97b5aebf17608b428967bfd76cfa75 Mon Sep 17 00:00:00 2001 From: careck Date: Fri, 12 Jun 2026 22:27:59 +1000 Subject: [PATCH 2/8] feat(draft): add NoteDraft struct and Workspace draft methods with tests Add save_draft, get_draft, delete_draft methods on Workspace. NoteDraft is serde-serializable with camelCase for the TS boundary. Six tests cover basic CRUD, upsert, noop delete, and FK cascade. --- krillnotes-core/src/core/draft_tests.rs | 109 +++++++++++++++++++ krillnotes-core/src/core/mod.rs | 3 + krillnotes-core/src/core/workspace/drafts.rs | 62 +++++++++++ krillnotes-core/src/core/workspace/mod.rs | 1 + krillnotes-core/src/lib.rs | 1 + 5 files changed, 176 insertions(+) create mode 100644 krillnotes-core/src/core/draft_tests.rs create mode 100644 krillnotes-core/src/core/workspace/drafts.rs diff --git a/krillnotes-core/src/core/draft_tests.rs b/krillnotes-core/src/core/draft_tests.rs new file mode 100644 index 00000000..7a7c8697 --- /dev/null +++ b/krillnotes-core/src/core/draft_tests.rs @@ -0,0 +1,109 @@ +#[cfg(test)] +mod tests { + use crate::core::permission::AllowAllGate; + use crate::{DeleteStrategy, Workspace}; + use ed25519_dalek::SigningKey; + use tempfile::NamedTempFile; + + fn test_gate() -> Box { + Box::new(AllowAllGate::new("test")) + } + + fn create_workspace() -> (Workspace, NamedTempFile) { + let temp = NamedTempFile::new().unwrap(); + let ws = Workspace::create( + temp.path(), + "", + "test-identity", + SigningKey::from_bytes(&[1u8; 32]), + test_gate(), + None, + temp.path().parent().unwrap(), + ) + .unwrap(); + (ws, temp) + } + + #[test] + fn test_get_draft_returns_none_when_no_draft() { + let (ws, _tmp) = create_workspace(); + let draft = ws.get_draft("nonexistent-note").unwrap(); + assert!(draft.is_none()); + } + + #[test] + fn test_save_and_get_draft() { + let (mut ws, _tmp) = create_workspace(); + let note_id = ws.create_note_root("TextNote").unwrap(); + + ws.save_draft( + ¬e_id, + "Draft Title", + r#"{"content":{"Text":"hello"}}"#, + r#"["tag1"]"#, + 1000, + ) + .unwrap(); + + let draft = ws + .get_draft(¬e_id) + .unwrap() + .expect("draft should exist"); + assert_eq!(draft.note_id, note_id); + assert_eq!(draft.title, "Draft Title"); + assert_eq!(draft.fields_json, r#"{"content":{"Text":"hello"}}"#); + assert_eq!(draft.tags_json, r#"["tag1"]"#); + assert_eq!(draft.base_modified_at, 1000); + assert!(draft.saved_at > 0); + } + + #[test] + fn test_save_draft_upserts() { + let (mut ws, _tmp) = create_workspace(); + let note_id = ws.create_note_root("TextNote").unwrap(); + + ws.save_draft(¬e_id, "First", "{}", "[]", 1000) + .unwrap(); + ws.save_draft(¬e_id, "Second", "{}", "[]", 1000) + .unwrap(); + + let draft = ws + .get_draft(¬e_id) + .unwrap() + .expect("draft should exist"); + assert_eq!(draft.title, "Second"); + } + + #[test] + fn test_delete_draft() { + let (mut ws, _tmp) = create_workspace(); + let note_id = ws.create_note_root("TextNote").unwrap(); + + ws.save_draft(¬e_id, "Title", "{}", "[]", 1000) + .unwrap(); + ws.delete_draft(¬e_id).unwrap(); + + let draft = ws.get_draft(¬e_id).unwrap(); + assert!(draft.is_none()); + } + + #[test] + fn test_delete_draft_noop_when_none() { + let (ws, _tmp) = create_workspace(); + ws.delete_draft("nonexistent").unwrap(); + } + + #[test] + fn test_draft_cascade_deleted_with_note() { + let (mut ws, _tmp) = create_workspace(); + let note_id = ws.create_note_root("TextNote").unwrap(); + + ws.save_draft(¬e_id, "Title", "{}", "[]", 1000) + .unwrap(); + ws.delete_note(¬e_id, DeleteStrategy::DeleteAll) + .unwrap(); + + let draft = ws.get_draft(¬e_id).unwrap(); + assert!(draft.is_none()); + } +} diff --git a/krillnotes-core/src/core/mod.rs b/krillnotes-core/src/core/mod.rs index a918a073..6163acfb 100644 --- a/krillnotes-core/src/core/mod.rs +++ b/krillnotes-core/src/core/mod.rs @@ -36,6 +36,9 @@ pub mod undo; pub mod user_script; pub mod workspace; +#[cfg(test)] +mod draft_tests; + #[doc(inline)] pub use attachment::AttachmentMeta; #[doc(inline)] diff --git a/krillnotes-core/src/core/workspace/drafts.rs b/krillnotes-core/src/core/workspace/drafts.rs new file mode 100644 index 00000000..33ee51fc --- /dev/null +++ b/krillnotes-core/src/core/workspace/drafts.rs @@ -0,0 +1,62 @@ +use crate::core::error::KrillnotesError; +use chrono::Utc; +use serde::{Deserialize, Serialize}; + +type Result = std::result::Result; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NoteDraft { + pub note_id: String, + pub title: String, + pub fields_json: String, + pub tags_json: String, + pub base_modified_at: i64, + pub saved_at: i64, +} + +impl super::Workspace { + pub fn save_draft( + &self, + note_id: &str, + title: &str, + fields_json: &str, + tags_json: &str, + base_modified_at: i64, + ) -> Result<()> { + let saved_at = Utc::now().timestamp(); + self.connection().execute( + "INSERT OR REPLACE INTO note_drafts (note_id, title, fields_json, tags_json, base_modified_at, saved_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![note_id, title, fields_json, tags_json, base_modified_at, saved_at], + )?; + Ok(()) + } + + pub fn get_draft(&self, note_id: &str) -> Result> { + let mut stmt = self.connection().prepare( + "SELECT note_id, title, fields_json, tags_json, base_modified_at, saved_at + FROM note_drafts WHERE note_id = ?1", + )?; + let mut rows = stmt.query_map([note_id], |row| { + Ok(NoteDraft { + note_id: row.get(0)?, + title: row.get(1)?, + fields_json: row.get(2)?, + tags_json: row.get(3)?, + base_modified_at: row.get(4)?, + saved_at: row.get(5)?, + }) + })?; + match rows.next() { + Some(row) => Ok(Some(row?)), + None => Ok(None), + } + } + + pub fn delete_draft(&self, note_id: &str) -> Result<()> { + self.connection() + .execute("DELETE FROM note_drafts WHERE note_id = ?1", [note_id])?; + Ok(()) + } +} diff --git a/krillnotes-core/src/core/workspace/mod.rs b/krillnotes-core/src/core/workspace/mod.rs index d3211f0e..543cf8ae 100644 --- a/krillnotes-core/src/core/workspace/mod.rs +++ b/krillnotes-core/src/core/workspace/mod.rs @@ -1145,6 +1145,7 @@ impl Workspace { // ── Domain sub-modules (split from this file for readability) ────── mod attachments; +pub mod drafts; mod hooks; mod notes; mod scripts; diff --git a/krillnotes-core/src/lib.rs b/krillnotes-core/src/lib.rs index 9e7548b3..2d6cb1a4 100644 --- a/krillnotes-core/src/lib.rs +++ b/krillnotes-core/src/lib.rs @@ -49,6 +49,7 @@ pub use core::{ undo::{RetractInverse, UndoResult}, user_script::UserScript, workspace::{ + drafts::NoteDraft, permissions::{CascadeImpactRow, EffectiveRoleInfo, InheritedGrant, PermissionGrantRow}, AddPosition, NoteSearchResult, SyncEventRecord, Workspace, }, From 6d8a69751627948cd245ab77fff3e9a152578788 Mon Sep 17 00:00:00 2001 From: careck Date: Fri, 12 Jun 2026 22:29:24 +1000 Subject: [PATCH 3/8] feat(draft): add Tauri commands for draft CRUD Expose save_draft, get_draft, delete_draft as Tauri commands. Registered in commands/mod.rs and generate_handler! macro. Also includes cargo fmt fix on draft_tests.rs. --- krillnotes-core/src/core/draft_tests.rs | 25 +++------ .../src-tauri/src/commands/drafts.rs | 54 +++++++++++++++++++ .../src-tauri/src/commands/mod.rs | 2 + krillnotes-desktop/src-tauri/src/lib.rs | 3 ++ 4 files changed, 66 insertions(+), 18 deletions(-) create mode 100644 krillnotes-desktop/src-tauri/src/commands/drafts.rs diff --git a/krillnotes-core/src/core/draft_tests.rs b/krillnotes-core/src/core/draft_tests.rs index 7a7c8697..ca9486da 100644 --- a/krillnotes-core/src/core/draft_tests.rs +++ b/krillnotes-core/src/core/draft_tests.rs @@ -45,10 +45,7 @@ mod tests { ) .unwrap(); - let draft = ws - .get_draft(¬e_id) - .unwrap() - .expect("draft should exist"); + let draft = ws.get_draft(¬e_id).unwrap().expect("draft should exist"); assert_eq!(draft.note_id, note_id); assert_eq!(draft.title, "Draft Title"); assert_eq!(draft.fields_json, r#"{"content":{"Text":"hello"}}"#); @@ -62,15 +59,10 @@ mod tests { let (mut ws, _tmp) = create_workspace(); let note_id = ws.create_note_root("TextNote").unwrap(); - ws.save_draft(¬e_id, "First", "{}", "[]", 1000) - .unwrap(); - ws.save_draft(¬e_id, "Second", "{}", "[]", 1000) - .unwrap(); + ws.save_draft(¬e_id, "First", "{}", "[]", 1000).unwrap(); + ws.save_draft(¬e_id, "Second", "{}", "[]", 1000).unwrap(); - let draft = ws - .get_draft(¬e_id) - .unwrap() - .expect("draft should exist"); + let draft = ws.get_draft(¬e_id).unwrap().expect("draft should exist"); assert_eq!(draft.title, "Second"); } @@ -79,8 +71,7 @@ mod tests { let (mut ws, _tmp) = create_workspace(); let note_id = ws.create_note_root("TextNote").unwrap(); - ws.save_draft(¬e_id, "Title", "{}", "[]", 1000) - .unwrap(); + ws.save_draft(¬e_id, "Title", "{}", "[]", 1000).unwrap(); ws.delete_draft(¬e_id).unwrap(); let draft = ws.get_draft(¬e_id).unwrap(); @@ -98,10 +89,8 @@ mod tests { let (mut ws, _tmp) = create_workspace(); let note_id = ws.create_note_root("TextNote").unwrap(); - ws.save_draft(¬e_id, "Title", "{}", "[]", 1000) - .unwrap(); - ws.delete_note(¬e_id, DeleteStrategy::DeleteAll) - .unwrap(); + ws.save_draft(¬e_id, "Title", "{}", "[]", 1000).unwrap(); + ws.delete_note(¬e_id, DeleteStrategy::DeleteAll).unwrap(); let draft = ws.get_draft(¬e_id).unwrap(); assert!(draft.is_none()); diff --git a/krillnotes-desktop/src-tauri/src/commands/drafts.rs b/krillnotes-desktop/src-tauri/src/commands/drafts.rs new file mode 100644 index 00000000..a102670e --- /dev/null +++ b/krillnotes-desktop/src-tauri/src/commands/drafts.rs @@ -0,0 +1,54 @@ +use tauri::{command, State, Window}; + +use crate::AppState; + +#[command] +pub fn save_draft( + window: Window, + state: State<'_, AppState>, + note_id: String, + title: String, + fields_json: String, + tags_json: String, + base_modified_at: i64, +) -> Result<(), String> { + let label = window.label(); + let workspaces = state.workspaces.lock().expect("Mutex poisoned"); + let workspace = workspaces.get(label).ok_or("No workspace open")?; + workspace + .save_draft(¬e_id, &title, &fields_json, &tags_json, base_modified_at) + .map_err(|e| { + log::error!("save_draft failed: {e}"); + e.to_string() + }) +} + +#[command] +pub fn get_draft( + window: Window, + state: State<'_, AppState>, + note_id: String, +) -> Result, String> { + let label = window.label(); + let workspaces = state.workspaces.lock().expect("Mutex poisoned"); + let workspace = workspaces.get(label).ok_or("No workspace open")?; + workspace.get_draft(¬e_id).map_err(|e| { + log::error!("get_draft failed: {e}"); + e.to_string() + }) +} + +#[command] +pub fn delete_draft( + window: Window, + state: State<'_, AppState>, + note_id: String, +) -> Result<(), String> { + let label = window.label(); + let workspaces = state.workspaces.lock().expect("Mutex poisoned"); + let workspace = workspaces.get(label).ok_or("No workspace open")?; + workspace.delete_draft(¬e_id).map_err(|e| { + log::error!("delete_draft failed: {e}"); + e.to_string() + }) +} diff --git a/krillnotes-desktop/src-tauri/src/commands/mod.rs b/krillnotes-desktop/src-tauri/src/commands/mod.rs index 16192357..05e11a5c 100644 --- a/krillnotes-desktop/src-tauri/src/commands/mod.rs +++ b/krillnotes-desktop/src-tauri/src/commands/mod.rs @@ -7,6 +7,7 @@ pub mod accepted_invites; pub mod attachments; pub mod contacts; +pub mod drafts; pub mod identity; pub mod invites; pub mod notes; @@ -22,6 +23,7 @@ pub mod workspace; pub use accepted_invites::*; pub use attachments::*; pub use contacts::*; +pub use drafts::*; pub use identity::*; pub use invites::*; pub use notes::*; diff --git a/krillnotes-desktop/src-tauri/src/lib.rs b/krillnotes-desktop/src-tauri/src/lib.rs index 54557b23..9b375d97 100644 --- a/krillnotes-desktop/src-tauri/src/lib.rs +++ b/krillnotes-desktop/src-tauri/src/lib.rs @@ -545,6 +545,9 @@ pub fn run() { revoke_permission, get_share_anchor_ids, is_root_owner, + save_draft, + get_draft, + delete_draft, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") From 67e69b6ebae31178511f2879d4df0ccef8526c72 Mon Sep 17 00:00:00 2001 From: careck Date: Fri, 12 Jun 2026 22:31:08 +1000 Subject: [PATCH 4/8] feat(draft): add DraftRecoveryBanner component --- .../src/components/DraftRecoveryBanner.tsx | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 krillnotes-desktop/src/components/DraftRecoveryBanner.tsx diff --git a/krillnotes-desktop/src/components/DraftRecoveryBanner.tsx b/krillnotes-desktop/src/components/DraftRecoveryBanner.tsx new file mode 100644 index 00000000..4403a14d --- /dev/null +++ b/krillnotes-desktop/src/components/DraftRecoveryBanner.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from 'react-i18next'; +import type { NoteDraft } from '../types'; + +interface DraftRecoveryBannerProps { + draft: NoteDraft; + noteModifiedAt: number; + onRestore: () => void; + onDiscard: () => void; +} + +export default function DraftRecoveryBanner({ draft, noteModifiedAt, onRestore, onDiscard }: DraftRecoveryBannerProps) { + const { t, i18n } = useTranslation(); + const isConflict = draft.baseModifiedAt !== noteModifiedAt; + const formattedTime = new Date(draft.savedAt * 1000).toLocaleString(i18n.language); + + const messageKey = isConflict ? 'draft.peerConflictMessage' : 'draft.recoveryMessage'; + + return ( +
+ {t(messageKey, { time: formattedTime })} +
+ + +
+
+ ); +} From a1dab2984eae7a88e79f75f07143f2c837f0b7f2 Mon Sep 17 00:00:00 2001 From: careck Date: Fri, 12 Jun 2026 22:31:43 +1000 Subject: [PATCH 5/8] feat(draft): add NoteDraft type and i18n keys for 7 locales --- krillnotes-desktop/src/i18n/locales/de.json | 24 +++++++++++++-------- krillnotes-desktop/src/i18n/locales/en.json | 10 +++++++-- krillnotes-desktop/src/i18n/locales/es.json | 12 ++++++++--- krillnotes-desktop/src/i18n/locales/fr.json | 14 ++++++++---- krillnotes-desktop/src/i18n/locales/ja.json | 8 ++++++- krillnotes-desktop/src/i18n/locales/ko.json | 8 ++++++- krillnotes-desktop/src/i18n/locales/zh.json | 8 ++++++- krillnotes-desktop/src/types.ts | 9 ++++++++ 8 files changed, 72 insertions(+), 21 deletions(-) diff --git a/krillnotes-desktop/src/i18n/locales/de.json b/krillnotes-desktop/src/i18n/locales/de.json index 72ddec5d..59c55871 100644 --- a/krillnotes-desktop/src/i18n/locales/de.json +++ b/krillnotes-desktop/src/i18n/locales/de.json @@ -35,6 +35,12 @@ "password": "Passwort", "remove": "Entfernen" }, + "draft": { + "recoveryMessage": "Ungespeicherte Änderungen von {{time}}.", + "peerConflictMessage": "Ungespeicherte Änderungen von {{time}} — diese Notiz wurde seitdem von einem Peer geändert.", + "restore": "Wiederherstellen", + "discard": "Verwerfen" + }, "settings": { "title": "Einstellungen", "workspaceDir": "Standard-Arbeitsbereichsverzeichnis", @@ -62,9 +68,9 @@ "sharingIndicatorsOff": "Aus", "sharingIndicatorsAuto": "Automatisch", "sharingIndicatorsOn": "An", - "syncOnClose": "Synchronisierung beim Schlie\u00dfen", + "syncOnClose": "Synchronisierung beim Schließen", "syncOnCloseAlways": "Immer synchronisieren", - "syncOnCloseAsk": "Vor dem Schlie\u00dfen fragen", + "syncOnCloseAsk": "Vor dem Schließen fragen", "syncOnCloseNever": "Nie synchronisieren" }, "themes": { @@ -793,20 +799,20 @@ "generate": "Generieren" }, "syncOnClose": { - "message": "Dieser Arbeitsbereich hat nicht synchronisierte \u00c4nderungen. Vor dem Schlie\u00dfen mit Peers synchronisieren?", - "syncAndClose": "Synchronisieren & Schlie\u00dfen", - "closeWithoutSync": "Ohne Synchronisierung schlie\u00dfen", + "message": "Dieser Arbeitsbereich hat nicht synchronisierte Änderungen. Vor dem Schließen mit Peers synchronisieren?", + "syncAndClose": "Synchronisieren & Schließen", + "closeWithoutSync": "Ohne Synchronisierung schließen", "cancel": "Abbrechen", - "syncing": "Synchronisiere\u2026", + "syncing": "Synchronisiere…", "errorTitle": "Synchronisierung fehlgeschlagen", - "closeAnyway": "Trotzdem schlie\u00dfen" + "closeAnyway": "Trotzdem schließen" }, "mobile": { - "backButton": "Zur\u00fcck" + "backButton": "Zurück" }, "validation": { "belowMinimum": "Der Wert muss mindestens {{min}} betragen", - "aboveMaximum": "Der Wert darf h\u00f6chstens {{max}} betragen", + "aboveMaximum": "Der Wert darf höchstens {{max}} betragen", "patternMismatch": "Der Wert entspricht nicht dem erforderlichen Format" } } diff --git a/krillnotes-desktop/src/i18n/locales/en.json b/krillnotes-desktop/src/i18n/locales/en.json index 77d9058b..0cafe0fa 100644 --- a/krillnotes-desktop/src/i18n/locales/en.json +++ b/krillnotes-desktop/src/i18n/locales/en.json @@ -35,6 +35,12 @@ "password": "Password", "remove": "Remove" }, + "draft": { + "recoveryMessage": "Unsaved changes from {{time}}.", + "peerConflictMessage": "Unsaved changes from {{time}} — this note was modified by a peer since then.", + "restore": "Restore", + "discard": "Discard" + }, "settings": { "title": "Settings", "workspaceDir": "Default Workspace Directory", @@ -797,7 +803,7 @@ "syncAndClose": "Sync & Close", "closeWithoutSync": "Close Without Syncing", "cancel": "Cancel", - "syncing": "Syncing\u2026", + "syncing": "Syncing…", "errorTitle": "Sync failed", "closeAnyway": "Close Anyway" }, @@ -809,4 +815,4 @@ "aboveMaximum": "Value must be at most {{max}}", "patternMismatch": "Value does not match the required format" } -} \ No newline at end of file +} diff --git a/krillnotes-desktop/src/i18n/locales/es.json b/krillnotes-desktop/src/i18n/locales/es.json index 915401fa..126c4f1d 100644 --- a/krillnotes-desktop/src/i18n/locales/es.json +++ b/krillnotes-desktop/src/i18n/locales/es.json @@ -35,6 +35,12 @@ "password": "Contraseña", "remove": "Eliminar" }, + "draft": { + "recoveryMessage": "Cambios no guardados de {{time}}.", + "peerConflictMessage": "Cambios no guardados de {{time}} — esta nota fue modificada por un par desde entonces.", + "restore": "Restaurar", + "discard": "Descartar" + }, "settings": { "title": "Ajustes", "workspaceDir": "Directorio de espacios de trabajo predeterminado", @@ -793,12 +799,12 @@ "generate": "Generar" }, "syncOnClose": { - "message": "Este espacio de trabajo tiene cambios sin sincronizar. \u00bfSincronizar con los pares antes de cerrar?", + "message": "Este espacio de trabajo tiene cambios sin sincronizar. ¿Sincronizar con los pares antes de cerrar?", "syncAndClose": "Sincronizar y cerrar", "closeWithoutSync": "Cerrar sin sincronizar", "cancel": "Cancelar", - "syncing": "Sincronizando\u2026", - "errorTitle": "Error de sincronizaci\u00f3n", + "syncing": "Sincronizando…", + "errorTitle": "Error de sincronización", "closeAnyway": "Cerrar de todos modos" }, "mobile": { diff --git a/krillnotes-desktop/src/i18n/locales/fr.json b/krillnotes-desktop/src/i18n/locales/fr.json index b0abc37f..7531151f 100644 --- a/krillnotes-desktop/src/i18n/locales/fr.json +++ b/krillnotes-desktop/src/i18n/locales/fr.json @@ -35,6 +35,12 @@ "password": "Mot de passe", "remove": "Supprimer" }, + "draft": { + "recoveryMessage": "Modifications non enregistrées de {{time}}.", + "peerConflictMessage": "Modifications non enregistrées de {{time}} — cette note a été modifiée par un pair depuis.", + "restore": "Restaurer", + "discard": "Abandonner" + }, "settings": { "title": "Paramètres", "workspaceDir": "Répertoire d'espaces de travail par défaut", @@ -793,13 +799,13 @@ "generate": "Générer" }, "syncOnClose": { - "message": "Cet espace de travail contient des modifications non synchronis\u00e9es. Synchroniser avec les pairs avant de fermer ?", + "message": "Cet espace de travail contient des modifications non synchronisées. Synchroniser avec les pairs avant de fermer ?", "syncAndClose": "Synchroniser et fermer", "closeWithoutSync": "Fermer sans synchroniser", "cancel": "Annuler", - "syncing": "Synchronisation\u2026", - "errorTitle": "\u00c9chec de la synchronisation", - "closeAnyway": "Fermer quand m\u00eame" + "syncing": "Synchronisation…", + "errorTitle": "Échec de la synchronisation", + "closeAnyway": "Fermer quand même" }, "mobile": { "backButton": "Retour" diff --git a/krillnotes-desktop/src/i18n/locales/ja.json b/krillnotes-desktop/src/i18n/locales/ja.json index 0082ec8e..bd0ad8af 100644 --- a/krillnotes-desktop/src/i18n/locales/ja.json +++ b/krillnotes-desktop/src/i18n/locales/ja.json @@ -35,6 +35,12 @@ "password": "パスワード", "remove": "削除" }, + "draft": { + "recoveryMessage": "{{time}}からの未保存の変更があります。", + "peerConflictMessage": "{{time}}からの未保存の変更があります — このノートはその後ピアによって変更されました。", + "restore": "復元", + "discard": "破棄" + }, "settings": { "title": "設定", "workspaceDir": "デフォルトのワークスペースディレクトリ", @@ -797,7 +803,7 @@ "syncAndClose": "同期して閉じる", "closeWithoutSync": "同期せずに閉じる", "cancel": "キャンセル", - "syncing": "同期中\u2026", + "syncing": "同期中…", "errorTitle": "同期に失敗しました", "closeAnyway": "そのまま閉じる" }, diff --git a/krillnotes-desktop/src/i18n/locales/ko.json b/krillnotes-desktop/src/i18n/locales/ko.json index 4c760931..608cb3d8 100644 --- a/krillnotes-desktop/src/i18n/locales/ko.json +++ b/krillnotes-desktop/src/i18n/locales/ko.json @@ -35,6 +35,12 @@ "password": "비밀번호", "remove": "제거" }, + "draft": { + "recoveryMessage": "{{time}}의 저장되지 않은 변경 사항이 있습니다.", + "peerConflictMessage": "{{time}}의 저장되지 않은 변경 사항이 있습니다 — 이 노트는 그 이후 피어에 의해 수정되었습니다.", + "restore": "복원", + "discard": "삭제" + }, "settings": { "title": "설정", "workspaceDir": "기본 작업 공간 디렉터리", @@ -797,7 +803,7 @@ "syncAndClose": "동기화 후 닫기", "closeWithoutSync": "동기화 없이 닫기", "cancel": "취소", - "syncing": "동기화 중\u2026", + "syncing": "동기화 중…", "errorTitle": "동기화 실패", "closeAnyway": "그래도 닫기" }, diff --git a/krillnotes-desktop/src/i18n/locales/zh.json b/krillnotes-desktop/src/i18n/locales/zh.json index 84422041..9c4085db 100644 --- a/krillnotes-desktop/src/i18n/locales/zh.json +++ b/krillnotes-desktop/src/i18n/locales/zh.json @@ -35,6 +35,12 @@ "password": "密码", "remove": "移除" }, + "draft": { + "recoveryMessage": "来自 {{time}} 的未保存更改。", + "peerConflictMessage": "来自 {{time}} 的未保存更改 — 此笔记此后已被对等方修改。", + "restore": "恢复", + "discard": "丢弃" + }, "settings": { "title": "设置", "workspaceDir": "默认工作区目录", @@ -797,7 +803,7 @@ "syncAndClose": "同步并关闭", "closeWithoutSync": "不同步直接关闭", "cancel": "取消", - "syncing": "同步中\u2026", + "syncing": "同步中…", "errorTitle": "同步失败", "closeAnyway": "仍然关闭" }, diff --git a/krillnotes-desktop/src/types.ts b/krillnotes-desktop/src/types.ts index 9638c8a2..0883af5a 100644 --- a/krillnotes-desktop/src/types.ts +++ b/krillnotes-desktop/src/types.ts @@ -415,3 +415,12 @@ export interface SyncEventRecord { eventType: string; detail: string | null; } + +export interface NoteDraft { + noteId: string; + title: string; + fieldsJson: string; + tagsJson: string; + baseModifiedAt: number; + savedAt: number; +} From 50d2fcf2110d4f92e71fd3ba954ffc316585881b Mon Sep 17 00:00:00 2001 From: careck Date: Fri, 12 Jun 2026 22:35:45 +1000 Subject: [PATCH 6/8] feat(draft): add debounced draft save and recovery to useNoteForm --- krillnotes-desktop/src/hooks/useNoteForm.ts | 107 +++++++++++++++++++- 1 file changed, 102 insertions(+), 5 deletions(-) diff --git a/krillnotes-desktop/src/hooks/useNoteForm.ts b/krillnotes-desktop/src/hooks/useNoteForm.ts index 72fdf505..bf3945e9 100644 --- a/krillnotes-desktop/src/hooks/useNoteForm.ts +++ b/krillnotes-desktop/src/hooks/useNoteForm.ts @@ -8,7 +8,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { confirm } from '@tauri-apps/plugin-dialog'; import { useTranslation } from 'react-i18next'; -import type { Note, FieldValue, SchemaInfo, FieldDefinition, SaveResult } from '../types'; +import type { Note, FieldValue, SchemaInfo, FieldDefinition, SaveResult, NoteDraft } from '../types'; import { defaultValueForFieldType } from '../utils/fieldValue'; export function useNoteForm( @@ -47,6 +47,18 @@ export function useNoteForm( const [noteErrors, setNoteErrors] = useState([]); const titleInputRef = useRef(null); + const [pendingDraft, setPendingDraft] = useState(null); + const baseModifiedAtRef = useRef(0); + const draftTimerRef = useRef | null>(null); + const editedFieldsRef = useRef(editedFields); + const editedTitleRef = useRef(editedTitle); + const editedTagsRef = useRef(editedTags); + const noteIdRef = useRef(null); + + useEffect(() => { editedFieldsRef.current = editedFields; }, [editedFields]); + useEffect(() => { editedTitleRef.current = editedTitle; }, [editedTitle]); + useEffect(() => { editedTagsRef.current = editedTags; }, [editedTags]); + useEffect(() => { noteIdRef.current = selectedNote?.id ?? null; }, [selectedNote]); // Effect 2: Reset form state when selected note changes useEffect(() => { @@ -67,6 +79,16 @@ export function useNoteForm( } }, [selectedNote?.id]); + useEffect(() => { + if (!selectedNote || isEditing) { + setPendingDraft(null); + return; + } + invoke('get_draft', { noteId: selectedNote.id }) + .then(draft => setPendingDraft(draft)) + .catch(console.error); + }, [selectedNote?.id, isEditing]); + // Effect 7: Auto-focus first editable field when edit mode activates. // Prefers title input; falls back to the first input/select/textarea in the panel. useEffect(() => { @@ -96,12 +118,38 @@ export function useNoteForm( } }, [selectedNote, schemaInfo.fieldGroups]); + const flushDraft = useCallback(() => { + if (draftTimerRef.current) { + clearTimeout(draftTimerRef.current); + draftTimerRef.current = null; + } + const nid = noteIdRef.current; + if (!nid) return; + invoke('save_draft', { + noteId: nid, + title: editedTitleRef.current, + fieldsJson: JSON.stringify(editedFieldsRef.current), + tagsJson: JSON.stringify(editedTagsRef.current), + baseModifiedAt: baseModifiedAtRef.current, + }).catch(console.error); + }, []); + + const scheduleDraftSave = useCallback(() => { + if (draftTimerRef.current) { + clearTimeout(draftTimerRef.current); + } + draftTimerRef.current = setTimeout(flushDraft, 3000); + }, [flushDraft]); + const handleEdit = useCallback(() => { invoke('get_all_tags').then(setAllTags).catch(console.error); setPreviousTab(activeTab); setActiveTab('fields'); setIsEditing(true); - }, [activeTab, setPreviousTab, setActiveTab, setIsEditing]); + if (selectedNote) { + baseModifiedAtRef.current = selectedNote.modifiedAt; + } + }, [activeTab, setPreviousTab, setActiveTab, setIsEditing, selectedNote]); const addTag = useCallback((tag: string) => { const normalised = tag.trim().toLowerCase(); @@ -110,12 +158,14 @@ export function useNoteForm( setTagInput(''); setTagSuggestions([]); setIsDirty(true); - }, [editedTags, setEditedTags, setTagInput, setTagSuggestions]); + scheduleDraftSave(); + }, [editedTags, setEditedTags, setTagInput, setTagSuggestions, scheduleDraftSave]); const removeTag = useCallback((tag: string) => { setEditedTags(prev => prev.filter(t => t !== tag)); setIsDirty(true); - }, [setEditedTags]); + scheduleDraftSave(); + }, [setEditedTags, scheduleDraftSave]); const handleTagInputChange = useCallback((value: string) => { setTagInput(value); @@ -157,6 +207,11 @@ export function useNoteForm( // selectedNote may have changed while the confirm dialog was open if (!selectedNote) return; setIsEditing(false); + invoke('delete_draft', { noteId: selectedNote.id }).catch(console.error); + if (draftTimerRef.current) { + clearTimeout(draftTimerRef.current); + draftTimerRef.current = null; + } if (previousTab) { setActiveTab(previousTab); setPreviousTab(null); @@ -214,6 +269,11 @@ export function useNoteForm( // Re-fetch view HTML after save — on_save may have changed field values. // Clear cached HTML so the render_view effect re-fetches. setViewHtml({}); + if (draftTimerRef.current) { + clearTimeout(draftTimerRef.current); + draftTimerRef.current = null; + } + invoke('delete_draft', { noteId: selectedNote.id }).catch(console.error); } catch (err) { setNoteErrors([t('notes.saveFailed', { error: String(err) })]); } @@ -241,7 +301,40 @@ export function useNoteForm( return next; }); setIsDirty(true); - }, [setEditedFields, evaluateGroupVisibility]); + scheduleDraftSave(); + }, [setEditedFields, evaluateGroupVisibility, scheduleDraftSave]); + + const handleRestoreDraft = useCallback(() => { + if (!pendingDraft || !selectedNote) return; + const fields = JSON.parse(pendingDraft.fieldsJson) as Record; + const tags = JSON.parse(pendingDraft.tagsJson) as string[]; + setEditedFields(fields); + setEditedTags(tags); + if (schemaInfo.titleCanEdit) { + setEditedTitle(pendingDraft.title); + } + setIsEditing(true); + setIsDirty(true); + baseModifiedAtRef.current = pendingDraft.baseModifiedAt; + setPreviousTab(activeTab); + setActiveTab('fields'); + invoke('delete_draft', { noteId: selectedNote.id }).catch(console.error); + setPendingDraft(null); + }, [pendingDraft, selectedNote, activeTab, schemaInfo.titleCanEdit, setEditedFields, setEditedTags, setEditedTitle, setIsEditing, setIsDirty, setPreviousTab, setActiveTab]); + + const handleDiscardDraft = useCallback(() => { + if (!selectedNote) return; + invoke('delete_draft', { noteId: selectedNote.id }).catch(console.error); + setPendingDraft(null); + }, [selectedNote]); + + useEffect(() => { + return () => { + if (draftTimerRef.current) { + clearTimeout(draftTimerRef.current); + } + }; + }, []); return { isEditing, @@ -272,5 +365,9 @@ export function useNoteForm( addTag, removeTag, handleTagInputChange, + pendingDraft, + handleRestoreDraft, + handleDiscardDraft, + scheduleDraftSave, }; } From 2151e8017bcd7cd33507e0fd81efae247245380f Mon Sep 17 00:00:00 2001 From: careck Date: Fri, 12 Jun 2026 22:35:49 +1000 Subject: [PATCH 7/8] feat(draft): render DraftRecoveryBanner in InfoPanel --- krillnotes-desktop/src/components/InfoPanel.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/krillnotes-desktop/src/components/InfoPanel.tsx b/krillnotes-desktop/src/components/InfoPanel.tsx index f69a460b..7d077f85 100644 --- a/krillnotes-desktop/src/components/InfoPanel.tsx +++ b/krillnotes-desktop/src/components/InfoPanel.tsx @@ -15,6 +15,7 @@ import FieldDisplay from './FieldDisplay'; import FieldEditor from './FieldEditor'; import TagPill from './TagPill'; import AttachmentsSection from './AttachmentsSection'; +import DraftRecoveryBanner from './DraftRecoveryBanner'; import { ChevronRight } from 'lucide-react'; import { defaultValueForFieldType, isEmptyFieldValue } from '../utils/fieldValue'; import { useSchema } from '../hooks/useSchema'; @@ -84,6 +85,7 @@ function InfoPanel({ selectedNote, onNoteUpdated, onDeleteRequest, requestEditMo groupCollapsed, setGroupCollapsed, groupVisible, fieldErrors, noteErrors, titleInputRef, handleFormKeyDown, handleEdit, handleCancel, handleSave, handleFieldChange, handleFieldBlur, addTag, removeTag, handleTagInputChange, + pendingDraft, handleRestoreDraft, handleDiscardDraft, scheduleDraftSave, } = useNoteForm( selectedNote, schemaInfo, { activeTab, setActiveTab, previousTab, setPreviousTab, setViewHtml }, @@ -291,6 +293,14 @@ function InfoPanel({ selectedNote, onNoteUpdated, onDeleteRequest, requestEditMo return (
+ {pendingDraft && selectedNote && !isEditing && ( + + )} {/* Header */}
{isEditing ? ( @@ -302,6 +312,7 @@ function InfoPanel({ selectedNote, onNoteUpdated, onDeleteRequest, requestEditMo onChange={(e) => { setEditedTitle(e.target.value); setIsDirty(true); + scheduleDraftSave(); }} className={`font-bold bg-background border border-border rounded-md px-2 py-1 ${isPhone ? 'text-2xl w-full' : 'text-4xl flex-1'}`} autoCorrect="off" From 340434deae706daaec23ea434768256402f70c15 Mon Sep 17 00:00:00 2001 From: careck Date: Sun, 14 Jun 2026 15:53:01 +1000 Subject: [PATCH 8/8] ci: pin time 0.3.47 to work around cookie/tauri-utils coherence conflict --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23fa24b0..4e294a8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,9 @@ jobs: patchelf \ xdg-utils + - name: Pin time crate (workaround cookie/tauri-utils coherence conflict) + run: cargo update -p time --precise 0.3.47 + - name: Check formatting run: cargo fmt --check