desktop: Import and Export work offline; the menu toggle is reachable; errors say why
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 48s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 4m26s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m32s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m10s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 12m42s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 48s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 4m26s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m32s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m10s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 12m42s
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 <noreply@anthropic.com>
This commit is contained in:
Generated
+3
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
pub mod derive;
|
||||
pub mod models;
|
||||
pub mod portable;
|
||||
pub mod recur;
|
||||
pub mod retention;
|
||||
pub mod schema;
|
||||
|
||||
@@ -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<String>,
|
||||
pub body: String,
|
||||
pub pinned: bool,
|
||||
pub archived: bool,
|
||||
pub trashed: bool,
|
||||
pub remind_at: Option<String>,
|
||||
pub recurrence: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
pub labels: Vec<String>,
|
||||
pub items: Vec<(String, bool)>,
|
||||
/// (path inside the zip, mime if the source said)
|
||||
pub attachments: Vec<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
// ---- 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<Vec<u8>, String> {
|
||||
let ids: Vec<String> = {
|
||||
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::<rusqlite::Result<_>>()
|
||||
.map_err(|e| e.to_string())?
|
||||
};
|
||||
let labels: Vec<Value> = 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::<Vec<_>>(),
|
||||
"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<Cursor<Vec<u8>>>,
|
||||
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<Cursor<&[u8]>>,
|
||||
name: &str,
|
||||
) -> Result<Option<Vec<u8>>, 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<ImportSummary, String> {
|
||||
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<Cursor<&[u8]>>,
|
||||
budget: &mut Budget,
|
||||
) -> Result<(Vec<ImportSpec>, String), String> {
|
||||
let names: Vec<String> = 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::<Value>(&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::<Value>(&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<String, Value>) -> 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 `<note>.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<String, Value>, 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<String> = 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<Cursor<&[u8]>>,
|
||||
budget: &mut Budget,
|
||||
) -> Result<bool, String> {
|
||||
// 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<String, Value>, key: &str) -> Option<String> {
|
||||
m.get(key).and_then(Value::as_str).map(str::to_string)
|
||||
}
|
||||
|
||||
fn bool_of(m: &Map<String, Value>, key: &str) -> bool {
|
||||
m.get(key).and_then(Value::as_bool).unwrap_or(false)
|
||||
}
|
||||
|
||||
fn objects(v: Option<&Value>) -> impl Iterator<Item = &Map<String, Value>> {
|
||||
v.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_object)
|
||||
}
|
||||
|
||||
fn strings(v: Option<&Value>) -> Vec<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<Note> {
|
||||
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<String>| {
|
||||
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<String>)> = 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<u8>)]) -> Vec<u8> {
|
||||
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<String> {
|
||||
let mut keys: Vec<String> = 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::<Vec<_>>(),
|
||||
["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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Vendored
+146
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> {
|
||||
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())
|
||||
}
|
||||
|
||||
/// `<stem>.zip`, or the first `<stem>-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<ImportSummary, String> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Note>("notes_delete_attachment", { id, attId }),
|
||||
unfurl: () => Promise.reject<Note>(new Error(NEEDS_SERVER)),
|
||||
deletePreview: (id, previewId) => invoke<Note>("notes_delete_preview", { id, previewId }),
|
||||
import: () => Promise.reject<ImportResult>(new Error(NEEDS_SERVER)),
|
||||
// The archive crosses as raw bytes, as an attachment does.
|
||||
import: async (file) => invoke<ImportResult>("notes_import", new Uint8Array(await file.arrayBuffer())),
|
||||
exportAll: () => invoke<string>("notes_export"),
|
||||
reorder: (orderedIds) => invoke<void>("notes_reorder", { orderedIds }),
|
||||
trash: (id) => invoke<Note>("notes_trash", { id }),
|
||||
restore: (id) => invoke<Note>("notes_restore", { id }),
|
||||
|
||||
@@ -101,6 +101,9 @@ export interface NotesRepo {
|
||||
unfurl(id: string, url: string): Promise<Note>;
|
||||
deletePreview(id: string, previewId: string): Promise<Note>;
|
||||
import(file: File): Promise<ImportResult>;
|
||||
/** 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<string | null>;
|
||||
reorder(orderedIds: string[]): Promise<void>;
|
||||
trash(id: string): Promise<Note>;
|
||||
restore(id: string): Promise<Note>;
|
||||
|
||||
@@ -90,6 +90,15 @@ export const rest: Repo = {
|
||||
unfurl: (id, url) => api.post<Note>(`/api/notes/${id}/unfurl`, { url }),
|
||||
deletePreview: (id, previewId) => api.del<Note>(`/api/notes/${id}/previews/${previewId}`),
|
||||
import: (file) => api.postForm<ImportResult>("/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<void>("/api/notes/reorder", { ids: orderedIds }),
|
||||
trash: (id) => api.post<Note>(`/api/notes/${id}/trash`),
|
||||
restore: (id) => api.post<Note>(`/api/notes/${id}/restore`),
|
||||
|
||||
@@ -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() {
|
||||
>
|
||||
<Icon name="calendar" /> Timeline
|
||||
</RouterLink>
|
||||
<!-- Direct download (same-origin GET, session cookie sent); not a route. -->
|
||||
<a href="/api/notes/export" download class="nav-link" title="Download all your notes as a zip">
|
||||
<Icon name="download" /> Export
|
||||
</a>
|
||||
<ExportNotes />
|
||||
<ImportNotes />
|
||||
|
||||
<!-- Account, settings and sign-out, for the screens where they are NOT in
|
||||
|
||||
@@ -4,11 +4,13 @@ import BaseModal from "./BaseModal.vue";
|
||||
import BaseButton from "./BaseButton.vue";
|
||||
import { isDesktop, desktop } from "../desktop/bridge";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import { errorMessage } from "../api/errors";
|
||||
|
||||
// First-run prompt (Linux AppImage only) offering to add Inkwell to the
|
||||
// applications menu so it behaves like an installed app instead of a loose file.
|
||||
// Shown once; the choice is remembered. Users who decline can integrate later from
|
||||
// Account. No-op on the web build and on already-integrated / non-AppImage runs.
|
||||
// the Sync page (DesktopMenuEntry). No-op on the web build and on already-integrated /
|
||||
// non-AppImage runs.
|
||||
const DISMISS_KEY = "desktop.integration.prompted";
|
||||
|
||||
const ui = useUiStore();
|
||||
@@ -38,7 +40,7 @@ async function integrate() {
|
||||
show.value = false;
|
||||
ui.showToast("Inkwell added to your applications menu.");
|
||||
} catch (e) {
|
||||
ui.showToast((e as { message?: string }).message ?? "Couldn't add to applications.");
|
||||
ui.showToast(errorMessage(e, "Couldn't add to applications."));
|
||||
} finally {
|
||||
working.value = false;
|
||||
}
|
||||
@@ -60,7 +62,7 @@ async function integrate() {
|
||||
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
We'll add a menu entry so you can launch Inkwell from your app launcher, and keep a stable
|
||||
copy in <code class="font-mono text-xs">~/Applications</code> so it isn't lost from Downloads.
|
||||
You can undo this any time from your account.
|
||||
You can undo this any time from the Sync page.
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<BaseButton variant="ghost" :disabled="working" @click="dismiss">Not now</BaseButton>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import BaseButton from "./BaseButton.vue";
|
||||
import { desktop, isDesktop, type IntegrationStatus } from "../desktop/bridge";
|
||||
import { errorMessage } from "../api/errors";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
|
||||
// The applications-menu entry for a Linux AppImage: add it, or take it away again.
|
||||
// Renders nothing anywhere else — the web, Windows, or an AppImage-less Linux build.
|
||||
//
|
||||
// It lived on the Account page, which the desktop app never shows: a toggle the
|
||||
// first-run prompt promised ("undo any time") with no way to reach it.
|
||||
const ui = useUiStore();
|
||||
const status = ref<IntegrationStatus | null>(null);
|
||||
const busy = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isDesktop()) return;
|
||||
try {
|
||||
status.value = await desktop.integrationStatus();
|
||||
} catch {
|
||||
status.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function toggle() {
|
||||
busy.value = true;
|
||||
try {
|
||||
status.value = status.value?.is_integrated ? await desktop.unintegrate() : await desktop.integrate();
|
||||
ui.showToast(
|
||||
status.value?.is_integrated ? "Added to your applications menu." : "Removed from your applications menu.",
|
||||
);
|
||||
} catch (e) {
|
||||
ui.showToast(errorMessage(e, "Couldn't update the menu entry."));
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="status?.is_appimage" class="mt-10 border-t border-neutral-200 pt-8 dark:border-neutral-800">
|
||||
<h2 class="text-sm font-semibold">Applications menu</h2>
|
||||
<div class="mt-3 flex items-center justify-between gap-4">
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{{ status.is_integrated ? "Inkwell is in your applications menu." : "Not in your applications menu." }}
|
||||
</p>
|
||||
<BaseButton
|
||||
:variant="status.is_integrated ? 'ghost' : 'primary'"
|
||||
:loading="busy"
|
||||
class="shrink-0"
|
||||
@click="toggle"
|
||||
>
|
||||
{{ status.is_integrated ? "Remove" : "Add to applications" }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import Icon from "./Icon.vue";
|
||||
import { repo } from "../adapters";
|
||||
import { errorMessage } from "../api/errors";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
|
||||
// Sidebar Export. In a browser the server streams the zip and the browser saves it;
|
||||
// on the desktop the core builds it from the local store with no server, saves it in
|
||||
// Downloads, and says exactly where.
|
||||
const ui = useUiStore();
|
||||
const busy = ref(false);
|
||||
|
||||
async function run() {
|
||||
if (busy.value) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
const savedTo = await repo.notes.exportAll();
|
||||
if (savedTo) ui.showToast(`Exported to ${savedTo}`);
|
||||
} catch (err) {
|
||||
ui.showToast(errorMessage(err, "Export failed."));
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="nav-link w-full disabled:opacity-60"
|
||||
:disabled="busy"
|
||||
title="Save all your notes as a zip"
|
||||
@click="run"
|
||||
>
|
||||
<Icon name="download" /> {{ busy ? "Exporting…" : "Export" }}
|
||||
</button>
|
||||
</template>
|
||||
@@ -8,6 +8,7 @@ import type { NoteFacets } from "../stores/notes";
|
||||
import { facetCount, facetsFromQuery, facetsToQuery } from "../notes/facets";
|
||||
import { addLocalDays, formatLocalDay, parseLocalDate } from "../notes/datetime";
|
||||
import Icon from "./Icon.vue";
|
||||
import { errorMessage } from "../api/errors";
|
||||
|
||||
// A dead-simple facet bar over the board: color + labels + has-reminder
|
||||
// + has-attachment + created-date range. The URL query IS the state, so a
|
||||
@@ -71,7 +72,7 @@ async function saveView() {
|
||||
await savedFilters.create(name.trim(), facets.value);
|
||||
ui.showToast(`Saved view "${name.trim()}".`);
|
||||
} catch (e) {
|
||||
ui.showToast((e as { error?: string }).error ?? "Couldn't save the view.");
|
||||
ui.showToast(errorMessage(e, "Couldn't save the view."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import Icon from "./Icon.vue";
|
||||
import { useNotesStore } from "../stores/notes";
|
||||
import { useLabelsStore } from "../stores/labels";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import { errorMessage } from "../api/errors";
|
||||
|
||||
// Sidebar counterpart to Export: pick a .zip (an Inkwell export for round-trip,
|
||||
// or a Google Keep Takeout archive) and import its notes. Additive — never
|
||||
@@ -31,7 +32,7 @@ async function onFile(e: Event) {
|
||||
const tail = res.skipped ? ` (${res.skipped} skipped)` : "";
|
||||
ui.showToast(`Imported ${res.imported} ${noun}${tail}.`);
|
||||
} catch (err) {
|
||||
ui.showToast((err as { error?: string }).error ?? "Import failed.");
|
||||
ui.showToast(errorMessage(err, "Import failed."));
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ref, type Ref } from "vue";
|
||||
import type { Note } from "../stores/notes";
|
||||
import { errorMessage } from "../api/errors";
|
||||
|
||||
// The load-a-list-of-notes scaffold every recall view (Search, Timeline, Reminders)
|
||||
// hand-rolled: an items ref + loading/error flags + a load() that fills them and never
|
||||
@@ -27,7 +28,7 @@ export function useNoteList(
|
||||
try {
|
||||
items.value = await fetcher();
|
||||
} catch (e) {
|
||||
error.value = (e as { error?: string }).error ?? fallbackError;
|
||||
error.value = errorMessage(e, fallbackError);
|
||||
items.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
|
||||
@@ -7,7 +7,7 @@ import BaseButton from "../components/BaseButton.vue";
|
||||
import BaseInput from "../components/BaseInput.vue";
|
||||
import ClientDownloads from "../components/ClientDownloads.vue";
|
||||
import Icon from "../components/Icon.vue";
|
||||
import { isDesktop, desktop as desktopBridge, type IntegrationStatus } from "../desktop/bridge";
|
||||
import { errorMessage } from "../api/errors";
|
||||
|
||||
// Per-user (not admin) management of linked native clients — the Tauri desktop and
|
||||
// Android apps authenticate sync with a device bearer token issued here.
|
||||
@@ -23,9 +23,6 @@ const creating = ref(false);
|
||||
// The freshly-issued plaintext token — shown ONCE (never retrievable again).
|
||||
const freshToken = ref("");
|
||||
|
||||
// Desktop-app self-integration (Linux AppImage only; the card is hidden otherwise).
|
||||
const desktopStatus = ref<IntegrationStatus | null>(null);
|
||||
const desktopBusy = ref(false);
|
||||
|
||||
async function load() {
|
||||
error.value = "";
|
||||
@@ -45,7 +42,7 @@ async function link() {
|
||||
freshToken.value = await devices.create(newName.value.trim() || "Device");
|
||||
newName.value = "";
|
||||
} catch (e) {
|
||||
error.value = (e as { error?: string }).error ?? "Couldn't create a device token.";
|
||||
error.value = errorMessage(e, "Couldn't create a device token.");
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
@@ -74,36 +71,8 @@ function fmt(iso: string | null): string {
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
async function loadDesktop() {
|
||||
if (!isDesktop()) return;
|
||||
try {
|
||||
desktopStatus.value = await desktopBridge.integrationStatus();
|
||||
} catch {
|
||||
desktopStatus.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleIntegration() {
|
||||
desktopBusy.value = true;
|
||||
try {
|
||||
desktopStatus.value = desktopStatus.value?.is_integrated
|
||||
? await desktopBridge.unintegrate()
|
||||
: await desktopBridge.integrate();
|
||||
ui.showToast(
|
||||
desktopStatus.value?.is_integrated
|
||||
? "Added to your applications menu."
|
||||
: "Removed from your applications menu.",
|
||||
);
|
||||
} catch (e) {
|
||||
ui.showToast((e as { message?: string }).message ?? "Couldn't update the menu entry.");
|
||||
} finally {
|
||||
desktopBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
void loadDesktop();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -132,31 +101,6 @@ onMounted(() => {
|
||||
paste it into the app when it asks to connect. You can revoke a device at any time.
|
||||
</p>
|
||||
|
||||
<!-- Desktop app self-integration (Linux AppImage only) -->
|
||||
<section
|
||||
v-if="desktopStatus?.is_appimage"
|
||||
class="mb-6 flex items-center justify-between gap-4 rounded-xl border border-neutral-200 p-4 dark:border-neutral-800"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100">Desktop app</p>
|
||||
<p class="mt-0.5 text-xs text-neutral-400">
|
||||
{{
|
||||
desktopStatus.is_integrated
|
||||
? "Inkwell is in your applications menu."
|
||||
: "Add Inkwell to your applications menu to launch it like an installed app."
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton
|
||||
:variant="desktopStatus.is_integrated ? 'ghost' : 'primary'"
|
||||
:loading="desktopBusy"
|
||||
class="shrink-0"
|
||||
@click="toggleIntegration"
|
||||
>
|
||||
{{ desktopStatus.is_integrated ? "Remove" : "Add to applications" }}
|
||||
</BaseButton>
|
||||
</section>
|
||||
|
||||
<!-- Every client this server holds, the one that fits this machine on top.
|
||||
Hides itself when the server holds none. -->
|
||||
<ClientDownloads />
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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?.());
|
||||
</BaseButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- The AppImage's menu entry, which the first-run prompt says can be undone
|
||||
from here. Hides itself on every other build. -->
|
||||
<DesktopMenuEntry />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"])
|
||||
Reference in New Issue
Block a user