fix: the CSV round trip keeps values that start with an apostrophe before a formula character (WR-21)
escapeCell left '=x as it was and unescapeCell then dropped its apostrophe on import. A value of apostrophes followed by a formula character now gets one more apostrophe on export, and the import drops exactly one, so unescapeCell is the inverse of escapeCell.
This commit is contained in:
@@ -35,7 +35,7 @@ The frontend (a Nuxt layer or app) asks `GET /_seo/api/v1/resolve?url=...` for t
|
||||
- **History** (`golem15_seo_revisions`): every SEO write (the SEO tab, rules, redirects, robots.txt, settings, the 404 log) is recorded with author, time, via (admin, MCP, import, auto for address changes, undo) and the changed fields before and after. Deleted records keep their values in history.
|
||||
- **Undo** of one change or of a whole batch, from the History preview (record actions **Undo this change** and **Undo the whole batch**) and through MCP. An undo is refused when a later change touched the same values (`history.undo_stale`, naming who and when), when it would create a redirect loop (`history.undo_loop`) or when the record is gone (`history.undo_gone`); an undo is itself recorded.
|
||||
- **Batches** (D-12): a list of changes (SEO by URL, redirects, URL rules) applied all or nothing under one batch id. A dry run answers the diff and the conflicts (loops, chains, noindex on a published record's address, a canonical pointing to a redirected or noindex address) and writes nothing.
|
||||
- **CSV import and export** of redirects, URL rules and the SEO of records (by URL), in the **Import and export** screen (element `golem15-seo-transfer`) and through MCP. Files are UTF-8 with a BOM, comma or semicolon; exported cells that start a formula (`=`, `+`, `-`, `@`, tab, CR) are prefixed with an apostrophe, which the import drops again. An import is previewed row by row (new, changed, unchanged, refused, with notes) and applied as one batch with via `import`, or not at all while a row is refused. The screen's apply token is an HMAC-SHA256 under the app key over the file and a fingerprint of the data the preview read; it is refused with `transfer.stale` when the data changed. The screen previews files up to 60 KB and downloads exports up to 250 KB; MCP imports take up to 900 KB per call and exports come in pages of 1000 rows.
|
||||
- **CSV import and export** of redirects, URL rules and the SEO of records (by URL), in the **Import and export** screen (element `golem15-seo-transfer`) and through MCP. Files are UTF-8 with a BOM, comma or semicolon; exported cells that start a formula (`=`, `+`, `-`, `@`, tab, CR), or apostrophes followed by one, are prefixed with an apostrophe, and the import drops exactly that one again, so a re-imported export is unchanged. An import is previewed row by row (new, changed, unchanged, refused, with notes) and applied as one batch with via `import`, or not at all while a row is refused. The screen's apply token is an HMAC-SHA256 under the app key over the file and a fingerprint of the data the preview read; it is refused with `transfer.stale` when the data changed. The screen previews files up to 60 KB and downloads exports up to 250 KB; MCP imports take up to 900 KB per call and exports come in pages of 1000 rows.
|
||||
- **Record events.** A deleted record loses its entries after commit; a created record gets its address snapshot; an updated record whose address changed gets its 301.
|
||||
- **Entry generation.** The `golem15.seo.generate_entries` job (conga) creates missing entries with the current address of every published record and refreshes the effective-value cache; a record whose address changed without a record event gets its address change (with its 301) like an admin save. Editor values are never changed.
|
||||
- **Field types** `seotext`, `mlseotext`, `seorobots`, `mlseorobots`, `seoimage` and `robotstxt`, with the custom elements `golem15-seo-text`, `golem15-seo-robots`, `golem15-seo-serp` (the result preview widget), `golem15-seo-image`, `golem15-seo-robotstxt` (the robots.txt editor), `golem15-seo-robotstester` (the tester widget) and `golem15-seo-transfer` (the import and export widget).
|
||||
|
||||
@@ -38,19 +38,27 @@ func formulaStart(s string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// escapable reports whether s is a formula cell behind any number of
|
||||
// apostrophes: the cells escapeCell prefixes.
|
||||
func escapable(s string) bool {
|
||||
return formulaStart(strings.TrimLeft(s, "'"))
|
||||
}
|
||||
|
||||
// escapeCell prefixes a formula cell with an apostrophe (CSV formula
|
||||
// injection, T-32-24).
|
||||
// injection, T-32-24). A value that already starts with apostrophes before
|
||||
// a formula character gets one more, so unescapeCell is its exact inverse
|
||||
// and '=x survives a round trip (WR-21).
|
||||
func escapeCell(s string) string {
|
||||
if formulaStart(s) {
|
||||
if escapable(s) {
|
||||
return "'" + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// unescapeCell drops the apostrophe escapeCell added, so an exported file
|
||||
// imports unchanged.
|
||||
// unescapeCell drops the one apostrophe escapeCell added, so an exported
|
||||
// file imports unchanged.
|
||||
func unescapeCell(s string) string {
|
||||
if strings.HasPrefix(s, "'") && formulaStart(s[1:]) {
|
||||
if strings.HasPrefix(s, "'") && escapable(s[1:]) {
|
||||
return s[1:]
|
||||
}
|
||||
return s
|
||||
|
||||
@@ -23,6 +23,9 @@ func TestCSV(t *testing.T) {
|
||||
{"/tab", "/x", "\tlead"},
|
||||
{"/cr", "/x", "\rlead"},
|
||||
{"/ok", "/x", "a=b"},
|
||||
{"/ap", "/x", "'=x"},
|
||||
{"/ap2", "/x", "''-)"},
|
||||
{"/ap3", "/x", "'plain"},
|
||||
}
|
||||
|
||||
t.Run("round trip with each delimiter", func(t *testing.T) {
|
||||
@@ -63,7 +66,7 @@ func TestCSV(t *testing.T) {
|
||||
if lines[1] != "'=1+1" || lines[2] != "'+x" || lines[3] != "'-x" || lines[4] != "'@x" || lines[5] != "a=b" {
|
||||
t.Fatalf("export = %q", lines)
|
||||
}
|
||||
for in, want := range map[string]string{"'=x": "=x", "'+x": "+x", "'abc": "'abc", "''=x": "''=x", "plain": "plain", "'": "'"} {
|
||||
for in, want := range map[string]string{"'=x": "=x", "'+x": "+x", "'abc": "'abc", "''=x": "'=x", "plain": "plain", "'": "'"} {
|
||||
if got := unescapeCell(in); got != want {
|
||||
t.Errorf("unescapeCell(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user