Skip to content

Commit 58110b3

Browse files
jcfischerclaude
andcommitted
release: v2.5.7 — resolve inline-reference spans in field values
Fixed: - tana_query / tana_tagged returned "" for reference-type field values (David D.V.'s bug report against v2.5.5: "Context": "" where Context pointed to Pr MB2). Tana stores references as empty spans: <span data-inlineref-node="NODE_ID"></span> FieldResolver ran stripHtml() on those and got empty strings back. The resolver now parses inline-ref spans, batch-looks up the referenced node names (one SQL per unique id set), and substitutes the display text before stripping residual HTML. Also handles data-inlineref-date spans (extracts the dateTimeString). No reindex required — fix applies at query time. Graceful fallback to empty string when the referenced node is missing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6312308 commit 58110b3

6 files changed

Lines changed: 249 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ All notable changes to Supertag CLI are documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [2.5.7] - 2026-04-17
9+
10+
### Fixed
11+
- **Reference-type field values returned empty string**`tana_query`, `tana_tagged`, and any code path through `FieldResolver` returned `""` for fields whose value is a reference to another node (e.g., `"Context": ""` where Context pointed to `Pr MB2`). Root cause: Tana stores inline references as empty `<span data-inlineref-node="NODE_ID"></span>` tags; the resolver ran `stripHtml()` which removed the span, leaving the empty content behind. The resolver now parses those spans, looks up the referenced node's name (batched, single SQL query per unique set), and substitutes the resolved display text before stripping residual HTML. Also resolves `data-inlineref-date` spans to their `dateTimeString` value.
12+
813
## [2.5.6] - 2026-04-16
914

1015
### Fixed

export/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pai/supertag-export",
3-
"version": "2.5.6",
3+
"version": "2.5.7",
44
"description": "Browser automation for Tana workspace exports using Playwright",
55
"type": "module",
66
"bin": {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "supertag-cli",
3-
"version": "2.5.6",
3+
"version": "2.5.7",
44
"description": "CLI for Tana integration - query, create, sync, and manage Tana workspaces with semantic search",
55
"type": "module",
66
"bin": {

src/services/field-resolver.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import { Database } from "bun:sqlite";
10-
import { stripHtml } from "../utils/html";
10+
import { resolveInlineRefsBatch } from "../utils/resolve-inline-refs";
1111

1212
/**
1313
* Field value map: field_name -> value (comma-joined if multiple)
@@ -182,7 +182,16 @@ export class FieldResolver {
182182
value_order: number;
183183
}[];
184184

185-
for (const row of rows) {
185+
// v2.5.7 fix: resolve <span data-inlineref-node="..."></span> and
186+
// data-inlineref-date spans to their display text before stripping
187+
// HTML. Tana stores references/dates as empty spans, so plain stripHtml
188+
// collapsed pure-reference values to empty strings (symptom:
189+
// "Context": "" in query output despite the reference being set).
190+
const rawTexts = rows.map((r) => r.value_text);
191+
const resolvedTexts = resolveInlineRefsBatch(rawTexts, this.db);
192+
193+
for (let idx = 0; idx < rows.length; idx++) {
194+
const row = rows[idx];
186195
let nodeMap = result.get(row.parent_id);
187196
if (!nodeMap) {
188197
nodeMap = new Map();
@@ -195,7 +204,7 @@ export class FieldResolver {
195204
nodeMap.set(row.field_name, values);
196205
}
197206

198-
values.push(stripHtml(row.value_text));
207+
values.push(resolvedTexts[idx]);
199208
}
200209
}
201210

src/utils/resolve-inline-refs.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* Resolve Tana inline-reference spans to display text.
3+
*
4+
* Tana stores references and dates inside field values as self-closing or
5+
* empty spans:
6+
*
7+
* <span data-inlineref-node="NODE_ID"></span>
8+
* <span data-inlineref-date="{&quot;dateTimeString&quot;:&quot;2026-01-26&quot;,...}"></span>
9+
*
10+
* When naively stripped of HTML, these collapse to empty strings — which is
11+
* what caused v2.5.5's reference-field bug ("Context" → "").
12+
*
13+
* This module resolves those spans to their display text:
14+
* - inlineref-node → target node's `name` from the nodes table
15+
* - inlineref-date → the `dateTimeString` value
16+
*
17+
* Any HTML that remains after substitution is stripped (existing behavior for
18+
* option fields with color spans).
19+
*/
20+
21+
import { Database } from "bun:sqlite";
22+
import { stripHtml } from "./html";
23+
24+
const INLINEREF_NODE_RE = /<span[^>]*data-inlineref-node="([^"]+)"[^>]*><\/span>/g;
25+
const INLINEREF_DATE_RE = /<span[^>]*data-inlineref-date="([^"]+)"[^>]*><\/span>/g;
26+
27+
/**
28+
* Resolve all inline-reference spans in a batch of strings. Uses a single
29+
* SQL query per unique referenced-node set to avoid N+1 lookups.
30+
*/
31+
export function resolveInlineRefsBatch(values: string[], db: Database): string[] {
32+
const nodeIds = new Set<string>();
33+
for (const v of values) {
34+
INLINEREF_NODE_RE.lastIndex = 0;
35+
let m: RegExpExecArray | null;
36+
while ((m = INLINEREF_NODE_RE.exec(v)) !== null) {
37+
nodeIds.add(decodeHtmlEntities(m[1]));
38+
}
39+
}
40+
41+
const nameMap = nodeIds.size > 0 ? lookupNodeNames([...nodeIds], db) : new Map<string, string>();
42+
43+
return values.map((v) => resolveOne(v, nameMap));
44+
}
45+
46+
/**
47+
* Resolve a single string's inline-reference spans. Prefer the batch variant
48+
* for bulk work — this is for one-off calls.
49+
*/
50+
export function resolveInlineRefs(value: string, db: Database): string {
51+
return resolveInlineRefsBatch([value], db)[0];
52+
}
53+
54+
function resolveOne(value: string, nameMap: Map<string, string>): string {
55+
let out = value;
56+
57+
out = out.replace(INLINEREF_NODE_RE, (_full, rawId) => {
58+
const id = decodeHtmlEntities(rawId);
59+
return nameMap.get(id) ?? "";
60+
});
61+
62+
out = out.replace(INLINEREF_DATE_RE, (_full, rawJson) => {
63+
const json = decodeHtmlEntities(rawJson);
64+
try {
65+
const parsed = JSON.parse(json) as { dateTimeString?: string };
66+
return parsed.dateTimeString ?? "";
67+
} catch {
68+
return "";
69+
}
70+
});
71+
72+
return stripHtml(out).trim();
73+
}
74+
75+
function lookupNodeNames(ids: string[], db: Database): Map<string, string> {
76+
const result = new Map<string, string>();
77+
// SQLite defaults cap to ~999 params; batch conservatively at 500.
78+
const BATCH = 500;
79+
for (let i = 0; i < ids.length; i += BATCH) {
80+
const batch = ids.slice(i, i + BATCH);
81+
const placeholders = batch.map(() => "?").join(", ");
82+
const rows = db
83+
.query(`SELECT id, name FROM nodes WHERE id IN (${placeholders})`)
84+
.all(...batch) as Array<{ id: string; name: string | null }>;
85+
for (const r of rows) {
86+
if (r.name) result.set(r.id, r.name);
87+
}
88+
}
89+
return result;
90+
}
91+
92+
function decodeHtmlEntities(s: string): string {
93+
return s
94+
.replace(/&quot;/g, '"')
95+
.replace(/&amp;/g, "&")
96+
.replace(/&lt;/g, "<")
97+
.replace(/&gt;/g, ">")
98+
.replace(/&#39;/g, "'")
99+
.replace(/&apos;/g, "'");
100+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* Tests for the inline-reference resolver (v2.5.7 fix).
3+
*
4+
* Tana stores field values that contain references or dates as empty
5+
* `<span>` tags with data attributes, e.g.:
6+
*
7+
* <span data-inlineref-node="abc123"></span>
8+
* <span data-inlineref-date="{&quot;dateTimeString&quot;:&quot;2026-01-26&quot;,...}"></span>
9+
*
10+
* Prior to this fix, FieldResolver ran stripHtml() on these and got back
11+
* empty strings — causing `tana_query` to return "Context": "" for reference
12+
* fields (David Delgado Vendrell's bug report against v2.5.5).
13+
*/
14+
15+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
16+
import { Database } from "bun:sqlite";
17+
import { mkdtempSync, rmSync } from "fs";
18+
import { tmpdir } from "os";
19+
import { join } from "path";
20+
import {
21+
resolveInlineRefs,
22+
resolveInlineRefsBatch,
23+
} from "../../src/utils/resolve-inline-refs";
24+
25+
function makeDb(): { db: Database; dir: string } {
26+
const dir = mkdtempSync(join(tmpdir(), "resolve-refs-"));
27+
const db = new Database(join(dir, "t.db"));
28+
db.run(`CREATE TABLE nodes (id TEXT PRIMARY KEY, name TEXT)`);
29+
return { db, dir };
30+
}
31+
32+
describe("resolveInlineRefs", () => {
33+
let db: Database;
34+
let dir: string;
35+
36+
beforeEach(() => {
37+
({ db, dir } = makeDb());
38+
db.run("INSERT INTO nodes (id, name) VALUES (?, ?)", ["abc123", "Pr MB2"]);
39+
db.run("INSERT INTO nodes (id, name) VALUES (?, ?)", ["xyz789", "Another Project"]);
40+
});
41+
42+
afterEach(() => {
43+
db.close();
44+
rmSync(dir, { recursive: true, force: true });
45+
});
46+
47+
it("resolves a pure inline node reference to the target name (regression test)", () => {
48+
const input = '<span data-inlineref-node="abc123"></span>';
49+
expect(resolveInlineRefs(input, db)).toBe("Pr MB2");
50+
});
51+
52+
it("resolves an inline reference embedded in surrounding text", () => {
53+
const input = 'Meeting with <span data-inlineref-node="abc123"></span> today';
54+
expect(resolveInlineRefs(input, db)).toBe("Meeting with Pr MB2 today");
55+
});
56+
57+
it("resolves multiple references in one value", () => {
58+
const input =
59+
'<span data-inlineref-node="abc123"></span>, <span data-inlineref-node="xyz789"></span>';
60+
expect(resolveInlineRefs(input, db)).toBe("Pr MB2, Another Project");
61+
});
62+
63+
it("returns empty string when the referenced node is missing (deleted/orphaned)", () => {
64+
const input = '<span data-inlineref-node="nonexistent"></span>';
65+
expect(resolveInlineRefs(input, db)).toBe("");
66+
});
67+
68+
it("resolves inline-ref-date to its dateTimeString", () => {
69+
const input =
70+
'<span data-inlineref-date="{&quot;dateTimeString&quot;:&quot;2026-01-26&quot;,&quot;timezone&quot;:&quot;UTC&quot;}"></span>';
71+
expect(resolveInlineRefs(input, db)).toBe("2026-01-26");
72+
});
73+
74+
it("handles plain text without any spans (no-op)", () => {
75+
expect(resolveInlineRefs("Pr MB2", db)).toBe("Pr MB2");
76+
});
77+
78+
it("strips residual HTML after reference substitution (option color spans)", () => {
79+
const input = '<span data-color="blue">DONE</span>';
80+
expect(resolveInlineRefs(input, db)).toBe("DONE");
81+
});
82+
83+
it("trims whitespace from the final result", () => {
84+
const input = ' <span data-inlineref-node="abc123"></span> ';
85+
expect(resolveInlineRefs(input, db)).toBe("Pr MB2");
86+
});
87+
88+
it("returns empty string for completely empty input", () => {
89+
expect(resolveInlineRefs("", db)).toBe("");
90+
});
91+
92+
it("handles malformed date JSON gracefully", () => {
93+
const input = '<span data-inlineref-date="not-valid-json"></span>';
94+
expect(resolveInlineRefs(input, db)).toBe("");
95+
});
96+
});
97+
98+
describe("resolveInlineRefsBatch", () => {
99+
let db: Database;
100+
let dir: string;
101+
102+
beforeEach(() => {
103+
({ db, dir } = makeDb());
104+
db.run("INSERT INTO nodes (id, name) VALUES (?, ?)", ["a", "Alpha"]);
105+
db.run("INSERT INTO nodes (id, name) VALUES (?, ?)", ["b", "Beta"]);
106+
db.run("INSERT INTO nodes (id, name) VALUES (?, ?)", ["c", "Gamma"]);
107+
});
108+
109+
afterEach(() => {
110+
db.close();
111+
rmSync(dir, { recursive: true, force: true });
112+
});
113+
114+
it("resolves references across many values in one pass", () => {
115+
const input = [
116+
'<span data-inlineref-node="a"></span>',
117+
'<span data-inlineref-node="b"></span>',
118+
'<span data-inlineref-node="c"></span>',
119+
"plain text",
120+
];
121+
expect(resolveInlineRefsBatch(input, db)).toEqual(["Alpha", "Beta", "Gamma", "plain text"]);
122+
});
123+
124+
it("performs a single node lookup per unique id (batch efficiency)", () => {
125+
// Repeat the same ref many times; the lookup should deduplicate.
126+
const input = Array.from({ length: 100 }, () => '<span data-inlineref-node="a"></span>');
127+
const results = resolveInlineRefsBatch(input, db);
128+
expect(results.every((r) => r === "Alpha")).toBe(true);
129+
});
130+
});

0 commit comments

Comments
 (0)