@opencraw/studio 0.0.34 → 0.0.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/index.d.ts +3 -1
  2. package/dist/index.esm.js +937 -653
  3. package/dist/src/index.d.ts +3 -1
  4. package/dist/src/studio-api/command.contract.d.ts +69 -0
  5. package/dist/src/studio-api/event.contract.d.ts +31 -0
  6. package/dist/src/studio-api/index.d.ts +8 -4
  7. package/dist/src/studio-api/studio-control-view.contract.d.ts +53 -0
  8. package/dist/src/studio-api/ui-request.contract.d.ts +15 -0
  9. package/dist/src/studio-instance/index.d.ts +3 -0
  10. package/dist/src/studio-instance/instance-file.repository.d.ts +14 -0
  11. package/dist/src/studio-instance/studio-instance.contract.d.ts +15 -0
  12. package/dist/src/studio-server/ask-ui.handler.d.ts +32 -0
  13. package/dist/src/studio-server/run-status.handler.d.ts +12 -0
  14. package/dist/src/studio-server/studio-http.use-case.d.ts +2 -0
  15. package/dist/src/studio-server/studio-status.handler.d.ts +10 -0
  16. package/dist/src/studio-server/workspace.store.d.ts +5 -1
  17. package/dist/studio-api/command.contract.d.ts +69 -0
  18. package/dist/studio-api/event.contract.d.ts +31 -0
  19. package/dist/studio-api/index.d.ts +8 -4
  20. package/dist/studio-api/studio-control-view.contract.d.ts +53 -0
  21. package/dist/studio-api/ui-request.contract.d.ts +15 -0
  22. package/dist/studio-instance/index.d.ts +3 -0
  23. package/dist/studio-instance/instance-file.repository.d.ts +14 -0
  24. package/dist/studio-instance/studio-instance.contract.d.ts +15 -0
  25. package/dist/studio-server/ask-ui.handler.d.ts +32 -0
  26. package/dist/studio-server/run-status.handler.d.ts +12 -0
  27. package/dist/studio-server/studio-http.use-case.d.ts +2 -0
  28. package/dist/studio-server/studio-status.handler.d.ts +10 -0
  29. package/dist/studio-server/workspace.store.d.ts +5 -1
  30. package/dist/ui/assets/{index-Db6-8OgZ.js → index-B40d4kiB.js} +26 -26
  31. package/dist/ui/assets/{pdf-rdp2xUbL.js → pdf-DS2CRtYv.js} +1 -1
  32. package/dist/ui/index.html +1 -1
  33. package/package.json +3 -3
package/dist/index.esm.js CHANGED
@@ -3,14 +3,14 @@ import { createServer } from 'node:http';
3
3
  import { promisify, stripVTControlCharacters } from 'node:util';
4
4
  import { fileURLToPath, pathToFileURL } from 'node:url';
5
5
  import { z } from 'zod';
6
- import { BODY_KINDS, formatOfResponse, HttpClient, BrowserClient, NODE_ID_ATTRIBUTE, sessionStateOf, rowsOfCells, parseXml, marginRows, findDeckTables, findGridTables, analyzeTables, parseRegion, regionText, deckRegionText, isDeckDocument, readRecipeSource, parseInputRecipe, parseOutputRecipe, calloutLabel, bindRecipeSet, memorySink, createCrawler, traceLine, UnknownHookError, resolveMissingPolicy, isWorkbookDocument, RecipeBindingError, RecipeValidationError, countMatches, matchesOf, tryParseJson, isPdfDocument, BrowserProfiles } from '@opencraw/core';
7
- import { randomBytes, createHash } from 'node:crypto';
6
+ import { BODY_KINDS, formatOfResponse, HttpClient, BrowserClient, NODE_ID_ATTRIBUTE, sessionStateOf, readRecipeSource, parseInputRecipe, parseOutputRecipe, calloutLabel, bindRecipeSet, memorySink, createCrawler, traceLine, UnknownHookError, rowsOfCells, parseXml, marginRows, findDeckTables, findGridTables, analyzeTables, parseRegion, regionText, deckRegionText, isDeckDocument, resolveMissingPolicy, isWorkbookDocument, RecipeBindingError, RecipeValidationError, countMatches, matchesOf, tryParseJson, isPdfDocument, BrowserProfiles } from '@opencraw/core';
7
+ import { randomBytes, randomUUID, createHash } from 'node:crypto';
8
8
  import { execFile } from 'node:child_process';
9
+ import { mkdir, writeFile, chmod, rm, readFile, rename, stat } from 'node:fs/promises';
10
+ import { homedir, tmpdir } from 'node:os';
9
11
  import { createRequire } from 'node:module';
10
12
  import { load } from 'cheerio';
11
- import { readFile, writeFile, rm, rename, mkdir, stat } from 'node:fs/promises';
12
13
  import { describeJson } from '@opencraw/probe';
13
- import { tmpdir } from 'node:os';
14
14
  import { existsSync, createReadStream } from 'node:fs';
15
15
 
