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`