Skip to content

Commit 6459b70

Browse files
committed
Merge branch 'wl-CG-0MLYUCGLI05C8SNE-cli-batch-export' into main
2 parents 5723ceb + 675b656 commit 6459b70

File tree

2 files changed

+223
-1
lines changed

2 files changed

+223
-1
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"build": "tsc --noEmit && vite build",
1010
"preview": "vite preview",
1111
"test": "vitest run",
12-
"replay": "tsx scripts/replay.ts"
12+
"replay": "tsx scripts/replay.ts",
13+
"transcripts:export": "tsx scripts/export-transcripts.ts"
1314
},
1415
"dependencies": {
1516
"phaser": "^3.90.0"

scripts/export-transcripts.ts

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
#!/usr/bin/env node
2+
/**
3+
* CLI Batch Export Script -- exports stored game transcripts from
4+
* browser IndexedDB to disk as individual JSON files.
5+
*
6+
* Usage:
7+
* npm run transcripts:export -- <gameType>
8+
*
9+
* Example:
10+
* npm run transcripts:export -- golf
11+
*
12+
* The tool:
13+
* 1. Parses CLI args for the game type (positional argument).
14+
* 2. Ensures a dev server is running at localhost:3000 (auto-starts if needed).
15+
* 3. Launches headless Chromium via Playwright and navigates to the game page.
16+
* 4. Reads all transcripts for the specified game type from IndexedDB.
17+
* 5. Writes each transcript as <gameType>-<ISO-timestamp>.json to
18+
* data/transcripts/<gameType>/, skipping files that already exist.
19+
* 6. Prints a summary of written and skipped files.
20+
*
21+
* See CG-0MLYUCGLI05C8SNE for full requirements.
22+
*/
23+
24+
import * as fs from 'node:fs';
25+
import * as path from 'node:path';
26+
import { chromium } from 'playwright';
27+
import type { Browser } from 'playwright';
28+
import { DEV_SERVER_URL, ensureDevServer, killDevServer } from './dev-server-utils';
29+
30+
// ── Types ───────────────────────────────────────────────────
31+
32+
/** Shape of a StoredTranscript as persisted in IndexedDB. */
33+
interface ExportedTranscript {
34+
id: string;
35+
gameType: string;
36+
savedAt: string;
37+
seq: number;
38+
transcript: unknown;
39+
}
40+
41+
// ── Constants ───────────────────────────────────────────────
42+
43+
const IDB_NAME = 'transcript-store';
44+
const IDB_STORE = 'transcripts';
45+
46+
// ── Helpers ─────────────────────────────────────────────────
47+
48+
/**
49+
* Sanitise an ISO timestamp for use in filenames.
50+
* Replaces colons with hyphens to avoid filesystem issues on Windows.
51+
*/
52+
function sanitiseTimestamp(iso: string): string {
53+
return iso.replace(/:/g, '-');
54+
}
55+
56+
/**
57+
* Parse CLI arguments. Expects a single positional argument: the game type.
58+
*/
59+
function parseArgs(): { gameType: string } {
60+
const args = process.argv.slice(2);
61+
const gameType = args[0];
62+
63+
if (!gameType || gameType.startsWith('-')) {
64+
console.error('Usage: npm run transcripts:export -- <gameType>');
65+
console.error('');
66+
console.error('Example:');
67+
console.error(' npm run transcripts:export -- golf');
68+
process.exit(1);
69+
}
70+
71+
return { gameType };
72+
}
73+
74+
// ── Main ────────────────────────────────────────────────────
75+
76+
async function main() {
77+
const { gameType } = parseArgs();
78+
79+
// Ensure dev server is running
80+
const devServerChild = await ensureDevServer();
81+
let browser: Browser | null = null;
82+
83+
try {
84+
// Launch headless Chromium
85+
browser = await chromium.launch({ headless: true });
86+
const context = await browser.newContext();
87+
const page = await context.newPage();
88+
89+
// Navigate to the game selector page
90+
console.log(`Navigating to ${DEV_SERVER_URL}/...`);
91+
await page.goto(`${DEV_SERVER_URL}/`, { waitUntil: 'domcontentloaded' });
92+
93+
// Read transcripts from IndexedDB via page.evaluate()
94+
console.log(`Reading transcripts for game type "${gameType}" from IndexedDB...`);
95+
const transcripts = await page.evaluate(
96+
({ dbName, storeName, gameType: gt }) => {
97+
return new Promise<ExportedTranscript[]>((resolve, reject) => {
98+
const request = indexedDB.open(dbName, 1);
99+
100+
request.onupgradeneeded = () => {
101+
// Database doesn't exist yet or is being created --
102+
// create the object store so the transaction doesn't fail,
103+
// but there will be no data.
104+
const db = request.result;
105+
if (!db.objectStoreNames.contains(storeName)) {
106+
const store = db.createObjectStore(storeName, { keyPath: 'id' });
107+
store.createIndex('gameType', 'gameType', { unique: false });
108+
store.createIndex('savedAt', 'savedAt', { unique: false });
109+
store.createIndex('gameType_savedAt', ['gameType', 'savedAt'], {
110+
unique: false,
111+
});
112+
}
113+
};
114+
115+
request.onsuccess = () => {
116+
const db = request.result;
117+
if (!db.objectStoreNames.contains(storeName)) {
118+
db.close();
119+
resolve([]);
120+
return;
121+
}
122+
123+
const tx = db.transaction(storeName, 'readonly');
124+
const store = tx.objectStore(storeName);
125+
const index = store.index('gameType');
126+
const getAll = index.getAll(gt);
127+
128+
getAll.onsuccess = () => {
129+
const entries = getAll.result as ExportedTranscript[];
130+
// Sort newest first (by savedAt descending)
131+
entries.sort(
132+
(a, b) =>
133+
new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime(),
134+
);
135+
db.close();
136+
resolve(entries);
137+
};
138+
139+
getAll.onerror = () => {
140+
db.close();
141+
reject(new Error(`Failed to read from IndexedDB: ${getAll.error?.message}`));
142+
};
143+
};
144+
145+
request.onerror = () => {
146+
reject(new Error(`Failed to open IndexedDB: ${request.error?.message}`));
147+
};
148+
});
149+
},
150+
{ dbName: IDB_NAME, storeName: IDB_STORE, gameType },
151+
);
152+
153+
if (transcripts.length === 0) {
154+
console.log(`No transcripts found for "${gameType}".`);
155+
return;
156+
}
157+
158+
console.log(`Found ${transcripts.length} transcript(s) for "${gameType}".`);
159+
160+
// Prepare output directory
161+
const outDir = path.resolve(`data/transcripts/${gameType}`);
162+
fs.mkdirSync(outDir, { recursive: true });
163+
164+
// Write each transcript to disk
165+
let written = 0;
166+
let skipped = 0;
167+
const writtenFiles: string[] = [];
168+
const skippedFiles: string[] = [];
169+
170+
for (const entry of transcripts) {
171+
const timestamp = sanitiseTimestamp(entry.savedAt);
172+
const fileName = `${gameType}-${timestamp}.json`;
173+
const filePath = path.join(outDir, fileName);
174+
175+
if (fs.existsSync(filePath)) {
176+
skipped++;
177+
skippedFiles.push(fileName);
178+
continue;
179+
}
180+
181+
fs.writeFileSync(filePath, JSON.stringify(entry.transcript, null, 2) + '\n');
182+
written++;
183+
writtenFiles.push(fileName);
184+
}
185+
186+
// Print summary
187+
console.log('');
188+
console.log('=== Export Summary ===');
189+
console.log(`Game type: ${gameType}`);
190+
console.log(`Total transcripts: ${transcripts.length}`);
191+
console.log(`Written: ${written}`);
192+
console.log(`Skipped (already exist): ${skipped}`);
193+
194+
if (writtenFiles.length > 0) {
195+
console.log('');
196+
console.log('Written files:');
197+
for (const f of writtenFiles) {
198+
console.log(` + ${f}`);
199+
}
200+
}
201+
202+
if (skippedFiles.length > 0) {
203+
console.log('');
204+
console.log('Skipped files:');
205+
for (const f of skippedFiles) {
206+
console.log(` ~ ${f}`);
207+
}
208+
}
209+
} catch (err) {
210+
const message = err instanceof Error ? err.message : String(err);
211+
console.error(`Error: ${message}`);
212+
process.exit(1);
213+
} finally {
214+
if (browser) {
215+
await browser.close();
216+
}
217+
killDevServer(devServerChild);
218+
}
219+
}
220+
221+
main();

0 commit comments

Comments
 (0)