all: delete the code nothing calls
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Failing after 1m21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m47s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m48s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m39s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 8m37s

From the audit (#5178). Each was unreachable from every client:

- Checklist add-item and delete-item: REST POST /items and DELETE /items/<id>,
  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 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 19:00:44 -04:00
co-authored by Claude Opus 5.5
parent fd1d50662f
commit 7eacd0569c
26 changed files with 159 additions and 491 deletions
@@ -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()),
)
@@ -40,7 +40,6 @@
<string name="editor_back">Back to notes</string>
<string name="editor_add_checklist">Add a checklist</string>
<string name="editor_body_hint">Take a note…</string>
<string name="editor_add_item">Add item</string>
<string name="editor_remove_item">Remove item</string>
<string name="editor_remove_label">Remove tag</string>
<string name="editor_reminder">Set a reminder</string>
+24 -87
View File
@@ -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<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::add_item(&conn, &note_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<Note, CoreError> {
self.patch_item(&note_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<Note, CoreError> {
self.patch_item(
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::update_item(
&conn,
&note_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<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::delete_item(&conn, &note_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<BodyTag> {
/// 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<Note, CoreError> {
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();
}
+36 -109
View File
@@ -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<String> {
let mut out: Vec<String> = Vec::new();
for line in body.split('\n') {
let chars: Vec<char> = 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<DerivedTag> {
let mut out = Vec::new();
for (n, line) in body.split('\n').enumerate() {
@@ -383,65 +367,29 @@ pub fn extract_items(body: &str) -> Vec<DerivedItem> {
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<F>(body: &str, index: usize, f: F) -> String
where
F: FnOnce(&TaskLine<'_>) -> Option<String>,
{
let lines: Vec<&str> = body.split('\n').collect();
let mut target: Option<usize> = 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<String> = 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<String> = 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<String> {
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<String> = 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:?}");
}
}
-1
View File
@@ -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,
}
+10 -26
View File
@@ -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<String> {
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<Note>
update_note(conn, id, &json!({ "body": body }))
}
pub fn add_item(conn: &Connection, id: &str, text: &str) -> rusqlite::Result<Note> {
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<Note> {
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,
+5 -4
View File
@@ -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}");
}
}
-13
View File
@@ -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<Note, String> {
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<Note, String> {
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
-2
View File
@@ -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,
+4 -2
View File
@@ -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.
+1 -4
View File
@@ -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<Note>("notes_complete_reminder", { id }),
snoozeReminder: (id, minutes) => invoke<Note>("notes_snooze_reminder", { id, minutes }),
setLabels: (id, labelIds) => invoke<Note>("notes_set_labels", { id, labelIds }),
addItem: (id, text) => invoke<Note>("notes_add_item", { id, text }),
updateItem: (id, itemId, changes) => invoke<Note>("notes_update_item", { id, itemId, changes }),
deleteItem: (id, itemId) => invoke<Note>("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<Note>("notes_delete_attachment", { id, attId }),
unfurl: () => Promise.reject<Note>(new Error(NEEDS_SERVER)),
deletePreview: (id, previewId) => invoke<Note>("notes_delete_preview", { id, previewId }),
// The archive crosses as raw bytes, as an attachment does.
import: async (file) => invoke<ImportResult>("notes_import", new Uint8Array(await file.arrayBuffer())),
+2 -5
View File
@@ -40,9 +40,9 @@ export type NoteChanges = Partial<
Pick<Note, "body" | "pinned" | "archived" | "remind_at" | "recurrence">
>;
/** 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<Note>;
snoozeReminder(id: string, minutes: number): Promise<Note>;
setLabels(id: string, labelIds: string[]): Promise<Note>;
addItem(id: string, text: string): Promise<Note>;
updateItem(id: string, itemId: string, changes: ChecklistItemChanges): Promise<Note>;
deleteItem(id: string, itemId: string): Promise<Note>;
uploadAttachment(id: string, file: File): Promise<Note>;
deleteAttachment(id: string, attId: string): Promise<Note>;
unfurl(id: string, url: string): Promise<Note>;
deletePreview(id: string, previewId: string): Promise<Note>;
import(file: File): Promise<ImportResult>;
/** Save every note as a zip. Resolves to the path it was saved at, or null when
-3
View File
@@ -84,13 +84,10 @@ export const rest: Repo = {
completeReminder: (id) => api.post<Note>(`/api/notes/${id}/reminder/complete`),
snoozeReminder: (id, minutes) => api.post<Note>(`/api/notes/${id}/reminder/snooze`, { minutes }),
setLabels: (id, labelIds) => api.put<Note>(`/api/notes/${id}/labels`, { label_ids: labelIds }),
addItem: (id, text) => api.post<Note>(`/api/notes/${id}/items`, { text }),
updateItem: (id, itemId, changes: ChecklistItemChanges) =>
api.patch<Note>(`/api/notes/${id}/items/${itemId}`, changes),
deleteItem: (id, itemId) => api.del<Note>(`/api/notes/${id}/items/${itemId}`),
uploadAttachment: (id, file) => api.postForm<Note>(`/api/notes/${id}/attachments`, fileForm(file)),
deleteAttachment: (id, attId) => api.del<Note>(`/api/notes/${id}/attachments/${attId}`),
unfurl: (id, url) => api.post<Note>(`/api/notes/${id}/unfurl`, { url }),
deletePreview: (id, previewId) => api.del<Note>(`/api/notes/${id}/previews/${previewId}`),
import: (file) => api.postForm<ImportResult>("/api/notes/import", fileForm(file)),
// A same-origin GET with the session cookie; the browser saves the attachment
-2
View File
@@ -13,7 +13,6 @@ const paths: Record<string, string> = {
tag: '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/>',
pencil: '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
checkbox: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="m9 12 2 2 4-4"/>',
image: '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
bell: '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
@@ -27,7 +26,6 @@ const paths: Record<string, string> = {
device: '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
sync: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/>',
paperclip: '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
filter: '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>',
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
-4
View File
@@ -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<string, ClientRelease>;
}
+1 -16
View File
@@ -184,18 +184,10 @@ export const useNotesStore = defineStore("notes", () => {
reconcile(await repo.notes.setLabels(id, labelIds));
}
async function addItem(id: string, text: string): Promise<void> {
reconcile(await repo.notes.addItem(id, text));
}
async function updateItem(id: string, itemId: string, changes: { text?: string; checked?: boolean }): Promise<void> {
async function updateItem(id: string, itemId: string, changes: { checked: boolean }): Promise<void> {
reconcile(await repo.notes.updateItem(id, itemId, changes));
}
async function deleteItem(id: string, itemId: string): Promise<void> {
reconcile(await repo.notes.deleteItem(id, itemId));
}
async function uploadAttachment(id: string, file: File): Promise<void> {
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<void> {
reconcile(await repo.notes.unfurl(id, url));
}
async function deletePreview(id: string, previewId: string): Promise<void> {
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,
-1
View File
@@ -6,7 +6,6 @@ export interface User {
id: string;
email: string;
display_name: string;
email_verified: boolean;
is_admin: boolean;
}
+3 -4
View File
@@ -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)
-1
View File
@@ -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,
}
+7 -36
View File
@@ -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")
-2
View File
@@ -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())
+3 -78
View File
@@ -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("/<note_id>/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("/<note_id>/items/<item_id>")
@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("/<note_id>/items/<item_id>")
@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("/<note_id>/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("/<note_id>/previews/<preview_id>")
@login_required
async def delete_preview(note_id: str, preview_id: str):
+8 -42
View File
@@ -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.
+8 -23
View File
@@ -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 ------------------------------------------------------------------
+9 -17
View File
@@ -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():
-8
View File
@@ -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(