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
+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}");
}
}