Skip to content

Commit 98f7210

Browse files
author
DavidQ
committed
Move reusable file persistence mechanics into engine persistence - PR_26140_048-move-file-persistence-to-engine
1 parent 83871d0 commit 98f7210

18 files changed

Lines changed: 304 additions & 225 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# PR_26140_048-move-file-persistence-to-engine
2+
3+
## Summary
4+
- Added `src/engine/persistence/FilePersistenceService.js` as the shared engine owner for browser text file persistence mechanics: file text reads, file-handle text reads/writes, and text download creation.
5+
- Updated V2/workspace file flows to import directly from `src/engine/persistence/index.js`: Workspace Manager V2, Preview Generator V2, Asset Manager V2, Object Vector Studio V2, Palette Manager V2, Text to Speech V2, shared project workspace import/export, and shared game manifest file loading.
6+
- Moved obvious reusable text file mechanics from active non-V2 tool files while keeping picker UI, repo path decisions, preview generation decisions, image/object URL preview logic, and manifest workflow behavior local to those tools.
7+
- Did not touch sample JSON.
8+
9+
## Files Changed
10+
- `src/engine/persistence/FilePersistenceService.js`
11+
- `src/engine/persistence/index.js`
12+
- `src/tools/common/GameManifestLoader.js`
13+
- `tools/shared/projectSystem.js`
14+
- `tools/workspace-manager-v2/js/services/WorkspaceManagerV2ContextService.js`
15+
- `tools/preview-generator-v2/PreviewGeneratorV2App.js`
16+
- `tools/asset-manager-v2/js/AssetManagerV2App.js`
17+
- `tools/object-vector-studio-v2/js/ToolStarterApp.js`
18+
- `tools/palette-manager-v2/controls/PaletteImportExportControl.js`
19+
- `tools/text2speech-V2/js/TextToSpeechToolApp.js`
20+
- `tools/Asset Pipeline/main.js`
21+
- `tools/Parallax Scene Studio/main.js`
22+
- `tools/SVG Asset Studio/main.js`
23+
- `tools/Sprite Editor/modules/spriteEditorApp.js`
24+
- `tools/Tilemap Studio/main.js`
25+
- `tools/Vector Map Editor/editor/VectorMapRuntimeExporter.js`
26+
- `tools/Vector Map Editor/editor/VectorMapSerializer.js`
27+
28+
## Behavior Notes
29+
- `readFileText()` preserves modern `file.text()` behavior and preserves FileReader fallback behavior for callers that still need it.
30+
- `downloadTextFile()` preserves existing JSON/SVG download behavior and supports the prior append-to-body pattern used by Asset Manager V2 and Text to Speech V2.
31+
- `readFileHandleText()` and `writeFileHandleText()` keep Workspace Manager V2 and Preview Generator V2 in charge of repo path validation, read-back verification, and user-facing workflow messages.
32+
- Remaining direct FileReader/object URL usage is image decode, image preview, fake repo-handle emulation, or engine runtime blob download behavior, not reusable text file persistence.
33+
34+
## Validation
35+
- PASS: `git diff --check`
36+
- PASS: `node --check` for all changed JavaScript files.
37+
- PASS: targeted import validation for import-safe changed modules.
38+
- PASS: targeted FilePersistenceService behavior validation for text reads, FileReader fallback, file-handle read/write, and text download creation.
39+
- PASS: `npm run test:workspace-v2` - 58 passed.
40+
- INFO: `npm run build` is unavailable in this repo (npm reports missing script); `npm run` was used to confirm available scripts.
41+
- SKIPPED: full samples smoke test, per request.
42+
- PASS: repo-structured ZIP created at `tmp/PR_26140_048-move-file-persistence-to-engine_delta.zip` and `zipfile.testzip()` returned ok.
43+
44+
## Coverage
45+
- Playwright impacted: Yes. Workspace/tool file flows and browser file persistence mechanics changed.
46+
- V8 coverage artifacts were generated by `npm run test:workspace-v2`:
47+
- `docs/dev/reports/playwright_v8_coverage_report.txt`
48+
- `docs/dev/reports/coverage_changed_js_guardrail.txt`
49+
- Confirmed covered changed runtime files include `src/engine/persistence/FilePersistenceService.js` (78%), `src/engine/persistence/index.js` (100%), `tools/workspace-manager-v2/js/services/WorkspaceManagerV2ContextService.js` (93%), `tools/preview-generator-v2/PreviewGeneratorV2App.js` (88%), `tools/asset-manager-v2/js/AssetManagerV2App.js` (63%), `tools/object-vector-studio-v2/js/ToolStarterApp.js` (94%), and `tools/text2speech-V2/js/TextToSpeechToolApp.js` (91%).
50+
- Valid WARN details were added for changed files with spaces in their paths that the existing guardrail parser did not list.
51+
52+
## Manual Validation
53+
- Open Workspace Manager V2, select repo, select a game, and verify the game dropdown/tool tiles still populate.
54+
- Launch Asset Manager V2, Object Vector Studio V2, Palette Manager V2, Text to Speech V2, and Preview Generator V2 from Workspace Manager and verify import/export or save/read-back actions behave as before.
55+
- Optional spot check legacy text file flows: Asset Pipeline JSON load, Parallax/Tilemap project/asset registry load/save, SVG load/save, Sprite project/registry load, and Vector Map editor/runtime JSON export.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
Toolbox Aid
3+
David Quesenberry
4+
05/20/2026
5+
FilePersistenceService.js
6+
*/
7+
function resolveWindowRef(windowRef = null) {
8+
return windowRef || globalThis.window || globalThis;
9+
}
10+
11+
function resolveDocumentRef(documentRef = null, windowRef = null) {
12+
return documentRef || resolveWindowRef(windowRef).document || globalThis.document || null;
13+
}
14+
15+
function resolveFileReader(file, windowRef = null) {
16+
return file?.ownerDocument?.defaultView?.FileReader
17+
|| resolveWindowRef(windowRef).FileReader
18+
|| globalThis.FileReader
19+
|| null;
20+
}
21+
22+
export async function readFileText(file, { errorMessage = "Unable to read selected file.", windowRef = null } = {}) {
23+
if (!file) {
24+
throw new Error(errorMessage);
25+
}
26+
if (typeof file.text === "function") {
27+
return String(await file.text());
28+
}
29+
30+
const FileReaderCtor = resolveFileReader(file, windowRef);
31+
if (typeof FileReaderCtor !== "function") {
32+
throw new Error(errorMessage);
33+
}
34+
35+
return new Promise((resolve, reject) => {
36+
const reader = new FileReaderCtor();
37+
reader.onerror = () => reject(new Error(errorMessage));
38+
reader.onload = () => resolve(String(reader.result ?? ""));
39+
reader.readAsText(file);
40+
});
41+
}
42+
43+
export async function readFileHandleText(fileHandle, {
44+
handleErrorMessage = "file handle cannot be read",
45+
textErrorMessage = "file text read-back is unavailable"
46+
} = {}) {
47+
if (!fileHandle || typeof fileHandle.getFile !== "function") {
48+
throw new Error(handleErrorMessage);
49+
}
50+
51+
const file = await fileHandle.getFile();
52+
if (!file || typeof file.text !== "function") {
53+
throw new Error(textErrorMessage);
54+
}
55+
56+
return {
57+
file,
58+
text: String(await file.text())
59+
};
60+
}
61+
62+
export async function writeFileHandleText(fileHandle, content, {
63+
handleErrorMessage = "file handle cannot be written"
64+
} = {}) {
65+
if (!fileHandle || typeof fileHandle.createWritable !== "function") {
66+
throw new Error(handleErrorMessage);
67+
}
68+
69+
const writable = await fileHandle.createWritable();
70+
await writable.write(String(content));
71+
await writable.close();
72+
return true;
73+
}
74+
75+
export function downloadTextFile(content, fileName, {
76+
appendToBody = false,
77+
documentRef = null,
78+
mimeType = "application/json",
79+
windowRef = null
80+
} = {}) {
81+
const browserWindow = resolveWindowRef(windowRef);
82+
const documentObject = resolveDocumentRef(documentRef, browserWindow);
83+
const BlobCtor = browserWindow.Blob || globalThis.Blob;
84+
const urlApi = browserWindow.URL || browserWindow.webkitURL || globalThis.URL;
85+
if (
86+
!documentObject
87+
|| typeof documentObject.createElement !== "function"
88+
|| typeof BlobCtor !== "function"
89+
|| typeof urlApi?.createObjectURL !== "function"
90+
|| typeof urlApi?.revokeObjectURL !== "function"
91+
) {
92+
return false;
93+
}
94+
95+
const blob = new BlobCtor([String(content)], { type: mimeType });
96+
const url = urlApi.createObjectURL(blob);
97+
const link = documentObject.createElement("a");
98+
link.href = url;
99+
link.download = fileName;
100+
link.rel = "noopener";
101+
if (appendToBody && documentObject.body) {
102+
documentObject.body.append(link);
103+
}
104+
link.click();
105+
if (appendToBody && typeof link.remove === "function") {
106+
link.remove();
107+
}
108+
urlApi.revokeObjectURL(url);
109+
return true;
110+
}