16
16
  const sentencePartSchema = z.object({
@@ -41,6 +41,25 @@ const outlineViewSchema = z.object({
41
41
  steps: z.array(outlineNodeSchema)
42
42
  });
43
43
 
44
+ /** What an outside controller (an agent, through the MCP server) can ask the open Studio page to do. Grows one `kind` at a time. */
45
+ const uiRequestSchema = z.discriminatedUnion('kind', [/** Open a folder, a recipe file or a document, exactly as a Studio link does (`open-path`); `workspace` is the folder a document belongs to. */
46
+ z.object({
47
+ kind: z.literal('open'),
48
+ path: z.string().min(1),
49
+ workspace: z.string().min(1).optional()
50
+ }),
51
+ /**
52
+ * Pick a value on the recipe's web page, as a click would: the page finds the element by `selector` (a CSS selector) or by its `text`,
53
+ * stages it the way a click does (the ways to reach it are shown to choose from) and, with `add`, adds it to the recipe.
54
+ */
55
+ z.object({
56
+ kind: z.literal('pick'),
57
+ recipeId: z.string().min(1),
58
+ selector: z.string().min(1).optional(),
59
+ text: z.string().min(1).optional(),
60
+ add: z.boolean().optional()
61
+ })]);
62
+
44
63
  /** A budget for a sample run; never authored into a recipe, only asked for by the caller. */
45
64
  const sampleBudgetSchema = z.object({
46
65
  maxRecords: z.number().int().positive().optional(),
@@ -373,7 +392,35 @@ const importDocumentCommandSchema = z.object({
373
392
  name: z.string().min(1),
374
393
  bytes: z.string()
375
394
  });
376
- const studioCommandSchema = z.discriminatedUnion('type', [openWorkspaceCommandSchema, chooseFolderCommandSchema, openPathCommandSchema, importDocumentCommandSchema, runSampleCommandSchema, stopRunCommandSchema, saveRecipeCommandSchema, fetchStartPageCommandSchema, detectUrlCommandSchema, saveOutlineCommandSchema, takeSnapshotCommandSchema, verifySelectorCommandSchema, inferSelectorCommandSchema, explainWhyCommandSchema, inspectPageCommandSchema, responsesSeenCommandSchema, documentTreeCommandSchema, pdfViewCommandSchema, tablePreviewCommandSchema, regionPreviewCommandSchema, gridViewCommandSchema, gridRowsCommandSchema, gridPreviewCommandSchema, deckViewCommandSchema, deckPreviewCommandSchema, startRecordingCommandSchema, stopRecordingCommandSchema]);
395
+ /** Asks every open Studio page to do something (`ui-request`); the answer says how many pages were listening. How an agent opens a recipe for the person who is watching. */
396
+ const askUiCommandSchema = z.object({
397
+ type: z.literal('ask-ui'),
398
+ request: uiRequestSchema
399
+ });
400
+ /** The page tells the server how a controller's request went, so the controller can find out (`ui-outcome`). */
401
+ const uiReportCommandSchema = z.object({
402
+ type: z.literal('ui-report'),
403
+ requestId: z.string().min(1),
404
+ ok: z.boolean(),
405
+ message: z.string(),
406
+ /** What the page found or did, for the controller to read. */
407
+ detail: z.record(z.string(), z.unknown()).optional()
408
+ });
409
+ /** Asks how a controller's request went: still waiting for the page, or done. */
410
+ const uiOutcomeCommandSchema = z.object({
411
+ type: z.literal('ui-outcome'),
412
+ requestId: z.string().min(1)
413
+ });
414
+ /** Asks what Studio is doing: its workspace, how many pages are open and the run in progress. */
415
+ const studioStatusCommandSchema = z.object({
416
+ type: z.literal('studio-status')
417
+ });
418
+ /** Asks how a recipe's sample run is going: still running, finished (with its first records) or never run. */
419
+ const runStatusCommandSchema = z.object({
420
+ type: z.literal('run-status'),
421
+ recipeId: z.string().min(1)
422
+ });
423
+ const studioCommandSchema = z.discriminatedUnion('type', [openWorkspaceCommandSchema, chooseFolderCommandSchema, openPathCommandSchema, importDocumentCommandSchema, runSampleCommandSchema, stopRunCommandSchema, saveRecipeCommandSchema, fetchStartPageCommandSchema, detectUrlCommandSchema, saveOutlineCommandSchema, takeSnapshotCommandSchema, verifySelectorCommandSchema, inferSelectorCommandSchema, explainWhyCommandSchema, inspectPageCommandSchema, responsesSeenCommandSchema, documentTreeCommandSchema, pdfViewCommandSchema, tablePreviewCommandSchema, regionPreviewCommandSchema, gridViewCommandSchema, gridRowsCommandSchema, gridPreviewCommandSchema, deckViewCommandSchema, deckPreviewCommandSchema, startRecordingCommandSchema, stopRecordingCommandSchema, askUiCommandSchema, uiReportCommandSchema, uiOutcomeCommandSchema, studioStatusCommandSchema, runStatusCommandSchema]);
377
424
 
378
425
  /**
379
426
  * What `take-snapshot` answers with: the rewritten document ready for the
@@ -531,7 +578,14 @@ const recordingStoppedEventSchema = z.object({
531
578
  type: z.literal('recording-stopped'),
532
579
  steps: z.array(recordedStepSchema)
533
580
  });
534
- const studioEventSchema = z.discriminatedUnion('type', [traceLineEventSchema, recordEventSchema, recordRejectedEventSchema, runFinishedEventSchema, workspaceChangedEventSchema, recordingCardEventSchema, recordingNoteEventSchema, recordingStoppedEventSchema]);
581
+ /** A controller (an agent, through the MCP server) asked the open page to do something; every page does it. */
582
+ const uiRequestEventSchema = z.object({
583
+ type: z.literal('ui-request'),
584
+ /** Names the request, so the page can report how it went (`ui-report`) and the controller can ask (`ui-outcome`). */
585
+ requestId: z.string().min(1),
586
+ request: uiRequestSchema
587
+ });
588
+ const studioEventSchema = z.discriminatedUnion('type', [traceLineEventSchema, recordEventSchema, recordRejectedEventSchema, runFinishedEventSchema, workspaceChangedEventSchema, recordingCardEventSchema, recordingNoteEventSchema, recordingStoppedEventSchema, uiRequestEventSchema]);
535
589
 
536
590
  /** One problem found on a recipe file, with the JSON path it lives at. */
537
591
  const recipeIssueSchema = z.object({
@@ -913,6 +967,45 @@ const deckTablePreviewViewSchema = z.object({
913
967
  error: z.string().optional()
914
968
  });
915
969
 
970
+ /** How many pages were listening when a controller asked them to do something: none means nobody has Studio open. */
971
+ z.object({
972
+ pages: z.number(),
973
+ requestId: z.string()
974
+ });
975
+ /** How a controller's request went: `pending` until a page reported, then what it said. */
976
+ z.object({
977
+ state: z.enum(['pending', 'done']),
978
+ ok: z.boolean().optional(),
979
+ message: z.string().optional(),
980
+ detail: z.record(z.string(), z.unknown()).optional()
981
+ });
982
+ /** What Studio is doing: its workspace, how many pages are open, and the recipe whose sample run is in progress. */
983
+ z.object({
984
+ folder: z.string().optional(),
985
+ pages: z.number(),
986
+ running: z.string().optional()
987
+ });
988
+ const rejectionSchema = z.object({
989
+ field: z.string(),
990
+ reason: z.string(),
991
+ url: z.string()
992
+ });
993
+ /** How a recipe's sample run is going; `result` only once it finished: the counts, the first records and the first rejections (the rest are in Studio). */
994
+ z.object({
995
+ recipeId: z.string(),
996
+ state: z.enum(['running', 'finished', 'never-run']),
997
+ result: z.object({
998
+ emitted: z.number(),
999
+ rejected: z.number(),
1000
+ duplicates: z.number(),
1001
+ durationMs: z.number(),
1002
+ error: z.string().optional(),
1003
+ stoppedBy: z.string().optional(),
1004
+ records: z.array(sampleRunRecordSchema),
1005
+ rejections: z.array(rejectionSchema)
1006
+ }).optional()
1007
+ });
1008
+
916
1009
  /** The cookie set on the page's own document response, so its own asset and API requests carry the token too. */
917
1010
  const TOKEN_COOKIE = 'opencraw_token';
918
1011
  /** A random token, generated once at server startup and required on every request. */
@@ -1064,30 +1157,41 @@ async function chooseFolderWithDialog(platform = process.platform, run = runDial
1064
1157
  throw new Error(`there is no folder dialog on this machine${tried === '' ? '' : ` (${tried} is not installed)`}: type the folder instead`);
1065
1158
  }
1066
1159
 
1067
- /** Asks the dialog, and clears `openDialog` once it has answered or failed, so the next request opens a new one. */
1068
- async function askOnce(state, ask) {
1069
- try {
1070
- return await ask();
1071
- } finally {
1072
- state.openDialog = undefined;
1073
- }
1160
+ /** The announcement's file: one per process and port, so two Studios in one process do not share it. */
1161
+ function fileOf(instance, directory) {
1162
+ return join(directory, `${String(instance.pid)}-${new URL(instance.url).port}.json`);
1163
+ }
1164
+ /** Where running Studios announce themselves, unless the caller says otherwise. */
1165
+ function defaultInstancesDirectory() {
1166
+ return join(homedir(), '.opencraw', 'studio');
1074
1167
  }
1075
1168
  /**
1076
- * Handles `choose-folder`: opens the folder dialog of the machine the server
1077
- * runs on and answers the folder picked. Only answers: the UI follows with
1078
- * `open-workspace`, the same as when the folder is typed. A dialog still
1079
- * open is not opened a second time; the second request waits for the first.
1169
+ * Writes the file that tells controllers this Studio is running. Failing to write it never stops Studio: it only means no agent can find it.
1080
1170
  *
1081
- * @param state - The server state holding the dialog to use.
1082
- * @returns The folder, or none when the dialog was closed.
1171
+ * @param instance - What to announce.
1172
+ * @param directory - Where announcements live.
1173
+ * @returns Whether the file was written.
1083
1174
  */
1084
- async function handleChooseFolder(state) {
1085
- if (state.chooseFolder === undefined) throw new Error('this server has no folder dialog: type the folder instead');
1086
- state.openDialog ??= askOnce(state, state.chooseFolder);
1087
- const folder = await state.openDialog;
1088
- return folder === undefined ? {} : {
1089
- folder
1090
- };
1175
+ async function announceInstance(instance, directory) {
1176
+ try {
1177
+ await mkdir(directory, {
1178
+ recursive: true
1179
+ });
1180
+ const file = fileOf(instance, directory);
1181
+ await writeFile(file, JSON.stringify(instance), {
1182
+ mode: 0o600
1183
+ });
1184
+ await chmod(file, 0o600);
1185
+ return true;
1186
+ } catch {
1187
+ return false;
1188
+ }
1189
+ }
1190
+ /** Removes the announcement, when Studio stops. */
1191
+ async function withdrawInstance(instance, directory) {
1192
+ await rm(fileOf(instance, directory), {
1193
+ force: true
1194
+ });
1091
1195
  }
1092
1196
 
1093
1197
  /** How much of the start of a response is looked at: enough for the engine to tell a PDF or an Office package from its bytes, and for JSON to start. */
@@ -1980,156 +2084,515 @@ async function savedRecipe(file) {
1980
2084
  }
1981
2085
  }
1982
2086
 
1983
- /** A cell reads as an ISO date (`2026-06-01`), an ISO moment (`2026-06-01T09:30:00`) or a bare time of day (`09:30:00`) — the three shapes `workbook-document`'s `read-xlsx.client.ts` writes for a spreadsheet date. */
1984
- const ISO_DATE_OR_TIME = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2})?$|^\d{2}:\d{2}:\d{2}$/;
1985
2087
  /**
1986
- * Builds the grid canvas's view model (studio plan §3.4, issue #94's 5c) from
1987
- * an already-read `WorkbookDocument` (`take-snapshot`'s cached body — no
1988
- * second read of the file): every sheet's cells, typed, with hidden sheets
1989
- * and rows marked, and merged ranges resolved to bounds so the grid draws
1990
- * them as one cell. Table-specific diagnostics (the matched rows of the
1991
- * current `table` extract's options) are not here — they depend on the
1992
- * picks so far, so they come from `table-preview.use-case.ts`'s
1993
- * `previewGridTable` instead, computed live as those options change.
2088
+ * Finds and parses one input recipe of a workspace folder, and the output
2089
+ * recipe it names.
1994
2090
  *
1995
- * @param document - The read workbook (a spreadsheet or a CSV).
1996
- * @returns The view.
2091
+ * @param folder - The workspace folder.
2092
+ * @param recipeId - The input recipe's id.
2093
+ * @returns The parsed pair.
2094
+ * @throws RecipeValidationError when either recipe does not parse.
2095
+ * @throws Error when the input or its output is not in the folder.
1997
2096
  */
1998
- function workbookDocumentView(document, options = {}) {
2097
+ async function loadRecipePair(folder, recipeId) {
2098
+ const documents = await readRecipeSource(folder);
2099
+ const inputDocument = documents.find(document => kindOf(document.content) === 'input' && idOf(document.content) === recipeId);
2100
+ if (inputDocument === undefined) throw new Error(`no input recipe "${recipeId}" in this workspace`);
2101
+ const input = parseInputRecipe(inputDocument.content, inputDocument.source);
2102
+ const outputDocument = documents.find(document => kindOf(document.content) === 'output' && idOf(document.content) === input.output);
2103
+ if (outputDocument === undefined) throw new Error(`no output recipe "${input.output}" in this workspace`);
1999
2104
  return {
2000
- sheets: document.sheets.map(sheet => sheetView(sheet, options.windowRows)),
2001
- ...(document.csv !== undefined && {
2002
- csv: document.csv
2003
- })
2105
+ input,
2106
+ output: parseOutputRecipe(outputDocument.content, outputDocument.source)
2004
2107
  };
2005
2108
  }
2006
- function sheetView(sheet, windowRows) {
2007
- const windowed = windowRows !== undefined && sheet.rows.length > windowRows;
2008
- return {
2009
- name: sheet.name,
2010
- hidden: sheet.hidden === true,
2011
- hiddenRows: sheet.hiddenRows === undefined ? [] : [...sheet.hiddenRows],
2012
- // Not `Math.max(...lengths)`: a spread of a few hundred thousand rows overflows the call stack.
2013
- columnCount: sheet.rows.reduce((widest, row) => Math.max(widest, row.length), 0),
2014
- rows: (windowed ? sheet.rows.slice(0, windowRows) : sheet.rows).map(row => row.map(cell => cellView(cell))),
2015
- ...(windowed && {
2016
- rowCount: sheet.rows.length
2017
- }),
2018
- merges: (sheet.merges ?? []).flatMap(reference => {
2019
- const range = rangeOf(reference);
2020
- return range === undefined ? [] : [{
2021
- ref: reference,
2022
- ...range
2023
- }];
2024
- }),
2025
- ...(sheet.columnWidths !== undefined && {
2026
- columnWidths: {
2027
- ...sheet.columnWidths
2028
- }
2029
- }),
2030
- ...(sheet.rowHeights !== undefined && {
2031
- rowHeights: {
2032
- ...sheet.rowHeights
2033
- }
2034
- }),
2035
- ...(sheet.defaultColumnWidth !== undefined && {
2036
- defaultColumnWidth: sheet.defaultColumnWidth
2037
- }),
2038
- ...(sheet.defaultRowHeight !== undefined && {
2039
- defaultRowHeight: sheet.defaultRowHeight
2040
- })
2041
- };
2109
+ function kindOf(content) {
2110
+ return content?.kind;
2111
+ }
2112
+ function idOf(content) {
2113
+ return content?.id;
2042
2114
  }
2115
+
2043
2116
  /**
2044
- * A run of one sheet's rows, typed, for a grid that only holds the screens around where it is scrolled to (the rest of a big file is never
2045
- * sent to the browser until it is needed).
2117
+ * The hooks a sample run uses: the ones Studio was started with, except that
2046
2118
  *
2047
- * @param document - The read workbook.
2048
- * @param sheetIndex - Which sheet, 0-based.
2049
- * @param from - The first row, 0-based.
2050
- * @param count - How many rows at most.
2051
- * @returns The rows (fewer at the end of the sheet, none past it).
2119
+ * - a hook the person stubbed returns the stub's value and calls nothing, and
2120
+ * - a hook that calls outside the process says so in the trace, once per call, because it may be slow
2121
+ * and it may cost money.
2122
+ *
2123
+ * @param hooks - The hooks Studio was started with.
2124
+ * @param stubs - Values to answer with instead of calling a hook, by hook name. A name that is not a hook is ignored.
2125
+ * @param announce - Where the trace lines go.
2126
+ * @returns The hooks to run the sample with.
2052
2127
  */
2053
- function workbookSheetRows(document, sheetIndex, from, count) {
2054
- const sheet = document.sheets[sheetIndex];
2055
- if (sheet === undefined) return [];
2056
- return sheet.rows.slice(from, from + count).map(row => row.map(cell => cellView(cell)));
2057
- }
2058
- function cellView(cell) {
2059
- return {
2060
- value: cell,
2061
- type: cellType(cell)
2062
- };
2063
- }
2064
- function cellType(cell) {
2065
- if (typeof cell === 'number') return 'number';
2066
- if (typeof cell === 'boolean') return 'boolean';
2067
- return ISO_DATE_OR_TIME.test(cell) ? 'date' : 'string';
2068
- }
2069
- /** `B10:B13` (or a single cell, `A1`) as 0-based inclusive bounds; `undefined` for anything else — mirrors `@opencraw/core`'s own (unexported) `rangeOf`/`cellOf` in `grid-table.algorithm.ts`: a small pure parse, kept here rather than reached for across the package boundary (a sibling slice's non-`index.ts` file is never imported, and this is `@opencraw/core`'s own private helper, not even on its barrel). */
2070
- function rangeOf(reference) {
2071
- const [from, to = from] = reference.split(':', 2);
2072
- const start = cellOf(from);
2073
- const end = cellOf(to);
2074
- if (start === undefined || end === undefined) return undefined;
2075
- return {
2076
- top: Math.min(start.row, end.row),
2077
- left: Math.min(start.column, end.column),
2078
- bottom: Math.max(start.row, end.row),
2079
- right: Math.max(start.column, end.column)
2080
- };
2081
- }
2082
- function cellOf(reference) {
2083
- const match = /^\$?([A-Z]+)\$?(\d+)$/i.exec(reference.trim());
2084
- if (match === null) return undefined;
2085
- const letters = match[1].toUpperCase();
2086
- let column = 0;
2087
- for (const char of letters) column = column * 26 + (char.codePointAt(0) ?? 64) - 64;
2088
- return {
2089
- row: Number(match[2]) - 1,
2090
- column: column - 1
2091
- };
2128
+ function calloutHooks(hooks, stubs, announce) {
2129
+ if (hooks === undefined) return undefined;
2130
+ const prepared = {};
2131
+ for (const [name, hook] of Object.entries(hooks)) {
2132
+ if (stubs !== undefined && Object.hasOwn(stubs, name)) {
2133
+ const stub = stubs[name];
2134
+ prepared[name] = () => {
2135
+ announce(`☎ hook "${name}" is stubbed: answering ${JSON.stringify(stub) ?? 'undefined'} without calling it`);
2136
+ return stub;
2137
+ };
2138
+ continue;
2139
+ }
2140
+ const label = calloutLabel(hook);
2141
+ prepared[name] = label === undefined ? hook : async (input, args, context) => {
2142
+ announce(`☎ hook "${name}" calls ${label} (slow, and it may cost: stub it to skip the call)`);
2143
+ return await hook(input, args, context);
2144
+ };
2145
+ }
2146
+ return prepared;
2092
2147
  }
2093
2148
 
2094
2149
  /**
2095
- * Builds the deck canvas's view model (studio plan §3.4, issue #94's 5d)
2096
- * from an already-read `DeckDocument` (`take-snapshot`'s cached body — no
2097
- * second read of the file): every slide's shapes (with their visual-row
2098
- * grouping, for the text-box-grid pick), native tables (through
2099
- * `workbook-view.mapper.ts`'s own sheet view — a deck table *is* a sheet),
2100
- * charts and notes. Table-specific diagnostics for a `table` extract's own
2101
- * options (the live preview) are not here — they come from
2102
- * `table-preview.use-case.ts`'s `previewDeckTable` instead, computed live as
2103
- * those options change, the same split `pdf-view.mapper.ts`/`workbook-view.mapper.ts`
2104
- * already use.
2150
+ * Runs one input recipe of a workspace folder as a sample: `debug: true` (so
2151
+ * each `record:emit` carries its scope snapshot and mapping trace, and each
2152
+ * `record:reject` its scope), an in-memory sink, and `budget` as the
2153
+ * crawler's `sample` (`@opencraw/core`'s `CrawlOptions.sample`, #89). Every
2154
+ * engine event becomes a trace line with the same `traceLine` formatter the
2155
+ * cli's `--trace` uses; every emitted record reaches `callbacks.onRecord` as
2156
+ * it happens, not only in the final result. Step events (`step:start`,
2157
+ * `step:skip`, `step:retry`) are aggregated by path into `result.steps`, so
2158
+ * `explain-why` (#92) can later say whether the step that bound a mapping's
2159
+ * source id ran, was skipped, or retried — without a second run.
2105
2160
  *
2106
- * @param document - The read deck.
2107
- * @returns The view.
2161
+ * @param folder - The workspace folder the recipe lives in.
2162
+ * @param recipeId - The input recipe to run.
2163
+ * @param budget - The sample budget; unset runs the recipe to completion (still capped by its own `limits`).
2164
+ * @param callbacks - Where trace lines and records go as the run proceeds.
2165
+ * @param browser - Browser launch settings for a web-mode recipe (an executable path override, headless…); the server-wide setting `studio-http.use-case.ts`'s `StudioServerOptions.browser` carries in, e.g. `OPENCRAW_CHROMIUM` in a sandbox with no full Playwright install.
2166
+ * @param plugins - Hooks, access plugins and captcha solvers the person started Studio with (`--hooks`, issue #150); none when it was started without.
2167
+ * @param stubs - Values a hook answers with instead of being called, by hook name, for this run only (issue #201): how a sample skips a slow or paid callout.
2168
+ * @returns A handle: `stop` closes the crawler cleanly, `result` resolves when the run ends.
2169
+ * @throws RecipeValidationError, RecipeBindingError, Error when the recipe or its output cannot be loaded or bound.
2108
2170
  */
2109
- function deckDocumentView(document) {
2110
- return {
2111
- width: document.width,
2112
- height: document.height,
2113
- slides: document.slides.map(slide => slideView(slide, document.height))
2114
- };
2115
- }
2116
- function slideView(slide, slideHeight) {
2117
- return {
2118
- number: slide.number,
2119
- ...(slide.title !== undefined && {
2120
- title: slide.title
2121
- }),
2122
- hidden: slide.hidden,
2123
- shapes: slide.shapes.map(shape => shapeView(shape)),
2124
- shapeRows: shapeRowsOf(slide.shapes, slideHeight),
2125
- tables: workbookDocumentView({
2126
- sheets: slide.tables
2127
- }).sheets,
2128
- charts: slide.charts.map(chart => chartView(chart)),
2129
- notes: slide.notes
2130
- };
2131
- }
2132
- function shapeView(shape) {
2171
+ async function runSample(folder, recipeId, budget, callbacks, browser, plugins, stubs) {
2172
+ const {
2173
+ input,
2174
+ output
2175
+ } = await loadRecipePair(folder, recipeId);
2176
+ const set = bindRecipeSet(output, [input]);
2177
+ const sink = memorySink();
2178
+ const records = [];
2179
+ const rejectedRecords = [];
2180
+ const steps = new Map();
2181
+ const crawler = createCrawler({
2182
+ sink,
2183
+ debug: true,
2184
+ sample: budget,
2185
+ browser,
2186
+ hooks: calloutHooks(plugins?.hooks, stubs, callbacks.onTraceLine),
2187
+ accessPlugins: plugins?.accessPlugins,
2188
+ captchaSolvers: plugins?.captchaSolvers,
2189
+ onEvent: event => {
2190
+ const line = traceLine(event);
2191
+ if (line !== undefined) callbacks.onTraceLine(line);
2192
+ trackStep(steps, event);
2193
+ if (event.type === 'record:emit') {
2194
+ const record = {
2195
+ key: event.key,
2196
+ data: event.data,
2197
+ scope: event.scope,
2198
+ mapping: event.mapping
2199
+ };
2200
+ records.push(record);
2201
+ callbacks.onRecord(record);
2202
+ } else if (event.type === 'record:reject') {
2203
+ const rejected = {
2204
+ field: event.field,
2205
+ reason: event.reason,
2206
+ url: event.url,
2207
+ scope: event.scope
2208
+ };
2209
+ rejectedRecords.push(rejected);
2210
+ callbacks.onRejected?.(rejected);
2211
+ }
2212
+ }
2213
+ });
2214
+ const result = runToResult(crawler, set, recipeId, records, rejectedRecords, steps, plugins);
2215
+ return {
2216
+ stop: () => crawler.close(),
2217
+ result
2218
+ };
2219
+ }
2220
+ /**
2221
+ * Folds one `step:*` event into `steps`, by path: `skip` always wins over an
2222
+ * earlier `ran`/`retried` outcome for the same path (a step skipped even
2223
+ * once is worth flagging), `retry` wins over a plain `ran`, and a path seen
2224
+ * only as `finish`/`start` (no skip, no retry) settles on `ran`. A `forEach`
2225
+ * runs its body once per item, so the same path folds repeatedly; this is
2226
+ * deliberately a summary across the whole sample, not a per-record trace.
2227
+ */
2228
+ function trackStep(steps, event) {
2229
+ if (event.type === 'step:start') {
2230
+ if (!steps.has(event.path)) steps.set(event.path, {
2231
+ path: event.path,
2232
+ stepType: event.stepType,
2233
+ stepId: event.stepId,
2234
+ outcome: 'ran'
2235
+ });
2236
+ return;
2237
+ }
2238
+ if (event.type === 'step:retry') {
2239
+ steps.set(event.path, {
2240
+ path: event.path,
2241
+ stepType: event.stepType,
2242
+ stepId: event.stepId,
2243
+ outcome: 'retried',
2244
+ error: event.error
2245
+ });
2246
+ return;
2247
+ }
2248
+ if (event.type === 'step:skip') {
2249
+ steps.set(event.path, {
2250
+ path: event.path,
2251
+ stepType: event.stepType,
2252
+ stepId: event.stepId,
2253
+ outcome: 'skipped',
2254
+ error: event.error
2255
+ });
2256
+ }
2257
+ }
2258
+ /**
2259
+ * Awaits the run and shapes its result. A throw from `crawler.run` (an unknown
2260
+ * hook, a recipe that cannot start) becomes the run's `error` instead of a
2261
+ * rejection: nothing awaits `result` but a `.then`, and an unhandled rejection
2262
+ * would take the whole Studio server down (issue #148).
2263
+ */
2264
+ async function runToResult(crawler, set, recipeId, records, rejectedRecords, steps, plugins) {
2265
+ try {
2266
+ const report = await crawler.run(set);
2267
+ const recipe = report.recipes[0];
2268
+ return {
2269
+ recipeId: recipe.recipeId,
2270
+ emitted: recipe.emitted,
2271
+ rejected: recipe.rejected,
2272
+ duplicates: recipe.duplicates,
2273
+ durationMs: recipe.durationMs,
2274
+ error: recipe.error,
2275
+ stoppedBy: recipe.stoppedBy,
2276
+ records,
2277
+ rejectedRecords,
2278
+ // eslint-disable-next-line unicorn/prefer-iterator-to-array -- `.toArray()` needs a `lib` newer than this repo's `es2022` (tsconfig.base.json)
2279
+ steps: [...steps.values()]
2280
+ };
2281
+ } catch (error) {
2282
+ return {
2283
+ recipeId,
2284
+ emitted: 0,
2285
+ rejected: 0,
2286
+ duplicates: 0,
2287
+ durationMs: 0,
2288
+ error: failureMessage(error, plugins),
2289
+ records,
2290
+ rejectedRecords,
2291
+ // eslint-disable-next-line unicorn/prefer-iterator-to-array -- `.toArray()` needs a `lib` newer than this repo's `es2022` (tsconfig.base.json)
2292
+ steps: [...steps.values()]
2293
+ };
2294
+ } finally {
2295
+ await crawler.close();
2296
+ }
2297
+ }
2298
+ /** What a run that could not start reports. A hook is the author's own code, which Studio only runs when it was started with `--hooks` (issue #150): say which case this is, instead of leaving "unknown hook" to read as a typo. */
2299
+ function failureMessage(error, plugins) {
2300
+ if (error instanceof UnknownHookError) {
2301
+ const message = stripVTControlCharacters(error.message);
2302
+ if (plugins !== undefined) return `${message}. It is not one of the hooks in ${plugins.source}: ${Object.keys(plugins.hooks).join(', ') || 'none exported'}.`;
2303
+ return `${message}. Studio was started without hooks: restart it with \`opencraw studio --hooks <file>\` (a module you trust; it runs as your code), or temporarily replace the hook step to preview the rest.`;
2304
+ }
2305
+ if (error instanceof Error) return stripVTControlCharacters(error.message);
2306
+ return stripVTControlCharacters(String(error));
2307
+ }
2308
+
2309
+ /** @returns A fresh, empty cache. */
2310
+ function createLastRunCache() {
2311
+ return {
2312
+ entries: new Map()
2313
+ };
2314
+ }
2315
+ /** Records a finished sample run, replacing whatever this recipe's cache entry held before. */
2316
+ function recordLastRun(cache, recipeId, result) {
2317
+ cache.entries.set(recipeId, {
2318
+ recipeId,
2319
+ result
2320
+ });
2321
+ }
2322
+ /** @returns The recipe's last completed sample run, or `undefined` when none has finished yet. */
2323
+ function lastRunOf(cache, recipeId) {
2324
+ return cache.entries.get(recipeId);
2325
+ }
2326
+
2327
+ /** Where the studio's own recorder profile lives when `StudioServerOptions.recorderProfileDir` is not given: never a crawl's own `session.browserProfile` directory, never the person's default browser profile. */
2328
+ function defaultRecorderProfileDir() {
2329
+ return join(tmpdir(), 'opencraw-studio', 'recorder-profile');
2330
+ }
2331
+ /** @returns Fresh state for a new server instance: no workspace open, no run, no recording, no sockets, an empty snapshot cache, no last runs, Playwright's own default browser, the default recorder profile directory. */
2332
+ function createStudioState() {
2333
+ return {
2334
+ sockets: new Set(),
2335
+ uiOutcomes: new Map(),
2336
+ snapshots: createSnapshotCache(),
2337
+ lastRuns: createLastRunCache(),
2338
+ recorderProfileDir: defaultRecorderProfileDir()
2339
+ };
2340
+ }
2341
+ /**
2342
+ * Sends one event to every connected client. A socket that errors while
2343
+ * sending (gone away without a clean close yet reaching us) is dropped
2344
+ * rather than left to fail every later broadcast too.
2345
+ *
2346
+ * @param state - The server state holding the connected sockets.
2347
+ * @param event - The event to send.
2348
+ */
2349
+ function broadcast(state, event) {
2350
+ const text = JSON.stringify(event);
2351
+ for (const socket of state.sockets) {
2352
+ try {
2353
+ socket.send(text);
2354
+ } catch {
2355
+ state.sockets.delete(socket);
2356
+ }
2357
+ }
2358
+ }
2359
+
2360
+ /** How many outcomes are kept: a controller asks soon after it sent the request, so a few dozen is plenty. */
2361
+ const KEPT_OUTCOMES = 50;
2362
+ /**
2363
+ * Handles `ask-ui`: tells every open page what a controller (an agent, through the MCP server) asked for, so
2364
+ * the person watching sees it happen. Nothing is done server-side: the page does it the way a click would, and
2365
+ * says how it went (`ui-report`).
2366
+ *
2367
+ * @param state - The server state holding the connected pages.
2368
+ * @param command - What to ask the pages.
2369
+ * @returns How many pages were listening (0 means nobody has Studio open) and the id to ask the outcome with.
2370
+ */
2371
+ function handleAskUi(state, command) {
2372
+ const requestId = randomUUID();
2373
+ broadcast(state, {
2374
+ type: 'ui-request',
2375
+ requestId,
2376
+ request: command.request
2377
+ });
2378
+ return {
2379
+ pages: state.sockets.size,
2380
+ requestId
2381
+ };
2382
+ }
2383
+ /**
2384
+ * Handles `ui-report`: a page says how a controller's request went. The first answer stays: every open page gets the
2385
+ * request, and a page that has nothing to do with it (another recipe open) does not report.
2386
+ *
2387
+ * @param state - The server state.
2388
+ * @param command - The request's id and the outcome.
2389
+ * @returns Whether the report was kept.
2390
+ */
2391
+ function handleUiReport(state, command) {
2392
+ if (state.uiOutcomes.has(command.requestId)) return {
2393
+ kept: false
2394
+ };
2395
+ state.uiOutcomes.set(command.requestId, {
2396
+ state: 'done',
2397
+ ok: command.ok,
2398
+ message: command.message,
2399
+ detail: command.detail
2400
+ });
2401
+ const [oldest] = state.uiOutcomes.keys();
2402
+ if (oldest !== undefined && state.uiOutcomes.size > KEPT_OUTCOMES) state.uiOutcomes.delete(oldest);
2403
+ return {
2404
+ kept: true
2405
+ };
2406
+ }
2407
+ /**
2408
+ * Handles `ui-outcome`: how a controller's request went.
2409
+ *
2410
+ * @param state - The server state.
2411
+ * @param command - The request's id.
2412
+ * @returns `pending` until a page reported, then what it said.
2413
+ */
2414
+ function handleUiOutcome(state, command) {
2415
+ return state.uiOutcomes.get(command.requestId) ?? {
2416
+ state: 'pending'
2417
+ };
2418
+ }
2419
+
2420
+ /** Asks the dialog, and clears `openDialog` once it has answered or failed, so the next request opens a new one. */
2421
+ async function askOnce(state, ask) {
2422
+ try {
2423
+ return await ask();
2424
+ } finally {
2425
+ state.openDialog = undefined;
2426
+ }
2427
+ }
2428
+ /**
2429
+ * Handles `choose-folder`: opens the folder dialog of the machine the server
2430
+ * runs on and answers the folder picked. Only answers: the UI follows with
2431
+ * `open-workspace`, the same as when the folder is typed. A dialog still
2432
+ * open is not opened a second time; the second request waits for the first.
2433
+ *
2434
+ * @param state - The server state holding the dialog to use.
2435
+ * @returns The folder, or none when the dialog was closed.
2436
+ */
2437
+ async function handleChooseFolder(state) {
2438
+ if (state.chooseFolder === undefined) throw new Error('this server has no folder dialog: type the folder instead');
2439
+ state.openDialog ??= askOnce(state, state.chooseFolder);
2440
+ const folder = await state.openDialog;
2441
+ return folder === undefined ? {} : {
2442
+ folder
2443
+ };
2444
+ }
2445
+
2446
+ /** A cell reads as an ISO date (`2026-06-01`), an ISO moment (`2026-06-01T09:30:00`) or a bare time of day (`09:30:00`) — the three shapes `workbook-document`'s `read-xlsx.client.ts` writes for a spreadsheet date. */
2447
+ const ISO_DATE_OR_TIME = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2})?$|^\d{2}:\d{2}:\d{2}$/;
2448
+ /**
2449
+ * Builds the grid canvas's view model (studio plan §3.4, issue #94's 5c) from
2450
+ * an already-read `WorkbookDocument` (`take-snapshot`'s cached body — no
2451
+ * second read of the file): every sheet's cells, typed, with hidden sheets
2452
+ * and rows marked, and merged ranges resolved to bounds so the grid draws
2453
+ * them as one cell. Table-specific diagnostics (the matched rows of the
2454
+ * current `table` extract's options) are not here — they depend on the
2455
+ * picks so far, so they come from `table-preview.use-case.ts`'s
2456
+ * `previewGridTable` instead, computed live as those options change.
2457
+ *
2458
+ * @param document - The read workbook (a spreadsheet or a CSV).
2459
+ * @returns The view.
2460
+ */
2461
+ function workbookDocumentView(document, options = {}) {
2462
+ return {
2463
+ sheets: document.sheets.map(sheet => sheetView(sheet, options.windowRows)),
2464
+ ...(document.csv !== undefined && {
2465
+ csv: document.csv
2466
+ })
2467
+ };
2468
+ }
2469
+ function sheetView(sheet, windowRows) {
2470
+ const windowed = windowRows !== undefined && sheet.rows.length > windowRows;
2471
+ return {
2472
+ name: sheet.name,
2473
+ hidden: sheet.hidden === true,
2474
+ hiddenRows: sheet.hiddenRows === undefined ? [] : [...sheet.hiddenRows],
2475
+ // Not `Math.max(...lengths)`: a spread of a few hundred thousand rows overflows the call stack.
2476
+ columnCount: sheet.rows.reduce((widest, row) => Math.max(widest, row.length), 0),
2477
+ rows: (windowed ? sheet.rows.slice(0, windowRows) : sheet.rows).map(row => row.map(cell => cellView(cell))),
2478
+ ...(windowed && {
2479
+ rowCount: sheet.rows.length
2480
+ }),
2481
+ merges: (sheet.merges ?? []).flatMap(reference => {
2482
+ const range = rangeOf(reference);
2483
+ return range === undefined ? [] : [{
2484
+ ref: reference,
2485
+ ...range
2486
+ }];
2487
+ }),
2488
+ ...(sheet.columnWidths !== undefined && {
2489
+ columnWidths: {
2490
+ ...sheet.columnWidths
2491
+ }
2492
+ }),
2493
+ ...(sheet.rowHeights !== undefined && {
2494
+ rowHeights: {
2495
+ ...sheet.rowHeights
2496
+ }
2497
+ }),
2498
+ ...(sheet.defaultColumnWidth !== undefined && {
2499
+ defaultColumnWidth: sheet.defaultColumnWidth
2500
+ }),
2501
+ ...(sheet.defaultRowHeight !== undefined && {
2502
+ defaultRowHeight: sheet.defaultRowHeight
2503
+ })
2504
+ };
2505
+ }
2506
+ /**
2507
+ * A run of one sheet's rows, typed, for a grid that only holds the screens around where it is scrolled to (the rest of a big file is never
2508
+ * sent to the browser until it is needed).
2509
+ *
2510
+ * @param document - The read workbook.
2511
+ * @param sheetIndex - Which sheet, 0-based.
2512
+ * @param from - The first row, 0-based.
2513
+ * @param count - How many rows at most.
2514
+ * @returns The rows (fewer at the end of the sheet, none past it).
2515
+ */
2516
+ function workbookSheetRows(document, sheetIndex, from, count) {
2517
+ const sheet = document.sheets[sheetIndex];
2518
+ if (sheet === undefined) return [];
2519
+ return sheet.rows.slice(from, from + count).map(row => row.map(cell => cellView(cell)));
2520
+ }
2521
+ function cellView(cell) {
2522
+ return {
2523
+ value: cell,
2524
+ type: cellType(cell)
2525
+ };
2526
+ }
2527
+ function cellType(cell) {
2528
+ if (typeof cell === 'number') return 'number';
2529
+ if (typeof cell === 'boolean') return 'boolean';
2530
+ return ISO_DATE_OR_TIME.test(cell) ? 'date' : 'string';
2531
+ }
2532
+ /** `B10:B13` (or a single cell, `A1`) as 0-based inclusive bounds; `undefined` for anything else — mirrors `@opencraw/core`'s own (unexported) `rangeOf`/`cellOf` in `grid-table.algorithm.ts`: a small pure parse, kept here rather than reached for across the package boundary (a sibling slice's non-`index.ts` file is never imported, and this is `@opencraw/core`'s own private helper, not even on its barrel). */
2533
+ function rangeOf(reference) {
2534
+ const [from, to = from] = reference.split(':', 2);
2535
+ const start = cellOf(from);
2536
+ const end = cellOf(to);
2537
+ if (start === undefined || end === undefined) return undefined;
2538
+ return {
2539
+ top: Math.min(start.row, end.row),
2540
+ left: Math.min(start.column, end.column),
2541
+ bottom: Math.max(start.row, end.row),
2542
+ right: Math.max(start.column, end.column)
2543
+ };
2544
+ }
2545
+ function cellOf(reference) {
2546
+ const match = /^\$?([A-Z]+)\$?(\d+)$/i.exec(reference.trim());
2547
+ if (match === null) return undefined;
2548
+ const letters = match[1].toUpperCase();
2549
+ let column = 0;
2550
+ for (const char of letters) column = column * 26 + (char.codePointAt(0) ?? 64) - 64;
2551
+ return {
2552
+ row: Number(match[2]) - 1,
2553
+ column: column - 1
2554
+ };
2555
+ }
2556
+
2557
+ /**
2558
+ * Builds the deck canvas's view model (studio plan §3.4, issue #94's 5d)
2559
+ * from an already-read `DeckDocument` (`take-snapshot`'s cached body — no
2560
+ * second read of the file): every slide's shapes (with their visual-row
2561
+ * grouping, for the text-box-grid pick), native tables (through
2562
+ * `workbook-view.mapper.ts`'s own sheet view — a deck table *is* a sheet),
2563
+ * charts and notes. Table-specific diagnostics for a `table` extract's own
2564
+ * options (the live preview) are not here — they come from
2565
+ * `table-preview.use-case.ts`'s `previewDeckTable` instead, computed live as
2566
+ * those options change, the same split `pdf-view.mapper.ts`/`workbook-view.mapper.ts`
2567
+ * already use.
2568
+ *
2569
+ * @param document - The read deck.
2570
+ * @returns The view.
2571
+ */
2572
+ function deckDocumentView(document) {
2573
+ return {
2574
+ width: document.width,
2575
+ height: document.height,
2576
+ slides: document.slides.map(slide => slideView(slide, document.height))
2577
+ };
2578
+ }
2579
+ function slideView(slide, slideHeight) {
2580
+ return {
2581
+ number: slide.number,
2582
+ ...(slide.title !== undefined && {
2583
+ title: slide.title
2584
+ }),
2585
+ hidden: slide.hidden,
2586
+ shapes: slide.shapes.map(shape => shapeView(shape)),
2587
+ shapeRows: shapeRowsOf(slide.shapes, slideHeight),
2588
+ tables: workbookDocumentView({
2589
+ sheets: slide.tables
2590
+ }).sheets,
2591
+ charts: slide.charts.map(chart => chartView(chart)),
2592
+ notes: slide.notes
2593
+ };
2594
+ }
2595
+ function shapeView(shape) {
2133
2596
  return {
2134
2597
  x: shape.x,
2135
2598
  y: shape.y,
@@ -2405,499 +2868,259 @@ function pdfDocumentView(document) {
2405
2868
  ...(margins.has(row) && {
2406
2869
  running: true
2407
2870
  })
2408
- })),
2409
- rowCount: page.rows.length,
2410
- cellCount: page.rows.reduce((total, row) => total + row.cells.length, 0),
2411
- hasTextLayer: page.rows.length > 0
2412
- }))
2413
- };
2414
- }
2415
-
2416
- /**
2417
- * Runs a `table` extract's options against an already-read PDF (studio plan
2418
- * §3.4, issue #94's 5b: the live preview) — a pure function of the document
2419
- * and the options, so re-running it on every keystroke as the person edits
2420
- * `selector`/`until`/`columns` costs nothing: no re-fetch, no re-parse
2421
- * (`readPdf` already ran once, when `take-snapshot` captured the document).
2422
- *
2423
- * The workbook grid's live preview (issue #94's 5c) is `previewGridTable`,
2424
- * below: the same idea (a pure re-run of the options against the cached
2425
- * document), but its own result shape — a workbook table has no page, no
2426
- * column bands, no wrapped-row regrouping, so folding it into
2427
- * `TablePreviewMatch`/`TablePreviewResult` would leave most of those fields
2428
- * meaningless for a grid pick; a `GridTablePreviewMatch`/`GridTablePreviewResult`
2429
- * sibling reads honestly instead.
2430
- *
2431
- * @param document - The already-read PDF.
2432
- * @param options - The `table` extract's options, not yet compiled to `RegExp`.
2433
- * @returns Every match, each with the exact row indices to highlight — or `error` (and no matches) when a pattern is not a valid regular expression, expected while the person is still typing it.
2434
- */
2435
- function previewPdfTable(document, options) {
2436
- let query;
2437
- try {
2438
- query = {
2439
- header: compile(options.header, 'selector'),
2440
- until: options.until === undefined ? undefined : compile(options.until, 'until'),
2441
- columns: options.columns === undefined ? undefined : Object.fromEntries(Object.entries(options.columns).map(([key, pattern]) => [key, compile(pattern, `columns.${key}`)])),
2442
- align: options.align
2443
- };
2444
- } catch (error) {
2445
- return {
2446
- matches: [],
2447
- error: error instanceof Error ? error.message : String(error)
2448
- };
2449
- }
2450
- const analyses = analyzeTables(document, query);
2451
- return {
2452
- matches: analyses.map(analysis => ({
2453
- page: analysis.page,
2454
- headerRowIndex: analysis.headerRowIndex,
2455
- matchedRowIndices: rowIndicesOf(analysis),
2456
- bands: analysis.bands,
2457
- bandTolerance: analysis.bandTolerance,
2458
- table: analysis.table
2459
- }))
2460
- };
2461
- }
2462
- function rowIndicesOf(analysis) {
2463
- const indices = new Set([analysis.headerRowIndex, ...analysis.rowGroups.flat()]);
2464
- if (analysis.untilRowIndex !== undefined) indices.add(analysis.untilRowIndex);
2465
- return [...indices].sort((a, b) => a - b);
2466
- }
2467
- function compile(source, where) {
2468
- try {
2469
- return new RegExp(source, 'i');
2470
- } catch (error) {
2471
- throw new Error(`${where}: invalid pattern ${source} (${error.message})`, {
2472
- cause: error
2473
- });
2474
- }
2475
- }
2476
- /**
2477
- * Runs a `table` extract's options against an already-read workbook (studio
2478
- * plan §3.4, issue #94's 5c: the live preview) — a pure function of the
2479
- * document and the options, exactly like `previewPdfTable` above: no
2480
- * re-fetch, no re-parse (`csvWorkbook`/`readXlsxWorkbook` already ran once,
2481
- * when `take-snapshot` captured the document).
2482
- *
2483
- * @param document - The already-read workbook.
2484
- * @param options - The `table` extract's options, not yet compiled to `RegExp`.
2485
- * @returns Every matched table — or `error` (and no matches) when a pattern is not a valid regular expression, expected while the person is still typing it.
2486
- */
2487
- function previewGridTable(document, options) {
2488
- let query;
2489
- try {
2490
- query = {
2491
- header: compile(options.header, 'selector'),
2492
- until: options.until === undefined ? undefined : compile(options.until, 'until'),
2493
- columns: options.columns === undefined ? undefined : Object.fromEntries(Object.entries(options.columns).map(([key, pattern]) => [key, compile(pattern, `columns.${key}`)])),
2494
- sheet: options.sheet === undefined ? undefined : compile(options.sheet, 'sheet'),
2495
- headerRows: options.headerRows,
2496
- fillDown: options.fillDown,
2497
- includeHidden: options.includeHidden
2498
- };
2499
- } catch (error) {
2500
- return {
2501
- matches: [],
2502
- error: error instanceof Error ? error.message : String(error)
2503
- };
2504
- }
2505
- const tables = findGridTables(document, query);
2506
- return {
2507
- matches: tables.map(table => ({
2508
- sheet: table.sheet,
2509
- title: table.title,
2510
- header: table.header,
2511
- rows: table.rows
2512
- }))
2513
- };
2514
- }
2515
- /**
2516
- * Runs a `table` extract's options against an already-read deck (studio plan
2517
- * §3.4, issue #94's 5d: the live preview) — a pure function of the document
2518
- * and the options, exactly like `previewPdfTable`/`previewGridTable` above:
2519
- * no re-fetch, no re-parse (`readPptxDeck` already ran once, when
2520
- * `take-snapshot` captured the document).
2521
- *
2522
- * @param document - The already-read deck.
2523
- * @param options - The `table` extract's options, not yet compiled to `RegExp`.
2524
- * @returns Every matched table — or `error` (and no matches) when a pattern is not a valid regular expression, expected while the person is still typing it.
2525
- */
2526
- function previewDeckTable(document, options) {
2527
- let query;
2528
- try {
2529
- query = {
2530
- slide: options.slide === undefined ? undefined : compile(options.slide, 'slide'),
2531
- shapes: options.shapes,
2532
- align: options.align,
2533
- header: compile(options.header, 'selector'),
2534
- until: options.until === undefined ? undefined : compile(options.until, 'until'),
2535
- columns: options.columns === undefined ? undefined : Object.fromEntries(Object.entries(options.columns).map(([key, pattern]) => [key, compile(pattern, `columns.${key}`)])),
2536
- headerRows: options.headerRows,
2537
- fillDown: options.fillDown,
2538
- includeHidden: options.includeHidden
2539
- };
2540
- } catch (error) {
2541
- return {
2542
- matches: [],
2543
- error: error instanceof Error ? error.message : String(error)
2544
- };
2545
- }
2546
- const tables = findDeckTables(document, query);
2547
- return {
2548
- matches: tables.map(table => ({
2549
- slide: table.slide,
2550
- slideTitle: table.slideTitle,
2551
- title: table.title,
2552
- header: table.header,
2553
- rows: table.rows
2554
- }))
2555
- };
2556
- }
2557
-
2558
- /**
2559
- * Runs a `region` extract's selector against an already-read PDF or deck
2560
- * (issues #121 and #122: the canvases' click-to-snap selection) — the
2561
- * engine's own `regionText` / `deckRegionText`, so what the canvas
2562
- * highlights and the text it shows are exactly what the step will read,
2563
- * and a pure function of the document, so re-running it on every change
2564
- * of the box costs nothing. A `slide=` on a PDF (or a `page=` on a deck)
2565
- * is the same mismatch the step would fail with, reported as `error`.
2566
- *
2567
- * @param document - The read PDF or deck (`take-snapshot`'s cached body).
2568
- * @param selector - The `region` selector, `page=1 x=72..252 y=640..664` or `slide=2 x=60..900 y=30..90`.
2569
- * @returns The matches, or `error` when the selector does not parse or does not fit the document.
2570
- */
2571
- function previewRegion(document, selector) {
2572
- try {
2573
- const region = parseRegion(selector);
2574
- if (document.kind === 'pdf') {
2575
- if (region.on !== 'page') return {
2576
- matches: [],
2577
- error: 'region: "slide=" reads a deck; this document is a PDF (name a "page=")'
2578
- };
2579
- return {
2580
- matches: regionText(document, region).map(match => ({
2581
- page: match.page,
2582
- text: match.text,
2583
- cells: match.cells,
2584
- shapes: []
2585
- }))
2586
- };
2587
- }
2588
- if (region.on !== 'slide') return {
2589
- matches: [],
2590
- error: 'region: "page=" reads a PDF; this document is a deck (name a "slide=")'
2591
- };
2592
- return {
2593
- matches: deckRegionText(document, region).map(match => ({
2594
- page: match.slide,
2595
- text: match.text,
2596
- cells: [],
2597
- shapes: match.shapes
2598
- }))
2599
- };
2600
- } catch (error) {
2601
- return {
2602
- matches: [],
2603
- error: error instanceof Error ? error.message : String(error)
2604
- };
2605
- }
2606
- }
2607
-
2608
- /**
2609
- * Handles `deck-preview`: the deck canvas's live preview of a `table`
2610
- * extract's options (`document-view`'s `table-preview.use-case.ts`'s
2611
- * `previewDeckTable`, studio plan §3.4, issue #94's 5d), off the same cached
2612
- * snapshot `take-snapshot`/`deck-view` already captured — a pure computation
2613
- * over it, so this answers instantly on every option change, no re-fetch or
2614
- * re-parse.
2615
- *
2616
- * @param state - The server state.
2617
- * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
2618
- * @returns The matched tables, or `error` when an option's pattern does not compile.
2619
- * @throws Error when no workspace is open, or `command.path` has no cached snapshot yet.
2620
- */
2621
- function handleDeckPreview(state, command) {
2622
- const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
2623
- if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
2624
- if (!isDeckDocument(snapshot.body)) throw new Error(`"${command.recipeId}" at "${command.path}" is not a deck snapshot: deck-preview only reads a read PowerPoint document`);
2625
- return previewDeckTable(snapshot.body, command.options);
2626
- }
2627
-
2628
- /**
2629
- * Handles `deck-view`: the deck canvas's slides, shapes, tables, charts and
2630
- * notes (`document-view`'s `deck-view.mapper.ts`, studio plan §3.4, issue
2631
- * #94's 5d), off the same cached snapshot `take-snapshot`/`document-tree`
2632
- * already use.
2633
- *
2634
- * @param state - The server state.
2635
- * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
2636
- * @returns The document's view.
2637
- * @throws Error when no workspace is open, `command.path` has no cached snapshot yet, or the snapshot's document is not a read deck.
2638
- */
2639
- function handleDeckView(state, command) {
2640
- const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
2641
- if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
2642
- if (!isDeckDocument(snapshot.body)) throw new Error(`"${command.recipeId}" at "${command.path}" is not a deck snapshot: the deck canvas only reads a read PowerPoint document`);
2643
- return deckDocumentView(snapshot.body);
2871
+ })),
2872
+ rowCount: page.rows.length,
2873
+ cellCount: page.rows.reduce((total, row) => total + row.cells.length, 0),
2874
+ hasTextLayer: page.rows.length > 0
2875
+ }))
2876
+ };
2644
2877
  }
