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