src/engine/persistence/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@ export { default as LocalStorageService } from './LocalStorageService.js';
1010
export { default as SessionStorageService } from './SessionStorageService.js';
1111
export { default as CookieStorageService } from './CookieStorageService.js';
1212
export { default as SaveSlotManager } from './SaveSlotManager.js';
13+
export { downloadTextFile, readFileHandleText, readFileText, writeFileHandleText } from './FilePersistenceService.js';
1314
export { compressText, decompressText, compressJson, decompressJson } from './CompressionService.js';
1415
export { serializeWorldState, deserializeWorldState } from './WorldSerializer.js';

src/tools/common/GameManifestLoader.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { asPositiveInteger } from "../../shared/number/index.js";
2-
import { SessionStorageService } from "../../engine/persistence/index.js";
2+
import { readFileText, SessionStorageService } from "../../engine/persistence/index.js";
33
import { isRecord } from "../../shared/types/typeGuards.js";
44

55
export { isRecord };
@@ -93,7 +93,7 @@ export class GameManifestLoader {
9393
return { ok: false, skipped: true };
9494
}
9595
try {
96-
return parseJson(await file.text(), file.name || "selected manifest file");
96+
return parseJson(await readFileText(file), file.name || "selected manifest file");
9797
} catch (error) {
9898
return { ok: false, message: `Manifest file could not be read: ${error.message}` };
9999
}

