@opencraw/studio 0.0.33 → 0.0.36

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 +2 -0
  2. package/dist/index.esm.js +1053 -855
  3. package/dist/src/index.d.ts +2 -0
  4. package/dist/src/studio-api/command.contract.d.ts +33 -0
  5. package/dist/src/studio-api/event.contract.d.ts +17 -0
  6. package/dist/src/studio-api/index.d.ts +8 -4
  7. package/dist/src/studio-api/studio-control-view.contract.d.ts +41 -0
  8. package/dist/src/studio-api/ui-request.contract.d.ts +9 -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 +12 -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 +2 -0
  17. package/dist/studio-api/command.contract.d.ts +33 -0
  18. package/dist/studio-api/event.contract.d.ts +17 -0
  19. package/dist/studio-api/index.d.ts +8 -4
  20. package/dist/studio-api/studio-control-view.contract.d.ts +41 -0
  21. package/dist/studio-api/ui-request.contract.d.ts +9 -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 +12 -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 +2 -0
  30. package/dist/ui/assets/{index-Db6-8OgZ.js → index-cNoQIuRS.js} +29 -29
  31. package/dist/ui/assets/{pdf-rdp2xUbL.js → pdf-OWIvAVVv.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';
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
7
  import { randomBytes, 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,14 @@ 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
+
44
52
  /** A budget for a sample run; never authored into a recipe, only asked for by the caller. */
45
53
  const sampleBudgetSchema = z.object({
46
54
  maxRecords: z.number().int().positive().optional(),
@@ -373,7 +381,21 @@ const importDocumentCommandSchema = z.object({
373
381
  name: z.string().min(1),
374
382
  bytes: z.string()
375
383
  });
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]);
384
+ /** 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. */
385
+ const askUiCommandSchema = z.object({
386
+ type: z.literal('ask-ui'),
387
+ request: uiRequestSchema
388
+ });
389
+ /** Asks what Studio is doing: its workspace, how many pages are open and the run in progress. */
390
+ const studioStatusCommandSchema = z.object({
391
+ type: z.literal('studio-status')
392
+ });
393
+ /** Asks how a recipe's sample run is going: still running, finished (with its first records) or never run. */
394
+ const runStatusCommandSchema = z.object({
395
+ type: z.literal('run-status'),
396
+ recipeId: z.string().min(1)
397
+ });
398
+ 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, studioStatusCommandSchema, runStatusCommandSchema]);
377
399
 
378
400
  /**
379
401
  * What `take-snapshot` answers with: the rewritten document ready for the
@@ -531,7 +553,12 @@ const recordingStoppedEventSchema = z.object({
531
553
  type: z.literal('recording-stopped'),
532
554
  steps: z.array(recordedStepSchema)
533
555
  });
534
- const studioEventSchema = z.discriminatedUnion('type', [traceLineEventSchema, recordEventSchema, recordRejectedEventSchema, runFinishedEventSchema, workspaceChangedEventSchema, recordingCardEventSchema, recordingNoteEventSchema, recordingStoppedEventSchema]);
556
+ /** A controller (an agent, through the MCP server) asked the open page to do something; every page does it. */
557
+ const uiRequestEventSchema = z.object({
558
+ type: z.literal('ui-request'),
559
+ request: uiRequestSchema
560
+ });
561
+ const studioEventSchema = z.discriminatedUnion('type', [traceLineEventSchema, recordEventSchema, recordRejectedEventSchema, runFinishedEventSchema, workspaceChangedEventSchema, recordingCardEventSchema, recordingNoteEventSchema, recordingStoppedEventSchema, uiRequestEventSchema]);
535
562
 
536
563
  /** One problem found on a recipe file, with the JSON path it lives at. */
537
564
  const recipeIssueSchema = z.object({
@@ -913,6 +940,37 @@ const deckTablePreviewViewSchema = z.object({
913
940
  error: z.string().optional()
914
941
  });
915
942
 
943
+ /** How many pages were listening when a controller asked them to do something: none means nobody has Studio open. */
944
+ z.object({
945
+ pages: z.number()
946
+ });
947
+ /** What Studio is doing: its workspace, how many pages are open, and the recipe whose sample run is in progress. */
948
+ z.object({
949
+ folder: z.string().optional(),
950
+ pages: z.number(),
951
+ running: z.string().optional()
952
+ });
953
+ const rejectionSchema = z.object({
954
+ field: z.string(),
955
+ reason: z.string(),
956
+ url: z.string()
957
+ });
958
+ /** 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). */
959
+ z.object({
960
+ recipeId: z.string(),
961
+ state: z.enum(['running', 'finished', 'never-run']),
962
+ result: z.object({
963
+ emitted: z.number(),
964
+ rejected: z.number(),
965
+ duplicates: z.number(),
966
+ durationMs: z.number(),
967
+ error: z.string().optional(),
968
+ stoppedBy: z.string().optional(),
969
+ records: z.array(sampleRunRecordSchema),
970
+ rejections: z.array(rejectionSchema)
971
+ }).optional()
972
+ });
973
+
916
974
  /** The cookie set on the page's own document response, so its own asset and API requests carry the token too. */
917
975
  const TOKEN_COOKIE = 'opencraw_token';
918
976
  /** A random token, generated once at server startup and required on every request. */
@@ -1064,30 +1122,41 @@ async function chooseFolderWithDialog(platform = process.platform, run = runDial
1064
1122
  throw new Error(`there is no folder dialog on this machine${tried === '' ? '' : ` (${tried} is not installed)`}: type the folder instead`);
1065
1123
  }
1066
1124
 
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
- }
1125
+ /** The announcement's file: one per process and port, so two Studios in one process do not share it. */
1126
+ function fileOf(instance, directory) {
1127
+ return join(directory, `${String(instance.pid)}-${new URL(instance.url).port}.json`);
1128
+ }
1129
+ /** Where running Studios announce themselves, unless the caller says otherwise. */
1130
+ function defaultInstancesDirectory() {
1131
+ return join(homedir(), '.opencraw', 'studio');
1074
1132
  }
1075
1133
  /**
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.
1134
+ * 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
1135
  *
1081
- * @param state - The server state holding the dialog to use.
1082
- * @returns The folder, or none when the dialog was closed.
1136
+ * @param instance - What to announce.
1137
+ * @param directory - Where announcements live.
1138
+ * @returns Whether the file was written.
1083
1139
  */
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
- };
1140
+ async function announceInstance(instance, directory) {
1141
+ try {
1142
+ await mkdir(directory, {
1143
+ recursive: true
1144
+ });
1145
+ const file = fileOf(instance, directory);
1146
+ await writeFile(file, JSON.stringify(instance), {
1147
+ mode: 0o600
1148
+ });
1149
+ await chmod(file, 0o600);
1150
+ return true;
1151
+ } catch {
1152
+ return false;
1153
+ }
1154
+ }
1155
+ /** Removes the announcement, when Studio stops. */
1156
+ async function withdrawInstance(instance, directory) {
1157
+ await rm(fileOf(instance, directory), {
1158
+ force: true
1159
+ });
1091
1160
  }
1092
1161
 
1093
1162
  /** 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,924 +2049,1000 @@ async function savedRecipe(file) {
1980
2049
  }
1981
2050
  }
1982
2051
 
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
2052
  /**
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.
2053
+ * Finds and parses one input recipe of a workspace folder, and the output
2054
+ * recipe it names.
1994
2055
  *
1995
- * @param document - The read workbook (a spreadsheet or a CSV).
1996
- * @returns The view.
2056
+ * @param folder - The workspace folder.
2057
+ * @param recipeId - The input recipe's id.
2058
+ * @returns The parsed pair.
2059
+ * @throws RecipeValidationError when either recipe does not parse.
2060
+ * @throws Error when the input or its output is not in the folder.
1997
2061
  */
1998
- function workbookDocumentView(document, options = {}) {
2062
+ async function loadRecipePair(folder, recipeId) {
2063
+ const documents = await readRecipeSource(folder);
2064
+ const inputDocument = documents.find(document => kindOf(document.content) === 'input' && idOf(document.content) === recipeId);
2065
+ if (inputDocument === undefined) throw new Error(`no input recipe "${recipeId}" in this workspace`);
2066
+ const input = parseInputRecipe(inputDocument.content, inputDocument.source);
2067
+ const outputDocument = documents.find(document => kindOf(document.content) === 'output' && idOf(document.content) === input.output);
2068
+ if (outputDocument === undefined) throw new Error(`no output recipe "${input.output}" in this workspace`);
1999
2069
  return {
2000
- sheets: document.sheets.map(sheet => sheetView(sheet, options.windowRows)),
2001
- ...(document.csv !== undefined && {
2002
- csv: document.csv
2003
- })
2070
+ input,
2071
+ output: parseOutputRecipe(outputDocument.content, outputDocument.source)
2004
2072
  };
2005
2073
  }
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
- };
2074
+ function kindOf(content) {
2075
+ return content?.kind;
2042
2076
  }
