Skip to content

Commit 2f00b1e

Browse files
authored
Merge pull request #69 from caioricciuti/fix/project-bundle-format
Project export: marker lines in cells and comma tags round-trip
2 parents bfb104d + 190b92b commit 2f00b1e

2 files changed

Lines changed: 80 additions & 9 deletions

File tree

‎src/lib/projectBundle/__tests__/format.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,17 @@ describe("queries", () => {
5151
["empty", query({ sql: "" })],
5252
["header-like SQL", query({ sql: "-- name: not a header\nSELECT 1" })],
5353
["folder", query({ folder: "team/finance" })],
54+
["tag with a comma", query({ tags: ["sales, weekly", "q4"] })],
55+
["tag that looks like JSON", query({ tags: ["[draft]", "b"] })],
5456
])("round-trips %s", (_label, original) => {
5557
expect(parseQuery(serializeQuery(original), "fallback")).toEqual(original);
5658
});
5759

60+
it("writes a plain tag list unless a tag holds a comma", () => {
61+
expect(serializeQuery(query({ tags: ["a", "b"] }))).toContain("-- tags: a, b\n");
62+
expect(serializeQuery(query({ tags: ["a, b", "c"] }))).toContain('-- tags: ["a, b","c"]\n');
63+
});
64+
5865
it("reads hand-written files without a header", () => {
5966
expect(parseQuery("SELECT 42;\n", "answer")).toEqual(
6067
query({ name: "answer", sql: "SELECT 42;" })
@@ -104,6 +111,23 @@ describe("notebooks", () => {
104111
expect(parseNotebook(serializeNotebook(notebook), "fallback")).toEqual(notebook);
105112
});
106113

114+
it("round-trips a cell whose text contains a cell marker line", () => {
115+
const tricky = {
116+
title: "Markers",
117+
cells: [
118+
{
119+
type: "markdown" as const,
120+
content: "How cells are stored:\n<!-- cell:python -->\nthat line",
121+
},
122+
{ type: "python" as const, content: "# <!-- cell:sql -->\n<!-- cell:sql collapsed -->" },
123+
{ type: "markdown" as const, content: "\\<!-- cell:markdown -->" },
124+
],
125+
};
126+
const text = serializeNotebook(tricky);
127+
expect(text).toContain("\n\\<!-- cell:python -->\n");
128+
expect(parseNotebook(text, "x")).toEqual(tricky);
129+
});
130+
107131
it("round-trips an empty notebook", () => {
108132
const empty = { title: "Empty", cells: [] };
109133
expect(parseNotebook(serializeNotebook(empty), "x")).toEqual(empty);

‎src/lib/projectBundle/format.ts‎

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -125,14 +125,40 @@ const oneLine = (text: string): string => text.replace(/\s*\n\s*/g, " ").trim();
125125

126126
const HEADER_LINE = /^-- (name|description|tags|folder):(?: (.*))?$/;
127127

128+
/** Either form the serializer writes: a JSON array, or names separated by commas. */
129+
function parseTags(value: string): string[] {
130+
const trimmed = value.trim();
131+
if (trimmed.startsWith("[")) {
132+
try {
133+
const parsed: unknown = JSON.parse(trimmed);
134+
if (Array.isArray(parsed)) {
135+
return parsed.map((tag) => String(tag).trim()).filter(Boolean);
136+
}
137+
} catch {
138+
// Not JSON after all: a tag list that happens to start with a bracket.
139+
}
140+
}
141+
return trimmed
142+
.split(",")
143+
.map((tag) => tag.trim())
144+
.filter(Boolean);
145+
}
146+
128147
export function serializeQuery(query: BundleQuery): string {
129148
const header = [`-- name: ${oneLine(query.name)}`];
130149
if (query.description) {
131150
for (const line of normalizeNewlines(query.description).split("\n")) {
132151
header.push(line ? `-- description: ${line}` : "-- description:");
133152
}
134153
}
135-
if (query.tags.length > 0) header.push(`-- tags: ${query.tags.map(oneLine).join(", ")}`);
154+
if (query.tags.length > 0) {
155+
// A comma inside a tag would read back as two tags, so such a list is
156+
// written as JSON; the plain form stays for every other file.
157+
const tags = query.tags.map(oneLine);
158+
header.push(
159+
`-- tags: ${tags.some((tag) => tag.includes(",")) ? JSON.stringify(tags) : tags.join(", ")}`
160+
);
161+
}
136162
if (query.folder && query.folder !== "default")
137163
header.push(`-- folder: ${oneLine(query.folder)}`);
138164
return withFinalNewline(`${header.join("\n")}\n\n${query.sql}`);
@@ -158,10 +184,7 @@ export function parseQuery(text: string, fallbackName: string): BundleQuery {
158184
description.push(value);
159185
break;
160186
case "tags":
161-
tags = value
162-
.split(",")
163-
.map((tag) => tag.trim())
164-
.filter(Boolean);
187+
tags = parseTags(value);
165188
break;
166189
case "folder":
167190
folder = value.trim() || "default";
@@ -188,6 +211,29 @@ export function parseQuery(text: string, fallbackName: string): BundleQuery {
188211

189212
const CELL_MARKER = /^<!-- cell:(sql|python|markdown)( collapsed)? -->$/;
190213
const CODE_FENCE_OPEN = /^(`{3,}|~{3,})\s*(sql|python|py)\b.*$/i;
214+
/** A content line that is a marker, or a marker already behind backslashes. */
215+
const MARKER_LIKE_LINE = /^(\\*)(<!-- cell:(?:sql|python|markdown)(?: collapsed)? -->)$/;
216+
217+
/**
218+
* A cell whose text contains a line that is exactly a cell marker would be
219+
* cut in two on import. Such a line gets one more backslash in the file
220+
* (so lines that already start with backslashes stay distinct), and
221+
* `unescapeMarkers` takes one away. Files from before this never contain
222+
* the escaped form, so they read as they always did.
223+
*/
224+
function escapeMarkers(content: string): string {
225+
return content
226+
.split("\n")
227+
.map((line) => line.replace(MARKER_LIKE_LINE, "\\$1$2"))
228+
.join("\n");
229+
}
230+
231+
function unescapeMarkers(content: string): string {
232+
return content
233+
.split("\n")
234+
.map((line) => (MARKER_LIKE_LINE.test(line) && line.startsWith("\\") ? line.slice(1) : line))
235+
.join("\n");
236+
}
191237

192238
type CodeCellType = "sql" | "python";
193239

@@ -205,11 +251,12 @@ export function serializeNotebook(notebook: BundleNotebook): string {
205251
const parts = [`# ${oneLine(notebook.title) || "Untitled Notebook"}`];
206252
for (const cell of notebook.cells) {
207253
const marker = `<!-- cell:${cell.type}${cell.collapsed ? " collapsed" : ""} -->`;
254+
const content = escapeMarkers(cell.content);
208255
if (cell.type === "sql" || cell.type === "python") {
209-
const fence = fenceFor(cell.content);
210-
parts.push(`${marker}\n${fence}${cell.type}\n${cell.content}\n${fence}`);
256+
const fence = fenceFor(content);
257+
parts.push(`${marker}\n${fence}${cell.type}\n${content}\n${fence}`);
211258
} else {
212-
parts.push(`${marker}\n${cell.content}`);
259+
parts.push(`${marker}\n${content}`);
213260
}
214261
}
215262
return withFinalNewline(parts.join("\n\n"));
@@ -273,7 +320,7 @@ export function parseNotebook(text: string, fallbackTitle: string): BundleNotebo
273320
const cellType = type === "sql" || type === "python" ? type : "markdown";
274321
return {
275322
type: cellType,
276-
content: cellType === "markdown" ? body.join("\n") : parseCodeBody(body),
323+
content: unescapeMarkers(cellType === "markdown" ? body.join("\n") : parseCodeBody(body)),
277324
...(collapsed ? { collapsed: true } : {}),
278325
};
279326
});

0 commit comments

Comments
 (0)