2645
2878
 
2646
2879
  /**
2647
- * Handles `document-tree`: the content pane's tree canvas for a JSON, YAML
2648
- * or XML document (`document-view`, studio plan §3.4, issue #94's 5a), off
2649
- * the same cached snapshot `take-snapshot`/`inspect-page` already use.
2880
+ * Runs a `table` extract's options against an already-read PDF (studio plan
2881
+ * §3.4, issue #94's 5b: the live preview) — a pure function of the document
2882
+ * and the options, so re-running it on every keystroke as the person edits
2883
+ * `selector`/`until`/`columns` costs nothing: no re-fetch, no re-parse
2884
+ * (`readPdf` already ran once, when `take-snapshot` captured the document).
2650
2885
  *
2651
- * @param state - The server state.
2652
- * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
2653
- * @returns The tree.
2654
- * @throws Error when no workspace is open, `command.path` has no cached snapshot yet, or the snapshot's document is not JSON/YAML/XML (no parsed body at all in web mode; PDF/workbook/deck in api mode — see `documentTreeView`'s own doc comment).
2655
- */
2656
- function handleDocumentTree(state, command) {
2657
- const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
2658
- if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
2659
- if (snapshot.body === undefined) throw new Error(`"${command.recipeId}" at "${command.path}" is a web-mode (HTML) snapshot: the tree canvas only reads a JSON/YAML/XML document`);
2660
- return documentTreeView(snapshot.body);
2661
- }
2662
-
2663
- /**
2664
- * Finds and parses one input recipe of a workspace folder, and the output
2665
- * recipe it names.
2886
+ * The workbook grid's live preview (issue #94's 5c) is `previewGridTable`,
2887
+ * below: the same idea (a pure re-run of the options against the cached
2888
+ * document), but its own result shape — a workbook table has no page, no
2889
+ * column bands, no wrapped-row regrouping, so folding it into
2890
+ * `TablePreviewMatch`/`TablePreviewResult` would leave most of those fields
2891
+ * meaningless for a grid pick; a `GridTablePreviewMatch`/`GridTablePreviewResult`
2892
+ * sibling reads honestly instead.
2666
2893
  *
2667
- * @param folder - The workspace folder.
2668
- * @param recipeId - The input recipe's id.
2669
- * @returns The parsed pair.
2670
- * @throws RecipeValidationError when either recipe does not parse.
2671
- * @throws Error when the input or its output is not in the folder.
2894
+ * @param document - The already-read PDF.
2895
+ * @param options - The `table` extract's options, not yet compiled to `RegExp`.
2896
+ * @returns Every match, each with the exact row indices to highlight — or `error` (and no matches) when a pattern is not a valid regular expression, expected while the person is still typing it.
2672
2897
  */
