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(