Core and desktop comments describe the code to a stranger (#6224)
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 12s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Android / Core and FFI clippy and tests (push) Successful in 1m3s
CI & Build / integration (push) Successful in 1m37s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m14s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m3s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m42s
Android / Build the server image (push) Successful in 1s
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 12s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Android / Core and FFI clippy and tests (push) Successful in 1m3s
CI & Build / integration (push) Successful in 1m37s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m14s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m3s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m42s
Android / Build the server image (push) Successful in 1s
Milestone 546 "Bring the repo to the comment standard (rule 32) and compose defaults (rule 198)", step #6224 "Comment standard sweep: Rust core and desktop client". Doc comments and comments across inkwell-core, the Tauri app and desktop/packaging now say what the code does and the non-obvious why in the present tense. Gone: milestone tags (M10.x, M11.3, M13, M304, M315, M314, milestone 325), issue and Scribe-note numbers, family-idea and family-rule citations, the run numbers and commit hashes behind past bugs, and the narration of what used to be (the stable channel before it held its own bundles, CARGO_PKG_VERSION as the shown version, the MANIFEST_TAG split, the store's missing tests, the old PKGBUILD). The schema's migration notes keep what each step does and why, without "until now". The forge is called the forge rather than Forgejo. Hazards stay as facts: the dev-rolling tag that must not shadow the branch, the manifest matched on this build's version, the APK's fixed name surviving the prune, the LIKE guard in v9, -wal/-shm moving with the renamed store, panic = abort ending the app. 34 files, comments only: a comment-stripping diff of every changed file against HEAD is identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+8
-9
@@ -9,15 +9,15 @@ edition = "2021"
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
# Local-first store (M10.4): bundled = compile SQLite in, so there's no system
|
||||
# Local-first store: bundled = compile SQLite in, so there's no system
|
||||
# libsqlite dependency to vary across the AppImage / native / Windows / Android builds.
|
||||
# functions = the `fold` SQL function tag names are compared and indexed by (#5385):
|
||||
# functions = the `fold` SQL function tag names are compared and indexed by:
|
||||
# SQLite's own lower() folds ASCII only, and the server folds every letter.
|
||||
rusqlite = { version = "0.32", features = ["bundled", "functions"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
# RFC3339 timestamps for created_at/updated_at/remind_at (Date.parse-able on the JS side).
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
# HTTP for the opt-in server handshake (M10.6) and the sync engine (M10.7).
|
||||
# HTTP for the opt-in server handshake and the sync engine.
|
||||
#
|
||||
# native-tls, NOT rustls, deliberately: on x86_64-pc-windows-msvc native-tls resolves
|
||||
# to `schannel` — pure-Rust bindings to the OS TLS stack — so nothing C or assembly
|
||||
@@ -43,12 +43,11 @@ flate2 = { version = "1", default-features = false, features = ["rust_backend"]
|
||||
# Without this the build dies at `openssl-sys`: "Could not find directory of
|
||||
# OpenSSL installation".
|
||||
#
|
||||
# `vendored` compiles OpenSSL from source with the NDK toolchain. The alternative
|
||||
# was rustls on Android only, which builds faster — but rustls ships its own root
|
||||
# store, so the phone would trust a DIFFERENT set of certificates than the desktop
|
||||
# does. A self-hosted server behind a private or enterprise CA would then work on
|
||||
# one surface and fail on another, and "the surfaces behave the same" is worth more
|
||||
# than build minutes.
|
||||
# `vendored` compiles OpenSSL from source with the NDK toolchain. Not rustls on
|
||||
# Android, though it builds faster: rustls ships its own root store, so the phone
|
||||
# would trust a DIFFERENT set of certificates than the desktop does, and a
|
||||
# self-hosted server behind a private or enterprise CA would work on one surface
|
||||
# and fail on another.
|
||||
#
|
||||
# Declared as a direct dependency purely to turn the feature on: cargo's feature
|
||||
# unification applies it to the copy `native-tls` pulls in transitively.
|
||||
|
||||
+1
-4
@@ -2,10 +2,7 @@
|
||||
//!
|
||||
//! Deliberately free of any UI framework. The desktop wraps it in Tauri commands;
|
||||
//! the Android client binds it through uniffi. Neither owns it, and a change to
|
||||
//! either must not require touching this crate — that separation is the whole point
|
||||
//! (see Scribe note 2730). It was already true before the split: every file here
|
||||
//! carried zero Tauri references, which is what made the extraction a move rather
|
||||
//! than a rewrite.
|
||||
//! either must not require touching this crate — that separation is the whole point.
|
||||
//!
|
||||
//! - `local` — the source of truth. Works with no server and no account.
|
||||
//! - `sync` — entirely opt-in. Nothing in it runs until a server is linked.
|
||||
|
||||
+20
-27
@@ -6,7 +6,7 @@
|
||||
//! letters, digits, `_` and `-`. On save these become labels attached with
|
||||
//! `via_tag = true`. The server and the web run the same cases
|
||||
//! (core/testdata/grammar.json).
|
||||
//! - `- [ ] item`: a checklist item. The body IS the checklist (M304) — there is no
|
||||
//! - `- [ ] item`: a checklist item. The body IS the checklist — there is no
|
||||
//! table of items beside it, so a list can sit between two paragraphs instead of
|
||||
//! only after them.
|
||||
//!
|
||||
@@ -17,9 +17,6 @@
|
||||
//! and the rewriters below are how one is edited.
|
||||
//!
|
||||
//! Dedupes case-insensitively, preserving first-seen order.
|
||||
//!
|
||||
//! Also derived `[[wiki-links]]` until they were removed (note 2897) — this is a
|
||||
//! capture-and-recall surface, and a linking system is organization.
|
||||
|
||||
/// Every `#tag` in ONE line, as `(start, end, name)` in char indices.
|
||||
///
|
||||
@@ -31,10 +28,9 @@ fn line_tags(chars: &[char]) -> Vec<(usize, usize, String)> {
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
if chars[i] == '#' {
|
||||
// Start of line or after whitespace, and nothing else. Any non-tag
|
||||
// character used to count, which made `(#todo)` a tag here and plain
|
||||
// text on the server, and made the `/#section` of a pasted URL a label.
|
||||
// Whitespace is the rule all three implementations now share (#5166).
|
||||
// Start of line or after whitespace, and nothing else, so `(#todo)` and a
|
||||
// pasted URL's `/#section` are not tags. All three implementations share
|
||||
// this rule.
|
||||
let boundary = i == 0 || chars[i - 1].is_whitespace();
|
||||
// A tag must start with a letter (so "#1" or a bare "#" is not a tag).
|
||||
if boundary && i + 1 < chars.len() && chars[i + 1].is_alphabetic() {
|
||||
@@ -54,8 +50,8 @@ fn line_tags(chars: &[char]) -> Vec<(usize, usize, String)> {
|
||||
|
||||
/// One `#tag` and exactly where it sits, for a renderer drawing the body itself.
|
||||
///
|
||||
/// The card no longer prints a chip for a tag whose text is still in the note — it
|
||||
/// colours the token where it was typed instead. To do that a renderer needs the
|
||||
/// The card colours a tag where it was typed rather than repeating it as a chip.
|
||||
/// To do that a renderer needs the
|
||||
/// SPAN, not just the name, and asking it to find the name again would be a second
|
||||
/// grammar quietly disagreeing with this one about what `##a` or `#1` is.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -142,8 +138,8 @@ fn collapse_blank_runs(text: &str) -> String {
|
||||
/// else is left exactly as written.
|
||||
///
|
||||
/// The MIRROR of `split_body_tags` in the server's `notes/tags.py`, and it has to stay
|
||||
/// one: a note lifted differently here than there would change under the operator the
|
||||
/// moment it synced. Same discipline, and the same reason, as `DerivedTint`.
|
||||
/// one: a note lifted differently here than there would change the moment it synced.
|
||||
/// Same discipline, and the same reason, as `DerivedTint`.
|
||||
///
|
||||
/// The conservative reading of "standalone" is deliberate. A trailing tag is
|
||||
/// ambiguous and the text does not say which it is — `buy milk #grocery` is filing,
|
||||
@@ -152,8 +148,8 @@ fn collapse_blank_runs(text: &str) -> String {
|
||||
///
|
||||
/// `standalone` tags become ORDINARY labels (`via_tag = 0`): nothing is left to derive
|
||||
/// them from, so the row becomes the record and the chip's × becomes the way to remove
|
||||
/// one. `inline` tags stay derived exactly as before. That is what `via_tag` means from
|
||||
/// here on — backed by text still in the body.
|
||||
/// one. `inline` tags stay derived. That is what `via_tag` means: backed by text still
|
||||
/// in the body.
|
||||
pub fn lift_standalone_tags(body: &str) -> (Vec<String>, Vec<String>, String) {
|
||||
let mut standalone: Vec<String> = Vec::new();
|
||||
let mut inline: Vec<String> = Vec::new();
|
||||
@@ -220,7 +216,7 @@ fn is_tag_char(c: char) -> bool {
|
||||
/// How two tag names compare: equal once every letter is lowercased, accented ones
|
||||
/// included, so "Café" and "CAFÉ" are one tag. The server's `labeling.named` folds
|
||||
/// the same way (Postgres `lower`, Python `str.lower`), and the store's `fold` SQL
|
||||
/// function is this (#5385).
|
||||
/// function is this.
|
||||
pub(crate) fn fold(name: &str) -> String {
|
||||
name.to_lowercase()
|
||||
}
|
||||
@@ -248,8 +244,7 @@ fn push_unique(out: &mut Vec<String>, candidate: &str) {
|
||||
// parse it would make a half-typed list stop being a list.
|
||||
|
||||
/// A checklist item, as found in the body. Its position in the returned vector is
|
||||
/// its identity — the same thing `position` meant when these were rows, and all the
|
||||
/// wire ever carried (`push.rs` sent text and checked, never an id).
|
||||
/// its identity; the wire carries text and checked, never an id.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DerivedItem {
|
||||
pub text: String,
|
||||
@@ -361,9 +356,8 @@ pub const DISPLAY_TITLE_CAP: usize = 200;
|
||||
/// twice, and they have to agree or a synced note is called different things on either
|
||||
/// side of the wire. Both run the `display_titles` cases in testdata/grammar.json.
|
||||
///
|
||||
/// It no longer needs the items, because the items ARE lines of the body now (M304).
|
||||
/// What it needs instead is to strip the task marker off: a list-only note is still
|
||||
/// named by its first item, and calling that note "- [ ] milk" would be showing
|
||||
/// The items ARE lines of the body, so it strips the task marker off: a list-only
|
||||
/// note is named by its first item, and calling that note "- [ ] milk" would be showing
|
||||
/// someone the storage rather than the note. An empty item is skipped rather than
|
||||
/// naming the note "", which is what a half-typed list would otherwise do.
|
||||
pub fn display_title(body: &str) -> String {
|
||||
@@ -431,9 +425,8 @@ pub fn set_item_checked(body: &str, index: usize, checked: bool) -> String {
|
||||
///
|
||||
/// Spaced exactly as `import_export.py:_note_markdown` writes a list — a blank line
|
||||
/// between prose and the list, and nothing between consecutive items. That is not
|
||||
/// cosmetic: the server migration folds existing rows into bodies using the same
|
||||
/// layout, so an export taken before the migration and one taken after have to
|
||||
/// agree byte for byte.
|
||||
/// cosmetic: the migrations that fold checklist rows into bodies use the same layout,
|
||||
/// so every surface lands on byte-identical bodies.
|
||||
///
|
||||
/// `checked` is a parameter rather than always false because the two migrations that
|
||||
/// fold existing rows into bodies have to carry the state those rows were in. A new
|
||||
@@ -513,8 +506,8 @@ mod tests {
|
||||
// ── lifting standalone tags ──────────────────────────────────────────────
|
||||
//
|
||||
// The MIRROR of `split_body_tags` in the server's notes/tags.py, case for case.
|
||||
// A note lifted differently here than there would change under the operator the
|
||||
// moment it synced, so these are the cases that file agrees to.
|
||||
// A note lifted differently here than there would change the moment it synced,
|
||||
// so these are the cases that file agrees to.
|
||||
|
||||
#[test]
|
||||
fn lifts_a_line_that_is_nothing_but_tags() {
|
||||
@@ -662,8 +655,8 @@ mod tests {
|
||||
#[test]
|
||||
fn append_spaces_like_the_exporter() {
|
||||
// Prose then a blank line then the list — byte-for-byte what
|
||||
// import_export.py:_note_markdown writes, which is what the server
|
||||
// migration will fold existing rows into.
|
||||
// import_export.py:_note_markdown writes, which is the layout the server
|
||||
// migration folds rows into.
|
||||
assert_eq!(append_item("a note", "milk", false), "a note\n\n- [ ] milk");
|
||||
// Nothing between consecutive items.
|
||||
let one = "a note\n\n- [ ] milk";
|
||||
|
||||
@@ -73,9 +73,7 @@ pub fn open(path: &Path) -> rusqlite::Result<Db> {
|
||||
/// Open a migrated, in-memory store.
|
||||
///
|
||||
/// Exists so a CONSUMER can test against a real schema without taking a rusqlite
|
||||
/// dependency of its own just to build a `Db` — which is exactly what the desktop
|
||||
/// crate was doing before the core was extracted. The Android bindings will want the
|
||||
/// same thing.
|
||||
/// dependency of its own just to build a `Db`.
|
||||
pub fn open_in_memory() -> rusqlite::Result<Db> {
|
||||
Ok(Db(Mutex::new(memory_conn()?)))
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ pub struct Note {
|
||||
pub previews: Vec<LinkPreview>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
/// How this account holds the note (#5175): `owner`, or `edit`/`view` for one
|
||||
/// How this account holds the note: `owner`, or `edit`/`view` for one
|
||||
/// someone shared with it. Named and valued as the server's, so the shared
|
||||
/// frontend gates the editor identically either way.
|
||||
pub permission: String,
|
||||
@@ -119,7 +119,7 @@ pub struct Label {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub count: Option<i64>,
|
||||
/// The newest `updated_at` among the untrashed notes carrying it, or None when
|
||||
/// nothing does. The drawer keeps the few most recent tags in reach (#5931).
|
||||
/// nothing does. The drawer keeps the few most recent tags in reach.
|
||||
pub last_used: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Trash retention for a device with no server (M11.3).
|
||||
//! Trash retention for a device with no server.
|
||||
//!
|
||||
//! The server owns this policy whenever there IS one: a linked client learns about
|
||||
//! every permanent deletion from the delta feed, as a tombstone, and does exactly
|
||||
@@ -123,8 +123,7 @@ mod tests {
|
||||
fn a_note_just_inside_the_window_survives() {
|
||||
// The comparison is STRICTLY older than the cutoff, so a note with a minute
|
||||
// of its 30 days still to run is kept. An exact tie isn't testable against a
|
||||
// wall clock — the sweep reads `now` microseconds after the row is stamped,
|
||||
// which is precisely how the first version of this test failed.
|
||||
// wall clock — the sweep reads `now` microseconds after the row is stamped.
|
||||
let conn = db();
|
||||
let almost = Duration::days(30) - Duration::minutes(1);
|
||||
trashed_note_aged(&conn, "boundary", almost);
|
||||
|
||||
+35
-44
@@ -112,13 +112,11 @@ CREATE TABLE sync_state (
|
||||
INSERT INTO sync_state (id) VALUES (1);
|
||||
"#;
|
||||
|
||||
// v2 (M10.7c): local tombstones.
|
||||
// v2: local tombstones.
|
||||
//
|
||||
// A permanent delete previously just dropped the row, which left NO record that it
|
||||
// ever existed. Offline, that means the delete can never be pushed — and the next
|
||||
// pull would faithfully resurrect the note from the server. A deletion that undoes
|
||||
// itself is about the worst outcome sync can produce, so deletes are now recorded
|
||||
// here until they've been acknowledged by the server and cleared.
|
||||
// A permanent delete is recorded here until the server acknowledges it. Dropping the
|
||||
// row alone leaves no record that it existed, so an offline delete could never be
|
||||
// pushed and the next pull would resurrect the note.
|
||||
const SCHEMA_V2: &str = r#"
|
||||
CREATE TABLE pending_deletes (
|
||||
entity TEXT NOT NULL, -- 'note' | 'label'
|
||||
@@ -128,7 +126,7 @@ CREATE TABLE pending_deletes (
|
||||
);
|
||||
"#;
|
||||
|
||||
// v3 (M10.7e): when the last successful sync finished.
|
||||
// v3: when the last successful sync finished.
|
||||
//
|
||||
// The cursor alone can't answer "is this up to date?" — it's a revision watermark,
|
||||
// not a time, and it doesn't move at all when a sync legitimately finds nothing new.
|
||||
@@ -137,24 +135,21 @@ const SCHEMA_V3: &str = r#"
|
||||
ALTER TABLE sync_state ADD COLUMN last_sync_at TEXT;
|
||||
"#;
|
||||
|
||||
// v4 (M11.3): WHEN a note was trashed.
|
||||
// v4: WHEN a note was trashed.
|
||||
//
|
||||
// The table only ever recorded THAT a note was trashed, which is enough to draw a
|
||||
// Trash view and nothing else. Retention needs an age: without a timestamp there is
|
||||
// no way to tell a note trashed this morning from one trashed last spring, so an
|
||||
// offline device could never expire its own trash — and the UI couldn't warn anyone
|
||||
// before it did.
|
||||
// Retention needs an age: without a timestamp there is no telling a note trashed this
|
||||
// morning from one trashed last spring, so an offline device could never expire its
|
||||
// own trash, and the UI couldn't warn anyone before it did.
|
||||
// It also records the LINKED server's retention window, captured from /api/config.
|
||||
// Once linked, the server's policy is the one that actually applies, so showing this
|
||||
// device's offline default would put a countdown on screen that doesn't match what
|
||||
// happens — a wrong deadline is worse than none.
|
||||
// Once linked, the server's policy is the one that applies, so showing this device's
|
||||
// offline default would put a countdown on screen that doesn't match what happens.
|
||||
const SCHEMA_V4: &str = r#"
|
||||
ALTER TABLE notes ADD COLUMN trashed_at TEXT;
|
||||
UPDATE notes SET trashed_at = updated_at WHERE trashed = 1;
|
||||
ALTER TABLE sync_state ADD COLUMN server_retention_days INTEGER;
|
||||
"#;
|
||||
|
||||
// v5 (M10.9): small key/value app preferences.
|
||||
// v5: small key/value app preferences.
|
||||
//
|
||||
// The first entry is the update channel, which is neither note data nor part of the
|
||||
// server link — so it belongs in neither `notes` nor `sync_state`. Generic on
|
||||
@@ -166,17 +161,16 @@ CREATE TABLE prefs (
|
||||
);
|
||||
"#;
|
||||
|
||||
// v6 (M13 step 2): `kind` is gone. A checklist is something a note HAS, not something
|
||||
// a note IS — the column was a mode flag with no enum and no constraint behind it,
|
||||
// and `note_items` was never tied to it. Dropping it loses nothing: a note that was
|
||||
// 'list' keeps every one of its items.
|
||||
// v6: drops `kind`. A checklist is something a note HAS, not something a note IS —
|
||||
// the column was a mode flag with no enum and no constraint behind it. A note that
|
||||
// was 'list' keeps every one of its items.
|
||||
//
|
||||
// SQLite has supported DROP COLUMN since 3.35 (2021); rusqlite bundles well past it.
|
||||
const SCHEMA_V6: &str = r#"
|
||||
ALTER TABLE notes DROP COLUMN kind;
|
||||
"#;
|
||||
|
||||
// v7 (M13 step 3): the title field is gone. A note is a body plus optional items, and
|
||||
// v7: drops the title field. A note is a body plus optional items, and
|
||||
// its NAME is the first non-empty line of that body, falling back to its first item —
|
||||
// derived at read time, never stored (see store::display_title).
|
||||
//
|
||||
@@ -186,7 +180,7 @@ ALTER TABLE notes DROP COLUMN title;
|
||||
ALTER TABLE note_revisions DROP COLUMN title;
|
||||
"#;
|
||||
|
||||
// v8 (M304): `checklist_items` is gone. The body IS the checklist — a `- [ ] milk`
|
||||
// v8: folds `checklist_items` into bodies. The body IS the checklist — a `- [ ] milk`
|
||||
// line is the item — so a list can sit between two paragraphs instead of only after
|
||||
// them, which a side table could never express no matter how it was styled.
|
||||
//
|
||||
@@ -251,13 +245,12 @@ fn migrate_v8(conn: &Connection) -> rusqlite::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// v9 (M315): `notes.color` is gone. A card is one neutral surface now and colour lives
|
||||
// only on a tag, so the column was written by a picker nothing read and read by nothing
|
||||
// at all. `labels.color` is untouched — that is the colour that survived.
|
||||
// v9: drops `notes.color`. A card is one neutral surface and colour lives only on a
|
||||
// tag; `labels.color` is untouched.
|
||||
//
|
||||
// The saved-filter sweep is the second half and not optional. `params` is opaque JSON
|
||||
// and a stored view could carry `"color": "teal"`; with the facet gone that key would
|
||||
// sit there forever, and a view that silently filters on a field the app no longer has
|
||||
// sit there forever, and a view that silently filters on a field the app doesn't have
|
||||
// is worse than one that visibly lost a criterion. Guarded on `json_valid` because a
|
||||
// corrupt blob must keep whatever it holds, not become NULL.
|
||||
//
|
||||
@@ -275,12 +268,11 @@ UPDATE saved_filters
|
||||
AND params LIKE '%"color"%';
|
||||
"#;
|
||||
|
||||
// v10 (#5168): an attachment can be created on THIS device.
|
||||
// v10: an attachment can be created on THIS device.
|
||||
//
|
||||
// Until now every attachment row arrived on the delta feed, so every one was already on
|
||||
// the server. A file attached here, offline, is not, and `uploaded = 0` is the queue
|
||||
// push drains once the note has landed. The rows already here all came from the
|
||||
// server, which is why the default is 1.
|
||||
// A file attached here, offline, is not on the server yet, and `uploaded = 0` is the
|
||||
// queue push drains once the note has landed. Rows from before this step all came
|
||||
// from the server, which is why the default is 1.
|
||||
//
|
||||
// `upload_error` holds a refusal that retrying won't fix — over the size limit, an id
|
||||
// already in use, bytes that don't match their hash. A row carrying one leaves the
|
||||
@@ -292,15 +284,15 @@ ALTER TABLE attachments ADD COLUMN uploaded INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE attachments ADD COLUMN upload_error TEXT;
|
||||
"#;
|
||||
|
||||
// v11 (#5175): notes other people shared with this account.
|
||||
// v11: notes other people shared with this account.
|
||||
//
|
||||
// The feed now carries them, so a note says how it is held: `permission` is `owner`,
|
||||
// The feed carries them, so a note says how it is held: `permission` is `owner`,
|
||||
// `edit` (its text may change here) or `view`. Every note already on a device is the
|
||||
// account's own, which is why the default is `owner`. `shared` is whether the owner
|
||||
// has shared it with anyone, and `shared_by_*` names the owner of one shared with us.
|
||||
//
|
||||
// `shares_synced` is whether this device has pulled with shares since it was linked.
|
||||
// The notes shared before this build sit below the cursor it already holds, so the
|
||||
// Notes shared before a device reaches this step sit below the cursor it holds, so the
|
||||
// first pull from a server offering `shares` starts again from zero (see
|
||||
// `engine::run_cycle`). A pull applies idempotently, so starting over costs a
|
||||
// download and nothing else.
|
||||
@@ -312,29 +304,29 @@ ALTER TABLE notes ADD COLUMN shared_by_name TEXT;
|
||||
ALTER TABLE sync_state ADD COLUMN shares_synced INTEGER NOT NULL DEFAULT 0;
|
||||
"#;
|
||||
|
||||
// v12 (#5176): a shared note's pin, archive and position are this account's own.
|
||||
// v12: a shared note's pin, archive and position are this account's own.
|
||||
//
|
||||
// `state_at` is when they last changed here, on a note someone else owns. It is kept
|
||||
// apart from `updated_at`, which stays the time of the note's TEXT: pinning a copy
|
||||
// whose text is behind the owner's must not make that text look like the newer
|
||||
// edit when it is pushed. Null on our own notes, where `updated_at` covers both.
|
||||
//
|
||||
// `sync_state.shares_synced` now holds a level rather than a flag (see
|
||||
// `sync_state.shares_synced` holds a level rather than a flag (see
|
||||
// `state::SHARED_STATE`), and a device reaching this level pulls everything once
|
||||
// more: the shared notes it holds carry their owner's pins until it does.
|
||||
const SCHEMA_V12: &str = r#"
|
||||
ALTER TABLE notes ADD COLUMN state_at TEXT;
|
||||
"#;
|
||||
|
||||
// v13 (#5180): saved views are gone. They never synced, so this device's set was its
|
||||
// v13: drops saved views. They never synced, so this device's set was its
|
||||
// own and nothing else holds a copy.
|
||||
const SCHEMA_V13: &str = r#"
|
||||
DROP TABLE saved_filters;
|
||||
"#;
|
||||
|
||||
// v14 (#5385): tag names compare by `fold`, which lowercases every letter. Until now
|
||||
// the index and the queries used SQLite's lower(), which folds ASCII only, so "Café"
|
||||
// and "CAFÉ" could be two tags on a device while the server held one.
|
||||
// v14: tag names compare by `fold`, which lowercases every letter. SQLite's lower()
|
||||
// folds ASCII only, so with it "Café" and "CAFÉ" could be two tags on a device while
|
||||
// the server held one.
|
||||
//
|
||||
// Pairs that already exist are merged before the index can be rebuilt. THE OLDER ROW
|
||||
// SURVIVES, as in `store::rename_label`, so every device holding the same pair picks
|
||||
@@ -452,7 +444,7 @@ fn register_fold(conn: &Connection) -> rusqlite::Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A database as it stood before M304 — items still in their own table.
|
||||
/// A database at v7, with items still in their own table.
|
||||
fn v7_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().expect("open");
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;").expect("fk");
|
||||
@@ -602,8 +594,7 @@ mod tests {
|
||||
.collect::<rusqlite::Result<Vec<String>>>()
|
||||
.expect("collect");
|
||||
assert!(!columns.iter().any(|c| c == "color"));
|
||||
// The one that survived. Getting this wrong would take every tag's colour with
|
||||
// it, which is the whole thing M315 was keeping.
|
||||
// The one that stays. Getting this wrong would take every tag's colour with it.
|
||||
let mut stmt = conn.prepare("PRAGMA table_info(labels)").expect("pragma");
|
||||
let label_columns: Vec<String> = stmt
|
||||
.query_map([], |r| r.get::<_, String>(1))
|
||||
|
||||
+28
-37
@@ -48,11 +48,9 @@ fn load_labels(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<NoteLab
|
||||
|
||||
/// The note's checklist, read out of its body. No query, because there is no table.
|
||||
///
|
||||
/// A `- [ ] milk` line IS the item (M304). The id is the item's ORDINAL rather than a
|
||||
/// uuid — which is all it ever amounted to anyway, since `push.rs` sent text and
|
||||
/// checked and never an id, and both sides replaced the whole list on every sync. It
|
||||
/// is also exactly what the rewriters in `derive` take, so a UI holding an id can act
|
||||
/// on it directly.
|
||||
/// A `- [ ] milk` line IS the item. The id is the item's ORDINAL rather than a uuid:
|
||||
/// sync carries text and checked, never an id, and the ordinal is what the rewriters
|
||||
/// in `derive` take, so a UI holding an id can act on it directly.
|
||||
fn items_of(body: &str) -> Vec<ChecklistItem> {
|
||||
derive::extract_items(body)
|
||||
.into_iter()
|
||||
@@ -122,10 +120,9 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||
permission, shared, shared_by_id, shared_by_name
|
||||
FROM notes WHERE id = ?1",
|
||||
[id],
|
||||
// By NAME, not by position. Dropping `color` shifted every column after it
|
||||
// and the two timestamps were missed (fa89da1): created_at read updated_at
|
||||
// and updated_at read trashed_at, with nothing failing. A name either
|
||||
// resolves or errors.
|
||||
// By NAME, not by position: dropping a column shifts every one after it, and
|
||||
// a positional read would then load the wrong timestamps with nothing
|
||||
// failing. A name either resolves or errors.
|
||||
|r| {
|
||||
Ok(Note {
|
||||
id: r.get("id")?,
|
||||
@@ -166,11 +163,11 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||
Ok(note)
|
||||
}
|
||||
|
||||
// ---- notes shared with this account (#5175) ---------------------------------
|
||||
// ---- notes shared with this account ------------------------------------------
|
||||
//
|
||||
// A note someone else owns arrives with `permission` `edit` (its text may change
|
||||
// here) or `view` (it may not). Its pin, archive and place on this board are this
|
||||
// account's own either way (#5176). Everything else about it — trash, reminders,
|
||||
// account's own either way. Everything else about it — trash, reminders,
|
||||
// labels, files — stays its owner's. The server enforces the same split;
|
||||
// refusing here as well tells the person at once, instead of letting an edit be made,
|
||||
// queued, and then thrown away by the next sync.
|
||||
@@ -268,21 +265,19 @@ fn find_or_create_label(conn: &Connection, name: &str) -> rusqlite::Result<Strin
|
||||
/// Attach the note's tag labels, LIFT its standalone tags out of the body, and write
|
||||
/// the shortened body back.
|
||||
///
|
||||
/// NAMED FOR THE MUTATION. It used to be `sync_tags` and only touched label rows; it
|
||||
/// now rewrites `notes.body`, and every caller writes the body just before calling —
|
||||
/// so this overwrites what they wrote, on purpose.
|
||||
/// NAMED FOR THE MUTATION: it rewrites `notes.body`, and every caller writes the body
|
||||
/// just before calling, so this overwrites what they wrote, on purpose.
|
||||
///
|
||||
/// `display_title` needs no attention here, unlike on the server: the core derives it
|
||||
/// on READ (`derive::display_title`, called from `load_note`) rather than storing
|
||||
/// it, so there is no persisted copy to go stale.
|
||||
///
|
||||
/// The two kinds of tag are handled differently, and that difference IS what `via_tag`
|
||||
/// means from here on — backed by text still in the body:
|
||||
/// means — backed by text still in the body:
|
||||
///
|
||||
/// standalone lifted out, attached as an ORDINARY label. Nothing derives it any
|
||||
/// more, and the way to remove it becomes the chip's ×.
|
||||
/// inline left in place, attached via_tag = 1, still detached when its text
|
||||
/// goes. Unchanged from before.
|
||||
/// standalone lifted out, attached as an ORDINARY label. Nothing derives it, and
|
||||
/// the way to remove it is the chip's ×.
|
||||
/// inline left in place, attached via_tag = 1, detached when its text goes.
|
||||
///
|
||||
/// Mirrors `_lift_and_reconcile_tags` in the server's `notes/tags.py`.
|
||||
fn lift_and_sync_tags(conn: &Connection, note_id: &str, body: &str) -> rusqlite::Result<()> {
|
||||
@@ -384,9 +379,8 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note
|
||||
|
||||
if let Some(f) = &q.facets {
|
||||
if let Some(text) = f.q.as_deref().filter(|s| !s.is_empty()) {
|
||||
// One placeholder, one bind. This pushed the pattern twice after the
|
||||
// title column went (95aa10c), and rusqlite refuses a count mismatch,
|
||||
// so every desktop search failed.
|
||||
// One placeholder, one bind: rusqlite refuses a count mismatch, which
|
||||
// would fail every desktop search.
|
||||
sql.push_str(" AND body LIKE ? ESCAPE '\\'");
|
||||
binds.push(format!("%{}%", escape_like(text)));
|
||||
}
|
||||
@@ -681,7 +675,7 @@ pub fn complete_reminder(conn: &Connection, id: &str) -> rusqlite::Result<Note>
|
||||
}
|
||||
|
||||
/// The longest snooze, in minutes: 30 days. The server's snooze route clamps to the
|
||||
/// same range (`notes.SNOOZE_MAX_MINUTES`), so a device and the web agree (#5386).
|
||||
/// same range (`notes.SNOOZE_MAX_MINUTES`), so a device and the web agree.
|
||||
pub const SNOOZE_MAX_MINUTES: i64 = 60 * 24 * 30;
|
||||
|
||||
pub fn snooze_reminder(conn: &Connection, id: &str, minutes: i64) -> rusqlite::Result<Note> {
|
||||
@@ -870,7 +864,7 @@ pub fn reorder(conn: &Connection, ordered_ids: &[String]) -> rusqlite::Result<()
|
||||
let total = ordered_ids.len() as i64;
|
||||
let at = now();
|
||||
for (i, id) in ordered_ids.iter().enumerate() {
|
||||
// A note someone shared with us moves on this account's board only (#5176),
|
||||
// A note someone shared with us moves on this account's board only,
|
||||
// stamped as its own state rather than as an edit.
|
||||
conn.execute(
|
||||
"UPDATE notes
|
||||
@@ -1024,11 +1018,10 @@ pub fn create_label(conn: &Connection, name: &str) -> rusqlite::Result<Label> {
|
||||
|
||||
/// Rename a label. Renaming ONTO a name another label already holds MERGES the two.
|
||||
///
|
||||
/// It cannot simply be an UPDATE: `idx_labels_name` is unique on `lower(name)`, so
|
||||
/// the bare statement failed with a raw SQLite "UNIQUE constraint failed" that
|
||||
/// reached the user as database internals. Merging is the operator's call, and it
|
||||
/// is the reading that matches what a person means — typing an existing tag's name
|
||||
/// onto this one says "these are the same thing."
|
||||
/// It cannot simply be an UPDATE: `idx_labels_name` is unique on `lower(name)`, so a
|
||||
/// bare statement fails with a raw SQLite "UNIQUE constraint failed". Merging is the
|
||||
/// reading that matches what a person means — typing an existing tag's name onto
|
||||
/// this one says "these are the same thing."
|
||||
///
|
||||
/// THE OLDER ROW SURVIVES, and takes the new spelling. Older rather than "the one
|
||||
/// that already held the name" because age is the property neither participant's
|
||||
@@ -1118,11 +1111,9 @@ pub fn merge_labels(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! The store had no tests of its own until two bugs shipped in it unnoticed:
|
||||
//! `load_note` reading its timestamps from the wrong columns after one was
|
||||
//! dropped (fa89da1), and the board's text facet binding its pattern twice for
|
||||
//! one placeholder (95aa10c). Both are the kind of thing only a real query
|
||||
//! against a real schema catches, which is what every test here runs.
|
||||
//! Every test here runs a real query against a real schema: reading columns by
|
||||
//! the wrong position, or binding a pattern twice for one placeholder, is the
|
||||
//! kind of thing only that catches.
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -1158,7 +1149,7 @@ mod tests {
|
||||
notes.iter().map(|n| n.id.as_str()).collect()
|
||||
}
|
||||
|
||||
// ---- notes shared with this account (#5175) --------------------------------
|
||||
// ---- notes shared with this account ----------------------------------------
|
||||
|
||||
/// A note someone else owns, as the feed leaves it: clean, and held at `permission`.
|
||||
fn shared(conn: &Connection, id: &str, permission: &str) {
|
||||
@@ -1583,8 +1574,8 @@ mod tests {
|
||||
assert!(edited.labels.is_empty());
|
||||
}
|
||||
|
||||
/// Every letter folds, accented ones included, as on the server (#5385). With
|
||||
/// SQLite's lower() the second note minted a "CAFÉ" tag of its own.
|
||||
/// Every letter folds, accented ones included, as on the server; SQLite's lower()
|
||||
/// would mint a second "CAFÉ" tag.
|
||||
#[test]
|
||||
fn a_tag_spelled_with_different_accented_capitals_is_the_same_tag() {
|
||||
let conn = db();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Local storage for attachment bytes (M10.7d).
|
||||
//! Local storage for attachment bytes.
|
||||
//!
|
||||
//! Content-addressed: a blob is filed under its own sha256, so the same image
|
||||
//! attached to five notes is stored once and re-downloading it is free. The hash is
|
||||
@@ -94,7 +94,7 @@ impl BlobStore {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Serving blobs to the webview (M10.7f) -----------------------------------
|
||||
// --- Serving blobs to the webview ---------------------------------------------
|
||||
//
|
||||
// A synced note's attachment `url` is the SERVER's relative path
|
||||
// (`/api/notes/<id>/attachments/<aid>`). In the desktop webview that resolves
|
||||
@@ -180,7 +180,7 @@ pub fn urldecode(value: &str) -> String {
|
||||
/// there. Echoing is safe only for the families that can't carry script, and
|
||||
/// `image/` is not quite one of them: `image/svg+xml` is a document that runs its own
|
||||
/// `<script>`, so it is served as an opaque download like everything else unfamiliar.
|
||||
/// The web made the same exclusion for the same reason (#1981).
|
||||
/// The web makes the same exclusion for the same reason.
|
||||
fn content_type_for(mime: &str) -> String {
|
||||
const RENDERABLE: &[&str] = &["image/", "audio/", "video/"];
|
||||
let familiar = (RENDERABLE.iter().any(|p| mime.starts_with(p)) && mime != "image/svg+xml")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! HTTP transport to an Inkwell server.
|
||||
//!
|
||||
//! Covers the compatibility handshake (M10.6) and device-token auth (M10.7a). The
|
||||
//! engine that moves notes — push, pull, cursor — grows on top of the same client,
|
||||
//! Covers the compatibility handshake and device-token auth. The engine that moves
|
||||
//! notes — push, pull, cursor — is built on the same client,
|
||||
//! which is why the timeout, identity headers and error vocabulary live here rather
|
||||
//! than inline at each call site.
|
||||
//!
|
||||
@@ -20,7 +20,7 @@ use super::wire;
|
||||
/// Timeout for the short request/response calls in this module. Kept tight because a
|
||||
/// user is watching a button while they run, and the most common mistake — a wrong
|
||||
/// host on a LAN — fails by hanging rather than refusing, so an unbounded wait would
|
||||
/// just look frozen. The sync engine's bulk transfers will need their own, longer one.
|
||||
/// just look frozen. Bulk transfers have their own, longer ones.
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Bulk transfers get much longer: a first full sync can be thousands of notes, and
|
||||
@@ -408,7 +408,7 @@ pub async fn push_changes<T: Serialize>(
|
||||
.map_err(|e| format!("Couldn't read the push reply from {base_url}: {e}"))
|
||||
}
|
||||
|
||||
// --- sharing (#5175) -----------------------------------------------------------
|
||||
// --- sharing -------------------------------------------------------------------
|
||||
//
|
||||
// The Share dialog on a linked device asks the server directly, over the device
|
||||
// token: who is on the instance, and who a note is shared with. Shares are the
|
||||
@@ -424,7 +424,7 @@ pub struct Member {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
/// A group the admin made, which a note can be shared with (#5177). Sharing with it
|
||||
/// A group the admin made, which a note can be shared with. Sharing with it
|
||||
/// reaches whoever is in it at the time.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct ShareGroup {
|
||||
|
||||
+26
-32
@@ -1,8 +1,8 @@
|
||||
//! Client<->server compatibility handshake (M10.6).
|
||||
//! Client<->server compatibility handshake.
|
||||
//!
|
||||
//! The desktop app is local-first: it never *needs* a server. When the user links
|
||||
//! one, this module decides whether the two can actually talk — before a single
|
||||
//! note moves. The sync engine (M10.7) consults it on link and on every sync.
|
||||
//! The app is local-first: it never *needs* a server. When the user links one, this
|
||||
//! module decides whether the two can actually talk — before a single note moves.
|
||||
//! The sync engine consults it on link and on every sync.
|
||||
//!
|
||||
//! The contract is two integers per side, versioning the WIRE PROTOCOL separately
|
||||
//! from either program's release version:
|
||||
@@ -22,28 +22,24 @@ use std::sync::OnceLock;
|
||||
|
||||
/// The sync wire protocol this client speaks.
|
||||
///
|
||||
/// v4 (M315): `color` left the note. NOT a floor raise on either side — see the note
|
||||
/// on [`MIN_SERVER_PROTOCOL_VERSION`].
|
||||
/// v5 (#5168): files attached here upload, and removed attachments and previews are
|
||||
/// pushed. Additive: both go only to a server advertising `attachment_sync`.
|
||||
/// v6 (#5175): notes shared with this account, asked for with `shares`.
|
||||
/// v7 (#5176): this account's own pin, archive and position on a shared note, sent
|
||||
/// v4: `color` left the note. NOT a floor raise on either side — see the note on
|
||||
/// [`MIN_SERVER_PROTOCOL_VERSION`].
|
||||
/// v5: files attached here upload, and removed attachments and previews are pushed.
|
||||
/// Additive: both go only to a server advertising `attachment_sync`.
|
||||
/// v6: notes shared with this account, asked for with `shares`.
|
||||
/// v7: this account's own pin, archive and position on a shared note, sent
|
||||
/// only to a server advertising `shared_state`.
|
||||
pub const CLIENT_PROTOCOL_VERSION: u32 = 7;
|
||||
|
||||
/// The oldest server protocol this client can drive — the symmetric half of the
|
||||
/// server's `min_client_protocol_version`.
|
||||
///
|
||||
/// STAYS AT 3 ACROSS v4, and the v2 precedent is the reason to say why rather than
|
||||
/// leave it looking like an oversight. v2 dropped `kind` and `title` and DID move both
|
||||
/// floors, on the rule that "dropping a field a client sends and expects back is
|
||||
/// breaking". `color` fails that test on the second half: a v3 client reading a v4
|
||||
/// server gets `"default"` from serde's default and draws the colour it derives
|
||||
/// locally, which is a board that looks exactly like the one it drew yesterday. A v3
|
||||
/// client PUSHING `color` to a v4 server has the key ignored — the server reads its
|
||||
/// payload key by key and never validates the shape. Neither direction errors, and
|
||||
/// neither loses anything a person can see; `title` was the note's NAME, and this is a
|
||||
/// field that no longer renders anywhere.
|
||||
/// STAYS AT 3 through v4. Dropping a field a client sends and expects back is
|
||||
/// breaking (v2 dropped `kind` and `title` and moved both floors), but `color` is not
|
||||
/// expected back: a v3 client reading a v4 server gets `"default"` from serde's
|
||||
/// default and draws the colour it derives locally, and a v3 client PUSHING `color` to
|
||||
/// a v4 server has the key ignored, since the server reads its payload key by key.
|
||||
/// Neither direction errors or loses anything a person can see.
|
||||
pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 3;
|
||||
|
||||
/// Capabilities without which syncing is meaningless, so their absence BLOCKS the
|
||||
@@ -64,7 +60,7 @@ pub const OPTIONAL_FEATURES: &[&str] = &[
|
||||
|
||||
/// The handshake fields of `GET /api/config`.
|
||||
///
|
||||
/// Every protocol field is optional because a server predating M10.6 simply won't
|
||||
/// Every protocol field is optional because a server older than the handshake won't
|
||||
/// send them. That case has to read as "this server is too old to sync", not as a
|
||||
/// parse failure — which would look to the user like they mistyped the URL.
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
@@ -190,7 +186,7 @@ pub fn evaluate(info: &ServerInfo) -> Compatibility {
|
||||
///
|
||||
/// The core can't know this: it is compiled into both the desktop and the Android
|
||||
/// app, and its own crate version is a number no build stamps. The app's build is
|
||||
/// what a reader of the header wants (note 3127 §5).
|
||||
/// what a reader of the header wants.
|
||||
///
|
||||
/// So the host names itself. `OnceLock` because identity is fixed for the life of
|
||||
/// the process and a second caller should be ignored rather than race the first.
|
||||
@@ -208,10 +204,9 @@ pub fn set_client_agent(name: &str, version: &str) {
|
||||
/// Headers this client puts on every request to a linked server, so the server can
|
||||
/// log or gate on client identity without a separate handshake round-trip.
|
||||
pub fn client_headers() -> [(&'static str, String); 2] {
|
||||
// `unidentified/unknown`, never a plausible default. Nothing reads this header
|
||||
// today, which is exactly why a wrong value could sit in it for months: the
|
||||
// first person to look at a server log is the first person who could catch it,
|
||||
// and only if what they see is obviously a host that never introduced itself.
|
||||
// `unidentified/unknown`, never a plausible default: a wrong value in a header
|
||||
// nothing checks would sit unnoticed, while an obviously missing one is caught by
|
||||
// the first person who reads a server log.
|
||||
let agent = CLIENT_AGENT
|
||||
.get()
|
||||
.cloned()
|
||||
@@ -259,7 +254,7 @@ pub fn normalize_base_url(raw: &str) -> Option<String> {
|
||||
|
||||
/// Whether a normalized base URL may carry this device's token, as far as the wire
|
||||
/// goes: true for any `https://` server, and for plain `http://` only when the
|
||||
/// host is private (family idea #5105, practice 13).
|
||||
/// host is private.
|
||||
///
|
||||
/// Plain HTTP on a home network is fine and stays supported. Across the internet,
|
||||
/// anyone on the path could read the token and act as this device until it is
|
||||
@@ -363,7 +358,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn server_without_protocol_fields_is_too_old() {
|
||||
// A pre-M10.6 server: /api/config parses, but carries no protocol block.
|
||||
// A server older than the handshake: /api/config parses, but carries no protocol block.
|
||||
let info = ServerInfo {
|
||||
site_name: Some("Inkwell".into()),
|
||||
version: Some("0.0.9".into()),
|
||||
@@ -473,10 +468,9 @@ mod tests {
|
||||
fn server_info_tolerates_unknown_and_absent_fields() {
|
||||
// Forward compatibility: a NEWER server sending fields we've never heard of
|
||||
// must not break the handshake.
|
||||
// Versions come from the constants, not literals: this test is about unknown
|
||||
// FIELDS, and pinning the numbers made it fail the moment the protocol moved
|
||||
// to v2 — for a reason that has nothing to do with what it checks. The feature
|
||||
// list likewise: a literal one went stale when `attachment_sync` was added.
|
||||
// Versions and features come from the constants, not literals: this test is
|
||||
// about unknown FIELDS, and pinned values would fail it whenever the protocol
|
||||
// moves.
|
||||
let features: Vec<&str> = REQUIRED_FEATURES
|
||||
.iter()
|
||||
.chain(OPTIONAL_FEATURES.iter())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The sync cycle (M10.7c).
|
||||
//! The sync cycle.
|
||||
//!
|
||||
//! Deliberately the ONLY way the UI can sync. Push and pull are each usable on their
|
||||
//! own inside this crate, but exposing them separately would let a caller pull
|
||||
@@ -37,9 +37,9 @@ pub async fn run_cycle(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
) -> Result<SyncOutcome, String> {
|
||||
// A device linked to a public plain-http:// address before links were checked
|
||||
// stops here, rather than sending its token in the clear on every cycle. Its
|
||||
// sync error says to re-link over https:// (family idea #5105, practice 13).
|
||||
// A device linked to a public plain-http:// address stops here, rather than
|
||||
// sending its token in the clear on every cycle. Its sync error says to re-link
|
||||
// over https://.
|
||||
super::compat::refuse_public_cleartext(base_url)?;
|
||||
|
||||
// What this server can do, asked before anything is sent: files attached here,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Linking a device to a server, and unlinking it.
|
||||
//!
|
||||
//! One flow for every client. The desktop and Android each wrote it out, the same
|
||||
//! steps in the same order; what differs between them is only how the token is
|
||||
//! One flow for every client. What differs between them is only how the token is
|
||||
//! kept (Android seals it, `state::TokenSeal`) and how the result is reported.
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! The app is local-first: `local` is the source of truth and everything works
|
||||
//! unlinked. Nothing in here runs until the user links a server.
|
||||
//!
|
||||
//! - `compat` — the version/capability handshake (M10.6): whether a given server can
|
||||
//! - `compat` — the version/capability handshake: whether a given server can
|
||||
//! be talked to at all. Pure decision logic, no I/O.
|
||||
//! - `client` — HTTP transport: the handshake call and device-token auth.
|
||||
//! - `state` — the persisted link record (server, token, change-feed cursor).
|
||||
@@ -12,7 +12,7 @@
|
||||
//! - `pull` / `push` — the two halves of that cycle.
|
||||
//! - `wire` — the delta-feed JSON shapes, as the server sends them.
|
||||
//! - `blobs` — attachment bytes on disk, filed under their own sha256.
|
||||
//! - `sharing` — the Share dialog's calls, straight to the server (#5175).
|
||||
//! - `sharing` — the Share dialog's calls, straight to the server.
|
||||
//!
|
||||
//! The UI surface that drives this lives in whichever client is wrapping the crate,
|
||||
//! not here.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Pull: bring a server's changes into the local store (M10.7b).
|
||||
//! Pull: bring a server's changes into the local store.
|
||||
//!
|
||||
//! The feed is a single monotonic sequence shared by notes and labels, so one
|
||||
//! integer cursor is a total-order watermark over both (docs/sync.md). We loop pages
|
||||
@@ -694,10 +694,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn children_are_replaced_not_merged() {
|
||||
// Was written over checklist items; they are lines of the body now (M304), so
|
||||
// attachments carry the point instead. It is the same property either way: a
|
||||
// delta is the note's FULL current state, so a child the server dropped has to
|
||||
// disappear locally rather than linger.
|
||||
// A delta is the note's FULL current state, so a child the server dropped has
|
||||
// to disappear locally rather than linger.
|
||||
let conn = db();
|
||||
let mut first = note("n1", 1);
|
||||
first.attachments = vec![attachment("a1"), attachment("a2")];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Push: send local changes to the server and apply what it says (M10.7c).
|
||||
//! Push: send local changes to the server and apply what it says.
|
||||
//!
|
||||
//! Three sources feed a push: rows flagged `dirty` (created or edited locally), rows
|
||||
//! in `pending_deletes` (permanently deleted locally — see `local::schema` v2 for why
|
||||
@@ -78,7 +78,7 @@ pub struct Change {
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<String>,
|
||||
/// A LABEL's colour. A note has none since M315, so a note change leaves this
|
||||
/// A LABEL's colour. A note has none, so a note change leaves this
|
||||
/// `None` and the key never reaches the wire.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<String>,
|
||||
@@ -100,8 +100,8 @@ pub struct Change {
|
||||
pub created_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// When this account last pinned, archived or moved a note someone else owns
|
||||
/// (#5176): `pinned`, `archived` and `position` on such a note are its own, and
|
||||
/// When this account last pinned, archived or moved a note someone else owns:
|
||||
/// `pinned`, `archived` and `position` on such a note are its own, and
|
||||
/// the server weighs them against this rather than `edited_at`, which stays the
|
||||
/// time of the text.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -154,7 +154,7 @@ pub struct Accepts {
|
||||
/// failure.
|
||||
pub attachment_sync: bool,
|
||||
/// This account's own pin, archive and position on a note someone else owns
|
||||
/// (`shared_state`, #5176).
|
||||
/// (`shared_state`).
|
||||
pub shared_state: bool,
|
||||
}
|
||||
|
||||
@@ -281,9 +281,9 @@ struct NoteRow {
|
||||
recurrence: Option<String>,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
/// `owner`, or `edit` or `view` for a note someone shared with us (#5175).
|
||||
/// `owner`, or `edit` or `view` for a note someone shared with us.
|
||||
permission: String,
|
||||
/// When this account last pinned, archived or moved it, if it isn't ours (#5176).
|
||||
/// When this account last pinned, archived or moved it, if it isn't ours.
|
||||
state_at: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Sharing a note from a linked device (#5175).
|
||||
//! Sharing a note from a linked device.
|
||||
//!
|
||||
//! Shares belong to the server: who is on the instance and who a note is shared
|
||||
//! with are never kept here, so each call goes straight to the server over the
|
||||
@@ -8,8 +8,8 @@
|
||||
//!
|
||||
//! The server address and token come from the CALLER, read with
|
||||
//! `state::credentials` and the caller's seal. A stored token may be sealed
|
||||
//! (Android keeps it under a Keystore key), and reading it raw here sent `sealed:…`
|
||||
//! as the bearer token, so every share call was refused (#5381).
|
||||
//! (Android keeps it under a Keystore key), and the server refuses `sealed:…` as a
|
||||
//! bearer token.
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
//! setups. Protecting the database file is the portable trade.
|
||||
//!
|
||||
//! A client that has somewhere better to keep a key passes a [`TokenSeal`], and the
|
||||
//! token is stored sealed. Android does, with a key in the Keystore (family idea
|
||||
//! #5105, practice 12); the desktop does not.
|
||||
//! token is stored sealed. Android does, with a key in the Keystore; the desktop
|
||||
//! does not.
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::Serialize;
|
||||
@@ -177,7 +177,7 @@ pub fn open_token(
|
||||
|
||||
/// The server address and the token to send it, or None when this device isn't
|
||||
/// linked. The ONE read of a link for a call to the server: every client goes
|
||||
/// through here, so none can send a token as stored when it is sealed (#5381).
|
||||
/// through here, so none can send a token as stored when it is sealed.
|
||||
///
|
||||
/// With a `seal`, the stored token is opened ([`open_token`]); a client without one
|
||||
/// (the desktop keeps its token plain) passes None and gets it as stored.
|
||||
@@ -217,11 +217,11 @@ fn forget_shared_notes(conn: &Connection) -> rusqlite::Result<()> {
|
||||
/// `sync_state.shares_synced`. Each level changed what the feed says about notes a
|
||||
/// device may already hold, below the cursor it already has.
|
||||
///
|
||||
/// Shared notes arrive at all (#5175).
|
||||
/// Shared notes arrive at all.
|
||||
pub const SHARES: i64 = 1;
|
||||
/// A shared note's pin, archive and position are this account's own (#5176). Before,
|
||||
/// the feed sent the owner's, and a device holding those would keep showing them
|
||||
/// until something next changed the note.
|
||||
/// A shared note's pin, archive and position are this account's own. A device still
|
||||
/// holding the owner's values would show them until something next changed the note,
|
||||
/// so reaching this level re-reads the feed.
|
||||
pub const SHARED_STATE: i64 = 2;
|
||||
|
||||
/// Start the change feed over the first time this device pulls at a sharing `level`
|
||||
@@ -291,7 +291,7 @@ pub fn mark_synced(conn: &Connection, when: &str) -> rusqlite::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Advance the consumed-change watermark. Called by the pull loop (M10.7b) only
|
||||
/// Advance the consumed-change watermark. Called by the pull loop only
|
||||
/// after a page has been fully applied.
|
||||
pub fn set_cursor(conn: &Connection, cursor: i64) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
|
||||
Reference in New Issue
Block a user