2077
+ function idOf(content) {
2078
+ return content?.id;
2079
+ }
2080
+
2043
2081
  /**
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).
2082
+ * The hooks a sample run uses: the ones Studio was started with, except that
2046
2083
  *
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).
2084
+ * - a hook the person stubbed returns the stub's value and calls nothing, and
2085
+ * - a hook that calls outside the process says so in the trace, once per call, because it may be slow
2086
+ * and it may cost money.
2087
+ *
2088
+ * @param hooks - The hooks Studio was started with.
2089
+ * @param stubs - Values to answer with instead of calling a hook, by hook name. A name that is not a hook is ignored.
2090
+ * @param announce - Where the trace lines go.
2091
+ * @returns The hooks to run the sample with.
2052
2092
  */
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
- };
2093
+ function calloutHooks(hooks, stubs, announce) {
2094
+ if (hooks === undefined) return undefined;
2095
+ const prepared = {};
2096
+ for (const [name, hook] of Object.entries(hooks)) {
2097
+ if (stubs !== undefined && Object.hasOwn(stubs, name)) {
2098
+ const stub = stubs[name];
2099
+ prepared[name] = () => {
2100
+ announce(`☎ hook "${name}" is stubbed: answering ${JSON.stringify(stub) ?? 'undefined'} without calling it`);
2101
+ return stub;
2102
+ };
2103
+ continue;
2104
+ }
2105
+ const label = calloutLabel(hook);
2106
+ prepared[name] = label === undefined ? hook : async (input, args, context) => {
2107
+ announce(`☎ hook "${name}" calls ${label} (slow, and it may cost: stub it to skip the call)`);
2108
+ return await hook(input, args, context);
2109
+ };
2110
+ }
2111
+ return prepared;
2092
2112
  }
2093
2113
 
