A note's name is one rule, pinned by the shared fixture

The server and the core each derived display_title and disagreed twice: the
server cut it at 200 characters and the core didn't, and the server split lines
with splitlines(), which also breaks on a lone \r or a U+2028, where the core and
every other reading of the grammar split on \n alone.

- grammar.json gains a display_titles section: blank lines, markers, an empty
  item, \r\n, a lone \r, U+2028, and a 201-character line of 'é' (the cut is
  characters, not bytes).
- derive::display_title and DISPLAY_TITLE_CAP are the core's half, moved next to
  strip_marker. The server splits on "\n". Both suites run the cases.

Behaviour: a device now names a note with a first line over 200 characters the
way the web always has, and the server names a note containing a lone \r or a
U+2028 the way devices always have.

Fixes #5398. DRY pass #2, batch 3 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:27:32 -04:00
co-authored by Claude Opus 5.5
parent 2809c26214
commit db9e9a2dfb
5 changed files with 63 additions and 27 deletions
+35
View File
@@ -341,6 +341,32 @@ fn render_task_line(indent: &str, bullet: char, checked: bool, text: &str) -> St
}
}
/// The longest a note's name runs, in characters (not bytes): the server's
/// `DISPLAY_TITLE_CAP`.
pub const DISPLAY_TITLE_CAP: usize = 200;
/// The note's NAME: the first line of its body that says anything, cut to
/// [`DISPLAY_TITLE_CAP`] characters.
///
/// Mirrors `derive_display_title` in the server's notes/helpers.py — one rule written
/// twice, and they have to agree or a synced note is called different things on either
/// side of the wire. Both run the `display_titles` cases in testdata/grammar.json.
///
/// It no longer needs the items, because the items ARE lines of the body now (M304).
/// What it needs instead is to strip the task marker off: a list-only note is still
/// named by its first item, and calling that note "- [ ] milk" would be showing
/// someone the storage rather than the note. An empty item is skipped rather than
/// naming the note "", which is what a half-typed list would otherwise do.
pub fn display_title(body: &str) -> String {
for line in body.split('\n') {
let text = strip_marker(line.trim()).trim();
if !text.is_empty() {
return text.chars().take(DISPLAY_TITLE_CAP).collect();
}
}
String::new()
}
/// The text of a line with its task marker removed, or the line as it was.
///
/// For naming a note: a list-only note is named by its first item, and calling one
@@ -759,6 +785,15 @@ mod tests {
}
}
#[test]
fn fixture_display_titles() {
for case in fixture()["display_titles"].as_array().unwrap() {
let body = case["body"].as_str().unwrap();
let want = case["title"].as_str().unwrap();
assert_eq!(display_title(body), want, "body {body:?}");
}
}
#[test]
fn fixture_lifts() {
for case in fixture()["lifts"].as_array().unwrap() {
+2 -23
View File
@@ -21,27 +21,6 @@ fn new_id() -> String {
Uuid::new_v4().to_string()
}
/// The note's NAME: the first line of its body that says anything.
///
/// Mirrors `derive_display_title` in the server's notes/helpers.py — one rule written
/// twice, and they have to agree or a synced note is called different things on either
/// side of the wire.
///
/// It no longer needs the items, because the items ARE lines of the body now (M304).
/// What it needs instead is to strip the task marker off: a list-only note is still
/// named by its first item, and calling that note "- [ ] milk" would be showing
/// someone the storage rather than the note. An empty item is skipped rather than
/// naming the note "", which is what a half-typed list would otherwise do.
fn display_title(body: &str) -> String {
for line in body.lines() {
let text = derive::strip_marker(line.trim()).trim();
if !text.is_empty() {
return text.to_string();
}
}
String::new()
}
fn escape_like(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('%', "\\%")
@@ -183,7 +162,7 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
note.items = items_of(&note.body);
note.attachments = load_attachments(conn, id)?;
note.previews = load_previews(conn, id)?;
note.display_title = display_title(&note.body);
note.display_title = derive::display_title(&note.body);
Ok(note)
}
@@ -294,7 +273,7 @@ fn find_or_create_label(conn: &Connection, name: &str) -> rusqlite::Result<Strin
/// so this overwrites what they wrote, on purpose.
///
/// `display_title` needs no attention here, unlike on the server: the core derives it
/// on READ (see `display_title` above, called from `load_note`) rather than storing
/// on READ (`derive::display_title`, called from `load_note`) rather than storing
/// it, so there is no persisted copy to go stale.
///
/// The two kinds of tag are handled differently, and that difference IS what `via_tag`
+14 -1
View File
@@ -1,9 +1,10 @@
{
"_about": [
"The cases every implementation of the note grammar is tested against.",
"One file, read by four test suites: core (derive.rs), the server (checklist.py, tags.py), the web (markdown.ts, colors.ts) and, for the tint, Android (DerivedTint.kt, which pins the same values by hand).",
"One file, read by four test suites: core (derive.rs), the server (checklist.py, tags.py, helpers.py), the web (markdown.ts, colors.ts) and, for the tint, Android (DerivedTint.kt, which pins the same values by hand).",
"A difference between any two implementations is a note that changes shape when it syncs. Add a case here, not to one suite.",
"Lives under core/ because core is the definition, and because core/ is in the desktop, Android and server file sets, so changing a case reruns every suite that reads it.",
"display_titles: a note's name is its first line that says anything once a task marker is stripped, cut to 200 characters (not bytes). Lines split on \\n alone, as the grammar does everywhere.",
"Tag cases are plain text on purpose. The web's renderer lets `code` and **bold** claim a `#` first, which the derivers do not; that is a rendering choice, not grammar, and is tested where it lives."
],
@@ -68,6 +69,18 @@
{ "body": "(#todo)\nnotes", "standalone": [], "inline": [], "lifted": "(#todo)\nnotes" }
],
"display_titles": [
{ "body": "", "title": "" },
{ "body": "\n\n Shopping \nmilk", "title": "Shopping" },
{ "body": "- [ ] milk\n- [x] eggs", "title": "milk" },
{ "body": "- [ ]\n- [ ] bread", "title": "bread" },
{ "body": " \n\t\nlate start", "title": "late start" },
{ "body": "one\r\ntwo", "title": "one" },
{ "body": "a\rb\nc", "title": "a\rb" },
{ "body": "a\u2028b\nc", "title": "a\u2028b" },
{ "body": "ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", "title": "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé" }
],
"tint": {
"keys": ["red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray"],
"hashes": [
+6 -3
View File
@@ -43,10 +43,13 @@ def derive_display_title(body: str | None) -> str:
would show someone the storage instead of the note — and skipping an EMPTY item, so
a half-typed list does not leave a note with no name.
Mirrors `display_title` in core/src/local/store.rs. Deterministic — a literal first
line, never generated.
Lines split on "\n" alone, as every other reading of the grammar does;
`splitlines` also broke on a lone "\r" or a U+2028, so a phone and the server named
the same note differently. Mirrors `derive::display_title` in the core, and both
run the `display_titles` cases in core/testdata/grammar.json. Deterministic — a
literal first line, never generated.
"""
for line in (body or "").splitlines():
for line in (body or "").split("\n"):
stripped = strip_marker(line.strip()).strip()
if stripped:
return stripped[:DISPLAY_TITLE_CAP]
+6
View File
@@ -13,6 +13,7 @@ from pathlib import Path
import pytest
from inkwell.notes.checklist import parse_items, render_item
from inkwell.notes.helpers import derive_display_title
from inkwell.notes.tags import parse_tags, split_body_tags
FIXTURE = json.loads(
@@ -41,3 +42,8 @@ def test_tags(case):
def test_lifts(case):
standalone, inline, lifted = split_body_tags(case["body"])
assert (standalone, inline, lifted) == (case["standalone"], case["inline"], case["lifted"])
@pytest.mark.parametrize("case", FIXTURE["display_titles"], ids=lambda c: repr(c["body"][:40]))
def test_display_titles(case):
assert derive_display_title(case["body"]) == case["title"]