From 7eacd0569c3da6b1ecdca1be648c4e4b1b1532ad Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 19:00:44 -0400 Subject: [PATCH] all: delete the code nothing calls From the audit (#5178). Each was unreachable from every client: - Checklist add-item and delete-item: REST POST /items and DELETE /items/, the Tauri commands, the store, rest and local adapters, the core's add_item/delete_item, set_item_text and remove_item, and the FFI exports. Adding, rewording and removing an item are body edits in every editor. The checked toggle stays, and its rewriter is simpler without the drop branch. - Manual unfurl: POST /unfurl and its adapters. Previews arrive in the background after a save (unfurl_queue). - The /api/config `android_client` key, android_release() and the APK_NAME/MANIFEST_NAME aliases. Phones poll /api/client/android. - users.email_verified and users.avatar_path (migration 0037). Nothing set the first or read the second; the SMTP reset never checked verification. - derive::extract_tags (only tests used it; the shared fixture now runs through extract_tag_spans), the unused check and link icons, and the unused editor_add_item string. - The blob scheme is renamed tsblob -> inkblob. URLs are built as notes are read, so nothing stored carries the old one. Co-Authored-By: Claude Opus 5.5 --- .../versions/0037_drop_unused_user_fields.py | 38 +++++ android/app/src/main/res/values/strings.xml | 1 - android/ffi/src/lib.rs | 111 +++----------- core/src/local/derive.rs | 145 +++++------------- core/src/local/models.rs | 1 - core/src/local/store.rs | 36 ++--- core/src/sync/blobs.rs | 9 +- desktop/src-tauri/src/commands/local.rs | 13 -- desktop/src-tauri/src/lib.rs | 2 - docs/public-hosting.md | 6 +- frontend/src/adapters/local.ts | 5 +- frontend/src/adapters/repo.ts | 7 +- frontend/src/adapters/rest.ts | 3 - frontend/src/components/Icon.vue | 2 - frontend/src/stores/config.ts | 4 - frontend/src/stores/notes.ts | 17 +- frontend/src/stores/session.ts | 1 - src/inkwell/app.py | 7 +- src/inkwell/auth.py | 1 - src/inkwell/client_dist.py | 43 +----- src/inkwell/models/user.py | 2 - src/inkwell/notes/__init__.py | 81 +--------- src/inkwell/notes/checklist.py | 50 +----- tests/test_client_dist.py | 31 +--- tests/test_notes.py | 26 ++-- tests/test_unfurl.py | 8 - 26 files changed, 159 insertions(+), 491 deletions(-) create mode 100644 alembic/versions/0037_drop_unused_user_fields.py diff --git a/alembic/versions/0037_drop_unused_user_fields.py b/alembic/versions/0037_drop_unused_user_fields.py new file mode 100644 index 0000000..e809209 --- /dev/null +++ b/alembic/versions/0037_drop_unused_user_fields.py @@ -0,0 +1,38 @@ +"""users: drop email_verified and avatar_path, which nothing used + +Revision ID: 0037 +Revises: 0036 +Create Date: 2026-10-07 + +Both came with the foundation (0001) for features that were never built (#5178). +Nothing ever set `email_verified`, so it was false for every account on the server +while the offline desktop reported it as true; nothing read or wrote `avatar_path`. +A field whose value means nothing is worse than no field: a later feature would +trust it. If address verification is ever built, it adds its own column, with a +migration that says what the existing accounts are. + +## Downgrade + +Adds both columns back empty: every account unverified, no avatar. That is what +they held before. +""" +import sqlalchemy as sa +from alembic import op + +revision = "0037" +down_revision = "0036" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_column("users", "email_verified") + op.drop_column("users", "avatar_path") + + +def downgrade() -> None: + op.add_column("users", sa.Column("avatar_path", sa.Text(), nullable=True)) + op.add_column( + "users", + sa.Column("email_verified", sa.Boolean(), nullable=False, server_default=sa.false()), + ) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index d49dfdd..ca3155d 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -40,7 +40,6 @@ Back to notes Add a checklist Take a note… - Add item Remove item Remove tag Set a reminder diff --git a/android/ffi/src/lib.rs b/android/ffi/src/lib.rs index 3bfec80..e197a3f 100644 --- a/android/ffi/src/lib.rs +++ b/android/ffi/src/lib.rs @@ -224,53 +224,29 @@ impl Inkwell { } // ──────────────────────────── checklist items ──────────────────────────── - // - // Every one of these returns the whole reloaded note rather than the item it - // touched. That is the core's shape, and it is the right one for a UI: ticking - // a box changes `updated_at` and can change what the board shows, so handing - // back only the item would leave Kotlin to guess at the rest. - pub fn add_item(&self, note_id: String, text: String) -> Result { - let conn = self.db.conn().map_err(CoreError::store)?; - local::store::add_item(&conn, ¬e_id, &text) - .map(Note::from) - .map_err(CoreError::store) - } - - /// Retitle one item. + /// Tick or untick one item. Adding, rewording and removing items are edits to + /// the body, which the editor makes like any other. /// - /// Split from `set_item_checked` rather than exposing the core's - /// `{text?, checked?}` patch, for the same reason `NoteEdit` exists: an - /// optional-field struct cannot say "leave this alone" in Kotlin without - /// colliding with "set it to null", and two unambiguous calls beat one - /// ambiguous one when each is three lines. - pub fn set_item_text( - &self, - note_id: String, - item_id: String, - text: String, - ) -> Result { - self.patch_item(¬e_id, &item_id, serde_json::json!({ "text": text })) - } - + /// Returns the whole reloaded note rather than the item it touched. That is the + /// core's shape, and it is the right one for a UI: ticking a box changes + /// `updated_at` and can change what the board shows, so handing back only the + /// item would leave Kotlin to guess at the rest. pub fn set_item_checked( &self, note_id: String, item_id: String, checked: bool, ) -> Result { - self.patch_item( + let conn = self.db.conn().map_err(CoreError::store)?; + local::store::update_item( + &conn, ¬e_id, &item_id, - serde_json::json!({ "checked": checked }), + &serde_json::json!({ "checked": checked }), ) - } - - pub fn delete_item(&self, note_id: String, item_id: String) -> Result { - let conn = self.db.conn().map_err(CoreError::store)?; - local::store::delete_item(&conn, ¬e_id, &item_id) - .map(Note::from) - .map_err(CoreError::store) + .map(Note::from) + .map_err(CoreError::store) } // ─────────────────────────────── reminders ─────────────────────────────── @@ -721,22 +697,6 @@ pub fn body_tags(body: String) -> Vec { /// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]` /// block names, so these stay Rust-side. impl Inkwell { - /// Apply a `{text}` or `{checked}` patch to one checklist item. - /// - /// The two public setters differ only in the key they write, and the lock + - /// convert + map-error dance around it is identical, so it lives once here. - fn patch_item( - &self, - note_id: &str, - item_id: &str, - changes: serde_json::Value, - ) -> Result { - let conn = self.db.conn().map_err(CoreError::store)?; - local::store::update_item(&conn, note_id, item_id, &changes) - .map(Note::from) - .map_err(CoreError::store) - } - /// The server URL + token, or the `NotLinked` state. Every networked call needs /// exactly this, and none of them may hold the lock past it. fn credentials(&self) -> Result<(String, String), CoreError> { @@ -868,55 +828,32 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } - /// The editor's whole checklist loop, in one pass: add a row, tick it, retitle - /// it, drop it. Each call returns the reloaded note, which is what the UI - /// splices back into the board rather than re-querying. + /// Ticking a box returns the reloaded note, which is what the UI splices back + /// into the board rather than re-querying, and leaves the item's text alone. #[test] - fn checklist_items_can_be_added_ticked_retitled_and_removed() { + fn ticking_an_item_rewrites_only_its_box() { let dir = scratch_dir(); let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open"); let note = app .create_note(NoteDraft { body: "Packing".to_string(), - items: Some(vec!["socks".to_string()]), + items: Some(vec!["socks".to_string(), "charger".to_string()]), }) .expect("create"); - assert_eq!(note.items.len(), 1); + assert_eq!(note.items.len(), 2); - let with_two = app - .add_item(note.id.clone(), "charger".to_string()) - .expect("add"); - assert_eq!(with_two.items.len(), 2); - // Appended, not prepended — a new row belongs at the bottom of the list the - // user is looking at. - assert_eq!(with_two.items[1].text, "charger"); - - let item_id = with_two.items[1].id.clone(); + let item_id = note.items[1].id.clone(); let ticked = app .set_item_checked(note.id.clone(), item_id.clone(), true) .expect("tick"); assert!(ticked.items[1].checked); - assert_eq!( - ticked.items[1].text, "charger", - "ticking a box must not disturb its text — both setters rewrite the \ - same line of the body now, so one clobbering the other is a live risk \ - rather than a theoretical one" - ); + assert!(!ticked.items[0].checked); + assert_eq!(ticked.items[1].text, "charger"); - let renamed = app - .set_item_text(note.id.clone(), item_id.clone(), "usb-c cable".to_string()) - .expect("rename"); - assert_eq!(renamed.items[1].text, "usb-c cable"); - assert!( - renamed.items[1].checked, - "and the same in the other direction" - ); - - let trimmed = app - .delete_item(note.id.clone(), item_id) - .expect("delete item"); - assert_eq!(trimmed.items.len(), 1); - assert_eq!(trimmed.items[0].text, "socks"); + let unticked = app + .set_item_checked(note.id.clone(), item_id, false) + .expect("untick"); + assert_eq!(unticked.body, note.body); std::fs::remove_dir_all(&dir).ok(); } diff --git a/core/src/local/derive.rs b/core/src/local/derive.rs index 81f7946..70ce99f 100644 --- a/core/src/local/derive.rs +++ b/core/src/local/derive.rs @@ -52,22 +52,6 @@ fn line_tags(chars: &[char]) -> Vec<(usize, usize, String)> { out } -/// Extract every `#tag` name (without the leading `#`) from `body`. -/// -/// Line by line, which changes nothing: a line start and a `\n` are both boundaries, -/// so the same tags come out. It means there is ONE scanner rather than two — this and -/// [`lift_standalone_tags`] cannot disagree about what a tag is. -pub fn extract_tags(body: &str) -> Vec { - let mut out: Vec = Vec::new(); - for line in body.split('\n') { - let chars: Vec = line.chars().collect(); - for (_, _, name) in line_tags(&chars) { - push_unique(&mut out, &name); - } - } - out -} - /// 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 @@ -89,13 +73,13 @@ pub struct DerivedTag { pub name: String, } -/// Every `#tag` in `body` with its position — the same scan [`extract_tags`] does, +/// Every `#tag` in `body` with its position — the scan [`lift_standalone_tags`] does, /// keeping the spans instead of throwing them away. /// -/// Not deduped, unlike `extract_tags`: two mentions of `#todo` are two pieces of text -/// to colour. Fences are not skipped either, and that is deliberate — `extract_tags` -/// does not skip them, so a `#tag` inside a code block IS a label on the note, and a -/// renderer that left it plain would be the only surface disagreeing. +/// Not deduped: two mentions of `#todo` are two pieces of text to colour. Fences are +/// not skipped either, and that is deliberate — a `#tag` inside a code block stays an +/// inline label on the note, and a renderer that left it plain would be the only +/// surface disagreeing. pub fn extract_tag_spans(body: &str) -> Vec { let mut out = Vec::new(); for (n, line) in body.split('\n').enumerate() { @@ -383,65 +367,29 @@ pub fn extract_items(body: &str) -> Vec { out } -/// Rewrite the `index`-th task line, or drop it when `f` returns None. +/// Tick or untick the `index`-th item, keeping its indent, bullet and text. +/// +/// The only edit to an item that isn't typing in the body: adding, rewording and +/// deleting one are all text edits, which every client makes in its editor. /// /// A body with fewer task lines than that is returned UNCHANGED rather than /// panicking: the index comes from a UI that may be a moment behind the store, and /// a stale tap should do nothing rather than take the app down. -fn map_task_line(body: &str, index: usize, f: F) -> String -where - F: FnOnce(&TaskLine<'_>) -> Option, -{ - let lines: Vec<&str> = body.split('\n').collect(); - let mut target: Option = None; - let mut seen = 0usize; - for (n, line) in lines.iter().enumerate() { - if parse_task_line(line).is_some() { - if seen == index { - target = Some(n); - break; - } - seen += 1; - } - } - let target = match target { - Some(n) => n, - None => return body.to_string(), - }; - let replacement = match parse_task_line(lines[target]) { - Some(parsed) => f(&parsed), - None => return body.to_string(), - }; - - let mut out: Vec = Vec::with_capacity(lines.len()); - for (n, line) in lines.iter().enumerate() { - if n != target { - out.push((*line).to_string()); - } else if let Some(new_line) = &replacement { - out.push(new_line.clone()); - } - // None at the target line drops it, which is `remove_item`. - } - out.join("\n") -} - -/// Tick or untick the `index`-th item. pub fn set_item_checked(body: &str, index: usize, checked: bool) -> String { - map_task_line(body, index, |t| { - Some(render_task_line(t.indent, t.bullet, checked, t.text)) - }) -} - -/// Replace the text of the `index`-th item, keeping its state and its bullet. -pub fn set_item_text(body: &str, index: usize, text: &str) -> String { - map_task_line(body, index, |t| { - Some(render_task_line(t.indent, t.bullet, t.checked, text.trim())) - }) -} - -/// Delete the `index`-th item, line and all. -pub fn remove_item(body: &str, index: usize) -> String { - map_task_line(body, index, |_| None) + let mut lines: Vec = body.split('\n').map(str::to_string).collect(); + let Some(line) = lines + .iter_mut() + .filter(|l| parse_task_line(l.as_str()).is_some()) + .nth(index) + else { + return body.to_string(); + }; + let Some(t) = parse_task_line(line.as_str()) else { + return body.to_string(); + }; + let ticked = render_task_line(t.indent, t.bullet, checked, t.text); + *line = ticked; + lines.join("\n") } /// Add an item at the end of the body. @@ -476,10 +424,19 @@ pub fn append_item(body: &str, text: &str, checked: bool) -> String { mod tests { use super::*; + /// The tag names in `body`, once each — what the shared fixture lists. + fn tag_names(body: &str) -> Vec { + let mut out = Vec::new(); + for t in extract_tag_spans(body) { + push_unique(&mut out, &t.name); + } + out + } + #[test] fn tags_basic() { assert_eq!( - extract_tags("a #todo and #Work-item_2 here"), + tag_names("a #todo and #Work-item_2 here"), vec!["todo", "Work-item_2"] ); } @@ -487,17 +444,12 @@ mod tests { #[test] fn tags_require_letter_start_and_boundary() { // "#1" (digit) and an in-word "#" (email-ish) are not tags. - assert_eq!(extract_tags("#1 nope a#b no but #Yes"), vec!["Yes"]); - } - - #[test] - fn tags_dedupe_case_insensitive() { - assert_eq!(extract_tags("#Home #home #HOME"), vec!["Home"]); + assert_eq!(tag_names("#1 nope a#b no but #Yes ##no"), vec!["Yes"]); } #[test] fn empty_body() { - assert!(extract_tags("").is_empty()); + assert!(extract_tag_spans("").is_empty()); } // ── tag spans, for the renderer that draws them in place ───────────────── @@ -523,16 +475,6 @@ mod tests { assert_eq!((spans[0].start, spans[0].end), (3, 8)); } - #[test] - fn tag_spans_agree_with_extract_tags_about_what_a_tag_is() { - let body = "#1 nope a#b no but #Yes ##no"; - let names: Vec = extract_tag_spans(body) - .into_iter() - .map(|t| t.name) - .collect(); - assert_eq!(names, extract_tags(body)); - } - // ── lifting standalone tags ────────────────────────────────────────────── // // The MIRROR of `split_body_tags` in the server's notes/tags.py, case for case. @@ -697,17 +639,6 @@ mod tests { ); } - #[test] - fn set_text_keeps_state() { - assert_eq!(set_item_text("- [x] old", 0, "new"), "- [x] new"); - } - - #[test] - fn remove_takes_the_whole_line() { - let body = "keep\n- [ ] drop\n- [ ] stay"; - assert_eq!(remove_item(body, 0), "keep\n- [ ] stay"); - } - #[test] fn append_spaces_like_the_exporter() { // Prose then a blank line then the list — byte-for-byte what @@ -755,16 +686,12 @@ mod tests { // that arrives late should be inert, not fatal. let body = "- [ ] only"; assert_eq!(set_item_checked(body, 7, true), body); - assert_eq!(remove_item(body, 7), body); - assert_eq!(set_item_text(body, 7, "x"), body); } #[test] fn a_plain_body_is_returned_byte_identical() { let body = "just prose\nwith two lines"; assert_eq!(set_item_checked(body, 0, true), body); - assert_eq!(set_item_text(body, 0, "x"), body); - assert_eq!(remove_item(body, 0), body); } #[test] @@ -828,7 +755,7 @@ mod tests { fn fixture_tags() { for case in fixture()["tags"].as_array().unwrap() { let body = case["body"].as_str().unwrap(); - assert_eq!(extract_tags(body), strings(&case["tags"]), "body {body:?}"); + assert_eq!(tag_names(body), strings(&case["tags"]), "body {body:?}"); } } diff --git a/core/src/local/models.rs b/core/src/local/models.rs index 21a8c8c..ef2ddc5 100644 --- a/core/src/local/models.rs +++ b/core/src/local/models.rs @@ -147,7 +147,6 @@ pub struct User { pub id: String, pub email: String, pub display_name: String, - pub email_verified: bool, pub is_admin: bool, } diff --git a/core/src/local/store.rs b/core/src/local/store.rs index ee688a3..456fe15 100644 --- a/core/src/local/store.rs +++ b/core/src/local/store.rs @@ -756,14 +756,13 @@ pub fn set_labels(conn: &Connection, id: &str, label_ids: &[String]) -> rusqlite load_note(conn, id) } -// ---- checklist items: every one of these is a body edit --------------------- +// ---- checklist items: a tick is a body edit ---------------------------------- // -// They keep their own names and signatures because the FFI, the Tauri commands and -// the REST shape all speak in items, and a checklist is still a thing a note HAS. -// What changed is where it is kept. Routing all three through `update_note` rather -// than writing the body directly is what gives them revision snapshotting, `#tag` -// re-derivation and the dirty/updated_at bookkeeping without any of it being -// written a second time here. +// It keeps the item's name and signature because the FFI, the Tauri commands and the +// REST shape all speak in items, and a checklist is still a thing a note HAS. Routing +// it through `update_note` rather than writing the body directly is what gives it +// revision snapshotting, `#tag` re-derivation and the dirty/updated_at bookkeeping +// without any of it being written a second time here. fn note_body(conn: &Connection, id: &str) -> rusqlite::Result { conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0)) @@ -779,11 +778,8 @@ fn set_body(conn: &Connection, id: &str, body: String) -> rusqlite::Result update_note(conn, id, &json!({ "body": body })) } -pub fn add_item(conn: &Connection, id: &str, text: &str) -> rusqlite::Result { - let body = note_body(conn, id)?; - set_body(conn, id, derive::append_item(&body, text, false)) -} - +/// Tick or untick one item. The only item change that isn't an edit to the body's +/// text: adding, rewording and removing an item happen in the editor. pub fn update_item( conn: &Connection, id: &str, @@ -795,24 +791,12 @@ pub fn update_item( None => return load_note(conn, id), }; let mut body = note_body(conn, id)?; - if let Some(text) = changes.get("text").and_then(Value::as_str) { - body = derive::set_item_text(&body, index, text); - } if let Some(checked) = changes.get("checked").and_then(Value::as_bool) { body = derive::set_item_checked(&body, index, checked); } set_body(conn, id, body) } -pub fn delete_item(conn: &Connection, id: &str, item_id: &str) -> rusqlite::Result { - let index = match item_index(item_id) { - Some(i) => i, - None => return load_note(conn, id), - }; - let body = note_body(conn, id)?; - set_body(conn, id, derive::remove_item(&body, index)) -} - /// The stored form of a declared content type: the bare media type, lowercase. /// Matches the server's `normalize_mime`, so both sides file a type the same way. fn normalize_mime(raw: &str) -> String { @@ -1355,7 +1339,7 @@ mod tests { .expect("refused"); // The words the person sees, exactly: the refusal prints as its message. assert_eq!(refused.to_string(), VIEW_ONLY); - assert!(add_item(&conn, "n", "eggs").is_err()); + assert!(update_item(&conn, "n", "0", &json!({ "checked": true })).is_err()); assert!(trash(&conn, "n").is_err()); assert!(snooze_reminder(&conn, "n", 10).is_err()); assert!(set_labels(&conn, "n", &[]).is_err()); @@ -1374,7 +1358,7 @@ mod tests { assert!(edited.labels.is_empty()); assert_eq!(edited.body, "their words, edited\n#mine"); assert!(dirty(&conn, "n")); - add_item(&conn, "n", "eggs").expect("an item is text"); + update_item(&conn, "n", "0", &json!({ "checked": true })).expect("a tick is text"); let reminded = update_note( &conn, diff --git a/core/src/sync/blobs.rs b/core/src/sync/blobs.rs index 0f46167..0f47152 100644 --- a/core/src/sync/blobs.rs +++ b/core/src/sync/blobs.rs @@ -104,8 +104,9 @@ impl BlobStore { // store. The webview then caches and range-requests them like any other resource, // which a `data:` URI would have thrown away. -/// The scheme the webview fetches attachment bytes over. -pub const BLOB_SCHEME: &str = "tsblob"; +/// The scheme the webview fetches attachment bytes over. Nothing stores a URL built +/// on it — `url_for` runs as each note is read — so renaming it costs nothing. +pub const BLOB_SCHEME: &str = "inkblob"; /// The blob directory, published once the app has resolved its data dir. /// @@ -279,9 +280,9 @@ mod tests { // The platform split is the whole risk of this feature, and CI is headless, // so at least pin that the right branch was taken for THIS build. if cfg!(any(windows, target_os = "android")) { - assert!(url.starts_with("http://tsblob.localhost/"), "{url}"); + assert!(url.starts_with("http://inkblob.localhost/"), "{url}"); } else { - assert!(url.starts_with("tsblob://localhost/"), "{url}"); + assert!(url.starts_with("inkblob://localhost/"), "{url}"); } } diff --git a/desktop/src-tauri/src/commands/local.rs b/desktop/src-tauri/src/commands/local.rs index 65cc893..5c86919 100644 --- a/desktop/src-tauri/src/commands/local.rs +++ b/desktop/src-tauri/src/commands/local.rs @@ -48,7 +48,6 @@ pub fn auth_me() -> User { id: "local".to_string(), email: "local@inkwell.app".to_string(), display_name: "You".to_string(), - email_verified: true, is_admin: false, } } @@ -99,12 +98,6 @@ pub fn notes_set_labels( store::set_labels(&conn, &id, &label_ids).map_err(|e| e.to_string()) } -#[tauri::command] -pub fn notes_add_item(id: String, text: String, db: State<'_, Db>) -> Result { - let conn = db.0.lock().map_err(|e| e.to_string())?; - store::add_item(&conn, &id, &text).map_err(|e| e.to_string()) -} - #[tauri::command] pub fn notes_update_item( id: String, @@ -116,12 +109,6 @@ pub fn notes_update_item( store::update_item(&conn, &id, &item_id, &changes).map_err(|e| e.to_string()) } -#[tauri::command] -pub fn notes_delete_item(id: String, item_id: String, db: State<'_, Db>) -> Result { - let conn = db.0.lock().map_err(|e| e.to_string())?; - store::delete_item(&conn, &id, &item_id).map_err(|e| e.to_string()) -} - /// Attach a file to a note, online or not (#5168). /// /// The file's bytes are the raw IPC body — a JSON number array would be several diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 0782323..26f9a4f 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -180,9 +180,7 @@ pub fn run() { commands::local::notes_complete_reminder, commands::local::notes_snooze_reminder, commands::local::notes_set_labels, - commands::local::notes_add_item, commands::local::notes_update_item, - commands::local::notes_delete_item, commands::local::notes_add_attachment, commands::local::notes_export, commands::local::notes_import, diff --git a/docs/public-hosting.md b/docs/public-hosting.md index fc6337d..5775553 100644 --- a/docs/public-hosting.md +++ b/docs/public-hosting.md @@ -135,8 +135,10 @@ address can be sent. Know these before you decide who gets an account. -- **No email verification.** `email_verified` exists on the user row and nothing sets - it. Email is used only for password resets (above). +- **No email verification.** An account's address is whatever was typed when it + registered, and email is used only for password resets (above), which go to that + address. Nothing changes an address afterwards, so someone who registered with a mistyped + one can't reset by email; an admin can issue them a reset link instead. - **An admin who forgets their own password**, with email off and no other admin, still needs a hand on the database. - **No second factor.** A password is the whole of it. diff --git a/frontend/src/adapters/local.ts b/frontend/src/adapters/local.ts index 5034076..ca2d417 100644 --- a/frontend/src/adapters/local.ts +++ b/frontend/src/adapters/local.ts @@ -4,7 +4,7 @@ // // 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) — those reject with a clear message +// yet (account auth, device linking) — 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. @@ -55,9 +55,7 @@ export const local: Repo = { completeReminder: (id) => invoke("notes_complete_reminder", { id }), snoozeReminder: (id, minutes) => invoke("notes_snooze_reminder", { id, minutes }), setLabels: (id, labelIds) => invoke("notes_set_labels", { id, labelIds }), - addItem: (id, text) => invoke("notes_add_item", { id, text }), updateItem: (id, itemId, changes) => invoke("notes_update_item", { id, itemId, changes }), - deleteItem: (id, itemId) => invoke("notes_delete_item", { id, itemId }), // Kept on this device and uploaded by the next sync once linked (#5168). The bytes // go as the raw IPC body; the name is percent-encoded because a header carries // only ASCII. @@ -70,7 +68,6 @@ export const local: Repo = { }, }), deleteAttachment: (id, attId) => invoke("notes_delete_attachment", { id, attId }), - unfurl: () => Promise.reject(new Error(NEEDS_SERVER)), deletePreview: (id, previewId) => invoke("notes_delete_preview", { id, previewId }), // The archive crosses as raw bytes, as an attachment does. import: async (file) => invoke("notes_import", new Uint8Array(await file.arrayBuffer())), diff --git a/frontend/src/adapters/repo.ts b/frontend/src/adapters/repo.ts index 4efed3e..3b28baa 100644 --- a/frontend/src/adapters/repo.ts +++ b/frontend/src/adapters/repo.ts @@ -40,9 +40,9 @@ export type NoteChanges = Partial< Pick >; +/** The one item change that isn't typing in the body: ticking its box. */ export interface ChecklistItemChanges { - text?: string; - checked?: boolean; + checked: boolean; } @@ -131,12 +131,9 @@ export interface NotesRepo { completeReminder(id: string): Promise; snoozeReminder(id: string, minutes: number): Promise; setLabels(id: string, labelIds: string[]): Promise; - addItem(id: string, text: string): Promise; updateItem(id: string, itemId: string, changes: ChecklistItemChanges): Promise; - deleteItem(id: string, itemId: string): Promise; uploadAttachment(id: string, file: File): Promise; deleteAttachment(id: string, attId: string): Promise; - unfurl(id: string, url: string): Promise; deletePreview(id: string, previewId: string): Promise; import(file: File): Promise; /** Save every note as a zip. Resolves to the path it was saved at, or null when diff --git a/frontend/src/adapters/rest.ts b/frontend/src/adapters/rest.ts index 72055f9..d039a47 100644 --- a/frontend/src/adapters/rest.ts +++ b/frontend/src/adapters/rest.ts @@ -84,13 +84,10 @@ export const rest: Repo = { completeReminder: (id) => api.post(`/api/notes/${id}/reminder/complete`), snoozeReminder: (id, minutes) => api.post(`/api/notes/${id}/reminder/snooze`, { minutes }), setLabels: (id, labelIds) => api.put(`/api/notes/${id}/labels`, { label_ids: labelIds }), - addItem: (id, text) => api.post(`/api/notes/${id}/items`, { text }), updateItem: (id, itemId, changes: ChecklistItemChanges) => api.patch(`/api/notes/${id}/items/${itemId}`, changes), - deleteItem: (id, itemId) => api.del(`/api/notes/${id}/items/${itemId}`), uploadAttachment: (id, file) => api.postForm(`/api/notes/${id}/attachments`, fileForm(file)), deleteAttachment: (id, attId) => api.del(`/api/notes/${id}/attachments/${attId}`), - unfurl: (id, url) => api.post(`/api/notes/${id}/unfurl`, { url }), deletePreview: (id, previewId) => api.del(`/api/notes/${id}/previews/${previewId}`), import: (file) => api.postForm("/api/notes/import", fileForm(file)), // A same-origin GET with the session cookie; the browser saves the attachment diff --git a/frontend/src/components/Icon.vue b/frontend/src/components/Icon.vue index 8ba0586..0894a18 100644 --- a/frontend/src/components/Icon.vue +++ b/frontend/src/components/Icon.vue @@ -13,7 +13,6 @@ const paths: Record = { tag: '', pencil: '', plus: '', - check: '', checkbox: '', image: '', bell: '', @@ -27,7 +26,6 @@ const paths: Record = { device: '', sync: '', paperclip: '', - link: '', filter: '', users: '', copy: '', diff --git a/frontend/src/stores/config.ts b/frontend/src/stores/config.ts index ca987e5..4435978 100644 --- a/frontend/src/stores/config.ts +++ b/frontend/src/stores/config.ts @@ -49,10 +49,6 @@ export interface PublicConfig { // Every client this server holds, keyed by platform id. Absent on a server that // holds none, and absent on the desktop's own offline config — the Tauri build // answers `config_get` locally and has no clients to hand out. - // - // `/api/config` also carries `android_client`, which is NOT declared here: it - // exists for phones in the field polling for their own update, not for this app, - // and reading it here would be a second path to the same fact. clients?: Record; } diff --git a/frontend/src/stores/notes.ts b/frontend/src/stores/notes.ts index a1b4c74..b3b282a 100644 --- a/frontend/src/stores/notes.ts +++ b/frontend/src/stores/notes.ts @@ -184,18 +184,10 @@ export const useNotesStore = defineStore("notes", () => { reconcile(await repo.notes.setLabels(id, labelIds)); } - async function addItem(id: string, text: string): Promise { - reconcile(await repo.notes.addItem(id, text)); - } - - async function updateItem(id: string, itemId: string, changes: { text?: string; checked?: boolean }): Promise { + async function updateItem(id: string, itemId: string, changes: { checked: boolean }): Promise { reconcile(await repo.notes.updateItem(id, itemId, changes)); } - async function deleteItem(id: string, itemId: string): Promise { - reconcile(await repo.notes.deleteItem(id, itemId)); - } - async function uploadAttachment(id: string, file: File): Promise { reconcile(await repo.notes.uploadAttachment(id, file)); } @@ -204,10 +196,6 @@ export const useNotesStore = defineStore("notes", () => { reconcile(await repo.notes.deleteAttachment(id, attId)); } - async function unfurl(id: string, url: string): Promise { - reconcile(await repo.notes.unfurl(id, url)); - } - async function deletePreview(id: string, previewId: string): Promise { reconcile(await repo.notes.deletePreview(id, previewId)); } @@ -299,12 +287,9 @@ export const useNotesStore = defineStore("notes", () => { snoozeReminder, saveEdit, setLabels, - addItem, updateItem, - deleteItem, uploadAttachment, deleteAttachment, - unfurl, deletePreview, importNotes, fetchOne, diff --git a/frontend/src/stores/session.ts b/frontend/src/stores/session.ts index acd8a7c..e23db20 100644 --- a/frontend/src/stores/session.ts +++ b/frontend/src/stores/session.ts @@ -6,7 +6,6 @@ export interface User { id: string; email: string; display_name: string; - email_verified: boolean; is_admin: boolean; } diff --git a/src/inkwell/app.py b/src/inkwell/app.py index 6a438c1..4b32c6b 100644 --- a/src/inkwell/app.py +++ b/src/inkwell/app.py @@ -212,10 +212,9 @@ def create_app() -> Quart: # linking — while it still has no token and possibly no account — to decide # whether it can talk to this server, and which optional features to offer. data.update(protocol_advertisement()) - # Which CLIENTS this server can hand out, if any — the whole set under - # `clients`, plus the older `android_client` key that phones in the field - # still read. Absent rather than null when it has none, so the web UI hides - # a download instead of offering a button that 404s. + # Which CLIENTS this server can hand out, if any, under `clients`. Absent + # rather than null when it has none, so the web UI hides a download instead + # of offering a button that 404s. data.update(client_advertisement()) return jsonify(data) diff --git a/src/inkwell/auth.py b/src/inkwell/auth.py index 264c639..9941884 100644 --- a/src/inkwell/auth.py +++ b/src/inkwell/auth.py @@ -52,7 +52,6 @@ def _serialize_user(user: User) -> dict: "id": str(user.id), "email": user.email, "display_name": user.display_name, - "email_verified": user.email_verified, "is_admin": user.is_admin, } diff --git a/src/inkwell/client_dist.py b/src/inkwell/client_dist.py index ff10ed9..8f27e05 100644 --- a/src/inkwell/client_dist.py +++ b/src/inkwell/client_dist.py @@ -176,12 +176,6 @@ PLATFORMS: tuple[Platform, ...] = ( BY_ID: dict[str, Platform] = {p.id: p for p in PLATFORMS} -# The Android names, still importable under their old spellings because docs and -# the CI lane refer to them. Derived from the table rather than restated, so the -# two cannot drift. -APK_NAME = BY_ID["android"].artifact -MANIFEST_NAME = BY_ID["android"].sidecar - # The copy CI bakes into the image. Inside the package, NOT under DATA_DIR: that # is a volume mount, and a file the image wrote there would vanish behind it. BAKED_ROOT = Path(__file__).resolve().parent / "client" @@ -293,40 +287,17 @@ def releases() -> dict[str, dict]: return {pid: r[1] for pid, r in found.items() if r is not None} -def android_release() -> dict | None: - """What Android build this server holds, or None. - - Kept as its own name because the back-compatible `/api/config` key below is - about Android specifically, and because saying so reads better than - `release("android")` at the two call sites that mean the phone. - """ - return release("android") - - def advertisement() -> dict: - """The `/api/config` fragment describing this server's clients. + """The `/api/config` fragment describing this server's clients: `clients`, the + whole table, which the web UI renders the downloads section from. Phones ask + `/api/client/android` for their own update instead. - Two keys, deliberately, and the older one is not deprecated here: - - `clients` is the whole table, which is what the web UI renders the downloads - section from. - - `android_client` is what phones in the field already read. It costs one - duplicated dict to not strand every installed Android client, and retiring it - is a later decision made when nothing polls it — not a tidy-up done in the - change that introduces its replacement. - - Both are ABSENT rather than null when empty, so a client testing for a key gets - one unambiguous answer instead of having to distinguish "no client" from "an - old server that never had this field". + ABSENT rather than empty when the server holds none, so a client testing for the + key gets one unambiguous answer instead of having to distinguish "no client" from + "an old server that never had this field". """ - data: dict = {} found = releases() - if found: - data["clients"] = found - if "android" in found: - data["android_client"] = found["android"] - return data + return {"clients": found} if found else {} @bp.get("/api/client") diff --git a/src/inkwell/models/user.py b/src/inkwell/models/user.py index ea3427e..2c27476 100644 --- a/src/inkwell/models/user.py +++ b/src/inkwell/models/user.py @@ -16,7 +16,6 @@ class User(Base): id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) # CITEXT so lookups/uniqueness are case-insensitive without lower() everywhere. email: Mapped[str] = mapped_column(CITEXT(), nullable=False, unique=True) - email_verified: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false()) # First registered user becomes admin (see auth.register); admin gates Settings. is_admin: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false()) # Nullable: leaves room for external-identity-only accounts later (rule 26). @@ -25,6 +24,5 @@ class User(Base): # Moved on by a password reset to sign the account out everywhere: every session # carries the epoch it signed in under, and one from an older epoch is refused. session_epoch: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0") - avatar_path: Mapped[str | None] = mapped_column(Text(), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/src/inkwell/notes/__init__.py b/src/inkwell/notes/__init__.py index 807e269..8484a94 100644 --- a/src/inkwell/notes/__init__.py +++ b/src/inkwell/notes/__init__.py @@ -32,18 +32,11 @@ from ..models.note_attachment import NoteAttachment from ..models.note_link_preview import NoteLinkPreview from ..models.note_revision import NoteRevision from ..note_state import OWN_FIELDS, archived_for, join_state, pinned_for, position_for, set_own_state -from .checklist import ( - append_item, - parse_items, - remove_item, - set_item_checked, - set_item_text, -) +from .checklist import append_item, parse_items, set_item_checked from ..responses import json_error, not_found, parse_uuid from ..retention import purge_note from ..settings import get_setting from ..unfurl_queue import schedule as schedule_unfurls -from ..unfurl import UnfurlError, unfurl from ._bp import bp from .body import write_body from .helpers import ( @@ -604,22 +597,11 @@ async def _rewrite_body(db, note: Note, body: str): return await _commit_note(db, note, await write_body(db, note, body)) -@bp.post("//items") -@login_required -async def add_item(note_id: str): - data = await request.get_json(silent=True) or {} - text = data["text"].strip() if isinstance(data.get("text"), str) else "" - async with session_scope() as db: - note = await _get_editable(db, note_id) - if note is None: - return not_found() - response = await _rewrite_body(db, note, append_item(note.body, text)) - return response, 201 - - @bp.patch("//items/") @login_required async def update_item(note_id: str, item_id: str): + """Tick or untick one item: the only item change that isn't an edit to the body's + text. Adding, rewording and removing items happen in the editor, as body edits.""" data = await request.get_json(silent=True) or {} index = _item_index(item_id) if index is None: @@ -631,28 +613,11 @@ async def update_item(note_id: str, item_id: str): if index >= len(parse_items(note.body)): return not_found() body = note.body - if "text" in data and isinstance(data["text"], str): - body = set_item_text(body, index, data["text"]) if "checked" in data: body = set_item_checked(body, index, bool(data["checked"])) return await _rewrite_body(db, note, body) -@bp.delete("//items/") -@login_required -async def delete_item(note_id: str, item_id: str): - index = _item_index(item_id) - if index is None: - return not_found() - async with session_scope() as db: - note = await _get_editable(db, note_id) - if note is None: - return not_found() - if index >= len(parse_items(note.body)): - return not_found() - return await _rewrite_body(db, note, remove_item(note.body, index)) - - # The reorder route is gone with M304. Reordering a checklist is moving a line, which # is something a text editor already does and no client ever called this for — the # only reference to it in the tree was a test asserting the route existed. @@ -746,46 +711,6 @@ async def delete_attachment(note_id: str, att_id: str): return jsonify(result) -@bp.post("//unfurl") -@login_required -async def unfurl_link(note_id: str): - """Fetch a link preview for a URL in this note and store it. Opt-in via the - enable_url_unfurl setting; SSRF-guarded server-side fetch (see unfurl.py).""" - data = await request.get_json(silent=True) or {} - url = (data.get("url") or "").strip() - if not url: - return json_error("url is required", 400) - async with session_scope() as db: - note = await _get_owned(db, note_id) - if note is None: - return not_found() - if not await get_setting(db, "enable_url_unfurl"): - return json_error("link previews are disabled", 403) - # Fetch OUTSIDE the DB session — network IO shouldn't hold a connection. - try: - preview = await unfurl(url) - except UnfurlError as e: - return json_error(str(e), 502) - async with session_scope() as db: - note = await _get_owned(db, note_id) - if note is None: - return not_found() - # Keyed by the ORIGINAL pasted url (what the note body contains, so the client - # matches it) — re-unfurling the same link updates the cached preview in place. - row = await db.scalar( - select(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id, NoteLinkPreview.url == url) - ) - if row is None: - row = NoteLinkPreview(note_id=note.id, url=url) - db.add(row) - row.title = preview["title"] - row.description = preview["description"] - row.image_url = preview["image_url"] - row.site_name = preview["site_name"] - await db.commit() - return jsonify(await _serialize_note(db, note, g.user_id)), 201 - - @bp.delete("//previews/") @login_required async def delete_preview(note_id: str, preview_id: str): diff --git a/src/inkwell/notes/checklist.py b/src/inkwell/notes/checklist.py index 04a29df..a5fed49 100644 --- a/src/inkwell/notes/checklist.py +++ b/src/inkwell/notes/checklist.py @@ -80,58 +80,24 @@ def strip_marker(line: str) -> str: return (match.group("text") or "") if match else line -def _rewrite(body: str, index: int, replace) -> str: - """Rewrite the `index`-th task line with `replace`, or drop it when `replace` - returns None. +def set_item_checked(body: str, index: int, checked: bool) -> str: + """Tick or untick the `index`-th item, keeping its text, indent and bullet. The + only edit to an item that isn't typing in the body. A body with fewer task lines than that is returned UNCHANGED rather than raising: the index comes from a client that may be a moment behind the server, and a stale request should do nothing rather than 500. """ lines = body.split("\n") - target = None - seen = 0 - for n, line in enumerate(lines): - if _TASK_RE.match(line): - if seen == index: - target = n - break - seen += 1 - if target is None: + tasks = [n for n, line in enumerate(lines) if _TASK_RE.match(line)] + if index >= len(tasks): return body - - match = _TASK_RE.match(lines[target]) - replacement = replace(match) - if replacement is None: - del lines[target] - else: - lines[target] = replacement + target = tasks[index] + m = _TASK_RE.match(lines[target]) + lines[target] = render_item(m.group("text") or "", checked, m.group("indent"), m.group("bullet")) return "\n".join(lines) -def set_item_checked(body: str, index: int, checked: bool) -> str: - """Tick or untick the `index`-th item, keeping its text, indent and bullet.""" - return _rewrite( - body, - index, - lambda m: render_item(m.group("text") or "", checked, m.group("indent"), m.group("bullet")), - ) - - -def set_item_text(body: str, index: int, text: str) -> str: - """Replace the text of the `index`-th item, keeping its state and its bullet.""" - return _rewrite( - body, - index, - lambda m: render_item(text.strip(), m.group("mark") in "xX", m.group("indent"), m.group("bullet")), - ) - - -def remove_item(body: str, index: int) -> str: - """Delete the `index`-th item, line and all.""" - return _rewrite(body, index, lambda _m: None) - - def append_item(body: str, text: str, checked: bool = False) -> str: """Add an item at the end of the body. diff --git a/tests/test_client_dist.py b/tests/test_client_dist.py index 00a0503..4b0957c 100644 --- a/tests/test_client_dist.py +++ b/tests/test_client_dist.py @@ -6,12 +6,9 @@ import pytest from inkwell import client_dist from inkwell.app import create_app from inkwell.client_dist import ( - APK_NAME, BY_ID, - MANIFEST_NAME, PLATFORMS, advertisement, - android_release, release, releases, ) @@ -127,8 +124,8 @@ def test_the_android_names_are_the_ones_the_image_build_writes(): Phones never ask for these names; they poll `/api/client/android`. The names changed once, with the rename to Inkwell (Scribe note 5071). """ - assert APK_NAME == "inkwell.apk" - assert MANIFEST_NAME == "inkwell-android.json" + assert BY_ID["android"].artifact == "inkwell.apk" + assert BY_ID["android"].sidecar == "inkwell-android.json" # --- absence is an ordinary answer ------------------------------------------- @@ -242,7 +239,7 @@ def test_the_android_payload_still_carries_every_field_it_used_to(): core/src/sync/client.rs is a plain serde struct and ignores what it does not know — but none of these may move or change meaning.""" place("android") - found = android_release() + found = release("android") for key in ("version", "version_code", "size", "sha256", "url"): assert key in found, key assert found["url"] == "/api/client/android/download" @@ -299,7 +296,7 @@ def test_a_platform_the_server_lacks_is_simply_not_in_the_set(): def test_the_baked_in_copy_is_used_when_nothing_was_dropped_in(): """The ordinary case for a self-hoster who just pulled the image.""" place("android", root=client_dist.BAKED_ROOT, version_code=300) - assert android_release()["version_code"] == 300 + assert release("android")["version_code"] == 300 def test_a_dropped_in_build_beats_the_one_the_image_shipped(): @@ -308,7 +305,7 @@ def test_a_dropped_in_build_beats_the_one_the_image_shipped(): place("android", version_code=99) # Lower version and all — precedence is about intent, not about newness. An # operator pinning an older client is doing it on purpose. - assert android_release()["version_code"] == 99 + assert release("android")["version_code"] == 99 def test_precedence_is_decided_per_platform_not_for_the_whole_set(): @@ -334,7 +331,7 @@ def test_a_broken_drop_in_does_not_shadow_the_baked_copy(): """ place("android", root=client_dist.BAKED_ROOT, version_code=300) place("android", size=999_999) # sidecar describing a different build - assert android_release()["version_code"] == 300 + assert release("android")["version_code"] == 300 # --- the advertisement ------------------------------------------------------- @@ -345,20 +342,8 @@ def test_the_advertisement_carries_the_whole_table(): assert set(advertisement()["clients"]) == {"linux-deb"} -def test_the_advertisement_still_carries_the_key_phones_already_read(): - """Not deprecated in the change that introduces its replacement. An installed - Android client reads `android_client`, and one duplicated dict is what it costs - to not strand it.""" - place("android") - data = advertisement() - assert data["android_client"] == data["clients"]["android"] - - -def test_no_android_client_means_no_android_key_even_when_others_are_present(): - place("windows") - data = advertisement() - assert "android_client" not in data - assert set(data["clients"]) == {"windows"} +def test_a_server_with_no_clients_advertises_nothing(): + assert advertisement() == {} # --- routes ------------------------------------------------------------------ diff --git a/tests/test_notes.py b/tests/test_notes.py index 75addcb..6971af9 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -9,14 +9,7 @@ from inkwell.app import create_app from inkwell.common import coerce_bool, parse_dt from inkwell.colors import NOTE_COLORS from inkwell.models.note import Note -from inkwell.notes.checklist import ( - append_item, - parse_items, - remove_item, - set_item_checked, - set_item_text, - strip_marker, -) +from inkwell.notes.checklist import append_item, parse_items, set_item_checked, strip_marker from inkwell.unfurl_queue import detect_urls from inkwell.notes import ( _ImportBudget, @@ -58,13 +51,17 @@ def test_all_note_routes_registered(app): "snooze_reminder", "export_notes", "import_notes", "list_titles", "reorder_notes", "create_note", "get_note", "update_note", "list_revisions", "restore_revision", - "set_note_labels", "add_item", "update_item", "delete_item", + "set_note_labels", "update_item", "upload_attachment", "get_attachment", - "delete_attachment", "unfurl_link", "delete_preview", "trash_note", + "delete_attachment", "delete_preview", "trash_note", "restore_note", "delete_note", ) } assert expected <= registered, f"unregistered note routes: {expected - registered}" + # Removed in #5178: an item is added, reworded and removed by editing the body, + # and previews arrive in the background after a save. + gone = {"notes.add_item", "notes.delete_item", "notes.unfurl_link"} + assert not gone & registered, f"removed note routes are back: {gone & registered}" def test_is_empty_note(): @@ -116,9 +113,9 @@ async def test_search_requires_auth(app): assert resp.status_code == 401 -async def test_add_item_requires_auth(app): +async def test_update_item_requires_auth(app): client = app.test_client() - resp = await client.post("/api/notes/00000000-0000-0000-0000-000000000000/items", json={"text": "x"}) + resp = await client.patch("/api/notes/00000000-0000-0000-0000-000000000000/items/0", json={"checked": True}) assert resp.status_code == 401 @@ -583,8 +580,6 @@ def test_uppercase_x_parses_and_normalises_on_rewrite(): def test_rewriters_preserve_indent_bullet_and_neighbours(): assert set_item_checked(" * [ ] milk", 0, True) == " * [x] milk" - assert set_item_text("- [x] old", 0, "new") == "- [x] new" - assert remove_item("keep\n- [ ] drop\n- [ ] stay", 0) == "keep\n- [ ] stay" # Addressed by ITEM, not by line. assert set_item_checked("note\n- [ ] a\nprose\n- [ ] b", 1, True) == "note\n- [ ] a\nprose\n- [x] b" @@ -594,14 +589,11 @@ def test_a_stale_index_does_nothing(): # should be inert, not a 500. body = "- [ ] only" assert set_item_checked(body, 7, True) == body - assert remove_item(body, 7) == body - assert set_item_text(body, 7, "x") == body def test_a_plain_body_is_returned_unchanged(): body = "just prose\nwith two lines" assert set_item_checked(body, 0, True) == body - assert remove_item(body, 0) == body def test_append_item_spacing(): diff --git a/tests/test_unfurl.py b/tests/test_unfurl.py index 333e081..0b20be2 100644 --- a/tests/test_unfurl.py +++ b/tests/test_unfurl.py @@ -58,14 +58,6 @@ def test_extract_preview_title_fallback_and_host_defaults(): assert p["site_name"] == "example.com" # falls back to host -async def test_unfurl_requires_auth(app): - client = app.test_client() - resp = await client.post( - "/api/notes/00000000-0000-0000-0000-000000000000/unfurl", json={"url": "https://x.com"} - ) - assert resp.status_code == 401 - - async def test_delete_preview_requires_auth(app): client = app.test_client() resp = await client.delete(