Core: tag names fold every letter, as the server does (#5385)
SQLite's lower() folds ASCII only, so a device could hold "Café" and "CAFÉ" as two tags while the server held one. derive::fold (to_lowercase) is now the one comparison. It is registered as the deterministic SQL function fold() on every connection (schema::migrate), and find_or_create, the rename clash, the pull clash and the unique index all use it. derive's push_unique used eq_ignore_ascii_case and now folds the same way. v14 merges pairs a device already holds before rebuilding the index. The older row survives, as in rename_label. Memberships move with via_tag kept, affected notes go dirty, and the merged-away row is a pending delete. It is written out rather than calling store::merge_labels so the migration doesn't depend on store code that later versions may change. rusqlite gains its "functions" feature (operator-approved, 2026-10-08). grammar.json gains "#café and #CAFÉ" -> ["café"], which all three implementations run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -11,7 +11,9 @@ serde_json = { workspace = true }
|
|||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
# Local-first store (M10.4): bundled = compile SQLite in, so there's no system
|
# Local-first store (M10.4): bundled = compile SQLite in, so there's no system
|
||||||
# libsqlite dependency to vary across the AppImage / native / Windows / Android builds.
|
# libsqlite dependency to vary across the AppImage / native / Windows / Android builds.
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
# functions = the `fold` SQL function tag names are compared and indexed by (#5385):
|
||||||
|
# SQLite's own lower() folds ASCII only, and the server folds every letter.
|
||||||
|
rusqlite = { version = "0.32", features = ["bundled", "functions"] }
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
# RFC3339 timestamps for created_at/updated_at/remind_at (Date.parse-able on the JS side).
|
# RFC3339 timestamps for created_at/updated_at/remind_at (Date.parse-able on the JS side).
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
|
|||||||
@@ -205,10 +205,10 @@ pub fn lift_standalone_tags(body: &str) -> (Vec<String>, Vec<String>, String) {
|
|||||||
|
|
||||||
// A tag that ALSO appears in prose stays derived: the prose copy still backs it,
|
// A tag that ALSO appears in prose stays derived: the prose copy still backs it,
|
||||||
// so deleting that copy should still detach the label.
|
// so deleting that copy should still detach the label.
|
||||||
let inline_lower: Vec<String> = inline.iter().map(|n| n.to_lowercase()).collect();
|
let inline_folded: Vec<String> = inline.iter().map(|n| fold(n)).collect();
|
||||||
let standalone = standalone
|
let standalone = standalone
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|n| !inline_lower.contains(&n.to_lowercase()))
|
.filter(|n| !inline_folded.contains(&fold(n)))
|
||||||
.collect();
|
.collect();
|
||||||
(standalone, inline, lifted)
|
(standalone, inline, lifted)
|
||||||
}
|
}
|
||||||
@@ -217,8 +217,17 @@ fn is_tag_char(c: char) -> bool {
|
|||||||
c.is_alphanumeric() || c == '_' || c == '-'
|
c.is_alphanumeric() || c == '_' || c == '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How two tag names compare: equal once every letter is lowercased, accented ones
|
||||||
|
/// included, so "Café" and "CAFÉ" are one tag. The server's `labeling.named` folds
|
||||||
|
/// the same way (Postgres `lower`, Python `str.lower`), and the store's `fold` SQL
|
||||||
|
/// function is this (#5385).
|
||||||
|
pub(crate) fn fold(name: &str) -> String {
|
||||||
|
name.to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
fn push_unique(out: &mut Vec<String>, candidate: &str) {
|
fn push_unique(out: &mut Vec<String>, candidate: &str) {
|
||||||
if !out.iter().any(|x| x.eq_ignore_ascii_case(candidate)) {
|
let folded = fold(candidate);
|
||||||
|
if !out.iter().any(|x| fold(x) == folded) {
|
||||||
out.push(candidate.to_string());
|
out.push(candidate.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+177
-1
@@ -7,6 +7,7 @@
|
|||||||
//! Migrations are gated on `PRAGMA user_version`; a change is a new entry at the end of
|
//! Migrations are gated on `PRAGMA user_version`; a change is a new entry at the end of
|
||||||
//! [`STEPS`].
|
//! [`STEPS`].
|
||||||
|
|
||||||
|
use rusqlite::functions::FunctionFlags;
|
||||||
use rusqlite::{params, Connection, OptionalExtension};
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
|
|
||||||
use crate::local::derive;
|
use crate::local::derive;
|
||||||
@@ -331,6 +332,62 @@ const SCHEMA_V13: &str = r#"
|
|||||||
DROP TABLE saved_filters;
|
DROP TABLE saved_filters;
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
// v14 (#5385): tag names compare by `fold`, which lowercases every letter. Until now
|
||||||
|
// the index and the queries used SQLite's lower(), which folds ASCII only, so "Café"
|
||||||
|
// and "CAFÉ" could be two tags on a device while the server held one.
|
||||||
|
//
|
||||||
|
// Pairs that already exist are merged before the index can be rebuilt. THE OLDER ROW
|
||||||
|
// SURVIVES, as in `store::rename_label`, so every device holding the same pair picks
|
||||||
|
// the same one. The merge is written out here rather than calling
|
||||||
|
// `store::merge_labels`: a migration replays on an old database before every later
|
||||||
|
// step, and store code is free to start reading a column a later step adds. What it
|
||||||
|
// does is that merge's: memberships move to the survivor (keeping `via_tag`, since
|
||||||
|
// the body's `#CAFÉ` now derives the survivor), the affected notes are marked dirty so
|
||||||
|
// their label sets reach the server, and the merged-away row is a pending delete.
|
||||||
|
fn migrate_v14(conn: &Connection) -> rusqlite::Result<()> {
|
||||||
|
let mut groups: Vec<(String, Vec<String>)> = Vec::new();
|
||||||
|
{
|
||||||
|
let mut stmt =
|
||||||
|
conn.prepare("SELECT id, name FROM labels ORDER BY created_at ASC, id ASC")?;
|
||||||
|
let mut rows = stmt.query([])?;
|
||||||
|
while let Some(row) = rows.next()? {
|
||||||
|
let id: String = row.get(0)?;
|
||||||
|
let key = derive::fold(&row.get::<_, String>(1)?);
|
||||||
|
match groups.iter_mut().find(|(k, _)| *k == key) {
|
||||||
|
Some((_, ids)) => ids.push(id),
|
||||||
|
None => groups.push((key, vec![id])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (_, ids) in groups.into_iter().filter(|(_, ids)| ids.len() > 1) {
|
||||||
|
let survivor = &ids[0];
|
||||||
|
for doomed in &ids[1..] {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag)
|
||||||
|
SELECT note_id, ?2, via_tag FROM note_labels WHERE label_id = ?1",
|
||||||
|
params![doomed, survivor],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE notes SET dirty = 1
|
||||||
|
WHERE id IN (SELECT note_id FROM note_labels WHERE label_id = ?1)",
|
||||||
|
[doomed],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO pending_deletes (entity, id, deleted_at)
|
||||||
|
VALUES ('label', ?1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
|
||||||
|
[doomed],
|
||||||
|
)?;
|
||||||
|
conn.execute("DELETE FROM labels WHERE id = ?1", [doomed])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.execute_batch(
|
||||||
|
"DROP INDEX idx_labels_name;
|
||||||
|
CREATE UNIQUE INDEX idx_labels_name ON labels (fold(name));",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// One schema version's change: SQL, or code for a change SQL alone can't make.
|
/// One schema version's change: SQL, or code for a change SQL alone can't make.
|
||||||
enum Step {
|
enum Step {
|
||||||
Sql(&'static str),
|
Sql(&'static str),
|
||||||
@@ -353,10 +410,16 @@ const STEPS: &[Step] = &[
|
|||||||
Step::Sql(SCHEMA_V11),
|
Step::Sql(SCHEMA_V11),
|
||||||
Step::Sql(SCHEMA_V12),
|
Step::Sql(SCHEMA_V12),
|
||||||
Step::Sql(SCHEMA_V13),
|
Step::Sql(SCHEMA_V13),
|
||||||
|
Step::Code(migrate_v14),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Bring the database up to the latest schema. Idempotent.
|
/// Bring the database up to the latest schema. Idempotent.
|
||||||
|
///
|
||||||
|
/// Also gives the connection the `fold` function first. The label index is built on
|
||||||
|
/// it, so a connection without it can't write a label at all, and every connection
|
||||||
|
/// the core opens comes through here.
|
||||||
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
||||||
|
register_fold(conn)?;
|
||||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||||
let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
||||||
for (to, step) in (1..).zip(STEPS) {
|
for (to, step) in (1..).zip(STEPS) {
|
||||||
@@ -372,6 +435,19 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `fold(name)`: [`derive::fold`] in SQL. Deterministic, so an index may use it, and
|
||||||
|
/// innocuous (no side effects), so the schema may name it.
|
||||||
|
fn register_fold(conn: &Connection) -> rusqlite::Result<()> {
|
||||||
|
conn.create_scalar_function(
|
||||||
|
"fold",
|
||||||
|
1,
|
||||||
|
FunctionFlags::SQLITE_UTF8
|
||||||
|
| FunctionFlags::SQLITE_DETERMINISTIC
|
||||||
|
| FunctionFlags::SQLITE_INNOCUOUS,
|
||||||
|
|ctx| Ok(ctx.get::<Option<String>>(0)?.map(|s| derive::fold(&s))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -483,7 +559,7 @@ mod tests {
|
|||||||
let version: i64 = conn
|
let version: i64 = conn
|
||||||
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||||||
.expect("version");
|
.expect("version");
|
||||||
assert_eq!(version, 13);
|
assert_eq!(version, 14);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every attachment that predates v10 came down the feed, so it is already on the
|
/// Every attachment that predates v10 came down the feed, so it is already on the
|
||||||
@@ -550,4 +626,104 @@ mod tests {
|
|||||||
.expect("count");
|
.expect("count");
|
||||||
assert_eq!(tables, 0);
|
assert_eq!(tables, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A database at v13: the last schema whose label index folded ASCII only.
|
||||||
|
fn v13_db() -> Connection {
|
||||||
|
let conn = Connection::open_in_memory().expect("open");
|
||||||
|
conn.execute_batch("PRAGMA foreign_keys = ON;").expect("fk");
|
||||||
|
for step in &STEPS[..13] {
|
||||||
|
match step {
|
||||||
|
Step::Sql(sql) => conn.execute_batch(sql).expect("batch"),
|
||||||
|
Step::Code(apply) => apply(&conn).expect("step"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conn.execute_batch("PRAGMA user_version = 13;")
|
||||||
|
.expect("v13");
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_label(conn: &Connection, id: &str, name: &str, created: &str) {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO labels (id, name, created_at, updated_at, dirty)
|
||||||
|
VALUES (?1, ?2, ?3, ?3, 0)",
|
||||||
|
params![id, name, created],
|
||||||
|
)
|
||||||
|
.expect("label");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tag(conn: &Connection, note: &str, label: &str, via_tag: bool) {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, ?3)",
|
||||||
|
params![note, label, via_tag],
|
||||||
|
)
|
||||||
|
.expect("membership");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One string per row, as `sql` builds it.
|
||||||
|
fn rows(conn: &Connection, sql: &str) -> Vec<String> {
|
||||||
|
conn.prepare(sql)
|
||||||
|
.expect("prepare")
|
||||||
|
.query_map([], |r| r.get(0))
|
||||||
|
.expect("query")
|
||||||
|
.collect::<rusqlite::Result<_>>()
|
||||||
|
.expect("rows")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Before v14 the index was on lower(name), which leaves É alone, so a device
|
||||||
|
/// could hold both of these. The older one survives and gains the other's notes.
|
||||||
|
#[test]
|
||||||
|
fn v14_merges_tags_that_differ_only_in_an_accented_letter() {
|
||||||
|
let conn = v13_db();
|
||||||
|
add_label(&conn, "old", "Café", "2026-01-01T00:00:00.000Z");
|
||||||
|
add_label(&conn, "new", "CAFÉ", "2026-02-01T00:00:00.000Z");
|
||||||
|
add_label(&conn, "other", "Tea", "2026-03-01T00:00:00.000Z");
|
||||||
|
add_note(&conn, "derived", "#CAFÉ");
|
||||||
|
add_note(&conn, "both", "both");
|
||||||
|
add_note(&conn, "untouched", "tea");
|
||||||
|
conn.execute("UPDATE notes SET dirty = 0", [])
|
||||||
|
.expect("clean");
|
||||||
|
tag(&conn, "derived", "new", true);
|
||||||
|
tag(&conn, "both", "old", false);
|
||||||
|
tag(&conn, "both", "new", false);
|
||||||
|
tag(&conn, "untouched", "other", false);
|
||||||
|
|
||||||
|
migrate(&conn).expect("migrate");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
rows(&conn, "SELECT id || ' ' || name FROM labels ORDER BY id"),
|
||||||
|
["old Café", "other Tea"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rows(
|
||||||
|
&conn,
|
||||||
|
"SELECT note_id || ' ' || label_id || ' ' || via_tag FROM note_labels ORDER BY note_id"
|
||||||
|
),
|
||||||
|
// `derived` stays derived: the body's #CAFÉ now names the survivor.
|
||||||
|
["both old 0", "derived old 1", "untouched other 0"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rows(&conn, "SELECT id FROM notes WHERE dirty = 1 ORDER BY id"),
|
||||||
|
["both", "derived"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rows(
|
||||||
|
&conn,
|
||||||
|
"SELECT id FROM pending_deletes WHERE entity = 'label'"
|
||||||
|
),
|
||||||
|
["new"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rebuilt index is what keeps the pair from coming back.
|
||||||
|
#[test]
|
||||||
|
fn after_v14_a_name_differing_only_in_an_accented_letter_is_refused() {
|
||||||
|
let conn = Connection::open_in_memory().expect("open");
|
||||||
|
migrate(&conn).expect("migrate");
|
||||||
|
add_label(&conn, "a", "Café", "2026-01-01T00:00:00.000Z");
|
||||||
|
let twin = conn.execute(
|
||||||
|
"INSERT INTO labels (id, name, created_at, updated_at) VALUES ('b', 'CAFÉ', 'x', 'x')",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert!(twin.is_err(), "the index let CAFÉ in beside Café");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-2
@@ -248,7 +248,7 @@ fn touch(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
|||||||
fn find_or_create_label(conn: &Connection, name: &str) -> rusqlite::Result<String> {
|
fn find_or_create_label(conn: &Connection, name: &str) -> rusqlite::Result<String> {
|
||||||
let existing: Option<String> = conn
|
let existing: Option<String> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT id FROM labels WHERE lower(name) = lower(?1)",
|
"SELECT id FROM labels WHERE fold(name) = fold(?1)",
|
||||||
[name],
|
[name],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
@@ -1039,7 +1039,7 @@ pub fn create_label(conn: &Connection, name: &str) -> rusqlite::Result<Label> {
|
|||||||
pub fn rename_label(conn: &Connection, id: &str, name: &str) -> rusqlite::Result<Label> {
|
pub fn rename_label(conn: &Connection, id: &str, name: &str) -> rusqlite::Result<Label> {
|
||||||
let clash: Option<(String, String)> = conn
|
let clash: Option<(String, String)> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT id, created_at FROM labels WHERE lower(name) = lower(?1) AND id <> ?2",
|
"SELECT id, created_at FROM labels WHERE fold(name) = fold(?1) AND id <> ?2",
|
||||||
params![name, id],
|
params![name, id],
|
||||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
)
|
)
|
||||||
@@ -1580,6 +1580,18 @@ mod tests {
|
|||||||
assert!(edited.labels.is_empty());
|
assert!(edited.labels.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every letter folds, accented ones included, as on the server (#5385). With
|
||||||
|
/// SQLite's lower() the second note minted a "CAFÉ" tag of its own.
|
||||||
|
#[test]
|
||||||
|
fn a_tag_spelled_with_different_accented_capitals_is_the_same_tag() {
|
||||||
|
let conn = db();
|
||||||
|
let first = note(&conn, "coffee at #Café");
|
||||||
|
let second = note(&conn, "coffee at #CAFÉ");
|
||||||
|
assert_eq!(second.labels.len(), 1);
|
||||||
|
assert_eq!(second.labels[0].id, first.labels[0].id);
|
||||||
|
assert_eq!(list_labels(&conn).expect("labels").len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- writes ----------------------------------------------------------------
|
// ---- writes ----------------------------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ fn is_dirty(conn: &Connection, note_id: &str) -> rusqlite::Result<bool> {
|
|||||||
|
|
||||||
fn upsert_label(conn: &Connection, label: &wire::Label) -> rusqlite::Result<()> {
|
fn upsert_label(conn: &Connection, label: &wire::Label) -> rusqlite::Result<()> {
|
||||||
// One label per name is enforced on both sides (locally a UNIQUE index on
|
// One label per name is enforced on both sides (locally a UNIQUE index on
|
||||||
// lower(name); on the server, per owner). A label created offline can therefore
|
// fold(name); on the server, per owner). A label created offline can therefore
|
||||||
// collide with one the server already had under a different id — "work" typed on
|
// collide with one the server already had under a different id — "work" typed on
|
||||||
// this machine and "work" that already existed.
|
// this machine and "work" that already existed.
|
||||||
//
|
//
|
||||||
@@ -186,7 +186,7 @@ fn upsert_label(conn: &Connection, label: &wire::Label) -> rusqlite::Result<()>
|
|||||||
// memberships onto it, then drop the husk.
|
// memberships onto it, then drop the husk.
|
||||||
let duplicates: Vec<String> = {
|
let duplicates: Vec<String> = {
|
||||||
let mut stmt =
|
let mut stmt =
|
||||||
conn.prepare("SELECT id FROM labels WHERE lower(name) = lower(?1) AND id <> ?2")?;
|
conn.prepare("SELECT id FROM labels WHERE fold(name) = fold(?1) AND id <> ?2")?;
|
||||||
let rows = stmt.query_map(params![label.name, label.id], |r| r.get::<_, String>(0))?;
|
let rows = stmt.query_map(params![label.name, label.id], |r| r.get::<_, String>(0))?;
|
||||||
rows.collect::<rusqlite::Result<Vec<String>>>()?
|
rows.collect::<rusqlite::Result<Vec<String>>>()?
|
||||||
};
|
};
|
||||||
@@ -219,7 +219,7 @@ fn upsert_label(conn: &Connection, label: &wire::Label) -> rusqlite::Result<()>
|
|||||||
|
|
||||||
for old in &duplicates {
|
for old in &duplicates {
|
||||||
// OR IGNORE guards a (note_id, label_id) collision. Today the unique index on
|
// OR IGNORE guards a (note_id, label_id) collision. Today the unique index on
|
||||||
// lower(name) makes that unreachable — two same-name labels can't coexist
|
// fold(name) makes that unreachable — two same-name labels can't coexist
|
||||||
// locally — so this is belt-and-braces against that index changing, not a
|
// locally — so this is belt-and-braces against that index changing, not a
|
||||||
// case we've seen. Anything it skips cascades away with the husk below, which
|
// case we've seen. Anything it skips cascades away with the husk below, which
|
||||||
// is correct: those are duplicates of a membership that now exists.
|
// is correct: those are duplicates of a membership that now exists.
|
||||||
|
|||||||
Vendored
+1
@@ -44,6 +44,7 @@
|
|||||||
{ "body": "#todo buy milk", "tags": ["todo"] },
|
{ "body": "#todo buy milk", "tags": ["todo"] },
|
||||||
{ "body": "buy #milk and #eggs", "tags": ["milk", "eggs"] },
|
{ "body": "buy #milk and #eggs", "tags": ["milk", "eggs"] },
|
||||||
{ "body": "#Todo and #todo", "tags": ["Todo"] },
|
{ "body": "#Todo and #todo", "tags": ["Todo"] },
|
||||||
|
{ "body": "#café and #CAFÉ", "tags": ["café"] },
|
||||||
{ "body": "line one\n#two", "tags": ["two"] },
|
{ "body": "line one\n#two", "tags": ["two"] },
|
||||||
{ "body": "#tag, and #more.", "tags": ["tag", "more"] },
|
{ "body": "#tag, and #more.", "tags": ["tag", "more"] },
|
||||||
{ "body": "#x-y_z9", "tags": ["x-y_z9"] },
|
{ "body": "#x-y_z9", "tags": ["x-y_z9"] },
|
||||||
|
|||||||
Reference in New Issue
Block a user