tools/Asset Pipeline/main.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { runAssetPipelineTooling } from "../shared/pipeline/assetPipelineTooling.js";
2+
import { readFileText } from "../../src/engine/persistence/index.js";
23
import { safeParseJson, toPrettyJson } from "../shared/debugInspectorData.js";
34
import { registerToolBootContract } from "../shared/toolBootContract.js";
45
import {
@@ -591,7 +592,7 @@ async function loadPipelineFromJsonFile(file) {
591592
return;
592593
}
593594
try {
594-
const text = await file.text();
595+
const text = await readFileText(file);
595596
const parsed = parseJsonObjectString(text);
596597
if (!parsed) {
597598
throw new Error("Selected file must contain a valid JSON object.");

tools/Parallax Scene Studio/main.js

Lines changed: 20 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
sanitizeAssetRegistry,
1616
upsertRegistryEntry
1717
} from "../shared/projectAssetRegistry.js";
18+
import { downloadTextFile, readFileText } from "../../src/engine/persistence/index.js";
1819
import { registerAssetPipelineCandidate } from "../shared/assetPipelineFoundation.js";
1920
import {
2021
getBlockingAssetValidationMessage,
@@ -361,19 +362,6 @@ function createTilemapParallaxPatch(parallaxDocument) {
361362
};
362363
}
363364

364-
function createDownload(fileName, content) {
365-
const blob = new Blob([content], { type: "application/json" });
366-
const href = URL.createObjectURL(blob);
367-
const anchor = document.createElement("a");
368-
anchor.href = href;
369-
anchor.download = fileName;
370-
anchor.style.display = "none";
371-
document.body.appendChild(anchor);
372-
anchor.click();
373-
document.body.removeChild(anchor);
374-
URL.revokeObjectURL(href);
375-
}
376-
377365
function summarizeGraphFindings(findings) {
378366
return Array.isArray(findings) && findings.length > 0
379367
? ` Graph findings: ${findings.length}.`
@@ -1210,7 +1198,7 @@ class ParallaxEditorApp {
12101198
};
12111199
const payload = JSON.stringify(output, null, 2);
12121200
const fileName = `${this.documentModel.map.name || "map"}.parallax.json`;
1213-
createDownload(fileName, payload);
1201+
downloadTextFile(payload, fileName);
12141202
this.updateStatus(`Saved ${fileName} (${output.assetRefs.parallaxSourceIds.length} parallax asset refs, ID-based layer references).${summarizeGraphFindings(findings)} Validation: ${summarizeAssetValidation(validation)}.`);
12151203
}
12161204

@@ -1223,7 +1211,7 @@ class ParallaxEditorApp {
12231211
}
12241212
const { findings } = buildAssetDependencyGraph(this.assetRegistry);
12251213
const payload = createRegistryDownloadPayload(this.assetRegistry);
1226-
createDownload("project.assets.json", payload);
1214+
downloadTextFile(payload, "project.assets.json");
12271215
this.updateStatus(`Saved project.assets.json (${this.assetRegistry.parallaxSources.length} parallax sources).${summarizeGraphFindings(findings)} Validation: ${summarizeAssetValidation(validation)}.`);
12281216
}
12291217

@@ -1236,7 +1224,7 @@ class ParallaxEditorApp {
12361224
const patch = createTilemapParallaxPatch(createRegistryManagedParallaxSaveDocument(this.documentModel));
12371225
const payload = JSON.stringify(patch, null, 2);
12381226
const fileName = `${this.documentModel.map.name || "map"}.tilemap-parallax.patch.json`;
1239-
createDownload(fileName, payload);
1227+
downloadTextFile(payload, fileName);
12401228
this.updateStatus(`Exported ${fileName}. Validation: ${summarizeAssetValidation(validation)}.`);
12411229
}
12421230

@@ -1272,22 +1260,21 @@ class ParallaxEditorApp {
12721260
return;
12731261
}
12741262
const fileBase = `${this.assetRegistry.projectId || this.documentModel.map.name || "parallax-project"}.package`;
1275-
createDownload(`${fileBase}.json`, `${JSON.stringify(packageResult.manifest, null, 2)}\n`);
1276-
createDownload(`${fileBase}.report.txt`, `${packageResult.reportText}\n`);
1263+
downloadTextFile(`${JSON.stringify(packageResult.manifest, null, 2)}\n`, `${fileBase}.json`);
1264+
downloadTextFile(`${packageResult.reportText}\n`, `${fileBase}.report.txt`);
12771265
this.updateStatus(`${summarizeProjectPackaging(packageResult)} Manifest and report exported.`);
12781266
}
12791267