2094
2114
  /**
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.
2115
+ * Runs one input recipe of a workspace folder as a sample: `debug: true` (so
2116
+ * each `record:emit` carries its scope snapshot and mapping trace, and each
2117
+ * `record:reject` its scope), an in-memory sink, and `budget` as the
2118
+ * crawler's `sample` (`@opencraw/core`'s `CrawlOptions.sample`, #89). Every
2119
+ * engine event becomes a trace line with the same `traceLine` formatter the
2120
+ * cli's `--trace` uses; every emitted record reaches `callbacks.onRecord` as
2121
+ * it happens, not only in the final result. Step events (`step:start`,
2122
+ * `step:skip`, `step:retry`) are aggregated by path into `result.steps`, so
2123
+ * `explain-why` (#92) can later say whether the step that bound a mapping's
2124
+ * source id ran, was skipped, or retried — without a second run.
2105
2125
  *
2106
- * @param document - The read deck.
2107
- * @returns The view.
2126
+ * @param folder - The workspace folder the recipe lives in.
2127
+ * @param recipeId - The input recipe to run.
2128
+ * @param budget - The sample budget; unset runs the recipe to completion (still capped by its own `limits`).
2129
+ * @param callbacks - Where trace lines and records go as the run proceeds.
2130
+ * @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.
2131
+ * @param plugins - Hooks, access plugins and captcha solvers the person started Studio with (`--hooks`, issue #150); none when it was started without.
2132
+ * @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.
2133
+ * @returns A handle: `stop` closes the crawler cleanly, `result` resolves when the run ends.
2134
+ * @throws RecipeValidationError, RecipeBindingError, Error when the recipe or its output cannot be loaded or bound.
2108
2135
  */
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) {
2133
- return {
2134
- x: shape.x,
2135
- y: shape.y,
2136
- width: shape.width,
2137
- height: shape.height,
2138
- text: shape.text,
2139
- ...(shape.placeholder !== undefined && {
2140
- placeholder: shape.placeholder
2141
- })
2142
- };
2143
- }
2144
- function chartView(chart) {
2136
+ async function runSample(folder, recipeId, budget, callbacks, browser, plugins, stubs) {
2137
+ const {
2138
+ input,
2139
+ output
2140
+ } = await loadRecipePair(folder, recipeId);
2141
+ const set = bindRecipeSet(output, [input]);
2142
+ const sink = memorySink();
2143
+ const records = [];
2144
+ const rejectedRecords = [];
2145
+ const steps = new Map();
2146
+ const crawler = createCrawler({
2147
+ sink,
2148
+ debug: true,
2149
+ sample: budget,
2150
+ browser,
2151
+ hooks: calloutHooks(plugins?.hooks, stubs, callbacks.onTraceLine),
2152
+ accessPlugins: plugins?.accessPlugins,
2153
+ captchaSolvers: plugins?.captchaSolvers,
2154
+ onEvent: event => {
2155
+ const line = traceLine(event);
2156
+ if (line !== undefined) callbacks.onTraceLine(line);
2157
+ trackStep(steps, event);
2158
+ if (event.type === 'record:emit') {
2159
+ const record = {
2160
+ key: event.key,
2161
+ data: event.data,
2162
+ scope: event.scope,
2163
+ mapping: event.mapping
2164
+ };
2165
+ records.push(record);
2166
+ callbacks.onRecord(record);
2167
+ } else if (event.type === 'record:reject') {
2168
+ const rejected = {
2169
+ field: event.field,
2170
+ reason: event.reason,
2171
+ url: event.url,
2172
+ scope: event.scope
2173
+ };
2174
+ rejectedRecords.push(rejected);
2175
+ callbacks.onRejected?.(rejected);
2176
+ }
2177
+ }
2178
+ });
2179
+ const result = runToResult(crawler, set, recipeId, records, rejectedRecords, steps, plugins);
2145
2180
  return {
2146
- type: chart.type,
2147
- ...(chart.title !== undefined && {
2148
- title: chart.title
2149
- }),
2150
- series: chart.series.map(series => ({
2151
- name: series.name,
2152
- categories: [...series.categories],
2153
- values: [...series.values]
2154
- }))
2181
+ stop: () => crawler.close(),
2182
+ result
2155
2183
  };
2156
2184
  }
2157
2185
  /**
2158
- * Groups a slide's shapes into visual rows the same way `deck-table.algorithm.ts`'s
2159
- * own (core-internal) `shapeTables` does: each shape is one cell, `y`
2160
- * flipped to the PDF-style bottom-up convention `rowsOfCells` (and the whole
2161
- * table-reading pipeline it feeds) works in, so the grouping this returns is
2162
- * byte-for-byte what `findDeckTables(document, { shapes: true })` groups at
2163
- * run time — a pick built from it matches for real, not just visually.
2164
- *
2165
- * @param shapes - The slide's shapes, in reading order.
2166
- * @param slideHeight - The deck's slide height, in points (`DeckDocument.height`).
2167
- * @returns Each row's shape indices, left to right; a blank shape (whitespace-only text) is never part of a row.
2186
+ * Folds one `step:*` event into `steps`, by path: `skip` always wins over an
2187
+ * earlier `ran`/`retried` outcome for the same path (a step skipped even
2188
+ * once is worth flagging), `retry` wins over a plain `ran`, and a path seen
2189
+ * only as `finish`/`start` (no skip, no retry) settles on `ran`. A `forEach`
2190
+ * runs its body once per item, so the same path folds repeatedly; this is
2191
+ * deliberately a summary across the whole sample, not a per-record trace.
2168
2192
  */
2169
- function shapeRowsOf(shapes, slideHeight) {
2170
- const indexOf = new Map();
2171
- const cells = shapes.flatMap((shape, index) => {
2172
- const text = shape.text.replaceAll(/\s+/g, ' ').trim();
2173
- if (text === '') return [];
2174
- const cell = {
2175
- x: shape.x,
2176
- y: slideHeight - shape.y - shape.height,
2177
- width: shape.width,
2178
- height: shape.height,
2179
- text
2180
- };
2181
- indexOf.set(cell, index);
2182
- return [cell];
2183
- });
2184
- return rowsOfCells(cells).map(row => row.cells.map(cell => indexOf.get(cell)));
2193
+ function trackStep(steps, event) {
2194
+ if (event.type === 'step:start') {
2195
+ if (!steps.has(event.path)) steps.set(event.path, {
2196
+ path: event.path,
2197
+ stepType: event.stepType,
2198
+ stepId: event.stepId,
2199
+ outcome: 'ran'
2200
+ });
2201
+ return;
2202
+ }
2203
+ if (event.type === 'step:retry') {
2204
+ steps.set(event.path, {
2205
+ path: event.path,
2206
+ stepType: event.stepType,
2207
+ stepId: event.stepId,
2208
+ outcome: 'retried',
2209
+ error: event.error
2210
+ });
2211
+ return;
2212
+ }
2213
+ if (event.type === 'step:skip') {
2214
+ steps.set(event.path, {
2215
+ path: event.path,
2216
+ stepType: event.stepType,
2217
+ stepId: event.stepId,
2218
+ outcome: 'skipped',
2219
+ error: event.error
2220
+ });
2221
+ }
2185
2222
  }
2186
-
2187
- const TEXT_LIMIT$1 = 120;
2188
2223
  /**
2189
- * Builds the document tree for `document-tree` (studio plan §3.4, issue
2190
- * #94's 5a) from the body `page-snapshot`'s `take-snapshot.use-case.ts`
2191
- * already fetched — no second read of the document.
2192
- *
2193
- * @param body - The parsed body (`HttpBody`); YAML and JSON Lines both
2194
- * arrive here already normalised to `{ kind: 'json' }` by `http.client.ts`.
2195
- * @returns The tree.
2196
- * @throws Error when `body`'s kind is not `json` or `xml` (PDF, workbook and
2197
- * deck documents get their own canvases — 5b/5c/5d, not this mapper).
2224
+ * Awaits the run and shapes its result. A throw from `crawler.run` (an unknown
2225
+ * hook, a recipe that cannot start) becomes the run's `error` instead of a
2226
+ * rejection: nothing awaits `result` but a `.then`, and an unhandled rejection
2227
+ * would take the whole Studio server down (issue #148).
2198
2228
  */
2199
- function documentTreeView(body) {
2200
- if (body.kind === 'json') return {
2201
- format: 'json',
2202
- root: jsonNode(body.data, '$', 'root')
2203
- };
2204
- if (body.kind === 'xml') return xmlTreeView(body.xml);
2205
- throw new Error(`document-tree: "${body.kind}" has no tree canvas (only "json" and "xml" bodies do)`);
2206
- }
2207
- function listFields(path, context) {
2208
- if (context === undefined) return {};
2209
- return {
2210
- listPath: context.generalPrefix + path.slice(context.itemPrefix.length),
2211
- listCount: context.count
2212
- };
2213
- }
2214
- function jsonNode(value, path, label, context) {
2215
- if (Array.isArray(value)) {
2216
- const children = value.map((item, index) => {
2217
- const itemPath = `${path}[${index}]`;
2218
- return jsonNode(item, itemPath, String(index), {
2219
- itemPrefix: itemPath,
2220
- generalPrefix: `${path}[*]`,
2221
- count: value.length
2222
- });
2223
- });
2229
+ async function runToResult(crawler, set, recipeId, records, rejectedRecords, steps, plugins) {
2230
+ try {
2231
+ const report = await crawler.run(set);
2232
+ const recipe = report.recipes[0];
2224
2233
  return {
2225
- id: path,
2226
- label,
2227
- valueType: 'array',
2228
- jsonpath: path,
2229
- ...listFields(path, context),
2230
- children
2234
+ recipeId: recipe.recipeId,
2235
+ emitted: recipe.emitted,
2236
+ rejected: recipe.rejected,
2237
+ duplicates: recipe.duplicates,
2238
+ durationMs: recipe.durationMs,
2239
+ error: recipe.error,
2240
+ stoppedBy: recipe.stoppedBy,
2241
+ records,
2242
+ rejectedRecords,
2243
+ // eslint-disable-next-line unicorn/prefer-iterator-to-array -- `.toArray()` needs a `lib` newer than this repo's `es2022` (tsconfig.base.json)
2244
+ steps: [...steps.values()]
2231
2245
  };
2232
- }
2233
- if (value !== null && typeof value === 'object') {
2234
- const entries = Object.entries(value);
2235
- // A plain object passes its own context through unchanged: only an array (or, in XML, a repeated element) starts a new one.
2236
- const children = entries.map(([key, item]) => jsonNode(item, `${path}${propertyAccess(key)}`, key, context));
2246
+ } catch (error) {
2237
2247
  return {
2238
- id: path,
2239
- label,
2240
- valueType: 'object',
2241
- jsonpath: path,
2242
- ...listFields(path, context),
2243
- children
2248
+ recipeId,
2249
+ emitted: 0,
2250
+ rejected: 0,
2251
+ duplicates: 0,
2252
+ durationMs: 0,
2253
+ error: failureMessage(error, plugins),
2254
+ records,
2255
+ rejectedRecords,
2256
+ // eslint-disable-next-line unicorn/prefer-iterator-to-array -- `.toArray()` needs a `lib` newer than this repo's `es2022` (tsconfig.base.json)
2257
+ steps: [...steps.values()]
2244
2258
  };
2259
+ } finally {
2260
+ await crawler.close();
2261
+ }
2262
+ }
2263
+ /** 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. */
2264
+ function failureMessage(error, plugins) {
2265
+ if (error instanceof UnknownHookError) {
2266
+ const message = stripVTControlCharacters(error.message);
2267
+ if (plugins !== undefined) return `${message}. It is not one of the hooks in ${plugins.source}: ${Object.keys(plugins.hooks).join(', ') || 'none exported'}.`;
2268
+ 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.`;
2245
2269
  }
2270
+ if (error instanceof Error) return stripVTControlCharacters(error.message);
2271
+ return stripVTControlCharacters(String(error));
2272
+ }
2273
+
2274
+ /** @returns A fresh, empty cache. */
2275
+ function createLastRunCache() {
2246
2276
  return {
2247
- id: path,
2248
- label,
2249
- valueType: leafType(value),
2250
- jsonpath: path,
2251
- preview: previewOf(value),
2252
- ...listFields(path, context),
2253
- children: []
2277
+ entries: new Map()
2254
2278
  };
2255
2279
  }
2256
- function leafType(value) {
2257
- if (value === null) return 'null';
2258
- if (typeof value === 'string') return 'string';
2259
- if (typeof value === 'number') return 'number';
2260
- if (typeof value === 'boolean') return 'boolean';
2261
- return 'string';
2280
+ /** Records a finished sample run, replacing whatever this recipe's cache entry held before. */
2281
+ function recordLastRun(cache, recipeId, result) {
2282
+ cache.entries.set(recipeId, {
2283
+ recipeId,
2284
+ result
2285
+ });
2262
2286
  }
2263
- function previewOf(value) {
2264
- const text = value === null ? 'null' : typeof value === 'string' ? value : JSON.stringify(value);
2265
- return text.length > TEXT_LIMIT$1 ? `${text.slice(0, TEXT_LIMIT$1)}…` : text;
2287
+ /** @returns The recipe's last completed sample run, or `undefined` when none has finished yet. */
2288
+ function lastRunOf(cache, recipeId) {
2289
+ return cache.entries.get(recipeId);
2266
2290
  }
2267
- /** A JSONPath property step: dot notation for a plain identifier, bracket notation (quoted) otherwise — jsonpath-plus accepts both, but only dot notation round-trips through a person reading the card. */
2268
- function propertyAccess(key) {
2269
- return /^[A-Z_]\w*$/i.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`;
2291
+
2292
+ /** 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. */
2293
+ function defaultRecorderProfileDir() {
2294
+ return join(tmpdir(), 'opencraw-studio', 'recorder-profile');
2270
2295
  }
2271
- // --- XML ---
2272
- const DEFAULT_NS_PREFIX = 'ns';
2273
- function xmlTreeView(xml) {
2274
- const document = parseXml(xml, 'document.xml');
2275
- const root = document.documentElement;
2276
- if (root === null) throw new Error('document-tree: the XML document has no root element');
2277
- const namespaces = declaredNamespaces(root);
2278
- const tree = xmlNode(root, '', namespaces, undefined);
2296
+ /** @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. */
2297
+ function createStudioState() {
2279
2298
  return {
2280
- format: 'xml',
2281
- root: tree,
2282
- ...(Object.keys(namespaces).length > 0 && {
2283
- namespaces
2284
- })
2299
+ sockets: new Set(),
2300
+ snapshots: createSnapshotCache(),
2301
+ lastRuns: createLastRunCache(),
2302
+ recorderProfileDir: defaultRecorderProfileDir()
2285
2303
  };
2286
2304
  }
2287
- /** Every `xmlns:*` the root declares, plus a synthesised prefix (`ns`, `ns2`, …) for a bare default `xmlns="…"` — XPath 1.0 has no default namespace, so a query needs a prefix even when the markup does not use one (mirrors `xpath.algorithm.ts`'s own `declaredPrefixes`, extended for the default case that function leaves out on purpose — it is resolved automatically there, but a *picked* card still needs to name it explicitly in `namespaces`). */
2288
- function declaredNamespaces(root) {
2289
- const namespaces = {};
2290
- let synthetic = 0;
2291
- for (const attribute of root.attributes) {
2292
- if (attribute.prefix === 'xmlns') namespaces[attribute.localName] = attribute.value;else if (attribute.name === 'xmlns') {
2293
- synthetic += 1;
2294
- namespaces[synthetic === 1 ? DEFAULT_NS_PREFIX : `${DEFAULT_NS_PREFIX}${synthetic}`] = attribute.value;
2305
+ /**
2306
+ * Sends one event to every connected client. A socket that errors while
2307
+ * sending (gone away without a clean close yet reaching us) is dropped
2308
+ * rather than left to fail every later broadcast too.
2309
+ *
2310
+ * @param state - The server state holding the connected sockets.
2311
+ * @param event - The event to send.
2312
+ */
2313
+ function broadcast(state, event) {
2314
+ const text = JSON.stringify(event);
2315
+ for (const socket of state.sockets) {
2316
+ try {
2317
+ socket.send(text);
2318
+ } catch {
2319
+ state.sockets.delete(socket);
2295
2320
  }
2296
2321
  }
2297
- return namespaces;
2298
2322
  }
2299
- function prefixFor(element, namespaces) {
2300
- if (element.namespaceURI === null || element.namespaceURI === undefined) return undefined;
2301
- if (element.prefix !== null && element.prefix !== undefined && element.prefix !== '') return element.prefix;
2302
- return Object.entries(namespaces).find(([, uri]) => uri === element.namespaceURI)?.[0];
2323
+
2324
+ /**
2325
+ * Handles `ask-ui`: tells every open page what a controller (an agent, through the MCP server) asked for, so
2326
+ * the person watching sees it happen. Nothing is done server-side: the page does it the way a click would.
2327
+ *
2328
+ * @param state - The server state holding the connected pages.
2329
+ * @param command - What to ask the pages.
2330
+ * @returns How many pages were listening; 0 means nobody has Studio open.
2331
+ */
2332
+ function handleAskUi(state, command) {
2333
+ broadcast(state, {
2334
+ type: 'ui-request',
2335
+ request: command.request
2336
+ });
2337
+ return {
2338
+ pages: state.sockets.size
2339
+ };
2303
2340
  }
2304
- function qualifiedName(element, namespaces) {
2305
- const prefix = prefixFor(element, namespaces);
2306
- return prefix === undefined ? element.localName ?? element.tagName : `${prefix}:${element.localName ?? element.tagName}`;
2341
+
2342
+ /** Asks the dialog, and clears `openDialog` once it has answered or failed, so the next request opens a new one. */
2343
+ async function askOnce(state, ask) {
2344
+ try {
2345
+ return await ask();
2346
+ } finally {
2347
+ state.openDialog = undefined;
2348
+ }
2307
2349
  }
2308
- function xmlNode(element, parentPath, namespaces, context) {
2309
- const name = qualifiedName(element, namespaces);
2310
- const siblingIndex = elementSiblingIndex(element);
2311
- const siblingCount = elementSiblingCount(element);
2312
- const step = siblingCount > 1 ? `${name}[${siblingIndex}]` : name;
2313
- const path = `${parentPath}/${step}`;
2314
- // I am one of several same-named siblings: my own listPath generalises myself, and my children inherit *my* context (innermost repetition wins) instead of whatever ancestor context I received.
2315
- const selfContext = siblingCount > 1 ? {
2316
- itemPrefix: path,
2317
- generalPrefix: `${parentPath}/${name}`,
2318
- count: siblingCount
2319
- } : context;
2320
- const childContext = siblingCount > 1 ? selfContext : context;
2321
- const attributes = elementAttributes(element);
2322
- const childElements = [...element.childNodes].filter(node => node.nodeType === 1);
2323
- const children = [...Object.entries(attributes).map(([key, value]) => attributeNode(key, value, path, childContext)), ...childElements.map(child => xmlNode(child, path, namespaces, childContext))];
2324
- const text = childElements.length === 0 ? textOf(element) : undefined;
2350
+ /**
2351
+ * Handles `choose-folder`: opens the folder dialog of the machine the server
2352
+ * runs on and answers the folder picked. Only answers: the UI follows with
2353
+ * `open-workspace`, the same as when the folder is typed. A dialog still
2354
+ * open is not opened a second time; the second request waits for the first.
2355
+ *
2356
+ * @param state - The server state holding the dialog to use.
2357
+ * @returns The folder, or none when the dialog was closed.
2358
+ */
2359
+ async function handleChooseFolder(state) {
2360
+ if (state.chooseFolder === undefined) throw new Error('this server has no folder dialog: type the folder instead');
2361
+ state.openDialog ??= askOnce(state, state.chooseFolder);
2362
+ const folder = await state.openDialog;
2363
+ return folder === undefined ? {} : {
2364
+ folder
2365
+ };
2366
+ }
2367
+
2368
+ /** 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. */
2369
+ const ISO_DATE_OR_TIME = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2})?$|^\d{2}:\d{2}:\d{2}$/;
2370
+ /**
2371
+ * Builds the grid canvas's view model (studio plan §3.4, issue #94's 5c) from
2372
+ * an already-read `WorkbookDocument` (`take-snapshot`'s cached body — no
2373
+ * second read of the file): every sheet's cells, typed, with hidden sheets
2374
+ * and rows marked, and merged ranges resolved to bounds so the grid draws
2375
+ * them as one cell. Table-specific diagnostics (the matched rows of the
2376
+ * current `table` extract's options) are not here — they depend on the
2377
+ * picks so far, so they come from `table-preview.use-case.ts`'s
2378
+ * `previewGridTable` instead, computed live as those options change.
2379
+ *
2380
+ * @param document - The read workbook (a spreadsheet or a CSV).
2381
+ * @returns The view.
2382
+ */
2383
+ function workbookDocumentView(document, options = {}) {
2325
2384
  return {
2326
- id: path,
2327
- label: name,
2328
- valueType: 'element',
2329
- xpath: path,
2330
- ...listFields(path, selfContext),
2331
- ...(Object.keys(attributes).length > 0 && {
2332
- attributes
2385
+ sheets: document.sheets.map(sheet => sheetView(sheet, options.windowRows)),
2386
+ ...(document.csv !== undefined && {
2387
+ csv: document.csv
2388
+ })
2389
+ };
2390
+ }
2391
+ function sheetView(sheet, windowRows) {
2392
+ const windowed = windowRows !== undefined && sheet.rows.length > windowRows;
2393
+ return {
2394
+ name: sheet.name,
2395
+ hidden: sheet.hidden === true,
2396
+ hiddenRows: sheet.hiddenRows === undefined ? [] : [...sheet.hiddenRows],
2397
+ // Not `Math.max(...lengths)`: a spread of a few hundred thousand rows overflows the call stack.
2398
+ columnCount: sheet.rows.reduce((widest, row) => Math.max(widest, row.length), 0),
2399
+ rows: (windowed ? sheet.rows.slice(0, windowRows) : sheet.rows).map(row => row.map(cell => cellView(cell))),
2400
+ ...(windowed && {
2401
+ rowCount: sheet.rows.length
2333
2402
  }),
2334
- ...(text !== undefined && {
2335
- preview: text,
2336
- valueType: childElements.length === 0 ? 'string' : 'element'
2403
+ merges: (sheet.merges ?? []).flatMap(reference => {
2404
+ const range = rangeOf(reference);
2405
+ return range === undefined ? [] : [{
2406
+ ref: reference,
2407
+ ...range
2408
+ }];
2337
2409
  }),
2338
- children
2410
+ ...(sheet.columnWidths !== undefined && {
2411
+ columnWidths: {
2412
+ ...sheet.columnWidths
2413
+ }
2414
+ }),
2415
+ ...(sheet.rowHeights !== undefined && {
2416
+ rowHeights: {
2417
+ ...sheet.rowHeights
2418
+ }
2419
+ }),
2420
+ ...(sheet.defaultColumnWidth !== undefined && {
2421
+ defaultColumnWidth: sheet.defaultColumnWidth
2422
+ }),
2423
+ ...(sheet.defaultRowHeight !== undefined && {
2424
+ defaultRowHeight: sheet.defaultRowHeight
2425
+ })
2339
2426
  };
2340
2427
  }
2341
- function attributeNode(name, value, elementPath, context) {
2342
- const path = `${elementPath}/@${name}`;
2428
+ /**
2429
+ * 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
2430
+ * sent to the browser until it is needed).
2431
+ *
2432
+ * @param document - The read workbook.
2433
+ * @param sheetIndex - Which sheet, 0-based.
2434
+ * @param from - The first row, 0-based.
2435
+ * @param count - How many rows at most.
2436
+ * @returns The rows (fewer at the end of the sheet, none past it).
2437
+ */
2438
+ function workbookSheetRows(document, sheetIndex, from, count) {
2439
+ const sheet = document.sheets[sheetIndex];
2440
+ if (sheet === undefined) return [];
2441
+ return sheet.rows.slice(from, from + count).map(row => row.map(cell => cellView(cell)));
2442
+ }
2443
+ function cellView(cell) {
2343
2444
  return {
2344
- id: path,
2345
- label: `@${name}`,
2346
- valueType: 'string',
2347
- xpath: path,
2348
- preview: previewOf(value),
2349
- ...listFields(path, context),
2350
- children: []
2445
+ value: cell,
2446
+ type: cellType(cell)
2351
2447
  };
2352
2448
  }
2353
- function elementAttributes(element) {
2354
- return Object.fromEntries([...element.attributes].filter(attribute => attribute.prefix !== 'xmlns' && attribute.name !== 'xmlns').map(attribute => [attribute.name, attribute.value]));
2355
- }
2356
- function elementSiblingIndex(element) {
2357
- let index = 1;
2358
- let sibling = element.previousSibling;
2359
- while (sibling !== null) {
2360
- if (sibling.nodeType === 1 && sibling.tagName === element.tagName) index += 1;
2361
- sibling = sibling.previousSibling;
2362
- }
2363
- return index;
2449
+ function cellType(cell) {
2450
+ if (typeof cell === 'number') return 'number';
2451
+ if (typeof cell === 'boolean') return 'boolean';
2452
+ return ISO_DATE_OR_TIME.test(cell) ? 'date' : 'string';
2364
2453
  }
2365
- function elementSiblingCount(element) {
2366
- const parent = element.parentNode;
2367
- if (parent === null) return 1;
2368
- return [...parent.childNodes].filter(node => node.nodeType === 1 && node.tagName === element.tagName).length;
2454
+ /** `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). */
2455
+ function rangeOf(reference) {
2456
+ const [from, to = from] = reference.split(':', 2);
2457
+ const start = cellOf(from);
2458
+ const end = cellOf(to);
2459
+ if (start === undefined || end === undefined) return undefined;
2460
+ return {
2461
+ top: Math.min(start.row, end.row),
2462
+ left: Math.min(start.column, end.column),
2463
+ bottom: Math.max(start.row, end.row),
2464
+ right: Math.max(start.column, end.column)
2465
+ };
2369
2466
  }
2370
- function textOf(element) {
2371
- const text = (element.textContent ?? '').trim().replaceAll(/\s+/g, ' ');
2372
- return text === '' ? undefined : previewOf(text);
2467
+ function cellOf(reference) {
2468
+ const match = /^\$?([A-Z]+)\$?(\d+)$/i.exec(reference.trim());
2469
+ if (match === null) return undefined;
2470
+ const letters = match[1].toUpperCase();
2471
+ let column = 0;
2472
+ for (const char of letters) column = column * 26 + (char.codePointAt(0) ?? 64) - 64;
2473
+ return {
2474
+ row: Number(match[2]) - 1,
2475
+ column: column - 1
2476
+ };
2373
2477
  }
2374
2478
 
2375
2479
  /**
2376
- * Builds the PDF canvas's view model (studio plan §3.4, issue #94's 5b) from
2377
- * an already-read `PdfDocument` (`take-snapshot`'s cached body — no second
2378
- * read of the file): every cell and row `readPdf` found, in PDF points, plus
2379
- * per-page counts and whether the page has a text layer at all. Table-specific
2380
- * diagnostics (the header row, the column bands, the wrapped-row regrouping)
2381
- * are not here — they depend on a `table` extract's own options, so they come
2382
- * from `table-preview.use-case.ts` instead, computed live as those options
2383
- * change.
2480
+ * Builds the deck canvas's view model (studio plan §3.4, issue #94's 5d)
2481
+ * from an already-read `DeckDocument` (`take-snapshot`'s cached body — no
2482
+ * second read of the file): every slide's shapes (with their visual-row
2483
+ * grouping, for the text-box-grid pick), native tables (through
2484
+ * `workbook-view.mapper.ts`'s own sheet view — a deck table *is* a sheet),
2485
+ * charts and notes. Table-specific diagnostics for a `table` extract's own
2486
+ * options (the live preview) are not here — they come from
2487
+ * `table-preview.use-case.ts`'s `previewDeckTable` instead, computed live as
2488
+ * those options change, the same split `pdf-view.mapper.ts`/`workbook-view.mapper.ts`
2489
+ * already use.
2384
2490
  *
2385
- * @param document - The read PDF.
2491
+ * @param document - The read deck.
2386
2492
  * @returns The view.
2387
2493
  */
2388
- function pdfDocumentView(document) {
2389
- const margins = marginRows(document);
2494
+ function deckDocumentView(document) {
2390
2495
  return {
2391
- pages: document.pages.map(page => ({
2392
- number: page.number,
2393
- width: page.width,
2394
- height: page.height,
2395
- rows: page.rows.map(row => ({
2396
- top: row.top,
2397
- bottom: row.bottom,
2398
- cells: row.cells.map(cell => ({
2399
- x: cell.x,
2400
- y: cell.y,
2401
- width: cell.width,
2402
- height: cell.height,
2403
- text: cell.text
2404
- })),
2405
- ...(margins.has(row) && {
2406
- running: true
2407
- })
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
- }))
2496
+ width: document.width,
2497
+ height: document.height,
2498
+ slides: document.slides.map(slide => slideView(slide, document.height))
2413
2499
  };
2414
2500
  }
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);
2501
+ function slideView(slide, slideHeight) {
2451
2502
  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
- }))
2503
+ number: slide.number,
2504
+ ...(slide.title !== undefined && {
2505
+ title: slide.title
2506
+ }),
2507
+ hidden: slide.hidden,
2508
+ shapes: slide.shapes.map(shape => shapeView(shape)),
2509
+ shapeRows: shapeRowsOf(slide.shapes, slideHeight),
2510
+ tables: workbookDocumentView({
2511
+ sheets: slide.tables
2512
+ }).sheets,
2513
+ charts: slide.charts.map(chart => chartView(chart)),
2514
+ notes: slide.notes
2460
2515
  };
2461
2516
  }
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);
2517
+ function shapeView(shape) {
2518
+ return {
2519
+ x: shape.x,
2520
+ y: shape.y,
2521
+ width: shape.width,
2522
+ height: shape.height,
2523
+ text: shape.text,
2524
+ ...(shape.placeholder !== undefined && {
2525
+ placeholder: shape.placeholder
2526
+ })
2527
+ };
2466
2528
  }
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
- }
2529
+ function chartView(chart) {
2530
+ return {
2531
+ type: chart.type,
2532
+ ...(chart.title !== undefined && {
2533
+ title: chart.title
2534
+ }),
2535
+ series: chart.series.map(series => ({
2536
+ name: series.name,
2537
+ categories: [...series.categories],
2538
+ values: [...series.values]
2539
+ }))
2540
+ };
2475
2541
  }
2476
2542
  /**
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).
2543
+ * Groups a slide's shapes into visual rows the same way `deck-table.algorithm.ts`'s
2544
+ * own (core-internal) `shapeTables` does: each shape is one cell, `y`
2545
+ * flipped to the PDF-style bottom-up convention `rowsOfCells` (and the whole
2546
+ * table-reading pipeline it feeds) works in, so the grouping this returns is
2547
+ * byte-for-byte what `findDeckTables(document, { shapes: true })` groups at
2548
+ * run time — a pick built from it matches for real, not just visually.
2482
2549
  *
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.
2550
+ * @param shapes - The slide's shapes, in reading order.
2551
+ * @param slideHeight - The deck's slide height, in points (`DeckDocument.height`).
2552
+ * @returns Each row's shape indices, left to right; a blank shape (whitespace-only text) is never part of a row.
2486
2553
  */
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)
2554
+ function shapeRowsOf(shapes, slideHeight) {
2555
+ const indexOf = new Map();
2556
+ const cells = shapes.flatMap((shape, index) => {
2557
+ const text = shape.text.replaceAll(/\s+/g, ' ').trim();
2558
+ if (text === '') return [];
2559
+ const cell = {
2560
+ x: shape.x,
2561
+ y: slideHeight - shape.y - shape.height,
2562
+ width: shape.width,
2563
+ height: shape.height,
2564
+ text
2503
2565
  };
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
- };
2566
+ indexOf.set(cell, index);
2567
+ return [cell];
2568
+ });
2569
+ return rowsOfCells(cells).map(row => row.cells.map(cell => indexOf.get(cell)));
2514
2570
  }
2571
+
2572
+ const TEXT_LIMIT$1 = 120;
2515
2573
  /**
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).
2574
+ * Builds the document tree for `document-tree` (studio plan §3.4, issue
2575
+ * #94's 5a) from the body `page-snapshot`'s `take-snapshot.use-case.ts`
2576
+ * already fetched — no second read of the document.
2521
2577
  *
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.
2578
+ * @param body - The parsed body (`HttpBody`); YAML and JSON Lines both
2579
+ * arrive here already normalised to `{ kind: 'json' }` by `http.client.ts`.
2580
+ * @returns The tree.
2581
+ * @throws Error when `body`'s kind is not `json` or `xml` (PDF, workbook and
2582
+ * deck documents get their own canvases — 5b/5c/5d, not this mapper).
2525
2583
  */
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
2584
+ function documentTreeView(body) {
2585
+ if (body.kind === 'json') return {
2586
+ format: 'json',
2587
+ root: jsonNode(body.data, '$', 'root')
2588
+ };
2589
+ if (body.kind === 'xml') return xmlTreeView(body.xml);
2590
+ throw new Error(`document-tree: "${body.kind}" has no tree canvas (only "json" and "xml" bodies do)`);
2591
+ }
2592
+ function listFields(path, context) {
2593
+ if (context === undefined) return {};
2594
+ return {
2595
+ listPath: context.generalPrefix + path.slice(context.itemPrefix.length),
2596
+ listCount: context.count
2597
+ };
2598
+ }
2599
+ function jsonNode(value, path, label, context) {
2600
+ if (Array.isArray(value)) {
2601
+ const children = value.map((item, index) => {
2602
+ const itemPath = `${path}[${index}]`;
2603
+ return jsonNode(item, itemPath, String(index), {
2604
+ itemPrefix: itemPath,
2605
+ generalPrefix: `${path}[*]`,
2606
+ count: value.length
2607
+ });
2608
+ });
2609
+ return {
2610
+ id: path,
2611
+ label,
2612
+ valueType: 'array',
2613
+ jsonpath: path,
2614
+ ...listFields(path, context),
2615
+ children
2539
2616
  };
2540
- } catch (error) {
2617
+ }
2618
+ if (value !== null && typeof value === 'object') {
2619
+ const entries = Object.entries(value);
2620
+ // A plain object passes its own context through unchanged: only an array (or, in XML, a repeated element) starts a new one.
2621
+ const children = entries.map(([key, item]) => jsonNode(item, `${path}${propertyAccess(key)}`, key, context));
2541
2622
  return {
2542
- matches: [],
2543
- error: error instanceof Error ? error.message : String(error)
2623
+ id: path,
2624
+ label,
2625
+ valueType: 'object',
2626
+ jsonpath: path,
2627
+ ...listFields(path, context),
2628
+ children
2544
2629
  };
2545
2630
  }
2546
- const tables = findDeckTables(document, query);
2547
2631
  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
- }))
2632
+ id: path,
2633
+ label,
2634
+ valueType: leafType(value),
2635
+ jsonpath: path,
2636
+ preview: previewOf(value),
2637
+ ...listFields(path, context),
2638
+ children: []
2555
2639
  };
2556
2640
  }
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
- };
2641
+ function leafType(value) {
2642
+ if (value === null) return 'null';
2643
+ if (typeof value === 'string') return 'string';
2644
+ if (typeof value === 'number') return 'number';
2645
+ if (typeof value === 'boolean') return 'boolean';
2646
+ return 'string';
2647
+ }
2648
+ function previewOf(value) {
2649
+ const text = value === null ? 'null' : typeof value === 'string' ? value : JSON.stringify(value);
2650
+ return text.length > TEXT_LIMIT$1 ? `${text.slice(0, TEXT_LIMIT$1)}…` : text;
2651
+ }
2652
+ /** A JSONPath property step: dot notation for a plain identifier, bracket notation (quoted) otherwise — jsonpath-plus accepts both, but only dot notation round-trips through a person reading the card. */
2653
+ function propertyAccess(key) {
2654
+ return /^[A-Z_]\w*$/i.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`;
2655
+ }
2656
+ // --- XML ---
2657
+ const DEFAULT_NS_PREFIX = 'ns';
2658
+ function xmlTreeView(xml) {
2659
+ const document = parseXml(xml, 'document.xml');
2660
+ const root = document.documentElement;
2661
+ if (root === null) throw new Error('document-tree: the XML document has no root element');
2662
+ const namespaces = declaredNamespaces(root);
2663
+ const tree = xmlNode(root, '', namespaces, undefined);
2664
+ return {
2665
+ format: 'xml',
2666
+ root: tree,
2667
+ ...(Object.keys(namespaces).length > 0 && {
2668
+ namespaces
2669
+ })
2670
+ };
2671
+ }
2672
+ /** Every `xmlns:*` the root declares, plus a synthesised prefix (`ns`, `ns2`, …) for a bare default `xmlns="…"` — XPath 1.0 has no default namespace, so a query needs a prefix even when the markup does not use one (mirrors `xpath.algorithm.ts`'s own `declaredPrefixes`, extended for the default case that function leaves out on purpose — it is resolved automatically there, but a *picked* card still needs to name it explicitly in `namespaces`). */
2673
+ function declaredNamespaces(root) {
2674
+ const namespaces = {};
2675
+ let synthetic = 0;
2676
+ for (const attribute of root.attributes) {
2677
+ if (attribute.prefix === 'xmlns') namespaces[attribute.localName] = attribute.value;else if (attribute.name === 'xmlns') {
2678
+ synthetic += 1;
2679
+ namespaces[synthetic === 1 ? DEFAULT_NS_PREFIX : `${DEFAULT_NS_PREFIX}${synthetic}`] = attribute.value;
2587
2680
  }
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
2681
  }
2682
+ return namespaces;
2606
2683
  }
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);
2684
+ function prefixFor(element, namespaces) {
2685
+ if (element.namespaceURI === null || element.namespaceURI === undefined) return undefined;
2686
+ if (element.prefix !== null && element.prefix !== undefined && element.prefix !== '') return element.prefix;
2687
+ return Object.entries(namespaces).find(([, uri]) => uri === element.namespaceURI)?.[0];
2626
2688
  }
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);
2689
+ function qualifiedName(element, namespaces) {
2690
+ const prefix = prefixFor(element, namespaces);
2691
+ return prefix === undefined ? element.localName ?? element.tagName : `${prefix}:${element.localName ?? element.tagName}`;
2644
2692
  }
2645
-
2646
- /**
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.
2650
- *
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);
2693
+ function xmlNode(element, parentPath, namespaces, context) {
2694
+ const name = qualifiedName(element, namespaces);
2695
+ const siblingIndex = elementSiblingIndex(element);
2696
+ const siblingCount = elementSiblingCount(element);
2697
+ const step = siblingCount > 1 ? `${name}[${siblingIndex}]` : name;
2698
+ const path = `${parentPath}/${step}`;
2699
+ // I am one of several same-named siblings: my own listPath generalises myself, and my children inherit *my* context (innermost repetition wins) instead of whatever ancestor context I received.
2700
+ const selfContext = siblingCount > 1 ? {
2701
+ itemPrefix: path,
2702
+ generalPrefix: `${parentPath}/${name}`,
2703
+ count: siblingCount
2704
+ } : context;
2705
+ const childContext = siblingCount > 1 ? selfContext : context;
2706
+ const attributes = elementAttributes(element);
2707
+ const childElements = [...element.childNodes].filter(node => node.nodeType === 1);
2708
+ const children = [...Object.entries(attributes).map(([key, value]) => attributeNode(key, value, path, childContext)), ...childElements.map(child => xmlNode(child, path, namespaces, childContext))];
2709
+ const text = childElements.length === 0 ? textOf(element) : undefined;
2710
+ return {
2711
+ id: path,
2712
+ label: name,
2713
+ valueType: 'element',
2714
+ xpath: path,
2715
+ ...listFields(path, selfContext),
2716
+ ...(Object.keys(attributes).length > 0 && {
2717
+ attributes
2718
+ }),
2719
+ ...(text !== undefined && {
2720
+ preview: text,
2721
+ valueType: childElements.length === 0 ? 'string' : 'element'
2722
+ }),
2723
+ children
2724
+ };
2661
2725
  }
2662
-
2663
- /**
2664
- * Finds and parses one input recipe of a workspace folder, and the output
2665
- * recipe it names.
2666
- *
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.
2672
- */
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`);
2726
+ function attributeNode(name, value, elementPath, context) {
2727
+ const path = `${elementPath}/@${name}`;
2680
2728
  return {
2681
- input,
2682
- output: parseOutputRecipe(outputDocument.content, outputDocument.source)
2729
+ id: path,
2730
+ label: `@${name}`,
2731
+ valueType: 'string',
2732
+ xpath: path,
2733
+ preview: previewOf(value),
2734
+ ...listFields(path, context),
2735
+ children: []
2683
2736
  };
2684
2737
  }
2685
- function kindOf(content) {
2686
- return content?.kind;
2738
+ function elementAttributes(element) {
2739
+ return Object.fromEntries([...element.attributes].filter(attribute => attribute.prefix !== 'xmlns' && attribute.name !== 'xmlns').map(attribute => [attribute.name, attribute.value]));
2687
2740
  }
2688
- function idOf(content) {
2689
- return content?.id;
2741
+ function elementSiblingIndex(element) {
2742
+ let index = 1;
2743
+ let sibling = element.previousSibling;
2744
+ while (sibling !== null) {
2745
+ if (sibling.nodeType === 1 && sibling.tagName === element.tagName) index += 1;
2746
+ sibling = sibling.previousSibling;
2747
+ }
2748
+ return index;
2749
+ }
2750
+ function elementSiblingCount(element) {
2751
+ const parent = element.parentNode;
2752
+ if (parent === null) return 1;
2753
+ return [...parent.childNodes].filter(node => node.nodeType === 1 && node.tagName === element.tagName).length;
2754
+ }
2755
+ function textOf(element) {
2756
+ const text = (element.textContent ?? '').trim().replaceAll(/\s+/g, ' ');
2757
+ return text === '' ? undefined : previewOf(text);
2690
2758
  }
2691
2759
 
2692
2760
  /**
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.
2761
+ * Builds the PDF canvas's view model (studio plan §3.4, issue #94's 5b) from
2762
+ * an already-read `PdfDocument` (`take-snapshot`'s cached body — no second
2763
+ * read of the file): every cell and row `readPdf` found, in PDF points, plus
2764
+ * per-page counts and whether the page has a text layer at all. Table-specific
2765
+ * diagnostics (the header row, the column bands, the wrapped-row regrouping)
2766
+ * are not here — they depend on a `table` extract's own options, so they come
2767
+ * from `table-preview.use-case.ts` instead, computed live as those options
2768
+ * change.
2698
2769
  *
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.
2770
+ * @param document - The read PDF.
2771
+ * @returns The view.
2703
2772
  */
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);
2720
- };
2721
- }
2722
- return prepared;
2773
+ function pdfDocumentView(document) {
2774
+ const margins = marginRows(document);
2775
+ return {
2776
+ pages: document.pages.map(page => ({
2777
+ number: page.number,
2778
+ width: page.width,
2779
+ height: page.height,
2780
+ rows: page.rows.map(row => ({
2781
+ top: row.top,
2782
+ bottom: row.bottom,
2783
+ cells: row.cells.map(cell => ({
2784
+ x: cell.x,
2785
+ y: cell.y,
2786
+ width: cell.width,
2787
+ height: cell.height,
2788
+ text: cell.text
2789
+ })),
2790
+ ...(margins.has(row) && {
2791
+ running: true
2792
+ })
2793
+ })),
2794
+ rowCount: page.rows.length,
2795
+ cellCount: page.rows.reduce((total, row) => total + row.cells.length, 0),
2796
+ hasTextLayer: page.rows.length > 0
2797
+ }))
2798
+ };
2723
2799
  }
2724
2800
 
2725
2801
  /**
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.
2802
+ * Runs a `table` extract's options against an already-read PDF (studio plan
2803
+ * §3.4, issue #94's 5b: the live preview) — a pure function of the document
2804
+ * and the options, so re-running it on every keystroke as the person edits
2805
+ * `selector`/`until`/`columns` costs nothing: no re-fetch, no re-parse
2806
+ * (`readPdf` already ran once, when `take-snapshot` captured the document).
2736
2807
  *
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.
2808
+ * The workbook grid's live preview (issue #94's 5c) is `previewGridTable`,
2809
+ * below: the same idea (a pure re-run of the options against the cached
2810
+ * document), but its own result shape — a workbook table has no page, no
2811
+ * column bands, no wrapped-row regrouping, so folding it into
2812
+ * `TablePreviewMatch`/`TablePreviewResult` would leave most of those fields
2813
+ * meaningless for a grid pick; a `GridTablePreviewMatch`/`GridTablePreviewResult`
2814
+ * sibling reads honestly instead.
2815
+ *
2816
+ * @param document - The already-read PDF.
2817
+ * @param options - The `table` extract's options, not yet compiled to `RegExp`.
2818
+ * @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.
2746
2819
  */
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);
2820
+ function previewPdfTable(document, options) {
2821
+ let query;
2822
+ try {
2823
+ query = {
2824
+ header: compile(options.header, 'selector'),
2825
+ until: options.until === undefined ? undefined : compile(options.until, 'until'),
2826
+ columns: options.columns === undefined ? undefined : Object.fromEntries(Object.entries(options.columns).map(([key, pattern]) => [key, compile(pattern, `columns.${key}`)])),
2827
+ align: options.align
2828
+ };
2829
+ } catch (error) {
2830
+ return {
2831
+ matches: [],
2832
+ error: error instanceof Error ? error.message : String(error)
2833
+ };
2834
+ }
2835
+ const analyses = analyzeTables(document, query);
2791
2836
  return {
2792
- stop: () => crawler.close(),
2793
- result
2837
+ matches: analyses.map(analysis => ({
2838
+ page: analysis.page,
2839
+ headerRowIndex: analysis.headerRowIndex,
2840
+ matchedRowIndices: rowIndicesOf(analysis),
2841
+ bands: analysis.bands,
2842
+ bandTolerance: analysis.bandTolerance,
2843
+ table: analysis.table
2844
+ }))
2794
2845
  };
2795
2846
  }
2796
- /**
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
2847
+ function rowIndicesOf(analysis) {
2848
+ const indices = new Set([analysis.headerRowIndex, ...analysis.rowGroups.flat()]);
2849
+ if (analysis.untilRowIndex !== undefined) indices.add(analysis.untilRowIndex);
2850
+ return [...indices].sort((a, b) => a - b);
2851
+ }
2852
+ function compile(source, where) {
2853
+ try {
2854
+ return new RegExp(source, 'i');
2855
+ } catch (error) {
2856
+ throw new Error(`${where}: invalid pattern ${source} (${error.message})`, {
2857
+ cause: error
2821
2858
  });
2822
- return;
2823
2859
  }
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
- });
2860
+ }
2861
+ /**
2862
+ * Runs a `table` extract's options against an already-read workbook (studio
2863
+ * plan §3.4, issue #94's 5c: the live preview) — a pure function of the
2864
+ * document and the options, exactly like `previewPdfTable` above: no
2865
+ * re-fetch, no re-parse (`csvWorkbook`/`readXlsxWorkbook` already ran once,
2866
+ * when `take-snapshot` captured the document).
2867
+ *
2868
+ * @param document - The already-read workbook.
2869
+ * @param options - The `table` extract's options, not yet compiled to `RegExp`.
2870
+ * @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.
2871
+ */
2872
+ function previewGridTable(document, options) {
2873
+ let query;
2874
+ try {
2875
+ query = {
2876
+ header: compile(options.header, 'selector'),
2877
+ until: options.until === undefined ? undefined : compile(options.until, 'until'),
2878
+ columns: options.columns === undefined ? undefined : Object.fromEntries(Object.entries(options.columns).map(([key, pattern]) => [key, compile(pattern, `columns.${key}`)])),
2879
+ sheet: options.sheet === undefined ? undefined : compile(options.sheet, 'sheet'),
2880
+ headerRows: options.headerRows,
2881
+ fillDown: options.fillDown,
2882
+ includeHidden: options.includeHidden
2883
+ };
2884
+ } catch (error) {
2885
+ return {
2886
+ matches: [],
2887
+ error: error instanceof Error ? error.message : String(error)
2888
+ };
2832
2889
  }
2890
+ const tables = findGridTables(document, query);
2891
+ return {
2892
+ matches: tables.map(table => ({
2893
+ sheet: table.sheet,
2894
+ title: table.title,
2895
+ header: table.header,
2896
+ rows: table.rows
2897
+ }))
2898
+ };
2833
2899
  }
2834
2900
  /**
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).
2901
+ * Runs a `table` extract's options against an already-read deck (studio plan
2902
+ * §3.4, issue #94's 5d: the live preview) — a pure function of the document
2903
+ * and the options, exactly like `previewPdfTable`/`previewGridTable` above:
2904
+ * no re-fetch, no re-parse (`readPptxDeck` already ran once, when
2905
+ * `take-snapshot` captured the document).
2906
+ *
2907
+ * @param document - The already-read deck.
2908
+ * @param options - The `table` extract's options, not yet compiled to `RegExp`.
2909
+ * @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.
2839
2910
  */
2840
- async function runToResult(crawler, set, recipeId, records, rejectedRecords, steps, plugins) {
2911
+ function previewDeckTable(document, options) {
2912
+ let query;
2841
2913
  try {
2842
- const report = await crawler.run(set);
2843
- const recipe = report.recipes[0];
2844
- 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()]
2914
+ query = {
2915
+ slide: options.slide === undefined ? undefined : compile(options.slide, 'slide'),
2916
+ shapes: options.shapes,
2917
+ align: options.align,
2918
+ header: compile(options.header, 'selector'),
2919
+ until: options.until === undefined ? undefined : compile(options.until, 'until'),
2920
+ columns: options.columns === undefined ? undefined : Object.fromEntries(Object.entries(options.columns).map(([key, pattern]) => [key, compile(pattern, `columns.${key}`)])),
2921
+ headerRows: options.headerRows,
2922
+ fillDown: options.fillDown,
2923
+ includeHidden: options.includeHidden
2856
2924
  };
2857
2925
  } catch (error) {
2858
2926
  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()]
2927
+ matches: [],
2928
+ error: error instanceof Error ? error.message : String(error)
2869
2929
  };
2870
- } finally {
2871
- await crawler.close();
2872
2930
  }
2931
+ const tables = findDeckTables(document, query);
2932
+ return {
2933
+ matches: tables.map(table => ({
2934
+ slide: table.slide,
2935
+ slideTitle: table.slideTitle,
2936
+ title: table.title,
2937
+ header: table.header,
2938
+ rows: table.rows
2939
+ }))
2940
+ };
2873
2941
  }
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.`;
2942
+
2943
+ /**
2944
+ * Runs a `region` extract's selector against an already-read PDF or deck
2945
+ * (issues #121 and #122: the canvases' click-to-snap selection) — the
2946
+ * engine's own `regionText` / `deckRegionText`, so what the canvas
2947
+ * highlights and the text it shows are exactly what the step will read,
2948
+ * and a pure function of the document, so re-running it on every change
2949
+ * of the box costs nothing. A `slide=` on a PDF (or a `page=` on a deck)
2950
+ * is the same mismatch the step would fail with, reported as `error`.
2951
+ *
2952
+ * @param document - The read PDF or deck (`take-snapshot`'s cached body).
2953
+ * @param selector - The `region` selector, `page=1 x=72..252 y=640..664` or `slide=2 x=60..900 y=30..90`.
2954
+ * @returns The matches, or `error` when the selector does not parse or does not fit the document.
2955
+ */
2956
+ function previewRegion(document, selector) {
2957
+ try {
2958
+ const region = parseRegion(selector);
2959
+ if (document.kind === 'pdf') {
2960
+ if (region.on !== 'page') return {
2961
+ matches: [],
2962
+ error: 'region: "slide=" reads a deck; this document is a PDF (name a "page=")'
2963
+ };
2964
+ return {
2965
+ matches: regionText(document, region).map(match => ({
2966
+ page: match.page,
2967
+ text: match.text,
2968
+ cells: match.cells,
2969
+ shapes: []
2970
+ }))
2971
+ };
2972
+ }
2973
+ if (region.on !== 'slide') return {
2974
+ matches: [],
2975
+ error: 'region: "page=" reads a PDF; this document is a deck (name a "slide=")'
2976
+ };
2977
+ return {
2978
+ matches: deckRegionText(document, region).map(match => ({
2979
+ page: match.slide,
2980
+ text: match.text,
2981
+ cells: [],
2982
+ shapes: match.shapes
2983
+ }))
2984
+ };
2985
+ } catch (error) {
2986
+ return {
2987
+ matches: [],
2988
+ error: error instanceof Error ? error.message : String(error)
2989
+ };
2880
2990
  }
2881
- if (error instanceof Error) return stripVTControlCharacters(error.message);
2882
- return stripVTControlCharacters(String(error));
2883
2991
  }
2884
2992
 
2885
- /** @returns A fresh, empty cache. */
2886
- function createLastRunCache() {
2887
- return {
2888
- entries: new Map()
2889
- };
2993
+ /**
2994
+ * Handles `deck-preview`: the deck canvas's live preview of a `table`
2995
+ * extract's options (`document-view`'s `table-preview.use-case.ts`'s
2996
+ * `previewDeckTable`, studio plan §3.4, issue #94's 5d), off the same cached
2997
+ * snapshot `take-snapshot`/`deck-view` already captured — a pure computation
2998
+ * over it, so this answers instantly on every option change, no re-fetch or
2999
+ * re-parse.
3000
+ *
3001
+ * @param state - The server state.
3002
+ * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
3003
+ * @returns The matched tables, or `error` when an option's pattern does not compile.
3004
+ * @throws Error when no workspace is open, or `command.path` has no cached snapshot yet.
3005
+ */
3006
+ function handleDeckPreview(state, command) {
3007
+ const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
3008
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
3009
+ 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`);
3010
+ return previewDeckTable(snapshot.body, command.options);
2890
3011
  }
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
- });
3012
+
3013
+ /**
3014
+ * Handles `deck-view`: the deck canvas's slides, shapes, tables, charts and
3015
+ * notes (`document-view`'s `deck-view.mapper.ts`, studio plan §3.4, issue
3016
+ * #94's 5d), off the same cached snapshot `take-snapshot`/`document-tree`
3017
+ * already use.
3018
+ *
3019
+ * @param state - The server state.
3020
+ * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
3021
+ * @returns The document's view.
3022
+ * @throws Error when no workspace is open, `command.path` has no cached snapshot yet, or the snapshot's document is not a read deck.
3023
+ */
3024
+ function handleDeckView(state, command) {
3025
+ const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
3026
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
3027
+ 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`);
3028
+ return deckDocumentView(snapshot.body);
2897
3029
  }
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);
3030
+
3031
+ /**
3032
+ * Handles `document-tree`: the content pane's tree canvas for a JSON, YAML
3033
+ * or XML document (`document-view`, studio plan §3.4, issue #94's 5a), off
3034
+ * the same cached snapshot `take-snapshot`/`inspect-page` already use.
3035
+ *
3036
+ * @param state - The server state.
3037
+ * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
3038
+ * @returns The tree.
3039
+ * @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).
3040
+ */
3041
+ function handleDocumentTree(state, command) {
3042
+ const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
3043
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
3044
+ 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`);
3045
+ return documentTreeView(snapshot.body);
2901
3046
  }
