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
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:
+36
-109
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user