1280-
handleLoadProject(event) {
1268+
async handleLoadProject(event) {
12811269
this.exitSimulationMode();
12821270
const file = event.target.files?.[0];
12831271
if (!file) {
12841272
return;
12851273
}
12861274

1287-
const reader = new FileReader();
1288-
reader.onload = () => {
1289-
try {
1290-
const raw = JSON.parse(String(reader.result));
1275+
try {
1276+
const text = await readFileText(file);
1277+
const raw = JSON.parse(text);
12911278
const guard = assertStandaloneToolDocument(raw, {
12921279
expectedLabel: "Parallax project",
12931280
acceptedSchemas: ["toolbox.parallax/1", "toolbox.tilemap/1"],
@@ -1324,25 +1311,21 @@ class ParallaxEditorApp {
13241311
} else {
13251312
this.updateStatus(`Loaded ${file.name} (validation: ${summarizeAssetValidation(validation)}).`);
13261313
}
1327-
} catch (error) {
1328-
this.updateStatus(`Load failed: ${error instanceof Error ? error.message : "invalid JSON"}`);
1329-
}
1314+
} catch (error) {
1315+
this.updateStatus(`Load failed: ${error instanceof Error ? error.message : "invalid JSON"}`);
1316+
}
13301317
this.refs.loadProjectInput.value = "";
1331-
};
1332-
1333-
reader.readAsText(file);
13341318
}
13351319

1336-
handleLoadAssetRegistry(event) {
1320+
async handleLoadAssetRegistry(event) {
13371321
const file = event.target.files?.[0];
13381322
if (!file) {
13391323
return;
13401324
}
13411325

1342-
const reader = new FileReader();
1343-
reader.onload = () => {
1344-
try {
1345-
const parsed = JSON.parse(String(reader.result));
1326+
try {
1327+
const text = await readFileText(file);
1328+
const parsed = JSON.parse(text);
13461329
this.assetRegistry = mergeAssetRegistries(this.assetRegistry, parsed);
13471330
const resolution = this.resolveAssetRefsFromRegistry();
13481331
this.invalidateImageCache();
@@ -1355,13 +1338,10 @@ class ParallaxEditorApp {
13551338
} else {
13561339
this.updateStatus(`Loaded ${file.name} (${this.assetRegistry.parallaxSources.length} parallax sources, validation: ${summarizeAssetValidation(validation)}).`);
13571340
}
1358-
} catch (error) {
1359-
this.updateStatus(`Asset registry load failed: ${error instanceof Error ? error.message : "invalid JSON"}`);
1360-
}
1341+
} catch (error) {
1342+
this.updateStatus(`Asset registry load failed: ${error instanceof Error ? error.message : "invalid JSON"}`);
1343+
}
13611344
this.refs.loadAssetRegistryInput.value = "";
1362-
};
1363-
1364-
reader.readAsText(file);
13651345
}
13661346

13671347
applyMapMetaFromInputs() {

tools/SVG Asset Studio/main.js

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ David Quesenberry
44
03/30/2026
55
main.js
66
*/
7+
import { downloadTextFile, readFileText } from "../../src/engine/persistence/index.js";
78
import { registerToolBootContract } from "../shared/toolBootContract.js";
89
import {
910
getToolLoadQuerySnapshot,
@@ -2313,19 +2314,6 @@ function serializeCurrentSvg() {
23132314
return `<?xml version="1.0" encoding="UTF-8"?>\n${serialized}\n`;
23142315
}
23152316

2316-
function downloadTextFile(fileName, content, mimeType) {
2317-
const blob = new Blob([content], { type: mimeType });
2318-
const href = URL.createObjectURL(blob);
2319-
const anchor = document.createElement("a");
2320-
anchor.href = href;
2321-
anchor.download = fileName;
2322-
anchor.style.display = "none";
2323-
document.body.appendChild(anchor);
2324-
anchor.click();
2325-
document.body.removeChild(anchor);
2326-
URL.revokeObjectURL(href);
2327-
}
2328-
23292317
function parseDimension(value, fallback) {
23302318
if (typeof value !== "string") {
23312319
return fallback;
@@ -3180,7 +3168,7 @@ function bindEvents() {
31803168
if (!file) {
31813169
return;
31823170
}
3183-
const text = await file.text();
3171+
const text = await readFileText(file);
31843172
loadSvgFromText(text, file.name);
31853173
refs.loadSvgInput.value = "";
31863174
});
@@ -3189,7 +3177,7 @@ function bindEvents() {
31893177
finalizePendingPolyline(true);
31903178
const content = serializeCurrentSvg();
31913179
const fileName = `${state.documentName || "background-art"}.svg`;
3192-
downloadTextFile(fileName, content, "image/svg+xml");
3180+
downloadTextFile(content, fileName, { mimeType: "image/svg+xml" });
31933181
setStatus(`Saved ${fileName}.`);
31943182
});
31953183

0 commit comments

Comments
 (0)