2902
3047
 
2903
3048
  /**
@@ -4835,38 +4980,6 @@ function remoteCallouts(plugins) {
4835
4980
  return found.sort((a, b) => order[a.kind] - order[b.kind] || a.name.localeCompare(b.name));
4836
4981
  }
4837
4982
 
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
4983
  /**
4871
4984
  * Handles `open-workspace`: reads the folder, remembers it as the server's
4872
4985
  * current workspace (every later command that names a recipe resolves it
@@ -5711,6 +5824,52 @@ function handleRegionPreview(state, command) {
5711
5824
  return previewRegion(snapshot.body, command.selector);
5712
5825
  }
5713
5826
 
5827
+ /** How many records and rejections a status carries: enough to judge a run, the rest stay in Studio. */
5828
+ const FIRST = 20;
5829
+ /**
5830
+ * Handles `run-status`: how a recipe's sample run is going, for a controller that started it and cannot watch
5831
+ * the event stream. A run in progress is `running`; one that finished carries its counts and first records.
5832
+ *
5833
+ * @param state - The server state holding the run in progress and the last runs.
5834
+ * @param command - The recipe asked about.
5835
+ * @returns The state of the recipe's sample run.
5836
+ */
5837
+ function handleRunStatus(state, command) {
5838
+ if (state.activeRun !== undefined && state.activeRunId === command.recipeId) return {
5839
+ recipeId: command.recipeId,
5840
+ state: 'running'
5841
+ };
5842
+ const last = lastRunOf(state.lastRuns, command.recipeId);
5843
+ if (last === undefined) return {
5844
+ recipeId: command.recipeId,
5845
+ state: 'never-run'
5846
+ };
5847
+ const {
5848
+ result
5849
+ } = last;
5850
+ return {
5851
+ recipeId: command.recipeId,
5852
+ state: 'finished',
5853
+ result: {
5854
+ emitted: result.emitted,
5855
+ rejected: result.rejected,
5856
+ duplicates: result.duplicates,
5857
+ durationMs: result.durationMs,
5858
+ error: result.error,
5859
+ stoppedBy: result.stoppedBy,
5860
+ records: result.records.slice(0, FIRST).map(record => ({
5861
+ key: record.key,
5862
+ data: record.data
5863
+ })),
5864
+ rejections: result.rejectedRecords.slice(0, FIRST).map(rejection => ({
5865
+ field: rejection.field,
5866
+ reason: rejection.reason,
5867
+ url: rejection.url
5868
+ }))
5869
+ }
5870
+ };
5871
+ }
5872
+
5714
5873
  /**
5715
5874
  * Handles `run-sample`: stops whatever sample was still running (one run at
5716
5875
  * a time), starts the new one, and returns once it has started, not once it
@@ -5753,6 +5912,7 @@ async function handleRunSample(state, command) {
5753
5912
  }
5754
5913
  }, state.browser, state.plugins, command.stubs);
5755
5914
  state.activeRun = handle;
5915
+ state.activeRunId = command.recipeId;
5756
5916
  void handle.result.then(result => {
5757
5917
  if (state.activeRun === handle) state.activeRun = undefined;
5758
5918
  recordLastRun(state.lastRuns, command.recipeId, result);
@@ -5875,6 +6035,20 @@ function serveStatic(root, pathname, response) {
5875
6035
  createReadStream(file).pipe(response);
5876
6036
  }
5877
6037
 
6038
+ /**
6039
+ * Handles `studio-status`: what Studio is doing right now.
6040
+ *
6041
+ * @param state - The server state.
6042
+ * @returns The open workspace, how many pages are open and the recipe being sampled.
6043
+ */
6044
+ function handleStudioStatus(state) {
6045
+ return {
6046
+ folder: state.folder,
6047
+ pages: state.sockets.size,
6048
+ running: state.activeRun === undefined ? undefined : state.activeRunId
6049
+ };
6050
+ }
6051
+
5878
6052
  /**
5879
6053
  * Handles `table-preview`: the PDF canvas's live preview of a `table`
5880
6054
  * extract's options (`document-view`'s `table-preview.use-case.ts`, studio
@@ -6114,6 +6288,15 @@ async function startStudioServer(options = {}) {
6114
6288
  const port = typeof address === 'object' && address !== null ? address.port : 0;
6115
6289
  const folderParam = options.initialFolder === undefined ? '' : `&folder=${encodeURIComponent(options.initialFolder)}`;
6116
6290
  const base = `http://127.0.0.1:${port}/?token=${token}`;
6291
+ const instancesDir = options.instancesDir ?? defaultInstancesDirectory();
6292
+ const instance = {
6293
+ pid: process.pid,
6294
+ url: `http://127.0.0.1:${port}`,
6295
+ token,
6296
+ folder: options.initialFolder,
6297
+ startedAt: new Date().toISOString()
6298
+ };
6299
+ await announceInstance(instance, instancesDir);
6117
6300
  return {
6118
6301
  url: `${base}${folderParam}`,
6119
6302
  token,
@@ -6122,7 +6305,10 @@ async function startStudioServer(options = {}) {
6122
6305
  const open = target?.open === undefined ? '' : `&open=${encodeURIComponent(target.open)}`;
6123
6306
  return `${base}${workspace === undefined ? '' : `&folder=${encodeURIComponent(workspace)}`}${open}`;
6124
6307
  },
6125
- close: () => stop(server, state)
6308
+ close: async () => {
6309
+ await withdrawInstance(instance, instancesDir);
6310
+ await stop(server, state);
6311
+ }
6126
6312
  };
6127
6313
  }
6128
6314
  async function stop(server, state) {
@@ -6321,6 +6507,18 @@ function dispatch(command, state) {
6321
6507
  {
6322
6508
  return handleStopRecording(state);
6323
6509
  }
6510
+ case 'ask-ui':
6511
+ {
6512
+ return Promise.resolve(handleAskUi(state, command));
6513
+ }
6514
+ case 'studio-status':
6515
+ {
6516
+ return Promise.resolve(handleStudioStatus(state));
6517
+ }
6518
+ case 'run-status':
6519
+ {
6520
+ return Promise.resolve(handleRunStatus(state, command));
6521
+ }
6324
6522
  }
6325
6523
  }
6326
6524
  function respondJson(response, status, body) {
@@ -6388,5 +6586,5 @@ async function main(argv, options = {}) {
6388
6586
  process.on('SIGTERM', shutdown);
6389
6587
  }
6390
6588
 
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 };
6589
+ 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
6590
  //# sourceMappingURL=index.esm.js.map