2673
- async function loadRecipePair(folder, recipeId) {
2674
- const documents = await readRecipeSource(folder);
2675
- const inputDocument = documents.find(document => kindOf(document.content) === 'input' && idOf(document.content) === recipeId);
2676
- if (inputDocument === undefined) throw new Error(`no input recipe "${recipeId}" in this workspace`);
2677
- const input = parseInputRecipe(inputDocument.content, inputDocument.source);
2678
- const outputDocument = documents.find(document => kindOf(document.content) === 'output' && idOf(document.content) === input.output);
2679
- if (outputDocument === undefined) throw new Error(`no output recipe "${input.output}" in this workspace`);
2898
+ function previewPdfTable(document, options) {
2899
+ let query;
2900
+ try {
2901
+ query = {
2902
+ header: compile(options.header, 'selector'),
2903
+ until: options.until === undefined ? undefined : compile(options.until, 'until'),
2904
+ columns: options.columns === undefined ? undefined : Object.fromEntries(Object.entries(options.columns).map(([key, pattern]) => [key, compile(pattern, `columns.${key}`)])),
2905
+ align: options.align
2906
+ };
2907
+ } catch (error) {
2908
+ return {
2909
+ matches: [],
2910
+ error: error instanceof Error ? error.message : String(error)
2911
+ };
2912
+ }
2913
+ const analyses = analyzeTables(document, query);
2680
2914
  return {
2681
- input,
2682
- output: parseOutputRecipe(outputDocument.content, outputDocument.source)
2915
+ matches: analyses.map(analysis => ({
2916
+ page: analysis.page,
2917
+ headerRowIndex: analysis.headerRowIndex,
2918
+ matchedRowIndices: rowIndicesOf(analysis),
2919
+ bands: analysis.bands,
2920
+ bandTolerance: analysis.bandTolerance,
2921
+ table: analysis.table
2922
+ }))
2683
2923
  };
2684
2924
  }
2685
- function kindOf(content) {
2686
- return content?.kind;
2925
+ function rowIndicesOf(analysis) {
2926
+ const indices = new Set([analysis.headerRowIndex, ...analysis.rowGroups.flat()]);
2927
+ if (analysis.untilRowIndex !== undefined) indices.add(analysis.untilRowIndex);
2928
+ return [...indices].sort((a, b) => a - b);
2687
2929
  }
2688
- function idOf(content) {
2689
- return content?.id;
2930
+ function compile(source, where) {
2931
+ try {
2932
+ return new RegExp(source, 'i');
2933
+ } catch (error) {
2934
+ throw new Error(`${where}: invalid pattern ${source} (${error.message})`, {
2935
+ cause: error
2936
+ });
2937
+ }
2690
2938
  }
2691
-
2692
2939
  /**
2693
- * The hooks a sample run uses: the ones Studio was started with, except that
2694
- *
2695
- * - a hook the person stubbed returns the stub's value and calls nothing, and
2696
- * - a hook that calls outside the process says so in the trace, once per call, because it may be slow
2697
- * and it may cost money.
2940
+ * Runs a `table` extract's options against an already-read workbook (studio
2941
+ * plan §3.4, issue #94's 5c: the live preview) — a pure function of the
2942
+ * document and the options, exactly like `previewPdfTable` above: no
2943
+ * re-fetch, no re-parse (`csvWorkbook`/`readXlsxWorkbook` already ran once,
2944
+ * when `take-snapshot` captured the document).
2698
2945
  *
2699
- * @param hooks - The hooks Studio was started with.
2700
- * @param stubs - Values to answer with instead of calling a hook, by hook name. A name that is not a hook is ignored.
2701
- * @param announce - Where the trace lines go.
2702
- * @returns The hooks to run the sample with.
2946
+ * @param document - The already-read workbook.
2947
+ * @param options - The `table` extract's options, not yet compiled to `RegExp`.
2948
+ * @returns Every matched table — or `error` (and no matches) when a pattern is not a valid regular expression, expected while the person is still typing it.
2703
2949
  */
2704
- function calloutHooks(hooks, stubs, announce) {
2705
- if (hooks === undefined) return undefined;
2706
- const prepared = {};
2707
- for (const [name, hook] of Object.entries(hooks)) {
2708
- if (stubs !== undefined && Object.hasOwn(stubs, name)) {
2709
- const stub = stubs[name];
2710
- prepared[name] = () => {
2711
- announce(`☎ hook "${name}" is stubbed: answering ${JSON.stringify(stub) ?? 'undefined'} without calling it`);
2712
- return stub;
2713
- };
2714
- continue;
2715
- }
2716
- const label = calloutLabel(hook);
2717
- prepared[name] = label === undefined ? hook : async (input, args, context) => {
2718
- announce(`☎ hook "${name}" calls ${label} (slow, and it may cost: stub it to skip the call)`);
2719
- return await hook(input, args, context);
2950
+ function previewGridTable(document, options) {
2951
+ let query;
2952
+ try {
2953
+ query = {
2954
+ header: compile(options.header, 'selector'),
2955
+ until: options.until === undefined ? undefined : compile(options.until, 'until'),
2956
+ columns: options.columns === undefined ? undefined : Object.fromEntries(Object.entries(options.columns).map(([key, pattern]) => [key, compile(pattern, `columns.${key}`)])),
2957
+ sheet: options.sheet === undefined ? undefined : compile(options.sheet, 'sheet'),
2958
+ headerRows: options.headerRows,
2959
+ fillDown: options.fillDown,
2960
+ includeHidden: options.includeHidden
2961
+ };
2962
+ } catch (error) {
2963
+ return {
2964
+ matches: [],
2965
+ error: error instanceof Error ? error.message : String(error)
2720
2966
  };
2721
2967
  }
2722
- return prepared;
2723
- }
2724
-
2725
- /**
2726
- * Runs one input recipe of a workspace folder as a sample: `debug: true` (so
2727
- * each `record:emit` carries its scope snapshot and mapping trace, and each
2728
- * `record:reject` its scope), an in-memory sink, and `budget` as the
2729
- * crawler's `sample` (`@opencraw/core`'s `CrawlOptions.sample`, #89). Every
2730
- * engine event becomes a trace line with the same `traceLine` formatter the
2731
- * cli's `--trace` uses; every emitted record reaches `callbacks.onRecord` as
2732
- * it happens, not only in the final result. Step events (`step:start`,
2733
- * `step:skip`, `step:retry`) are aggregated by path into `result.steps`, so
2734
- * `explain-why` (#92) can later say whether the step that bound a mapping's
2735
- * source id ran, was skipped, or retried — without a second run.
2736
- *
2737
- * @param folder - The workspace folder the recipe lives in.
2738
- * @param recipeId - The input recipe to run.
2739
- * @param budget - The sample budget; unset runs the recipe to completion (still capped by its own `limits`).
2740
- * @param callbacks - Where trace lines and records go as the run proceeds.
2741
- * @param browser - Browser launch settings for a web-mode recipe (an executable path override, headless…); the server-wide setting `studio-http.use-case.ts`'s `StudioServerOptions.browser` carries in, e.g. `OPENCRAW_CHROMIUM` in a sandbox with no full Playwright install.
2742
- * @param plugins - Hooks, access plugins and captcha solvers the person started Studio with (`--hooks`, issue #150); none when it was started without.
2743
- * @param stubs - Values a hook answers with instead of being called, by hook name, for this run only (issue #201): how a sample skips a slow or paid callout.
2744
- * @returns A handle: `stop` closes the crawler cleanly, `result` resolves when the run ends.
2745
- * @throws RecipeValidationError, RecipeBindingError, Error when the recipe or its output cannot be loaded or bound.
2746
- */
2747
- async function runSample(folder, recipeId, budget, callbacks, browser, plugins, stubs) {
2748
- const {
2749
- input,
2750
- output
2751
- } = await loadRecipePair(folder, recipeId);
2752
- const set = bindRecipeSet(output, [input]);
2753
- const sink = memorySink();
2754
- const records = [];
2755
- const rejectedRecords = [];
2756
- const steps = new Map();
2757
- const crawler = createCrawler({
2758
- sink,
2759
- debug: true,
2760
- sample: budget,
2761
- browser,
2762
- hooks: calloutHooks(plugins?.hooks, stubs, callbacks.onTraceLine),
2763
- accessPlugins: plugins?.accessPlugins,
2764
- captchaSolvers: plugins?.captchaSolvers,
2765
- onEvent: event => {
2766
- const line = traceLine(event);
2767
- if (line !== undefined) callbacks.onTraceLine(line);
2768
- trackStep(steps, event);
2769
- if (event.type === 'record:emit') {
2770
- const record = {
2771
- key: event.key,
2772
- data: event.data,
2773
- scope: event.scope,
2774
- mapping: event.mapping
2775
- };
2776
- records.push(record);
2777
- callbacks.onRecord(record);
2778
- } else if (event.type === 'record:reject') {
2779
- const rejected = {
2780
- field: event.field,
2781
- reason: event.reason,
2782
- url: event.url,
2783
- scope: event.scope
2784
- };
2785
- rejectedRecords.push(rejected);
2786
- callbacks.onRejected?.(rejected);
2787
- }
2788
- }
2789
- });
2790
- const result = runToResult(crawler, set, recipeId, records, rejectedRecords, steps, plugins);
2968
+ const tables = findGridTables(document, query);
2791
2969
  return {
2792
- stop: () => crawler.close(),
2793
- result
2970
+ matches: tables.map(table => ({
2971
+ sheet: table.sheet,
2972
+ title: table.title,
2973
+ header: table.header,
2974
+ rows: table.rows
2975
+ }))
2794
2976
  };
2795
2977
  }
2796
2978
  /**
2797
- * Folds one `step:*` event into `steps`, by path: `skip` always wins over an
2798
- * earlier `ran`/`retried` outcome for the same path (a step skipped even
2799
- * once is worth flagging), `retry` wins over a plain `ran`, and a path seen
2800
- * only as `finish`/`start` (no skip, no retry) settles on `ran`. A `forEach`
2801
- * runs its body once per item, so the same path folds repeatedly; this is
2802
- * deliberately a summary across the whole sample, not a per-record trace.
2803
- */
2804
- function trackStep(steps, event) {
2805
- if (event.type === 'step:start') {
2806
- if (!steps.has(event.path)) steps.set(event.path, {
2807
- path: event.path,
2808
- stepType: event.stepType,
2809
- stepId: event.stepId,
2810
- outcome: 'ran'
2811
- });
2812
- return;
2813
- }
2814
- if (event.type === 'step:retry') {
2815
- steps.set(event.path, {
2816
- path: event.path,
2817
- stepType: event.stepType,
2818
- stepId: event.stepId,
2819
- outcome: 'retried',
2820
- error: event.error
2821
- });
2822
- return;
2823
- }
2824
- if (event.type === 'step:skip') {
2825
- steps.set(event.path, {
2826
- path: event.path,
2827
- stepType: event.stepType,
2828
- stepId: event.stepId,
2829
- outcome: 'skipped',
2830
- error: event.error
2831
- });
2979
+ * Runs a `table` extract's options against an already-read deck (studio plan
2980
+ * §3.4, issue #94's 5d: the live preview) — a pure function of the document
2981
+ * and the options, exactly like `previewPdfTable`/`previewGridTable` above:
2982
+ * no re-fetch, no re-parse (`readPptxDeck` already ran once, when
2983
+ * `take-snapshot` captured the document).
2984
+ *
2985
+ * @param document - The already-read deck.
2986
+ * @param options - The `table` extract's options, not yet compiled to `RegExp`.
2987
+ * @returns Every matched table — or `error` (and no matches) when a pattern is not a valid regular expression, expected while the person is still typing it.
2988
+ */
2989
+ function previewDeckTable(document, options) {
2990
+ let query;
2991
+ try {
2992
+ query = {
2993
+ slide: options.slide === undefined ? undefined : compile(options.slide, 'slide'),
2994
+ shapes: options.shapes,
2995
+ align: options.align,
2996
+ header: compile(options.header, 'selector'),
2997
+ until: options.until === undefined ? undefined : compile(options.until, 'until'),
2998
+ columns: options.columns === undefined ? undefined : Object.fromEntries(Object.entries(options.columns).map(([key, pattern]) => [key, compile(pattern, `columns.${key}`)])),
2999
+ headerRows: options.headerRows,
3000
+ fillDown: options.fillDown,
3001
+ includeHidden: options.includeHidden
3002
+ };
3003
+ } catch (error) {
3004
+ return {
3005
+ matches: [],
3006
+ error: error instanceof Error ? error.message : String(error)
3007
+ };
2832
3008
  }
3009
+ const tables = findDeckTables(document, query);
3010
+ return {
3011
+ matches: tables.map(table => ({
3012
+ slide: table.slide,
3013
+ slideTitle: table.slideTitle,
3014
+ title: table.title,
3015
+ header: table.header,
3016
+ rows: table.rows
3017
+ }))
3018
+ };
2833
3019
  }
3020
+
2834
3021
  /**
2835
- * Awaits the run and shapes its result. A throw from `crawler.run` (an unknown
2836
- * hook, a recipe that cannot start) becomes the run's `error` instead of a
2837
- * rejection: nothing awaits `result` but a `.then`, and an unhandled rejection
2838
- * would take the whole Studio server down (issue #148).
3022
+ * Runs a `region` extract's selector against an already-read PDF or deck
3023
+ * (issues #121 and #122: the canvases' click-to-snap selection) — the
3024
+ * engine's own `regionText` / `deckRegionText`, so what the canvas
3025
+ * highlights and the text it shows are exactly what the step will read,
3026
+ * and a pure function of the document, so re-running it on every change
3027
+ * of the box costs nothing. A `slide=` on a PDF (or a `page=` on a deck)
3028
+ * is the same mismatch the step would fail with, reported as `error`.
3029
+ *
3030
+ * @param document - The read PDF or deck (`take-snapshot`'s cached body).
3031
+ * @param selector - The `region` selector, `page=1 x=72..252 y=640..664` or `slide=2 x=60..900 y=30..90`.
3032
+ * @returns The matches, or `error` when the selector does not parse or does not fit the document.
2839
3033
  */
2840
- async function runToResult(crawler, set, recipeId, records, rejectedRecords, steps, plugins) {
3034
+ function previewRegion(document, selector) {
2841
3035
  try {
2842
- const report = await crawler.run(set);
2843
- const recipe = report.recipes[0];
3036
+ const region = parseRegion(selector);
3037
+ if (document.kind === 'pdf') {
3038
+ if (region.on !== 'page') return {
3039
+ matches: [],
3040
+ error: 'region: "slide=" reads a deck; this document is a PDF (name a "page=")'
3041
+ };
3042
+ return {
3043
+ matches: regionText(document, region).map(match => ({
3044
+ page: match.page,
3045
+ text: match.text,
3046
+ cells: match.cells,
3047
+ shapes: []
3048
+ }))
3049
+ };
3050
+ }
3051
+ if (region.on !== 'slide') return {
3052
+ matches: [],
3053
+ error: 'region: "page=" reads a PDF; this document is a deck (name a "slide=")'
3054
+ };
2844
3055
  return {
2845
- recipeId: recipe.recipeId,
2846
- emitted: recipe.emitted,
2847
- rejected: recipe.rejected,
2848
- duplicates: recipe.duplicates,
2849
- durationMs: recipe.durationMs,
2850
- error: recipe.error,
2851
- stoppedBy: recipe.stoppedBy,
2852
- records,
2853
- rejectedRecords,
2854
- // eslint-disable-next-line unicorn/prefer-iterator-to-array -- `.toArray()` needs a `lib` newer than this repo's `es2022` (tsconfig.base.json)
2855
- steps: [...steps.values()]
3056
+ matches: deckRegionText(document, region).map(match => ({
3057
+ page: match.slide,
3058
+ text: match.text,
3059
+ cells: [],
3060
+ shapes: match.shapes
3061
+ }))
2856
3062
  };
2857
3063
  } catch (error) {
2858
3064
  return {
2859
- recipeId,
2860
- emitted: 0,
2861
- rejected: 0,
2862
- duplicates: 0,
2863
- durationMs: 0,
2864
- error: failureMessage(error, plugins),
2865
- records,
2866
- rejectedRecords,
2867
- // eslint-disable-next-line unicorn/prefer-iterator-to-array -- `.toArray()` needs a `lib` newer than this repo's `es2022` (tsconfig.base.json)
2868
- steps: [...steps.values()]
3065
+ matches: [],
3066
+ error: error instanceof Error ? error.message : String(error)
2869
3067
  };
2870
- } finally {
2871
- await crawler.close();
2872
- }
2873
- }
2874
- /** What a run that could not start reports. A hook is the author's own code, which Studio only runs when it was started with `--hooks` (issue #150): say which case this is, instead of leaving "unknown hook" to read as a typo. */
2875
- function failureMessage(error, plugins) {
2876
- if (error instanceof UnknownHookError) {
2877
- const message = stripVTControlCharacters(error.message);
2878
- if (plugins !== undefined) return `${message}. It is not one of the hooks in ${plugins.source}: ${Object.keys(plugins.hooks).join(', ') || 'none exported'}.`;
2879
- return `${message}. Studio was started without hooks: restart it with \`opencraw studio --hooks <file>\` (a module you trust; it runs as your code), or temporarily replace the hook step to preview the rest.`;
2880
3068
  }
2881
- if (error instanceof Error) return stripVTControlCharacters(error.message);
2882
- return stripVTControlCharacters(String(error));
2883
3069
  }
2884
3070
 
2885
- /** @returns A fresh, empty cache. */
2886
- function createLastRunCache() {
2887
- return {
2888
- entries: new Map()
2889
- };
3071
+ /**
3072
+ * Handles `deck-preview`: the deck canvas's live preview of a `table`
3073
+ * extract's options (`document-view`'s `table-preview.use-case.ts`'s
3074
+ * `previewDeckTable`, studio plan §3.4, issue #94's 5d), off the same cached
3075
+ * snapshot `take-snapshot`/`deck-view` already captured — a pure computation
3076
+ * over it, so this answers instantly on every option change, no re-fetch or
3077
+ * re-parse.
3078
+ *
3079
+ * @param state - The server state.
3080
+ * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
3081
+ * @returns The matched tables, or `error` when an option's pattern does not compile.
3082
+ * @throws Error when no workspace is open, or `command.path` has no cached snapshot yet.
3083
+ */
3084
+ function handleDeckPreview(state, command) {
3085
+ const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
3086
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
3087
+ if (!isDeckDocument(snapshot.body)) throw new Error(`"${command.recipeId}" at "${command.path}" is not a deck snapshot: deck-preview only reads a read PowerPoint document`);
3088
+ return previewDeckTable(snapshot.body, command.options);
2890
3089
  }
2891
- /** Records a finished sample run, replacing whatever this recipe's cache entry held before. */
2892
- function recordLastRun(cache, recipeId, result) {
2893
- cache.entries.set(recipeId, {
2894
- recipeId,
2895
- result
2896
- });
3090
+
3091
+ /**
3092
+ * Handles `deck-view`: the deck canvas's slides, shapes, tables, charts and
3093
+ * notes (`document-view`'s `deck-view.mapper.ts`, studio plan §3.4, issue
3094
+ * #94's 5d), off the same cached snapshot `take-snapshot`/`document-tree`
3095
+ * already use.
3096
+ *
3097
+ * @param state - The server state.
3098
+ * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
3099
+ * @returns The document's view.
3100
+ * @throws Error when no workspace is open, `command.path` has no cached snapshot yet, or the snapshot's document is not a read deck.
3101
+ */
3102
+ function handleDeckView(state, command) {
3103
+ const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
3104
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
3105
+ if (!isDeckDocument(snapshot.body)) throw new Error(`"${command.recipeId}" at "${command.path}" is not a deck snapshot: the deck canvas only reads a read PowerPoint document`);
3106
+ return deckDocumentView(snapshot.body);
2897
3107
  }
2898
- /** @returns The recipe's last completed sample run, or `undefined` when none has finished yet. */
2899
- function lastRunOf(cache, recipeId) {
2900
- return cache.entries.get(recipeId);
3108
+
3109
+ /**
3110
+ * Handles `document-tree`: the content pane's tree canvas for a JSON, YAML
3111
+ * or XML document (`document-view`, studio plan §3.4, issue #94's 5a), off
3112
+ * the same cached snapshot `take-snapshot`/`inspect-page` already use.
3113
+ *
3114
+ * @param state - The server state.
3115
+ * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
3116
+ * @returns The tree.
3117
+ * @throws Error when no workspace is open, `command.path` has no cached snapshot yet, or the snapshot's document is not JSON/YAML/XML (no parsed body at all in web mode; PDF/workbook/deck in api mode — see `documentTreeView`'s own doc comment).
3118
+ */
3119
+ function handleDocumentTree(state, command) {
3120
+ const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
3121
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
3122
+ if (snapshot.body === undefined) throw new Error(`"${command.recipeId}" at "${command.path}" is a web-mode (HTML) snapshot: the tree canvas only reads a JSON/YAML/XML document`);
3123
+ return documentTreeView(snapshot.body);
2901
3124
  }
2902
3125
 
2903
3126
  /**
@@ -4835,38 +5058,6 @@ function remoteCallouts(plugins) {
4835
5058
  return found.sort((a, b) => order[a.kind] - order[b.kind] || a.name.localeCompare(b.name));
4836
5059
  }
4837
5060
 
4838
- /** Where the studio's own recorder profile lives when `StudioServerOptions.recorderProfileDir` is not given: never a crawl's own `session.browserProfile` directory, never the person's default browser profile. */
4839
- function defaultRecorderProfileDir() {
4840
- return join(tmpdir(), 'opencraw-studio', 'recorder-profile');
4841
- }
4842
- /** @returns Fresh state for a new server instance: no workspace open, no run, no recording, no sockets, an empty snapshot cache, no last runs, Playwright's own default browser, the default recorder profile directory. */
4843
- function createStudioState() {
4844
- return {
4845
- sockets: new Set(),
4846
- snapshots: createSnapshotCache(),
4847
- lastRuns: createLastRunCache(),
4848
- recorderProfileDir: defaultRecorderProfileDir()
4849
- };
4850
- }
4851
- /**
4852
- * Sends one event to every connected client. A socket that errors while
4853
- * sending (gone away without a clean close yet reaching us) is dropped
4854
- * rather than left to fail every later broadcast too.
4855
- *
4856
- * @param state - The server state holding the connected sockets.
4857
- * @param event - The event to send.
4858
- */
4859
- function broadcast(state, event) {
4860
- const text = JSON.stringify(event);
4861
- for (const socket of state.sockets) {
4862
- try {
4863
- socket.send(text);
4864
- } catch {
4865
- state.sockets.delete(socket);
4866
- }
4867
- }
4868
- }
4869
-
4870
5061
  /**
4871
5062
  * Handles `open-workspace`: reads the folder, remembers it as the server's
4872
5063
  * current workspace (every later command that names a recipe resolves it
@@ -5711,6 +5902,52 @@ function handleRegionPreview(state, command) {
5711
5902
  return previewRegion(snapshot.body, command.selector);
5712
5903
  }
5713
5904
 
5905
+ /** How many records and rejections a status carries: enough to judge a run, the rest stay in Studio. */
5906
+ const FIRST = 20;
5907
+ /**
5908
+ * Handles `run-status`: how a recipe's sample run is going, for a controller that started it and cannot watch
5909
+ * the event stream. A run in progress is `running`; one that finished carries its counts and first records.
5910
+ *
5911
+ * @param state - The server state holding the run in progress and the last runs.
5912
+ * @param command - The recipe asked about.
5913
+ * @returns The state of the recipe's sample run.
5914
+ */
5915
+ function handleRunStatus(state, command) {
5916
+ if (state.activeRun !== undefined && state.activeRunId === command.recipeId) return {
5917
+ recipeId: command.recipeId,
5918
+ state: 'running'
5919
+ };
5920
+ const last = lastRunOf(state.lastRuns, command.recipeId);
5921
+ if (last === undefined) return {
5922
+ recipeId: command.recipeId,
5923
+ state: 'never-run'
5924
+ };
5925
+ const {
5926
+ result
5927
+ } = last;
5928
+ return {
5929
+ recipeId: command.recipeId,
5930
+ state: 'finished',
5931
+ result: {
5932
+ emitted: result.emitted,
5933
+ rejected: result.rejected,
5934
+ duplicates: result.duplicates,
5935
+ durationMs: result.durationMs,
5936
+ error: result.error,
5937
+ stoppedBy: result.stoppedBy,
5938
+ records: result.records.slice(0, FIRST).map(record => ({
5939
+ key: record.key,
5940
+ data: record.data
5941
+ })),
5942
+ rejections: result.rejectedRecords.slice(0, FIRST).map(rejection => ({
5943
+ field: rejection.field,
5944
+ reason: rejection.reason,
5945
+ url: rejection.url
5946
+ }))
5947
+ }
5948
+ };
5949
+ }
5950
+
5714
5951
  /**
5715
5952
  * Handles `run-sample`: stops whatever sample was still running (one run at
5716
5953
  * a time), starts the new one, and returns once it has started, not once it
@@ -5753,6 +5990,7 @@ async function handleRunSample(state, command) {
5753
5990
  }
5754
5991
  }, state.browser, state.plugins, command.stubs);
5755
5992
  state.activeRun = handle;
5993
+ state.activeRunId = command.recipeId;
5756
5994
  void handle.result.then(result => {
5757
5995
  if (state.activeRun === handle) state.activeRun = undefined;
5758
5996
  recordLastRun(state.lastRuns, command.recipeId, result);
@@ -5875,6 +6113,20 @@ function serveStatic(root, pathname, response) {
5875
6113
  createReadStream(file).pipe(response);
5876
6114
  }
5877
6115
 
6116
+ /**
6117
+ * Handles `studio-status`: what Studio is doing right now.
6118
+ *
6119
+ * @param state - The server state.
6120
+ * @returns The open workspace, how many pages are open and the recipe being sampled.
6121
+ */
6122
+ function handleStudioStatus(state) {
6123
+ return {
6124
+ folder: state.folder,
6125
+ pages: state.sockets.size,
6126
+ running: state.activeRun === undefined ? undefined : state.activeRunId
6127
+ };
6128
+ }
6129
+
5878
6130
  /**
5879
6131
  * Handles `table-preview`: the PDF canvas's live preview of a `table`
5880
6132
  * extract's options (`document-view`'s `table-preview.use-case.ts`, studio
@@ -6114,6 +6366,15 @@ async function startStudioServer(options = {}) {
6114
6366
  const port = typeof address === 'object' && address !== null ? address.port : 0;
6115
6367
  const folderParam = options.initialFolder === undefined ? '' : `&folder=${encodeURIComponent(options.initialFolder)}`;
6116
6368
  const base = `http://127.0.0.1:${port}/?token=${token}`;
6369
+ const instancesDir = options.instancesDir ?? defaultInstancesDirectory();
6370
+ const instance = {
6371
+ pid: process.pid,
6372
+ url: `http://127.0.0.1:${port}`,
6373
+ token,
6374
+ folder: options.initialFolder,
6375
+ startedAt: new Date().toISOString()
6376
+ };
6377
+ await announceInstance(instance, instancesDir);
6117
6378
  return {
6118
6379
  url: `${base}${folderParam}`,
6119
6380
  token,
@@ -6122,7 +6383,10 @@ async function startStudioServer(options = {}) {
6122
6383
  const open = target?.open === undefined ? '' : `&open=${encodeURIComponent(target.open)}`;
6123
6384
  return `${base}${workspace === undefined ? '' : `&folder=${encodeURIComponent(workspace)}`}${open}`;
6124
6385
  },
6125
- close: () => stop(server, state)
6386
+ close: async () => {
6387
+ await withdrawInstance(instance, instancesDir);
6388
+ await stop(server, state);
6389
+ }
6126
6390
  };
6127
6391
  }
6128
6392
  async function stop(server, state) {
@@ -6321,6 +6585,26 @@ function dispatch(command, state) {
6321
6585
  {
6322
6586
  return handleStopRecording(state);
6323
6587
  }
6588
+ case 'ask-ui':
6589
+ {
6590
+ return Promise.resolve(handleAskUi(state, command));
6591
+ }
6592
+ case 'ui-report':
6593
+ {
6594
+ return Promise.resolve(handleUiReport(state, command));
6595
+ }
6596
+ case 'ui-outcome':
6597
+ {
6598
+ return Promise.resolve(handleUiOutcome(state, command));
6599
+ }
6600
+ case 'studio-status':
6601
+ {
6602
+ return Promise.resolve(handleStudioStatus(state));
6603
+ }
6604
+ case 'run-status':
6605
+ {
6606
+ return Promise.resolve(handleRunStatus(state, command));
6607
+ }
6324
6608
  }
6325
6609
  }
6326
6610
  function respondJson(response, status, body) {
@@ -6388,5 +6672,5 @@ async function main(argv, options = {}) {
6388
6672
  process.on('SIGTERM', shutdown);
6389
6673
  }
6390
6674
 
6391
- export { chooseFolderCommandSchema, chooseFolderViewSchema, deckChartSeriesViewSchema, deckChartViewSchema, deckDocumentViewSchema, deckPreviewCommandSchema, deckPreviewOptionsSchema, deckShapeViewSchema, deckSlideViewSchema, deckTablePreviewMatchSchema, deckTablePreviewViewSchema, deckViewCommandSchema, detectUrlCommandSchema, detectUrlViewSchema, documentTreeCommandSchema, documentTreeNodeViewSchema, documentTreeViewSchema, domTreeNodeViewSchema, explainWhyCommandSchema, fetchStartPage, fetchStartPageCommandSchema, fieldPickSchema, fieldTraceSchema, gridCellViewSchema, gridMergeViewSchema, gridPreviewCommandSchema, gridPreviewOptionsSchema, gridRowsCommandSchema, gridRowsViewSchema, gridSheetViewSchema, gridTablePreviewMatchSchema, gridTablePreviewViewSchema, gridViewCommandSchema, importDocument, importDocumentCommandSchema, importDocumentViewSchema, inferSelectorCommandSchema, inferSelectorViewSchema, inspectPageCommandSchema, inspectViewSchema, loadRecipePair, loadedHooksSchema, main, missingWhyTargetSchema, observedResponseSchema, openPathCommandSchema, openPathViewSchema, openWorkspace, openWorkspaceCommandSchema, outlineBracketSchema, outlineCardSchema, outlineNodeSchema, outlineViewSchema, pageDataFindingSchema, pdfCellViewSchema, pdfDocumentViewSchema, pdfPageViewSchema, pdfRowViewSchema, pdfViewCommandSchema, recipeIssueSchema, recipeListingSchema, recordEventSchema, recordRejectedEventSchema, recordingCardEventSchema, recordingNoteEventSchema, recordingStoppedEventSchema, regionPreviewCommandSchema, regionPreviewMatchSchema, regionPreviewViewSchema, rejectedWhyTargetSchema, responsesSeenCommandSchema, responsesSeenViewSchema, runFinishedEventSchema, runSample, runSampleCommandSchema, sampleBudgetSchema, sampleRunRecordSchema, sampleRunViewSchema, saveOutlineCommandSchema, saveRecipe, saveRecipeCommandSchema, selectorAlternativeSchema, sentencePartSchema, snapshotViewSchema, startPageViewSchema, startRecordingCommandSchema, startStudioServer, stopRecordingCommandSchema, stopRunCommandSchema, stoppedBySchema, studioCommandSchema, studioEventSchema, tablePreviewBandSchema, tablePreviewCommandSchema, tablePreviewMatchSchema, tablePreviewOptionsSchema, tablePreviewTableSchema, tablePreviewViewSchema, takeSnapshotCommandSchema, traceLineEventSchema, verifySelectorCommandSchema, verifySelectorViewSchema, whyTargetSchema, whyViewSchema, workbookDocumentViewSchema, workspaceChangedEventSchema, workspaceViewSchema };
6675
+ export { chooseFolderCommandSchema, chooseFolderViewSchema, deckChartSeriesViewSchema, deckChartViewSchema, deckDocumentViewSchema, deckPreviewCommandSchema, deckPreviewOptionsSchema, deckShapeViewSchema, deckSlideViewSchema, deckTablePreviewMatchSchema, deckTablePreviewViewSchema, deckViewCommandSchema, defaultInstancesDirectory, detectUrlCommandSchema, detectUrlViewSchema, documentTreeCommandSchema, documentTreeNodeViewSchema, documentTreeViewSchema, domTreeNodeViewSchema, explainWhyCommandSchema, fetchStartPage, fetchStartPageCommandSchema, fieldPickSchema, fieldTraceSchema, gridCellViewSchema, gridMergeViewSchema, gridPreviewCommandSchema, gridPreviewOptionsSchema, gridRowsCommandSchema, gridRowsViewSchema, gridSheetViewSchema, gridTablePreviewMatchSchema, gridTablePreviewViewSchema, gridViewCommandSchema, importDocument, importDocumentCommandSchema, importDocumentViewSchema, inferSelectorCommandSchema, inferSelectorViewSchema, inspectPageCommandSchema, inspectViewSchema, loadRecipePair, loadedHooksSchema, main, missingWhyTargetSchema, observedResponseSchema, openPathCommandSchema, openPathViewSchema, openWorkspace, openWorkspaceCommandSchema, outlineBracketSchema, outlineCardSchema, outlineNodeSchema, outlineViewSchema, pageDataFindingSchema, pdfCellViewSchema, pdfDocumentViewSchema, pdfPageViewSchema, pdfRowViewSchema, pdfViewCommandSchema, recipeIssueSchema, recipeListingSchema, recordEventSchema, recordRejectedEventSchema, recordingCardEventSchema, recordingNoteEventSchema, recordingStoppedEventSchema, regionPreviewCommandSchema, regionPreviewMatchSchema, regionPreviewViewSchema, rejectedWhyTargetSchema, responsesSeenCommandSchema, responsesSeenViewSchema, runFinishedEventSchema, runSample, runSampleCommandSchema, sampleBudgetSchema, sampleRunRecordSchema, sampleRunViewSchema, saveOutlineCommandSchema, saveRecipe, saveRecipeCommandSchema, selectorAlternativeSchema, sentencePartSchema, snapshotViewSchema, startPageViewSchema, startRecordingCommandSchema, startStudioServer, stopRecordingCommandSchema, stopRunCommandSchema, stoppedBySchema, studioCommandSchema, studioEventSchema, tablePreviewBandSchema, tablePreviewCommandSchema, tablePreviewMatchSchema, tablePreviewOptionsSchema, tablePreviewTableSchema, tablePreviewViewSchema, takeSnapshotCommandSchema, traceLineEventSchema, verifySelectorCommandSchema, verifySelectorViewSchema, whyTargetSchema, whyViewSchema, workbookDocumentViewSchema, workspaceChangedEventSchema, workspaceViewSchema };
6392
6676
  //# sourceMappingURL=index.esm.js.map