From 5989ffc1c68fd64d7e695a3001b0dbc886da8516 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 11:36:42 -0400 Subject: [PATCH] desktop: Import and Export work offline; the menu toggle is reachable; errors say why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export and Import were a link to the server and a reject("needs a server") on the desktop. Both now run in the core with no server: - core/src/local/portable.rs builds the same zip the server writes (notes.json, a Markdown file per note, each attachment this device holds) and reads either export marker or a Google Keep Takeout zip, with the server's decompression budget and an all-or-nothing transaction. Export saves to Downloads (no new plugin) and the sidebar says where; Import takes the archive as raw IPC bytes. - core/testdata/portable.json pins the format for both copies: the server runs its Keep and native readers against it (test_portable_fixture.py) and checks its real export's keys (test_integration.py); the core runs the same cases. - Found on the way: both importers skipped a Keep note that is only a photo as "empty". It now imports, on the server and in the core. - New dependency, approved: `zip` (deflate only) plus `flate2` on its pure-Rust backend, both already in the lockfile. The AppImage applications-menu toggle moves from Account, which the desktop never shows, to the Sync page; the first-run prompt now says so. errorMessage (#5236) replaces the hand-rolled `.error ?? …` / `.message ?? e` reads at the remaining catch sites, so a desktop failure shows its real reason. Task #5170. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 3 + core/Cargo.toml | 9 + core/src/local/mod.rs | 1 + core/src/local/portable.rs | 894 ++++++++++++++++++ core/src/local/store.rs | 2 +- core/testdata/portable.json | 146 +++ desktop/src-tauri/src/commands/local.rs | 55 ++ desktop/src-tauri/src/lib.rs | 2 + frontend/src/adapters/local.ts | 10 +- frontend/src/adapters/repo.ts | 3 + frontend/src/adapters/rest.ts | 9 + frontend/src/components/AppShell.vue | 6 +- .../components/DesktopIntegrationPrompt.vue | 8 +- frontend/src/components/DesktopMenuEntry.vue | 58 ++ frontend/src/components/ExportNotes.vue | 38 + frontend/src/components/FilterBar.vue | 3 +- frontend/src/components/ImportNotes.vue | 3 +- frontend/src/composables/useNoteList.ts | 3 +- frontend/src/views/AccountView.vue | 60 +- frontend/src/views/BoardView.vue | 3 +- frontend/src/views/LoginView.vue | 4 +- frontend/src/views/RegisterView.vue | 4 +- frontend/src/views/SettingsView.vue | 3 +- frontend/src/views/SyncView.vue | 22 +- src/inkwell/notes/import_export.py | 4 +- tests/test_integration.py | 58 ++ tests/test_portable_fixture.py | 42 + 27 files changed, 1365 insertions(+), 88 deletions(-) create mode 100644 core/src/local/portable.rs create mode 100644 core/testdata/portable.json create mode 100644 frontend/src/components/DesktopMenuEntry.vue create mode 100644 frontend/src/components/ExportNotes.vue create mode 100644 tests/test_portable_fixture.py diff --git a/Cargo.lock b/Cargo.lock index b512314..b43fc57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1859,6 +1859,7 @@ name = "inkwell-core" version = "0.1.0" dependencies = [ "chrono", + "flate2", "log", "openssl-sys", "reqwest 0.12.28", @@ -1867,6 +1868,7 @@ dependencies = [ "serde_json", "sha2", "uuid", + "zip", ] [[package]] @@ -5765,6 +5767,7 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" dependencies = [ "arbitrary", "crc32fast", + "flate2", "indexmap 2.14.0", "memchr", ] diff --git a/core/Cargo.toml b/core/Cargo.toml index b07dcd2..a5a02ff 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -26,6 +26,15 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] } reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] } # Verifying downloaded attachment bytes against the sha256 the server advertised. sha2 = "0.10" +# Export and import with no server (local/portable.rs): the same zip the server +# writes and reads, so a backup crosses between surfaces. Deflate only — every +# other method (bzip2, zstd, lzma, AES) is off, since neither the server's exports +# nor Google Takeout use them and several pull in C code. +zip = { version = "4", default-features = false, features = ["deflate-flate2"] } +# zip's deflate goes through flate2, which needs a backend chosen. miniz_oxide +# (`rust_backend`) is pure Rust, so the Windows and Android cross-compiles stay +# free of C; both crates were already in the lockfile, via the updater and png. +flate2 = { version = "1", default-features = false, features = ["rust_backend"] } # Android has no system OpenSSL to link against, and `native-tls` resolves to # OpenSSL there — unlike Windows, where it lands on schannel and costs nothing. diff --git a/core/src/local/mod.rs b/core/src/local/mod.rs index 4ba8e4c..e5c71e7 100644 --- a/core/src/local/mod.rs +++ b/core/src/local/mod.rs @@ -6,6 +6,7 @@ pub mod derive; pub mod models; +pub mod portable; pub mod recur; pub mod retention; pub mod schema; diff --git a/core/src/local/portable.rs b/core/src/local/portable.rs new file mode 100644 index 0000000..aebef20 --- /dev/null +++ b/core/src/local/portable.rs @@ -0,0 +1,894 @@ +//! A whole store as a zip, out and back in, with no server. +//! +//! The same archive the server writes at `GET /api/notes/export` and reads at +//! `POST /api/notes/import` (`src/inkwell/notes/import_export.py`), so a backup taken +//! on one surface restores on any other. Both copies are held to +//! `core/testdata/portable.json`; change the format there first. +//! +//! - **Export:** `notes.json` (the machine format), a Markdown file per note for +//! reading, and each attachment this device holds. +//! - **Import:** an Inkwell export (either app marker) or a Google Keep Takeout zip. +//! Additive: notes are created, never matched against existing ones. Decompression +//! is capped per entry and in total, so a zip bomb fails instead of filling memory. + +use std::io::{Cursor, Read, Write}; + +use chrono::{DateTime, SecondsFormat, Utc}; +use rusqlite::{params, Connection}; +use serde::Serialize; +use serde_json::{json, Map, Value}; +use zip::write::SimpleFileOptions; +use zip::{CompressionMethod, ZipArchive, ZipWriter}; + +use super::models::{Note, NoteCreateInput}; +use super::{derive, recur, store}; +use crate::sync::blobs::BlobStore; + +/// The `app` an export's notes.json carries. "thoughtsync" is what every export +/// written before the rename says, and those are people's backups. +const APP_MARKERS: [&str; 2] = ["inkwell", "thoughtsync"]; + +const MAX_ENTRIES: usize = 10_000; +const MAX_ENTRY_BYTES: u64 = 64 * 1024 * 1024; +const MAX_TOTAL_BYTES: u64 = 512 * 1024 * 1024; + +/// What an import did, in the server's words. +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct ImportSummary { + /// "inkwell" or "keep". + pub source: String, + pub imported: usize, + pub skipped: usize, +} + +/// One note as either importer reads it, before anything is written. Mirrors the +/// server's "common import spec". +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ImportSpec { + pub title: Option, + pub body: String, + pub pinned: bool, + pub archived: bool, + pub trashed: bool, + pub remind_at: Option, + pub recurrence: Option, + pub created_at: Option, + pub updated_at: Option, + pub labels: Vec, + pub items: Vec<(String, bool)>, + /// (path inside the zip, mime if the source said) + pub attachments: Vec<(String, Option)>, +} + +// ---- export ----------------------------------------------------------------- + +/// The export's file name without `.zip`, dated like the server's: +/// `inkwell-export-YYYYMMDD`. +pub fn export_stem() -> String { + format!("inkwell-export-{}", Utc::now().format("%Y%m%d")) +} + +/// Every live note (not trashed) as an export archive. +pub fn export_zip(conn: &Connection, blobs: &BlobStore) -> Result, String> { + let ids: Vec = { + let mut stmt = conn + .prepare("SELECT id FROM notes WHERE trashed = 0 ORDER BY created_at") + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([], |r| r.get(0)) + .map_err(|e| e.to_string())?; + rows.collect::>() + .map_err(|e| e.to_string())? + }; + let labels: Vec = store::list_labels(conn) + .map_err(|e| e.to_string())? + .into_iter() + .map(|l| json!({ "name": l.name, "color": l.color })) + .collect(); + + let mut zip = ZipWriter::new(Cursor::new(Vec::new())); + let opts = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); + let mut notes = Vec::with_capacity(ids.len()); + for id in &ids { + let note = store::get_note(conn, id).map_err(|e| e.to_string())?; + let short: String = note.id.chars().take(8).collect(); + + let mut files = Vec::new(); + for (i, att) in note.attachments.iter().enumerate() { + // A file this device hasn't downloaded yet is left out of the list as well + // as the archive, so an importer never goes looking for it. + let Some(bytes) = att.sha256.as_deref().and_then(|sha| blobs.read(sha)) else { + continue; + }; + // A folder per attachment, so two files with one name in one note don't + // collide, and the importer still gets the real name from the basename. + let name = store::safe_filename(att.filename.as_deref().unwrap_or("")); + let path = format!("attachments/{short}/{i}/{name}"); + write_entry(&mut zip, &path, &bytes, opts)?; + files.push(json!({ "file": path, "mime": att.mime })); + } + + let md = format!("notes/{}-{short}.md", slugify(¬e.display_title)); + write_entry(&mut zip, &md, note_markdown(¬e).as_bytes(), opts)?; + notes.push(json!({ + "id": note.id, + "display_title": note.display_title, + "body": note.body, + "pinned": note.pinned, + "archived": note.archived, + "remind_at": note.remind_at, + "recurrence": note.recurrence, + "created_at": note.created_at, + "updated_at": note.updated_at, + "labels": note.labels.iter().map(|l| l.name.clone()).collect::>(), + "attachments": files, + })); + } + + let doc = json!({ + "app": "inkwell", + "version": 1, + "exported_at": Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), + "labels": labels, + "notes": notes, + }); + let text = serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())?; + write_entry(&mut zip, "notes.json", text.as_bytes(), opts)?; + zip.finish() + .map(Cursor::into_inner) + .map_err(|e| e.to_string()) +} + +fn write_entry( + zip: &mut ZipWriter>>, + path: &str, + bytes: &[u8], + opts: SimpleFileOptions, +) -> Result<(), String> { + zip.start_file(path, opts).map_err(|e| e.to_string())?; + zip.write_all(bytes).map_err(|e| e.to_string()) +} + +/// One note as a readable Markdown file with a small frontmatter block. notes.json is +/// the machine format; this is for a person opening the archive. Mirrors +/// `_note_markdown` on the server. +pub fn note_markdown(note: &Note) -> String { + let mut out = vec![ + "---".to_string(), + format!("display_name: {}", note.display_title), + ]; + if !note.labels.is_empty() { + let names: Vec<&str> = note.labels.iter().map(|l| l.name.as_str()).collect(); + out.push(format!("labels: [{}]", names.join(", "))); + } + if note.pinned { + out.push("pinned: true".into()); + } + if note.archived { + out.push("archived: true".into()); + } + if let Some(at) = ¬e.remind_at { + out.push(format!("remind_at: {at}")); + } + out.push(format!( + "created: {}", + note.created_at.as_deref().unwrap_or("") + )); + out.push(format!( + "updated: {}", + note.updated_at.as_deref().unwrap_or("") + )); + out.push("---".into()); + out.push(String::new()); + if !note.body.is_empty() { + out.push(note.body.clone()); + } + out.join("\n") + "\n" +} + +/// A filesystem-safe slug from a note's name, for its .md file. Mirrors `_slugify`: +/// keep word characters, spaces and hyphens; collapse runs of space, underscore and +/// hyphen into one hyphen; at most 60 characters; "note" when nothing is left. +pub fn slugify(text: &str) -> String { + let kept: String = text + .trim() + .to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-' || c.is_whitespace()) + .collect(); + let mut slug = String::new(); + let mut gap = false; + for c in kept.chars() { + if c.is_whitespace() || c == '_' || c == '-' { + gap = true; + } else { + if gap && !slug.is_empty() { + slug.push('-'); + } + gap = false; + slug.push(c); + } + } + let slug: String = slug.chars().take(60).collect(); + let slug = slug.trim_end_matches('-').to_string(); + if slug.is_empty() { + "note".into() + } else { + slug + } +} + +// ---- import ----------------------------------------------------------------- + +/// Bytes pulled out of the archive, capped per entry and across the whole import. +/// Each read stops one byte past what is left, so an oversized or size-lying entry +/// is caught mid-read rather than after it has been inflated. +struct Budget { + remaining: u64, +} + +impl Budget { + fn read( + &mut self, + zip: &mut ZipArchive>, + name: &str, + ) -> Result>, String> { + let cap = MAX_ENTRY_BYTES.min(self.remaining); + let entry = match zip.by_name(name) { + Ok(entry) => entry, + Err(zip::result::ZipError::FileNotFound) => return Ok(None), + Err(e) => return Err(e.to_string()), + }; + let mut data = Vec::new(); + entry + .take(cap + 1) + .read_to_end(&mut data) + .map_err(|e| e.to_string())?; + if data.len() as u64 > cap { + return Err(TOO_LARGE.into()); + } + self.remaining -= data.len() as u64; + Ok(Some(data)) + } +} + +const TOO_LARGE: &str = "that archive is too large to import"; + +/// Import an Inkwell export or a Google Keep Takeout zip into this store. +/// +/// All or nothing for the notes: any failure rolls the whole import back. Attachment +/// bytes already written to the blob store stay there, which is harmless — they are +/// keyed by content and nothing points at them. +pub fn import_zip( + conn: &Connection, + blobs: &BlobStore, + bytes: &[u8], +) -> Result { + let mut zip = ZipArchive::new(Cursor::new(bytes)) + .map_err(|_| "that file isn't a valid .zip archive".to_string())?; + if zip.len() > MAX_ENTRIES { + return Err("that archive has too many files to import".into()); + } + let mut budget = Budget { + remaining: MAX_TOTAL_BYTES, + }; + let (specs, source) = read_specs(&mut zip, &mut budget)?; + if specs.is_empty() { + return Err( + "no importable notes found — expected an Inkwell export or a Google Keep Takeout zip" + .into(), + ); + } + + let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?; + let mut summary = ImportSummary { + source, + imported: 0, + skipped: 0, + }; + for spec in &specs { + if create_imported(&tx, blobs, spec, &mut zip, &mut budget)? { + summary.imported += 1; + } else { + summary.skipped += 1; + } + } + tx.commit().map_err(|e| e.to_string())?; + Ok(summary) +} + +fn read_specs( + zip: &mut ZipArchive>, + budget: &mut Budget, +) -> Result<(Vec, String), String> { + let names: Vec = zip.file_names().map(str::to_string).collect(); + for name in names.iter().filter(|n| basename(n) == "notes.json") { + let Some(raw) = budget.read(zip, name)? else { + continue; + }; + let Ok(doc) = serde_json::from_slice::(&raw) else { + continue; + }; + let marker = doc.get("app").and_then(Value::as_str).unwrap_or(""); + if APP_MARKERS.contains(&marker) { + let specs = doc + .get("notes") + .and_then(Value::as_array) + .map(|notes| { + notes + .iter() + .filter_map(Value::as_object) + .map(native_spec) + .collect() + }) + .unwrap_or_default(); + return Ok((specs, "inkwell".into())); + } + } + + const KEEP_KEYS: [&str; 6] = [ + "textContent", + "listContent", + "isPinned", + "isArchived", + "isTrashed", + "userEditedTimestampUsec", + ]; + let mut specs = Vec::new(); + for name in &names { + if !name.to_lowercase().ends_with(".json") || basename(name) == "notes.json" { + continue; + } + let Some(raw) = budget.read(zip, name)? else { + continue; + }; + let Ok(Value::Object(note)) = serde_json::from_slice::(&raw) else { + continue; + }; + if KEEP_KEYS.iter().any(|k| note.contains_key(*k)) { + specs.push(keep_spec(¬e, dirname(name))); + } + } + let source = if specs.is_empty() { "" } else { "keep" }; + Ok((specs, source.into())) +} + +/// One note from an Inkwell export's notes.json. Mirrors `_native_spec`. +pub fn native_spec(n: &Map) -> ImportSpec { + ImportSpec { + title: str_of(n, "title"), + body: str_of(n, "body").unwrap_or_default(), + pinned: bool_of(n, "pinned"), + archived: bool_of(n, "archived"), + trashed: false, // an export carries only live notes + remind_at: str_of(n, "remind_at").and_then(|s| instant(&s)), + recurrence: recur::normalize(n.get("recurrence").and_then(Value::as_str)) + .map(str::to_string), + created_at: str_of(n, "created_at").and_then(|s| instant(&s)), + updated_at: str_of(n, "updated_at").and_then(|s| instant(&s)), + labels: strings(n.get("labels")), + items: objects(n.get("items")) + .map(|it| { + ( + str_of(it, "text").unwrap_or_default(), + bool_of(it, "checked"), + ) + }) + .collect(), + attachments: objects(n.get("attachments")) + .filter_map(|a| { + Some(( + str_of(a, "file").filter(|f| !f.is_empty())?, + str_of(a, "mime"), + )) + }) + .collect(), + } +} + +/// One Google Keep note (a Takeout `.json`). `dir` is the folder the JSON sits +/// in, which its attachment paths are relative to. Mirrors `_keep_spec`. +pub fn keep_spec(k: &Map, dir: &str) -> ImportSpec { + // Keep stores links separately from the text; fold them in so they survive. + let mut body = str_of(k, "textContent").unwrap_or_default(); + let urls: Vec = objects(k.get("annotations")) + .filter_map(|a| str_of(a, "url")) + .filter(|u| !u.is_empty() && !body.contains(u.as_str())) + .collect(); + if !urls.is_empty() { + let extra = urls.join("\n"); + body = if body.trim().is_empty() { + extra + } else { + format!("{body}\n\n{extra}") + }; + } + + let attachments = objects(k.get("attachments")) + .filter_map(|a| { + let fp = str_of(a, "filePath").filter(|p| !p.is_empty())?; + let file = if dir.is_empty() { + fp.clone() + } else { + format!("{dir}/{fp}") + }; + let mime = str_of(a, "mimetype").or_else(|| mime_from_name(&fp).map(str::to_string)); + Some((file, mime)) + }) + .collect(); + + ImportSpec { + title: str_of(k, "title"), + body, + pinned: bool_of(k, "isPinned"), + archived: bool_of(k, "isArchived"), + trashed: bool_of(k, "isTrashed"), + remind_at: None, // Keep's reminders aren't in its Takeout JSON + recurrence: None, + created_at: k.get("createdTimestampUsec").and_then(usec_instant), + updated_at: k.get("userEditedTimestampUsec").and_then(usec_instant), + labels: objects(k.get("labels")) + .filter_map(|l| str_of(l, "name")) + .collect(), + items: objects(k.get("listContent")) + .map(|li| { + ( + str_of(li, "text").unwrap_or_default(), + bool_of(li, "isChecked"), + ) + }) + .collect(), + attachments, + } +} + +/// Write one imported note. False, with nothing written, when there is nothing in it. +fn create_imported( + conn: &Connection, + blobs: &BlobStore, + spec: &ImportSpec, + zip: &mut ZipArchive>, + budget: &mut Budget, +) -> Result { + // An imported title becomes the first body line — Inkwell has no title field, and + // dropping it would lose text someone wrote. Skipped when the body already opens + // with it, so re-importing an export doesn't stack duplicates. + let mut body = spec.body.clone(); + let title = spec.title.as_deref().unwrap_or("").trim(); + let first_line = body.trim_start().split('\n').next().unwrap_or("").trim(); + if !title.is_empty() && first_line != title { + body = if body.trim().is_empty() { + title.to_string() + } else { + format!("{title}\n{body}") + }; + } + let items: Vec<(&str, bool)> = spec + .items + .iter() + .map(|(t, c)| (t.trim(), *c)) + .filter(|(t, _)| !t.is_empty()) + .collect(); + // A note that is only a photo is still a note — Keep has plenty of them. + if body.trim().is_empty() && items.is_empty() && spec.attachments.is_empty() { + return Ok(false); + } + for (text, checked) in &items { + body = derive::append_item(&body, text, *checked); + } + + let err = |e: rusqlite::Error| e.to_string(); + let note = store::create_note(conn, &NoteCreateInput { body, items: None }).map_err(err)?; + for name in spec + .labels + .iter() + .map(|n| n.trim()) + .filter(|n| !n.is_empty()) + { + let label = store::create_label(conn, name).map_err(err)?; + conn.execute( + "INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 0)", + params![note.id, label.id], + ) + .map_err(err)?; + } + for (file, mime) in &spec.attachments { + let Some(raw) = budget.read(zip, file)? else { + continue; + }; + store::add_attachment( + conn, + blobs, + ¬e.id, + basename(file), + mime.as_deref().unwrap_or(""), + &raw, + )?; + } + + // Last, because adding an attachment touches the note: the source's own times are + // what this note should carry, not the moment it was imported. + let trashed_at = spec + .trashed + .then(|| Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)); + conn.execute( + "UPDATE notes SET pinned = ?1, archived = ?2, remind_at = ?3, recurrence = ?4, + trashed = ?5, trashed_at = ?6, + created_at = COALESCE(?7, created_at), updated_at = COALESCE(?8, updated_at) + WHERE id = ?9", + params![ + spec.pinned, + spec.archived, + spec.remind_at, + spec.recurrence, + spec.trashed, + trashed_at, + spec.created_at, + spec.updated_at, + note.id + ], + ) + .map_err(err)?; + Ok(true) +} + +// ---- small readers -------------------------------------------------------------- + +fn str_of(m: &Map, key: &str) -> Option { + m.get(key).and_then(Value::as_str).map(str::to_string) +} + +fn bool_of(m: &Map, key: &str) -> bool { + m.get(key).and_then(Value::as_bool).unwrap_or(false) +} + +fn objects(v: Option<&Value>) -> impl Iterator> { + v.and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_object) +} + +fn strings(v: Option<&Value>) -> Vec { + v.and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() +} + +/// Any RFC 3339 instant, in the store's own form (UTC, milliseconds, `Z`). Anything +/// else is treated as absent, as the server's `parse_dt` does. +fn instant(raw: &str) -> Option { + DateTime::parse_from_rfc3339(raw).ok().map(|t| { + t.with_timezone(&Utc) + .to_rfc3339_opts(SecondsFormat::Millis, true) + }) +} + +/// Keep's timestamps: integer microseconds since the epoch. +fn usec_instant(v: &Value) -> Option { + let usec = v.as_i64().or_else(|| v.as_str()?.parse().ok())?; + DateTime::from_timestamp_micros(usec).map(|t| t.to_rfc3339_opts(SecondsFormat::Millis, true)) +} + +/// The image types the server infers from a name when the source gave no mime. +fn mime_from_name(name: &str) -> Option<&'static str> { + let ext = name.rsplit_once('.')?.1.to_ascii_lowercase(); + match ext.as_str() { + "png" => Some("image/png"), + "jpg" | "jpeg" => Some("image/jpeg"), + "gif" => Some("image/gif"), + "webp" => Some("image/webp"), + _ => None, + } +} + +fn basename(path: &str) -> &str { + path.rsplit('/').next().unwrap_or(path) +} + +fn dirname(path: &str) -> &str { + path.rsplit_once('/').map(|(d, _)| d).unwrap_or("") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::local::schema; + + fn store() -> (Connection, BlobStore, std::path::PathBuf) { + let conn = Connection::open_in_memory().unwrap(); + schema::migrate(&conn).unwrap(); + let dir = std::env::temp_dir().join(format!("inkwell-portable-{}", uuid::Uuid::new_v4())); + let blobs = BlobStore::new(dir.clone()).unwrap(); + (conn, blobs, dir) + } + + fn all(conn: &Connection) -> Vec { + let q = super::super::models::ListQuery { + view: "notes".into(), + label_id: None, + facets: None, + sort: None, + }; + store::list_notes(conn, &q).unwrap() + } + + fn fixture() -> Value { + serde_json::from_str(include_str!("../../testdata/portable.json")) + .expect("portable.json parses") + } + + /// The fixture's expected spec against ours, field by field, timestamps as instants. + fn assert_spec(got: &ImportSpec, want: &Value, about: &str) { + let s = |k: &str| want.get(k).and_then(Value::as_str).map(str::to_string); + let t = |k: &str| s(k).map(|v| DateTime::parse_from_rfc3339(&v).unwrap()); + let ours = |v: &Option| { + v.as_deref() + .map(|x| DateTime::parse_from_rfc3339(x).unwrap()) + }; + assert_eq!(got.title, s("title"), "{about}: title"); + assert_eq!(Some(got.body.clone()), s("body"), "{about}: body"); + assert_eq!( + Some(got.pinned), + want["pinned"].as_bool(), + "{about}: pinned" + ); + assert_eq!( + Some(got.archived), + want["archived"].as_bool(), + "{about}: archived" + ); + assert_eq!( + Some(got.trashed), + want["trashed"].as_bool(), + "{about}: trashed" + ); + assert_eq!(got.labels, strings(want.get("labels")), "{about}: labels"); + let items: Vec<(String, bool)> = objects(want.get("items")) + .map(|i| (str_of(i, "text").unwrap(), bool_of(i, "checked"))) + .collect(); + assert_eq!(got.items, items, "{about}: items"); + let atts: Vec<(String, Option)> = objects(want.get("attachments")) + .map(|a| (str_of(a, "file").unwrap(), str_of(a, "mime"))) + .collect(); + assert_eq!(got.attachments, atts, "{about}: attachments"); + assert_eq!( + ours(&got.created_at), + t("created_at"), + "{about}: created_at" + ); + assert_eq!( + ours(&got.updated_at), + t("updated_at"), + "{about}: updated_at" + ); + if want.get("remind_at").is_some() { + assert_eq!(ours(&got.remind_at), t("remind_at"), "{about}: remind_at"); + } + if want.get("recurrence").is_some() { + assert_eq!(got.recurrence, s("recurrence"), "{about}: recurrence"); + } + } + + #[test] + fn keep_notes_read_as_the_fixture_says() { + let cases = fixture()["keep"].as_array().unwrap().clone(); + assert!(!cases.is_empty()); + for case in &cases { + let note = case["note"].as_object().unwrap(); + let got = keep_spec(note, case["dir"].as_str().unwrap()); + assert_spec(&got, &case["spec"], case["about"].as_str().unwrap()); + } + } + + #[test] + fn native_notes_read_as_the_fixture_says() { + let cases = fixture()["native"].as_array().unwrap().clone(); + assert!(!cases.is_empty()); + for case in &cases { + let got = native_spec(case["note"].as_object().unwrap()); + assert_spec(&got, &case["spec"], case["about"].as_str().unwrap()); + } + } + + fn zip_of(files: &[(&str, Vec)]) -> Vec { + let mut zip = ZipWriter::new(Cursor::new(Vec::new())); + let opts = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); + for (name, bytes) in files { + write_entry(&mut zip, name, bytes, opts).unwrap(); + } + zip.finish().unwrap().into_inner() + } + + fn sorted_keys(v: &Value) -> Vec { + let mut keys: Vec = v.as_object().unwrap().keys().cloned().collect(); + keys.sort(); + keys + } + + #[test] + fn an_export_writes_the_fixture_keys_and_imports_back_whole() { + let (conn, blobs, dir) = store(); + let note = store::create_note( + &conn, + &NoteCreateInput { + body: "Trip #travel\n\n- [ ] passport".into(), + items: None, + }, + ) + .unwrap(); + store::update_note(&conn, ¬e.id, &json!({ "pinned": true, "recurrence": "weekly", "remind_at": "2026-11-01T09:00:00.000Z" })).unwrap(); + store::add_attachment( + &conn, + &blobs, + ¬e.id, + "ticket.pdf", + "application/pdf", + b"%PDF-1", + ) + .unwrap(); + let trashed = store::create_note( + &conn, + &NoteCreateInput { + body: "gone".into(), + items: None, + }, + ) + .unwrap(); + store::trash(&conn, &trashed.id).unwrap(); + + let archive = export_zip(&conn, &blobs).unwrap(); + + // The shape, against the keys the server's export is held to as well. + let keys = &fixture()["export"]; + let mut zip = ZipArchive::new(Cursor::new(archive.as_slice())).unwrap(); + let mut raw = String::new(); + zip.by_name("notes.json") + .unwrap() + .read_to_string(&mut raw) + .unwrap(); + let doc: Value = serde_json::from_str(&raw).unwrap(); + let want = |k: &str| { + let mut v = strings(keys.get(k)); + v.sort(); + v + }; + assert_eq!(sorted_keys(&doc), want("document")); + assert_eq!(doc["app"], "inkwell"); + let notes = doc["notes"].as_array().unwrap(); + assert_eq!(notes.len(), 1, "the trashed note is not exported"); + assert_eq!(sorted_keys(¬es[0]), want("note")); + assert_eq!(sorted_keys(&doc["labels"][0]), want("label")); + let att = ¬es[0]["attachments"][0]; + assert_eq!(sorted_keys(att), want("attachment")); + let mut bytes = Vec::new(); + zip.by_name(att["file"].as_str().unwrap()) + .unwrap() + .read_to_end(&mut bytes) + .unwrap(); + assert_eq!(bytes, b"%PDF-1"); + assert!(zip + .file_names() + .any(|n| n.starts_with("notes/trip-travel-") && n.ends_with(".md"))); + + // And back into an empty store, whole. + let (fresh, fresh_blobs, fresh_dir) = store(); + let summary = import_zip(&fresh, &fresh_blobs, &archive).unwrap(); + assert_eq!( + summary, + ImportSummary { + source: "inkwell".into(), + imported: 1, + skipped: 0 + } + ); + let back = all(&fresh); + assert_eq!(back.len(), 1); + let back = &back[0]; + let original = store::get_note(&conn, ¬e.id).unwrap(); + assert_eq!(back.body, original.body); + assert!(back.pinned); + assert_eq!(back.recurrence.as_deref(), Some("weekly")); + assert_eq!(back.remind_at, original.remind_at); + assert_eq!( + back.created_at, original.created_at, + "the source's own time, not the import's" + ); + assert_eq!( + back.labels + .iter() + .map(|l| l.name.as_str()) + .collect::>(), + ["travel"] + ); + assert_eq!(back.attachments.len(), 1); + assert_eq!(back.attachments[0].filename.as_deref(), Some("ticket.pdf")); + let sha = back.attachments[0].sha256.clone().unwrap(); + assert_eq!(fresh_blobs.read(&sha).unwrap(), b"%PDF-1"); + + std::fs::remove_dir_all(dir).ok(); + std::fs::remove_dir_all(fresh_dir).ok(); + } + + #[test] + fn a_keep_takeout_imports_including_a_photo_only_note() { + let (conn, blobs, dir) = store(); + let list = serde_json::to_vec(&fixture()["keep"][0]["note"]).unwrap(); + let photo_only = + br#"{"textContent": "", "isPinned": false, "attachments": [{"filePath": "p.png"}]}"#; + let archive = zip_of(&[ + ("Takeout/Keep/Groceries.json", list), + ("Takeout/Keep/photo.jpg", b"jpeg bytes".to_vec()), + ("Takeout/Keep/scan.png", b"png bytes".to_vec()), + ("Takeout/Keep/Photo.json", photo_only.to_vec()), + ("Takeout/Keep/p.png", b"p".to_vec()), + ( + "Takeout/Keep/empty.json", + br#"{"textContent": " "}"#.to_vec(), + ), + ( + "Takeout/Keep/unrelated.json", + br#"{"hello": "world"}"#.to_vec(), + ), + ]); + let summary = import_zip(&conn, &blobs, &archive).unwrap(); + assert_eq!( + summary, + ImportSummary { + source: "keep".into(), + imported: 2, + skipped: 1 + } + ); + + let notes = all(&conn); + let groceries = notes + .iter() + .find(|n| n.body.starts_with("Groceries")) + .expect("the list note"); + assert!(groceries.body.contains("- [x] Eggs")); + assert!(groceries.pinned); + assert_eq!(groceries.attachments.len(), 2); + assert_eq!( + groceries.created_at.as_deref(), + Some("2020-09-13T12:26:40.000Z") + ); + let photo = notes + .iter() + .find(|n| n.body.is_empty()) + .expect("the photo-only note"); + assert_eq!(photo.attachments[0].mime, "image/png"); + + std::fs::remove_dir_all(dir).ok(); + } + + #[test] + fn what_is_not_an_archive_or_holds_no_notes_is_refused() { + let (conn, blobs, dir) = store(); + assert!(import_zip(&conn, &blobs, b"not a zip") + .unwrap_err() + .contains("valid .zip")); + let other = zip_of(&[( + "notes.json", + br#"{"app": "someone-else", "notes": [{"body": "x"}]}"#.to_vec(), + )]); + assert!(import_zip(&conn, &blobs, &other) + .unwrap_err() + .contains("no importable notes")); + assert!(all(&conn).is_empty()); + std::fs::remove_dir_all(dir).ok(); + } + + #[test] + fn slugs_match_the_servers() { + assert_eq!(slugify("Trip #travel"), "trip-travel"); + assert_eq!(slugify(" Hello, World! "), "hello-world"); + assert_eq!(slugify("snake_case -- dashes"), "snake-case-dashes"); + assert_eq!(slugify("!!!"), "note"); + assert_eq!(slugify(""), "note"); + assert_eq!(slugify(&"a".repeat(80)).len(), 60); + } +} diff --git a/core/src/local/store.rs b/core/src/local/store.rs index 55891a9..c1a98d1 100644 --- a/core/src/local/store.rs +++ b/core/src/local/store.rs @@ -679,7 +679,7 @@ fn normalize_mime(raw: &str) -> String { /// A file's name reduced to its last path component, as the server's /// `_safe_filename` does — the name is for display and download, never a path. -fn safe_filename(raw: &str) -> String { +pub(crate) fn safe_filename(raw: &str) -> String { let base = raw.trim().replace('\\', "/"); let base = base.rsplit('/').next().unwrap_or("").trim(); let capped: String = base.chars().take(255).collect(); diff --git a/core/testdata/portable.json b/core/testdata/portable.json new file mode 100644 index 0000000..f5e06df --- /dev/null +++ b/core/testdata/portable.json @@ -0,0 +1,146 @@ +{ + "_about": [ + "The export/import format, pinned for every implementation of it.", + "Read by the server (tests/test_portable_fixture.py, and the export check in test_integration.py) and by the core (local/portable.rs). The desktop's offline Export and Import are the core's copy; a backup taken on one surface has to restore on the other.", + "`keep` and `native` are notes as they arrive, with the spec each importer must make of them. `export` names the keys an export writes, at the top of notes.json and on each note. A difference between the server and the core here is a backup that loses something on the way across." + ], + "keep": [ + { + "about": "A list note keeps its text as well as its items; annotation URLs are folded into the body; the colour is read past.", + "dir": "Takeout/Keep", + "note": { + "title": "Groceries", + "textContent": "for the weekend", + "listContent": [ + { "text": "Milk", "isChecked": false }, + { "text": "Eggs", "isChecked": true } + ], + "annotations": [{ "url": "https://example.com/recipe" }], + "labels": [{ "name": "shopping" }], + "color": "TEAL", + "isPinned": true, + "isArchived": false, + "isTrashed": false, + "createdTimestampUsec": 1600000000000000, + "userEditedTimestampUsec": 1600000100000000, + "attachments": [ + { "filePath": "photo.jpg", "mimetype": "image/jpeg" }, + { "filePath": "scan.png" } + ] + }, + "spec": { + "title": "Groceries", + "body": "for the weekend\n\nhttps://example.com/recipe", + "pinned": true, + "archived": false, + "trashed": false, + "labels": ["shopping"], + "items": [ + { "text": "Milk", "checked": false }, + { "text": "Eggs", "checked": true } + ], + "attachments": [ + { "file": "Takeout/Keep/photo.jpg", "mime": "image/jpeg" }, + { "file": "Takeout/Keep/scan.png", "mime": "image/png" } + ], + "created_at": "2020-09-13T12:26:40Z", + "updated_at": "2020-09-13T12:28:20Z" + } + }, + { + "about": "A trashed, archived text note with no timestamps.", + "dir": "", + "note": { + "textContent": "old idea", + "isArchived": true, + "isTrashed": true + }, + "spec": { + "title": null, + "body": "old idea", + "pinned": false, + "archived": true, + "trashed": true, + "labels": [], + "items": [], + "attachments": [], + "created_at": null, + "updated_at": null + } + } + ], + "native": [ + { + "about": "A current export: the checklist is already in the body, a reminder and recurrence come across, and the pre-M315 colour is read past.", + "note": { + "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", + "display_title": "Trip", + "body": "Trip\n\n- [ ] passport", + "color": "blue", + "pinned": true, + "archived": false, + "remind_at": "2026-11-01T09:00:00+00:00", + "recurrence": "weekly", + "created_at": "2026-07-19T00:00:00+00:00", + "updated_at": "2026-07-20T08:30:00.250000+00:00", + "labels": ["travel"], + "attachments": [{ "file": "attachments/6ba7b810/0/ticket.pdf", "mime": "application/pdf" }] + }, + "spec": { + "title": null, + "body": "Trip\n\n- [ ] passport", + "pinned": true, + "archived": false, + "trashed": false, + "remind_at": "2026-11-01T09:00:00Z", + "recurrence": "weekly", + "labels": ["travel"], + "items": [], + "attachments": [{ "file": "attachments/6ba7b810/0/ticket.pdf", "mime": "application/pdf" }], + "created_at": "2026-07-19T00:00:00Z", + "updated_at": "2026-07-20T08:30:00.250Z" + } + }, + { + "about": "An export from before M304 carries a title and a separate items array; an unknown recurrence is dropped.", + "note": { + "title": "shopping", + "body": "", + "recurrence": "fortnightly", + "items": [{ "text": "milk", "checked": true }] + }, + "spec": { + "title": "shopping", + "body": "", + "pinned": false, + "archived": false, + "trashed": false, + "remind_at": null, + "recurrence": null, + "labels": [], + "items": [{ "text": "milk", "checked": true }], + "attachments": [], + "created_at": null, + "updated_at": null + } + } + ], + "export": { + "document": ["app", "version", "exported_at", "labels", "notes"], + "note": [ + "id", + "display_title", + "body", + "pinned", + "archived", + "remind_at", + "recurrence", + "created_at", + "updated_at", + "labels", + "attachments" + ], + "label": ["name", "color"], + "attachment": ["file", "mime"] + } +} diff --git a/desktop/src-tauri/src/commands/local.rs b/desktop/src-tauri/src/commands/local.rs index a950131..65cc893 100644 --- a/desktop/src-tauri/src/commands/local.rs +++ b/desktop/src-tauri/src/commands/local.rs @@ -7,6 +7,7 @@ use serde_json::Value; use tauri::State; use inkwell_core::local::models::*; +use inkwell_core::local::portable::{self, ImportSummary}; use inkwell_core::local::retention; use inkwell_core::local::store; use inkwell_core::local::Db; @@ -150,6 +151,60 @@ pub async fn notes_add_attachment( store::add_attachment(&conn, &blobs, note_id, &filename, mime, bytes) } +/// Write every note to a zip in the Downloads folder and return where it went. +/// +/// The same archive the server's Export makes, built here so it works with no server. +/// Downloads rather than a Save As dialog, which would need another plugin; the path +/// comes back so the window can say exactly where the file is. Never overwrites: a +/// second export the same day gets `-2`, `-3`, and so on. +#[tauri::command] +pub async fn notes_export( + app: tauri::AppHandle, + db: State<'_, Db>, + blobs: State<'_, BlobStore>, +) -> Result { + use tauri::Manager; + let bytes = { + let conn = db.0.lock().map_err(|e| e.to_string())?; + portable::export_zip(&conn, &blobs)? + }; + let dir = app + .path() + .download_dir() + .or_else(|_| app.path().home_dir()) + .map_err(|e| format!("Couldn't find a folder to save to: {e}"))?; + let path = unused_path(&dir, &portable::export_stem()); + std::fs::write(&path, bytes).map_err(|e| format!("Couldn't save {}: {e}", path.display()))?; + Ok(path.display().to_string()) +} + +/// `.zip`, or the first `-N.zip` that doesn't exist yet. +fn unused_path(dir: &std::path::Path, stem: &str) -> std::path::PathBuf { + let first = dir.join(format!("{stem}.zip")); + if !first.exists() { + return first; + } + (2..) + .map(|n| dir.join(format!("{stem}-{n}.zip"))) + .find(|p| !p.exists()) + .unwrap_or(first) +} + +/// Import an Inkwell export or a Google Keep Takeout zip. The archive is the raw IPC +/// body, as an attachment's bytes are. Additive: nothing already here is changed. +#[tauri::command] +pub async fn notes_import( + request: tauri::ipc::Request<'_>, + db: State<'_, Db>, + blobs: State<'_, BlobStore>, +) -> Result { + let tauri::ipc::InvokeBody::Raw(bytes) = request.body() else { + return Err("The file's contents didn't arrive.".to_string()); + }; + let conn = db.0.lock().map_err(|e| e.to_string())?; + portable::import_zip(&conn, &blobs, bytes) +} + #[tauri::command] pub fn notes_delete_attachment( id: String, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 5209468..c593d43 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -178,6 +178,8 @@ pub fn run() { commands::local::notes_update_item, commands::local::notes_delete_item, commands::local::notes_add_attachment, + commands::local::notes_export, + commands::local::notes_import, commands::local::notes_delete_attachment, commands::local::notes_delete_preview, commands::local::notes_reorder, diff --git a/frontend/src/adapters/local.ts b/frontend/src/adapters/local.ts index 1bbb80c..d2a54fa 100644 --- a/frontend/src/adapters/local.ts +++ b/frontend/src/adapters/local.ts @@ -4,9 +4,9 @@ // // Argument keys are camelCase; Tauri converts them to the Rust commands' snake_case // parameters (e.g. labelIds -> label_ids). A few operations have no offline meaning -// yet (account auth, device linking, URL unfurl, file import) — those reject with a -// clear message rather than silently failing; the board, editor, attachments, -// capture, filters, labels, checklists and reminders all work fully offline. +// yet (account auth, device linking, URL unfurl) — those reject with a clear message +// rather than silently failing; the board, editor, attachments, capture, filters, +// labels, checklists, reminders, import and export all work fully offline. import { invoke } from "../desktop/bridge"; import type { Note, NoteRevision } from "../stores/notes"; @@ -72,7 +72,9 @@ export const local: Repo = { deleteAttachment: (id, attId) => invoke("notes_delete_attachment", { id, attId }), unfurl: () => Promise.reject(new Error(NEEDS_SERVER)), deletePreview: (id, previewId) => invoke("notes_delete_preview", { id, previewId }), - import: () => Promise.reject(new Error(NEEDS_SERVER)), + // The archive crosses as raw bytes, as an attachment does. + import: async (file) => invoke("notes_import", new Uint8Array(await file.arrayBuffer())), + exportAll: () => invoke("notes_export"), reorder: (orderedIds) => invoke("notes_reorder", { orderedIds }), trash: (id) => invoke("notes_trash", { id }), restore: (id) => invoke("notes_restore", { id }), diff --git a/frontend/src/adapters/repo.ts b/frontend/src/adapters/repo.ts index 66be22e..b832d52 100644 --- a/frontend/src/adapters/repo.ts +++ b/frontend/src/adapters/repo.ts @@ -101,6 +101,9 @@ export interface NotesRepo { unfurl(id: string, url: string): Promise; deletePreview(id: string, previewId: string): Promise; import(file: File): Promise; + /** Save every note as a zip. Resolves to the path it was saved at, or null when + * the browser took the download and the file is wherever downloads go. */ + exportAll(): Promise; reorder(orderedIds: string[]): Promise; trash(id: string): Promise; restore(id: string): Promise; diff --git a/frontend/src/adapters/rest.ts b/frontend/src/adapters/rest.ts index 651197a..916820e 100644 --- a/frontend/src/adapters/rest.ts +++ b/frontend/src/adapters/rest.ts @@ -90,6 +90,15 @@ export const rest: Repo = { unfurl: (id, url) => api.post(`/api/notes/${id}/unfurl`, { url }), deletePreview: (id, previewId) => api.del(`/api/notes/${id}/previews/${previewId}`), import: (file) => api.postForm("/api/notes/import", fileForm(file)), + // A same-origin GET with the session cookie; the browser saves the attachment + // response itself, so there is no path to report. + exportAll: () => { + const link = document.createElement("a"); + link.href = "/api/notes/export"; + link.download = ""; + link.click(); + return Promise.resolve(null); + }, reorder: (orderedIds) => api.post("/api/notes/reorder", { ids: orderedIds }), trash: (id) => api.post(`/api/notes/${id}/trash`), restore: (id) => api.post(`/api/notes/${id}/restore`), diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index 8320f39..c8480bd 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -10,6 +10,7 @@ import { useUiStore } from "../stores/ui"; import BaseModal from "./BaseModal.vue"; import CommandPalette from "./CommandPalette.vue"; import Icon from "./Icon.vue"; +import ExportNotes from "./ExportNotes.vue"; import ImportNotes from "./ImportNotes.vue"; import LabelsModal from "./LabelsModal.vue"; import { isDesktop } from "../desktop/bridge"; @@ -484,10 +485,7 @@ async function signOut() { > Timeline - - - Export - + -
-
-

Desktop app

-

- {{ - desktopStatus.is_integrated - ? "Inkwell is in your applications menu." - : "Add Inkwell to your applications menu to launch it like an installed app." - }} -

-
- - {{ desktopStatus.is_integrated ? "Remove" : "Add to applications" }} - -
- diff --git a/frontend/src/views/BoardView.vue b/frontend/src/views/BoardView.vue index ae04364..66fdeed 100644 --- a/frontend/src/views/BoardView.vue +++ b/frontend/src/views/BoardView.vue @@ -12,6 +12,7 @@ import FilterBar from "../components/FilterBar.vue"; import NoteGrid from "../components/NoteGrid.vue"; import NoteEditor from "../components/NoteEditor.vue"; import { isDesktop, onCaptured, onSynced, sync as syncBridge } from "../desktop/bridge"; +import { errorMessage } from "../api/errors"; const notes = useNotesStore(); const config = useConfigStore(); @@ -208,7 +209,7 @@ async function reload() { try { await notes.load(currentView.value, currentLabel.value, facets.value); } catch (e) { - loadError.value = (e as { error?: string }).error ?? "Couldn't load your notes."; + loadError.value = errorMessage(e, "Couldn't load your notes."); } } diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 4398cb7..35f5bc1 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -5,7 +5,7 @@ import { useSessionStore } from "../stores/session"; import { useConfigStore } from "../stores/config"; import BaseInput from "../components/BaseInput.vue"; import BaseButton from "../components/BaseButton.vue"; -import type { ApiError } from "../api/client"; +import { errorMessage } from "../api/errors"; const session = useSessionStore(); const config = useConfigStore(); @@ -33,7 +33,7 @@ async function submit() { await session.login(email.value, password.value); await router.replace(safeRedirect(route.query.redirect)); } catch (e) { - error.value = (e as ApiError).error ?? "Could not sign in."; + error.value = errorMessage(e, "Could not sign in."); } finally { loading.value = false; } diff --git a/frontend/src/views/RegisterView.vue b/frontend/src/views/RegisterView.vue index c0624af..967c380 100644 --- a/frontend/src/views/RegisterView.vue +++ b/frontend/src/views/RegisterView.vue @@ -5,7 +5,7 @@ import { useSessionStore } from "../stores/session"; import { useConfigStore } from "../stores/config"; import BaseInput from "../components/BaseInput.vue"; import BaseButton from "../components/BaseButton.vue"; -import type { ApiError } from "../api/client"; +import { errorMessage } from "../api/errors"; const session = useSessionStore(); const config = useConfigStore(); @@ -28,7 +28,7 @@ async function submit() { await session.register(email.value, password.value, displayName.value); await router.replace("/"); } catch (e) { - error.value = (e as ApiError).error ?? "Could not create your account."; + error.value = errorMessage(e, "Could not create your account."); } finally { loading.value = false; } diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index f77e1c4..267903f 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from "vue"; import { api } from "../api/client"; import { useConfigStore } from "../stores/config"; import BaseButton from "../components/BaseButton.vue"; +import { errorMessage } from "../api/errors"; interface SettingItem { key: string; @@ -74,7 +75,7 @@ async function save() { saved.value = true; await config.reload(); // header/site name reflects changes immediately } catch (e) { - error.value = (e as { error?: string }).error ?? "Could not save settings."; + error.value = errorMessage(e, "Could not save settings."); } finally { saving.value = false; } diff --git a/frontend/src/views/SyncView.vue b/frontend/src/views/SyncView.vue index 7896332..4cab2b3 100644 --- a/frontend/src/views/SyncView.vue +++ b/frontend/src/views/SyncView.vue @@ -4,6 +4,7 @@ import { useUiStore } from "../stores/ui"; import BaseButton from "../components/BaseButton.vue"; import BaseInput from "../components/BaseInput.vue"; import Icon from "../components/Icon.vue"; +import DesktopMenuEntry from "../components/DesktopMenuEntry.vue"; import { SUGGESTED_CAPTURE_SHORTCUT, capture as captureBridge, @@ -20,6 +21,7 @@ import { type UpdateChannel, type UpdateStatus, } from "../desktop/bridge"; +import { errorMessage } from "../api/errors"; // Opt-in server sync for the desktop app. Being UNLINKED is the normal resting // state, not an incomplete setup — the app is local-first and fully usable having @@ -111,7 +113,7 @@ async function saveShortcut(value: string) { // The message comes from the core and names the actual reason — "something // else is already using it" reads very differently from "that is not a // shortcut this system understands", and both are things you can act on. - shortcutError.value = String((e as { message?: string }).message ?? e); + shortcutError.value = errorMessage(e, "Couldn't save the shortcut."); } finally { savingShortcut.value = false; } @@ -124,7 +126,7 @@ async function checkUpdates() { update.value = await updateBridge.check(); channel.value = update.value.channel; } catch (e) { - updateError.value = String((e as Error)?.message ?? e); + updateError.value = errorMessage(e, "The update check failed."); } finally { checking.value = false; checkedOnce.value = true; @@ -141,7 +143,7 @@ async function switchChannel(next: UpdateChannel) { checkedOnce.value = false; await checkUpdates(); } catch (e) { - updateError.value = String((e as Error)?.message ?? e); + updateError.value = errorMessage(e, "The update check failed."); } } @@ -153,7 +155,7 @@ async function installUpdate() { // means it failed. await updateBridge.install(); } catch (e) { - updateError.value = String((e as Error)?.message ?? e); + updateError.value = errorMessage(e, "The update check failed."); } finally { installing.value = false; } @@ -191,7 +193,7 @@ async function runProbe() { try { probe.value = await syncBridge.probe(url.value); } catch (e) { - probeError.value = String((e as Error)?.message ?? e); + probeError.value = errorMessage(e, "Couldn't reach that server."); } finally { probing.value = false; } @@ -219,7 +221,7 @@ async function connect() { ui.showToast(`Connected to ${result.status.server_url}.`); await syncNow(); } catch (e) { - linkError.value = String((e as Error)?.message ?? e); + linkError.value = errorMessage(e, "Couldn't link to that server."); } finally { linking.value = false; } @@ -261,7 +263,7 @@ async function syncNow() { showOutcome(await syncBridge.now()); pending.value = await syncBridge.hasPending(); } catch (e) { - syncError.value = String((e as Error)?.message ?? e); + syncError.value = errorMessage(e, "Sync failed."); } finally { syncing.value = false; } @@ -303,7 +305,7 @@ async function disconnect() { : "Disconnected. This device now works offline only.", ); } catch (e) { - ui.showToast(String((e as Error)?.message ?? e)); + ui.showToast(errorMessage(e, "Couldn't disconnect.")); } } @@ -676,5 +678,9 @@ onBeforeUnmount(() => stopSyncListener?.()); + + + diff --git a/src/inkwell/notes/import_export.py b/src/inkwell/notes/import_export.py index a36a99d..9b1d704 100644 --- a/src/inkwell/notes/import_export.py +++ b/src/inkwell/notes/import_export.py @@ -250,7 +250,9 @@ async def _create_imported_note( items = spec.get("items") or [] item_texts = [t for t in ((it.get("text") or "").strip() for it in items) if t] - if is_empty_note(body, item_texts): + # A note that is only a photo is still a note — Keep has plenty of them, and they + # used to be skipped as empty. The core's importer keeps them too (portable.rs). + if is_empty_note(body, item_texts) and not spec.get("attachments"): return False # Items fold into the body, which is where a checklist lives now (M304). Done diff --git a/tests/test_integration.py b/tests/test_integration.py index a9d8921..2c6bd95 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -874,3 +874,61 @@ async def test_a_preview_arriving_or_leaving_moves_its_note_in_the_feed(app_clie preview_id = (await (await app_client.get(f"/api/notes/{nid}")).get_json())["previews"][0]["id"] await app_client.delete(f"/api/notes/{nid}/previews/{preview_id}") assert await _note_revision(app_client, nid) > arrived + + +# --- the export format, pinned against the shared fixture (#5170) --------------- + + +async def test_an_export_writes_the_keys_the_shared_fixture_names(app_client, db): + """The desktop exports offline with the core's copy of this format, and both + copies are held to core/testdata/portable.json. This is the server's side.""" + import io + import json + import zipfile + from pathlib import Path + + keys = json.loads( + (Path(__file__).resolve().parents[1] / "core" / "testdata" / "portable.json").read_text(encoding="utf-8") + )["export"] + await _signed_in(app_client, "exporter") + assert (await app_client.post("/api/labels", json={"name": "travel"})).status_code == 201 + nid = await _pushed_note(app_client, "exported") + raw = b"%PDF-1" + assert (await _put(app_client, str(uuid.uuid4()), nid, raw)).status_code == 201 + + resp = await app_client.get("/api/notes/export") + assert resp.status_code == 200 + with zipfile.ZipFile(io.BytesIO(await resp.get_data())) as zf: + doc = json.loads(zf.read("notes.json")) + assert sorted(doc) == sorted(keys["document"]) + assert doc["app"] == "inkwell" + [note] = doc["notes"] + assert sorted(note) == sorted(keys["note"]) + assert [sorted(lb) for lb in doc["labels"]] == [sorted(keys["label"])] + [att] = note["attachments"] + assert sorted(att) == sorted(keys["attachment"]) + assert zf.read(att["file"]) == raw, "the listed file is in the archive" + + +async def test_a_keep_note_that_is_only_a_photo_imports(app_client, db): + """It used to be skipped as empty. The core's importer keeps it as well.""" + import io + import json + import zipfile + + from werkzeug.datastructures import FileStorage + + await _signed_in(app_client, "keeper") + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("Takeout/Keep/Photo.json", json.dumps({"textContent": "", "attachments": [{"filePath": "p.png"}]})) + zf.writestr("Takeout/Keep/p.png", b"png bytes") + zf.writestr("Takeout/Keep/Empty.json", json.dumps({"textContent": " "})) + resp = await app_client.post( + "/api/notes/import", files={"file": FileStorage(io.BytesIO(buf.getvalue()), filename="takeout.zip")} + ) + assert resp.status_code == 201 + assert await resp.get_json() == {"source": "keep", "imported": 1, "skipped": 1} + [note] = (await db.scalars(select(Note))).all() + [att] = (await db.scalars(select(NoteAttachment).where(NoteAttachment.note_id == note.id))).all() + assert att.mime == "image/png" diff --git a/tests/test_portable_fixture.py b/tests/test_portable_fixture.py new file mode 100644 index 0000000..840ac3a --- /dev/null +++ b/tests/test_portable_fixture.py @@ -0,0 +1,42 @@ +"""The server's half of the shared export/import fixture (core/testdata/portable.json). + +The core runs the same cases (local/portable.rs), because the desktop exports and +imports offline with its own copy of this format. A case that passes here and fails +there is a backup that loses something when it crosses from one surface to the other. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from inkwell.common import parse_dt +from inkwell.notes.import_export import _keep_spec, _native_spec + +FIXTURE = json.loads( + (Path(__file__).resolve().parents[1] / "core" / "testdata" / "portable.json").read_text(encoding="utf-8") +) + +_TIMES = ("created_at", "updated_at", "remind_at") + + +def _assert_spec(got: dict, want: dict) -> None: + # Only the keys the fixture names: each importer may carry more, but none may + # disagree on these. Timestamps compare as instants, since the server holds + # datetimes and the core holds strings. + for key, expected in want.items(): + if key in _TIMES: + assert got.get(key) == parse_dt(expected), key + else: + assert got.get(key) == expected, key + + +@pytest.mark.parametrize("case", FIXTURE["keep"], ids=lambda c: c["about"]) +def test_keep_notes(case): + _assert_spec(_keep_spec(case["note"], case["dir"]), case["spec"]) + + +@pytest.mark.parametrize("case", FIXTURE["native"], ids=lambda c: c["about"]) +def test_native_notes(case): + _assert_spec(_native_spec(case["note"]), case["spec"])