@sutrata/editor 0.1.2 → 0.3.0
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/ai/VoiceConfirmDialog.d.ts +1 -0
- package/dist/ai/VoiceConfirmDialog.js +6 -6
- package/dist/ai/VoiceToolbar.d.ts +1 -0
- package/dist/ai/VoiceToolbar.js +22 -16
- package/dist/ai/clean-response.d.ts +7 -0
- package/dist/ai/clean-response.js +58 -0
- package/dist/ai/format-transcript.d.ts +3 -0
- package/dist/ai/format-transcript.js +7 -0
- package/dist/ai/prompts.d.ts +8 -1
- package/dist/ai/prompts.js +34 -7
- package/dist/autocomplete/suggestions.js +1 -1
- package/dist/context/DocumentContext.d.ts +34 -5
- package/dist/context/DocumentContext.js +152 -51
- package/dist/editor/EditorView.js +35 -5
- package/dist/editor/SelectionMenu.js +5 -3
- package/dist/editor/editor-bus.d.ts +3 -0
- package/dist/editor/editor-bus.js +13 -0
- package/dist/editor/external-change.d.ts +11 -0
- package/dist/editor/external-change.js +105 -0
- package/dist/editor/external-decorations-plugin.d.ts +23 -0
- package/dist/editor/external-decorations-plugin.js +151 -0
- package/dist/editor/frontmatter-field-view.js +3 -0
- package/dist/editor/omitted-scene-plugin.d.ts +9 -0
- package/dist/editor/omitted-scene-plugin.js +44 -0
- package/dist/editor/plugins.js +4 -0
- package/dist/editor/private-metadata-plugin.d.ts +13 -0
- package/dist/editor/private-metadata-plugin.js +100 -0
- package/dist/editor/prosemirror-to-sutra.js +179 -74
- package/dist/editor/scene-heading-view.js +2 -0
- package/dist/editor/schema.js +74 -13
- package/dist/editor/sutra-to-prosemirror.d.ts +2 -0
- package/dist/editor/sutra-to-prosemirror.js +32 -34
- package/dist/extensions/ai-provider.d.ts +44 -0
- package/dist/extensions/ai-provider.js +27 -0
- package/dist/extensions/collab.d.ts +21 -0
- package/dist/extensions/collab.js +6 -0
- package/dist/extensions/command-registry.d.ts +49 -0
- package/dist/extensions/command-registry.js +28 -0
- package/dist/extensions/decorations.d.ts +48 -0
- package/dist/extensions/decorations.js +6 -0
- package/dist/extensions/export-registry.d.ts +59 -0
- package/dist/extensions/export-registry.js +10 -0
- package/dist/extensions/panel-registry.d.ts +30 -0
- package/dist/extensions/panel-registry.js +16 -0
- package/dist/extensions/registries.d.ts +15 -0
- package/dist/extensions/registries.js +12 -0
- package/dist/extensions/registry-store.d.ts +26 -0
- package/dist/extensions/registry-store.js +35 -0
- package/dist/extensions/session.d.ts +32 -0
- package/dist/extensions/session.js +20 -0
- package/dist/extensions/speech-provider.d.ts +35 -0
- package/dist/extensions/speech-provider.js +17 -0
- package/dist/extensions/storage-adapter.d.ts +36 -1
- package/dist/file/builtin-exporters.d.ts +9 -0
- package/dist/file/builtin-exporters.js +72 -0
- package/dist/file/docx-exporter.js +7 -4
- package/dist/file/docx-importer.d.ts +9 -0
- package/dist/file/docx-importer.js +47 -19
- package/dist/file/scene-number.d.ts +5 -0
- package/dist/file/scene-number.js +8 -0
- package/dist/file/workflow-reports.js +13 -7
- package/dist/findreplace/FindReplace.js +5 -2
- package/dist/import/AIImportDialog.d.ts +11 -0
- package/dist/import/AIImportDialog.js +112 -0
- package/dist/import/ai-import.d.ts +51 -0
- package/dist/import/ai-import.js +200 -0
- package/dist/import/apply-import.d.ts +7 -0
- package/dist/import/apply-import.js +35 -0
- package/dist/import/extract.d.ts +17 -0
- package/dist/import/extract.js +18 -0
- package/dist/index.d.ts +18 -3
- package/dist/index.js +7 -0
- package/dist/locales/bn.json +8 -0
- package/dist/locales/en.json +8 -0
- package/dist/locales/gu.json +8 -0
- package/dist/locales/hi.json +8 -0
- package/dist/locales/kn.json +8 -0
- package/dist/locales/ml.json +8 -0
- package/dist/locales/mr.json +8 -0
- package/dist/locales/or.json +8 -0
- package/dist/locales/pa.json +8 -0
- package/dist/locales/si.json +8 -0
- package/dist/locales/ta.json +8 -0
- package/dist/locales/te.json +8 -0
- package/dist/navigator/SceneNavigator.js +125 -131
- package/dist/navigator/StatisticsDialog.js +5 -1
- package/dist/navigator/scene-edits.d.ts +18 -0
- package/dist/navigator/scene-edits.js +143 -0
- package/dist/navigator/scene-list.js +4 -4
- package/dist/navigator/scene-numbering.d.ts +37 -0
- package/dist/navigator/scene-numbering.js +88 -0
- package/dist/settings/SettingsDialog.js +8 -120
- package/dist/shell/AppBar.js +25 -4
- package/dist/shell/AppShell.js +18 -4
- package/dist/shell/ElementToolbar.d.ts +5 -0
- package/dist/shell/ElementToolbar.js +35 -25
- package/dist/shell/LanguageStatus.js +6 -0
- package/dist/shell/StatusBar.js +11 -6
- package/dist/shell/builtin-commands.d.ts +10 -0
- package/dist/shell/builtin-commands.js +43 -0
- package/dist/shell/icons.d.ts +6 -0
- package/dist/shell/icons.js +6 -0
- package/dist/shell/use-commands.d.ts +7 -0
- package/dist/shell/use-commands.js +24 -0
- package/dist/shell/use-panel-context.d.ts +3 -0
- package/dist/shell/use-panel-context.js +39 -0
- package/dist/source/SourceView.js +39 -3
- package/dist/styles/screenplay.css +114 -1
- package/dist/titlepage/ExportDialog.d.ts +5 -0
- package/dist/titlepage/ExportDialog.js +59 -96
- package/dist/titlepage/frontmatter-form.js +1 -1
- package/package.json +4 -4
- package/dist/ai/OnboardingDialog.d.ts +0 -6
- package/dist/ai/OnboardingDialog.js +0 -115
- package/dist/ai/ai-client.d.ts +0 -71
- package/dist/ai/ai-client.js +0 -494
- package/dist/ai/generated-providers.json +0 -3696
- package/dist/ai/voice-service.d.ts +0 -15
- package/dist/ai/voice-service.js +0 -81
- package/dist/editor/character-view.d.ts +0 -35
- package/dist/editor/character-view.js +0 -112
- package/dist/editor/transliterate-input.d.ts +0 -10
- package/dist/editor/transliterate-input.js +0 -88
- package/dist/extensions/types.d.ts +0 -73
- package/dist/extensions/types.js +0 -12
|
@@ -3,6 +3,7 @@ import { resolveStyle } from '../styles/registry';
|
|
|
3
3
|
import { styleToDocxParagraphStyles } from '../styles/docx-adapter';
|
|
4
4
|
import { SCRIPT_UNICODE_RANGES } from '../styles/script-ranges';
|
|
5
5
|
import { DEFAULT_COVER_LAYOUT } from '../styles/types';
|
|
6
|
+
import { sceneNumberOf, isOmitted } from './scene-number';
|
|
6
7
|
/** Flatten inline span trees into flat formatting runs. `skipNotes` drops inline
|
|
7
8
|
* `[[ ]]` note spans entirely (see the "Skip notes" export option). */
|
|
8
9
|
function flattenSpans(spans, props = {}, skipNotes = false) {
|
|
@@ -418,13 +419,15 @@ export async function exportToDocx(ast, styleOverride, customStyles = [], skipNo
|
|
|
418
419
|
}
|
|
419
420
|
}
|
|
420
421
|
else if (node.type === 'scene-heading') {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
422
|
+
// Scene number: the {#id} (§6.1); an omitted scene prints OMITTED (§7.4).
|
|
423
|
+
const sceneNumber = sceneNumberOf(node);
|
|
424
|
+
const headingRuns = runsFor([{ text: isOmitted(node) ? 'OMITTED' : node.text.toUpperCase(), bold: true }]);
|
|
425
|
+
if (sceneNumber) {
|
|
426
|
+
headingRuns.push(new TextRun({ text: '\t' }), ...runsFor([{ text: sceneNumber, bold: true }]));
|
|
424
427
|
}
|
|
425
428
|
childrenNodes.push(new Paragraph({
|
|
426
429
|
style: 'CineSceneHeading',
|
|
427
|
-
tabStops:
|
|
430
|
+
tabStops: sceneNumber
|
|
428
431
|
? [{ type: TabStopType.RIGHT, position: contentRightEdgeTwips }]
|
|
429
432
|
: undefined,
|
|
430
433
|
children: headingRuns,
|
|
@@ -3,6 +3,15 @@ export interface DocxImportResult {
|
|
|
3
3
|
bodyText: string | null;
|
|
4
4
|
warnings: string[];
|
|
5
5
|
}
|
|
6
|
+
/** True when the document carries the CineXxx paragraph styles exportToDocx applies. */
|
|
7
|
+
export declare function isSutrataDocx(buffer: ArrayBuffer): Promise<boolean>;
|
|
8
|
+
/**
|
|
9
|
+
* Plain text of any Word document, one line per paragraph (empty paragraphs
|
|
10
|
+
* become blank lines), with bold/italic/underline runs as Sutra emphasis
|
|
11
|
+
* markup. Table cells are read row by row, left to right. Used by the
|
|
12
|
+
* AI-assisted import for documents Sutrata did not write.
|
|
13
|
+
*/
|
|
14
|
+
export declare function readDocxText(buffer: ArrayBuffer): Promise<string>;
|
|
6
15
|
/** Parses a .docx file (as produced by exportToDocx, possibly re-saved from Word with
|
|
7
16
|
* corrections and/or font changes) back into Sutra body text. The title page, if any,
|
|
8
17
|
* is not reimported — only content carrying the CineXxx paragraph styles the exporter
|
|
@@ -56,19 +56,10 @@ function parseParagraph(p) {
|
|
|
56
56
|
return { style, text: runs.map(r => r.text).join(''), runs, isPageBreak };
|
|
57
57
|
}
|
|
58
58
|
function parseTable(tbl) {
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (tc.localName !== 'tc')
|
|
64
|
-
continue;
|
|
65
|
-
const paras = Array.from(tc.children)
|
|
66
|
-
.filter(c => c.localName === 'p')
|
|
67
|
-
.map(p => parseParagraph(p));
|
|
68
|
-
cells.push(paras);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
return { cells };
|
|
59
|
+
const rows = Array.from(tbl.getElementsByTagNameNS(W_NS, 'tr')).map(tr => Array.from(tr.children)
|
|
60
|
+
.filter(tc => tc.localName === 'tc')
|
|
61
|
+
.map(tc => Array.from(tc.children).filter(c => c.localName === 'p').map(p => parseParagraph(p))));
|
|
62
|
+
return { cells: rows[0] ?? [], rows };
|
|
72
63
|
}
|
|
73
64
|
/** Rebuilds bold/italic/underline markup from formatting runs, matching the parser's inline syntax.
|
|
74
65
|
* Types that don't support inline spans in Sutra source (headings, character cues, notes, etc.)
|
|
@@ -207,12 +198,7 @@ function isBodyStart(item) {
|
|
|
207
198
|
return true;
|
|
208
199
|
return item.para.style !== null && BODY_STYLES.has(item.para.style);
|
|
209
200
|
}
|
|
210
|
-
|
|
211
|
-
* corrections and/or font changes) back into Sutra body text. The title page, if any,
|
|
212
|
-
* is not reimported — only content carrying the CineXxx paragraph styles the exporter
|
|
213
|
-
* applies to the screenplay body is recognized. */
|
|
214
|
-
export async function importDocx(buffer) {
|
|
215
|
-
const warnings = [];
|
|
201
|
+
async function readBodyItems(buffer) {
|
|
216
202
|
let zip;
|
|
217
203
|
try {
|
|
218
204
|
zip = await JSZip.loadAsync(buffer);
|
|
@@ -239,6 +225,48 @@ export async function importDocx(buffer) {
|
|
|
239
225
|
else if (child.localName === 'tbl')
|
|
240
226
|
items.push({ kind: 'table', table: parseTable(child) });
|
|
241
227
|
}
|
|
228
|
+
return items;
|
|
229
|
+
}
|
|
230
|
+
/** True when the document carries the CineXxx paragraph styles exportToDocx applies. */
|
|
231
|
+
export async function isSutrataDocx(buffer) {
|
|
232
|
+
// Only styled paragraphs count: any Word document may contain tables.
|
|
233
|
+
return (await readBodyItems(buffer)).some(item => item.kind === 'para' && isBodyStart(item));
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Plain text of any Word document, one line per paragraph (empty paragraphs
|
|
237
|
+
* become blank lines), with bold/italic/underline runs as Sutra emphasis
|
|
238
|
+
* markup. Table cells are read row by row, left to right. Used by the
|
|
239
|
+
* AI-assisted import for documents Sutrata did not write.
|
|
240
|
+
*/
|
|
241
|
+
export async function readDocxText(buffer) {
|
|
242
|
+
const lines = [];
|
|
243
|
+
const addPara = (para) => {
|
|
244
|
+
if (para.isPageBreak && !para.text.trim()) {
|
|
245
|
+
lines.push('');
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
lines.push(renderInline(para.runs, { allowBold: true, allowItalic: true, allowUnderline: true }).replace(/\s+$/, ''));
|
|
249
|
+
};
|
|
250
|
+
for (const item of await readBodyItems(buffer)) {
|
|
251
|
+
if (item.kind === 'para')
|
|
252
|
+
addPara(item.para);
|
|
253
|
+
else
|
|
254
|
+
for (const row of item.table.rows)
|
|
255
|
+
for (const cell of row) {
|
|
256
|
+
for (const p of cell)
|
|
257
|
+
addPara(p);
|
|
258
|
+
lines.push('');
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
262
|
+
}
|
|
263
|
+
/** Parses a .docx file (as produced by exportToDocx, possibly re-saved from Word with
|
|
264
|
+
* corrections and/or font changes) back into Sutra body text. The title page, if any,
|
|
265
|
+
* is not reimported — only content carrying the CineXxx paragraph styles the exporter
|
|
266
|
+
* applies to the screenplay body is recognized. */
|
|
267
|
+
export async function importDocx(buffer) {
|
|
268
|
+
const warnings = [];
|
|
269
|
+
const items = await readBodyItems(buffer);
|
|
242
270
|
const startIdx = items.findIndex(isBodyStart);
|
|
243
271
|
if (startIdx === -1) {
|
|
244
272
|
warnings.push('No Sutrata screenplay content was found in this document — nothing was imported.');
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { SceneHeadingNode } from '@sutrata/parser';
|
|
2
|
+
/** The printed scene number: the `{#id}` (format spec §6.1, §7.4). */
|
|
3
|
+
export declare function sceneNumberOf(node: SceneHeadingNode): string | null;
|
|
4
|
+
/** `& status: omitted` (format spec §7.4). */
|
|
5
|
+
export declare function isOmitted(node: SceneHeadingNode): boolean;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The printed scene number: the `{#id}` (format spec §6.1, §7.4). */
|
|
2
|
+
export function sceneNumberOf(node) {
|
|
3
|
+
return node.id || null;
|
|
4
|
+
}
|
|
5
|
+
/** `& status: omitted` (format spec §7.4). */
|
|
6
|
+
export function isOmitted(node) {
|
|
7
|
+
return node.metadata.some(m => m.key === 'status' && m.value.trim().toLowerCase() === 'omitted');
|
|
8
|
+
}
|
|
@@ -8,6 +8,7 @@ import { printPaginatorScript } from './print-paginator';
|
|
|
8
8
|
// from the paper's inches lands exactly on one sheet.
|
|
9
9
|
const CSS_PX_PER_IN = 96;
|
|
10
10
|
import { DEFAULT_COVER_LAYOUT } from '../styles/types';
|
|
11
|
+
import { sceneNumberOf, isOmitted } from './scene-number';
|
|
11
12
|
// CSV Escape Helper
|
|
12
13
|
function esc(val) {
|
|
13
14
|
const str = val ? String(val) : '';
|
|
@@ -25,14 +26,15 @@ export function extractWorkflowData(ast) {
|
|
|
25
26
|
sceneCount++;
|
|
26
27
|
const rawHeading = node.text.toUpperCase();
|
|
27
28
|
// Find metadata keys
|
|
28
|
-
const
|
|
29
|
-
const sceneNum = numMeta || node.id || String(sceneCount);
|
|
29
|
+
const sceneNum = sceneNumberOf(node) || String(sceneCount);
|
|
30
30
|
const synopsis = node.metadata.find(m => m.key === 'synopsis')?.value ?? '';
|
|
31
31
|
const actors = node.metadata.find(m => m.key === 'actors')?.value ?? '';
|
|
32
32
|
const status = node.metadata.find(m => m.key === 'status')?.value ?? '';
|
|
33
33
|
const estDuration = node.metadata.find(m => m.key === 'est-duration')?.value ?? '';
|
|
34
|
-
// Collect
|
|
35
|
-
const shotLines = node.metadata
|
|
34
|
+
// Collect shots: `& shots:` list items (§7.3) and repeated `& shots: text` lines
|
|
35
|
+
const shotLines = node.metadata
|
|
36
|
+
.filter(m => m.key === 'shots')
|
|
37
|
+
.flatMap(m => m.items ?? (m.value ? [m.value] : []));
|
|
36
38
|
scenes.push({
|
|
37
39
|
number: sceneNum,
|
|
38
40
|
heading: rawHeading,
|
|
@@ -613,11 +615,15 @@ async function buildScreenplayPrintHtml(ast, title, styleOverride, customStyles
|
|
|
613
615
|
}
|
|
614
616
|
else if (node.type === 'scene-heading') {
|
|
615
617
|
sceneOrdinal++;
|
|
616
|
-
|
|
618
|
+
// Scene number: the {#id} (format spec §6.1).
|
|
619
|
+
const printedNumber = sceneNumberOf(node);
|
|
620
|
+
const sceneLabel = printedNumber || String(sceneOrdinal);
|
|
621
|
+
// An omitted scene (§7.4) prints OMITTED in place of its heading; its body is a comment.
|
|
622
|
+
const title = isOmitted(node) ? 'OMITTED' : textHtml(node.text.toUpperCase());
|
|
617
623
|
bodyHtml += tagScene(`
|
|
618
624
|
<div class="print-scene-heading">
|
|
619
|
-
<span class="print-scene-title">${
|
|
620
|
-
${
|
|
625
|
+
<span class="print-scene-title">${title}</span>
|
|
626
|
+
${printedNumber ? `<span class="print-scene-num">${escapeHtml(printedNumber)}</span>` : ''}
|
|
621
627
|
</div>
|
|
622
628
|
`, sceneLabel, true);
|
|
623
629
|
for (const sub of node.children) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
3
|
+
import { useCanEdit } from '../extensions/session';
|
|
3
4
|
import { useDocument } from '../context/DocumentContext';
|
|
4
5
|
import { useTranslation } from '../i18n/useTranslation';
|
|
5
6
|
import { findAll, replaceAll } from './find-engine';
|
|
@@ -9,6 +10,7 @@ import { setFindHighlights, clearFindHighlights } from '../editor/find-highlight
|
|
|
9
10
|
import { setSourceFindHighlights, clearSourceFindHighlights, scrollToSourceMatch } from '../source/find-highlight-source';
|
|
10
11
|
export function FindReplace() {
|
|
11
12
|
const { text, setText, mode, findReplaceVisible, setFindReplaceVisible, findMode, setFindMode } = useDocument();
|
|
13
|
+
const canEdit = useCanEdit();
|
|
12
14
|
const { t } = useTranslation();
|
|
13
15
|
const [query, setQuery] = useState('');
|
|
14
16
|
const [replacement, setReplacement] = useState('');
|
|
@@ -157,8 +159,9 @@ export function FindReplace() {
|
|
|
157
159
|
}, [setFindReplaceVisible, goNext, goPrev]);
|
|
158
160
|
if (!findReplaceVisible)
|
|
159
161
|
return null;
|
|
160
|
-
|
|
162
|
+
// Read-only sessions can search but not replace.
|
|
163
|
+
const isReplace = findMode === 'replace' && canEdit;
|
|
161
164
|
const hasMatches = matches.length > 0;
|
|
162
|
-
return (_jsxs("div", { className: "cs-find-replace", role: "dialog", "aria-label": isReplace ? 'Find and Replace' : 'Find', children: [_jsxs("div", { className: "cs-fr-header", children: [_jsxs("div", { className: "cs-fr-mode-tabs", children: [_jsx("button", { className: `cs-fr-tab${!isReplace ? ' cs-fr-tab-active' : ''}`, onClick: () => setFindMode('find'), children: t('findreplace.findOnly') }), _jsx("button", { className: `cs-fr-tab${isReplace ? ' cs-fr-tab-active' : ''}`, onClick: () => setFindMode('replace'), children: t('findreplace.title') })] }), _jsx("button", { className: "cs-fr-close", "aria-label": "Close", onClick: () => setFindReplaceVisible(false), children: "\u00D7" })] }), _jsxs("div", { className: "cs-fr-row", children: [_jsx("input", { ref: queryRef, className: "cs-fr-input", type: "text", placeholder: t('findreplace.find'), value: query, onChange: e => setQuery(e.target.value), onKeyDown: handleQueryKeyDown, "aria-label": "Find" }), _jsx("button", { type: "button", className: "cs-fr-nav-btn", "aria-label": "Previous match", title: "Previous match (Shift+Enter)", onClick: goPrev, disabled: !hasMatches, children: "\u25B2" }), _jsx("button", { type: "button", className: "cs-fr-nav-btn", "aria-label": "Next match", title: "Next match (Enter)", onClick: goNext, disabled: !hasMatches, children: "\u25BC" }), _jsx("button", { type: "button", className: `cs-fr-toggle${caseSensitive ? ' cs-fr-active' : ''}`, onClick: () => setCaseSensitive(v => !v), title: t('findreplace.caseSensitive'), "aria-pressed": caseSensitive, children: "Aa" })] }), _jsxs("div", { className: "cs-fr-status", children: [query && !hasMatches && (_jsx("span", { className: "cs-fr-nomatch", children: t('findreplace.noMatches') })), hasMatches && (_jsxs("span", { className: "cs-fr-nav-count", children: [currentIndex + 1, " of ", matches.length] }))] }), isReplace && (_jsxs(_Fragment, { children: [_jsx("div", { className: "cs-fr-row", children: _jsx("input", { className: "cs-fr-input", type: "text", placeholder: t('findreplace.replace'), value: replacement, onChange: e => setReplacement(e.target.value), onKeyDown: e => { if (e.key === 'Escape')
|
|
165
|
+
return (_jsxs("div", { className: "cs-find-replace", role: "dialog", "aria-label": isReplace ? 'Find and Replace' : 'Find', children: [_jsxs("div", { className: "cs-fr-header", children: [_jsxs("div", { className: "cs-fr-mode-tabs", children: [_jsx("button", { className: `cs-fr-tab${!isReplace ? ' cs-fr-tab-active' : ''}`, onClick: () => setFindMode('find'), children: t('findreplace.findOnly') }), canEdit && _jsx("button", { className: `cs-fr-tab${isReplace ? ' cs-fr-tab-active' : ''}`, onClick: () => setFindMode('replace'), children: t('findreplace.title') })] }), _jsx("button", { className: "cs-fr-close", "aria-label": "Close", onClick: () => setFindReplaceVisible(false), children: "\u00D7" })] }), _jsxs("div", { className: "cs-fr-row", children: [_jsx("input", { ref: queryRef, className: "cs-fr-input", type: "text", placeholder: t('findreplace.find'), value: query, onChange: e => setQuery(e.target.value), onKeyDown: handleQueryKeyDown, "aria-label": "Find" }), _jsx("button", { type: "button", className: "cs-fr-nav-btn", "aria-label": "Previous match", title: "Previous match (Shift+Enter)", onClick: goPrev, disabled: !hasMatches, children: "\u25B2" }), _jsx("button", { type: "button", className: "cs-fr-nav-btn", "aria-label": "Next match", title: "Next match (Enter)", onClick: goNext, disabled: !hasMatches, children: "\u25BC" }), _jsx("button", { type: "button", className: `cs-fr-toggle${caseSensitive ? ' cs-fr-active' : ''}`, onClick: () => setCaseSensitive(v => !v), title: t('findreplace.caseSensitive'), "aria-pressed": caseSensitive, children: "Aa" })] }), _jsxs("div", { className: "cs-fr-status", children: [query && !hasMatches && (_jsx("span", { className: "cs-fr-nomatch", children: t('findreplace.noMatches') })), hasMatches && (_jsxs("span", { className: "cs-fr-nav-count", children: [currentIndex + 1, " of ", matches.length] }))] }), isReplace && (_jsxs(_Fragment, { children: [_jsx("div", { className: "cs-fr-row", children: _jsx("input", { className: "cs-fr-input", type: "text", placeholder: t('findreplace.replace'), value: replacement, onChange: e => setReplacement(e.target.value), onKeyDown: e => { if (e.key === 'Escape')
|
|
163
166
|
setFindReplaceVisible(false); }, "aria-label": "Replace with" }) }), _jsxs("div", { className: "cs-fr-actions", children: [_jsx("button", { className: "cs-fr-btn", onClick: handleReplaceOne, disabled: !hasMatches, children: t('findreplace.replaceOne') }), _jsx("button", { className: "cs-fr-btn", onClick: handleReplaceAll, disabled: !query || !hasMatches, children: t('findreplace.replaceAll') })] })] }))] }));
|
|
164
167
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* AI-assisted import (OSS spec §5.5): pick a Word/text/Markdown file (or paste
|
|
4
|
+
* text), confirm the provider's data policy, let the AI format it as Sutra in
|
|
5
|
+
* checked chunks, review side by side with per-block type correction, then
|
|
6
|
+
* replace or append as one undoable edit. Sutrata's own DOCX skips the AI.
|
|
7
|
+
* Rendered only when useAI() is non-null (a provider, and an edit session).
|
|
8
|
+
*/
|
|
9
|
+
export declare function AIImportDialog({ onClose }: {
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
}): React.JSX.Element | null;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { useDocument } from '../context/DocumentContext';
|
|
4
|
+
import { useAI } from '../extensions/ai-provider';
|
|
5
|
+
import { readImportFile, IMPORT_ACCEPT } from './extract';
|
|
6
|
+
import { runAiImport, splitBlocks, blockKind, retypeBlock } from './ai-import';
|
|
7
|
+
import { applyImport } from './apply-import';
|
|
8
|
+
const KIND_LABELS = {
|
|
9
|
+
scene_heading: 'Scene heading',
|
|
10
|
+
action: 'Action',
|
|
11
|
+
character: 'Character + dialogue',
|
|
12
|
+
transition: 'Transition',
|
|
13
|
+
centered: 'Centered',
|
|
14
|
+
lyrics: 'Lyrics',
|
|
15
|
+
note: 'Note',
|
|
16
|
+
section: 'Section',
|
|
17
|
+
other: 'Other',
|
|
18
|
+
};
|
|
19
|
+
const KIND_OPTIONS = Object.keys(KIND_LABELS).filter(k => k !== 'other');
|
|
20
|
+
/**
|
|
21
|
+
* AI-assisted import (OSS spec §5.5): pick a Word/text/Markdown file (or paste
|
|
22
|
+
* text), confirm the provider's data policy, let the AI format it as Sutra in
|
|
23
|
+
* checked chunks, review side by side with per-block type correction, then
|
|
24
|
+
* replace or append as one undoable edit. Sutrata's own DOCX skips the AI.
|
|
25
|
+
* Rendered only when useAI() is non-null (a provider, and an edit session).
|
|
26
|
+
*/
|
|
27
|
+
export function AIImportDialog({ onClose }) {
|
|
28
|
+
const ai = useAI();
|
|
29
|
+
const { text, setText, showToast } = useDocument();
|
|
30
|
+
const [step, setStep] = useState({ name: 'source' });
|
|
31
|
+
const [pasted, setPasted] = useState('');
|
|
32
|
+
const [error, setError] = useState('');
|
|
33
|
+
const [agreed, setAgreed] = useState(false);
|
|
34
|
+
const abortRef = useRef(null);
|
|
35
|
+
const fileRef = useRef(null);
|
|
36
|
+
const formatted = useMemo(() => (step.name === 'review' ? step.blocks.join('\n\n') : ''), [step]);
|
|
37
|
+
if (!ai)
|
|
38
|
+
return null;
|
|
39
|
+
const close = () => {
|
|
40
|
+
abortRef.current?.abort();
|
|
41
|
+
onClose();
|
|
42
|
+
};
|
|
43
|
+
const pickFile = async (file) => {
|
|
44
|
+
if (!file)
|
|
45
|
+
return;
|
|
46
|
+
setError('');
|
|
47
|
+
try {
|
|
48
|
+
const source = await readImportFile(file);
|
|
49
|
+
if (source.kind === 'sutra') {
|
|
50
|
+
setStep({ name: 'review', fileName: source.name, source: source.sutra, blocks: splitBlocks(source.sutra), chunks: [], warnings: source.warnings });
|
|
51
|
+
}
|
|
52
|
+
else if (!source.text.trim()) {
|
|
53
|
+
setError(`${source.name} has no text to import.`);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
setStep({ name: 'confirm', fileName: source.name, text: source.text });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const start = async (fileName, source) => {
|
|
64
|
+
if (!(await ai.isConfigured())) {
|
|
65
|
+
if (ai.openSetup) {
|
|
66
|
+
close();
|
|
67
|
+
ai.openSetup();
|
|
68
|
+
}
|
|
69
|
+
else
|
|
70
|
+
setError(`${ai.displayName} is not available right now.`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const controller = new AbortController();
|
|
74
|
+
abortRef.current = controller;
|
|
75
|
+
setStep({ name: 'running', done: 0, total: 1 });
|
|
76
|
+
try {
|
|
77
|
+
const result = await runAiImport(ai, source, {
|
|
78
|
+
signal: controller.signal,
|
|
79
|
+
onProgress: (done, total) => setStep({ name: 'running', done, total }),
|
|
80
|
+
});
|
|
81
|
+
const warnings = result.chunks.flatMap(c => c.problems.map(p => `Part ${c.index + 1}: ${p}`));
|
|
82
|
+
setStep({ name: 'review', fileName, source, blocks: splitBlocks(result.sutra), chunks: result.chunks, warnings });
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
if (controller.signal.aborted) {
|
|
86
|
+
setStep({ name: 'source' });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
90
|
+
setStep({ name: 'confirm', fileName, text: source });
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
abortRef.current = null;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const finish = (how) => {
|
|
97
|
+
if (step.name !== 'review')
|
|
98
|
+
return;
|
|
99
|
+
applyImport(formatted, how, text, setText);
|
|
100
|
+
showToast(`Imported ${step.fileName} (${step.blocks.length} blocks).`, 'success');
|
|
101
|
+
onClose();
|
|
102
|
+
};
|
|
103
|
+
return (_jsx("div", { className: "cs-dialog-overlay", onClick: close, children: _jsxs("div", { className: "cs-dialog cs-ai-import-dialog", role: "dialog", "aria-modal": "true", "aria-label": "Import with AI", onClick: e => e.stopPropagation(), onKeyDown: e => { if (e.key === 'Escape')
|
|
104
|
+
close(); }, style: { maxWidth: step.name === 'review' ? '1100px' : '560px', width: '94%' }, children: [_jsxs("div", { className: "cs-dialog-header", children: [_jsx("span", { className: "cs-dialog-title", children: "Import with AI" }), _jsx("button", { className: "cs-fr-close", "aria-label": "Close", onClick: close, children: "\u00D7" })] }), _jsxs("div", { className: "cs-ai-import-body", children: [error && _jsx("div", { className: "cs-ai-import-error", role: "alert", children: error }), step.name === 'source' && (_jsxs(_Fragment, { children: [_jsx("p", { className: "cs-ai-import-hint", children: "Import a screenplay written in Word, a text file or Markdown. The AI formats it as Sutra; you review the result before anything changes. A Word file exported from Sutrata is imported directly, without AI." }), _jsx("input", { ref: fileRef, type: "file", accept: IMPORT_ACCEPT, "aria-label": "Choose a file to import", onChange: e => void pickFile(e.target.files?.[0]) }), _jsx("label", { className: "cs-settings-label", htmlFor: "cs-ai-import-paste", style: { display: 'block', marginTop: 12 }, children: "\u2026or paste the text" }), _jsx("textarea", { id: "cs-ai-import-paste", className: "cs-ai-import-paste", rows: 8, value: pasted, onChange: e => setPasted(e.target.value) }), _jsx("div", { className: "cs-ai-import-actions", children: _jsx("button", { type: "button", className: "cs-confirm-btn-primary", disabled: !pasted.trim(), onClick: () => setStep({ name: 'confirm', fileName: 'pasted text', text: pasted.trim() }), children: "Continue" }) })] })), step.name === 'confirm' && (_jsxs(_Fragment, { children: [_jsxs("p", { className: "cs-ai-import-hint", children: [_jsx("strong", { children: step.fileName }), " (", step.text.length.toLocaleString(), " characters) will be sent to", ' ', _jsx("strong", { children: ai.displayName }), " to be formatted."] }), _jsx("div", { className: "cs-ai-import-policy", "aria-label": "Data policy", children: ai.dataPolicyText }), _jsxs("label", { className: "cs-ai-import-agree", children: [_jsx("input", { type: "checkbox", checked: agreed, onChange: e => setAgreed(e.target.checked) }), "I understand, send this text to ", ai.displayName] }), _jsxs("div", { className: "cs-ai-import-actions", children: [_jsx("button", { type: "button", className: "cs-confirm-btn-cancel", onClick: () => setStep({ name: 'source' }), children: "Back" }), _jsx("button", { type: "button", className: "cs-confirm-btn-primary", disabled: !agreed, onClick: () => void start(step.fileName, step.text), children: "Format with AI" })] })] })), step.name === 'running' && (_jsxs(_Fragment, { children: [_jsxs("p", { className: "cs-ai-import-hint", role: "status", children: [_jsx("span", { className: "cs-spinner", style: { width: 10, height: 10, borderWidth: 1.5, marginRight: 8 } }), "Formatting part ", Math.min(step.done + 1, step.total), " of ", step.total, "\u2026"] }), _jsx("div", { className: "cs-ai-import-actions", children: _jsx("button", { type: "button", className: "cs-confirm-btn-cancel", onClick: () => abortRef.current?.abort(), children: "Cancel" }) })] })), step.name === 'review' && (_jsxs(_Fragment, { children: [step.warnings.length > 0 && (_jsxs("div", { className: "cs-export-warnings cs-ai-import-warnings", role: "alert", children: [_jsx("div", { className: "cs-export-warn-title", children: "Check these parts before importing" }), step.warnings.map((w, i) => _jsxs("div", { className: "cs-export-warn-item", children: ["\u2022 ", w] }, i))] })), _jsxs("div", { className: "cs-ai-import-review", children: [_jsxs("section", { "aria-label": "Source", children: [_jsx("div", { className: "cs-ai-import-col-title", children: "Source" }), _jsx("pre", { className: "cs-ai-import-source", children: step.source })] }), _jsxs("section", { "aria-label": "Formatted", children: [_jsxs("div", { className: "cs-ai-import-col-title", children: ["Formatted (", step.blocks.length, " blocks)"] }), _jsx("ol", { className: "cs-ai-import-blocks", children: step.blocks.map((block, i) => {
|
|
105
|
+
const kind = blockKind(block);
|
|
106
|
+
return (_jsxs("li", { className: "cs-ai-import-block", children: [_jsxs("select", { "aria-label": `Block ${i + 1} type`, className: "cs-settings-select", value: kind, onChange: e => {
|
|
107
|
+
const blocks = [...step.blocks];
|
|
108
|
+
blocks[i] = retypeBlock(block, e.target.value);
|
|
109
|
+
setStep({ ...step, blocks });
|
|
110
|
+
}, children: [kind === 'other' && _jsx("option", { value: "other", children: KIND_LABELS.other }), KIND_OPTIONS.map(k => _jsx("option", { value: k, children: KIND_LABELS[k] }, k))] }), _jsx("pre", { children: block })] }, i));
|
|
111
|
+
}) })] })] }), _jsxs("div", { className: "cs-ai-import-actions", children: [_jsx("button", { type: "button", className: "cs-confirm-btn-cancel", onClick: close, children: "Cancel" }), _jsx("button", { type: "button", className: "cs-confirm-btn-cancel", onClick: () => finish('append'), disabled: !step.blocks.length, children: "Append to document" }), _jsx("button", { type: "button", className: "cs-confirm-btn-primary", title: "Replaces the script; the title page is kept", onClick: () => finish('replace'), disabled: !step.blocks.length, children: "Replace script" })] })] }))] })] }) }));
|
|
112
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { AIProvider } from '../extensions/ai-provider';
|
|
2
|
+
/**
|
|
3
|
+
* AI-assisted import of screenplays written elsewhere (OSS spec §5.5).
|
|
4
|
+
*
|
|
5
|
+
* The source is split into chunks at blank lines (preferring likely scene
|
|
6
|
+
* boundaries), each chunk is formatted by the injected AIProvider, and every
|
|
7
|
+
* result is checked: it must parse to at least one block, and its words must
|
|
8
|
+
* match the chunk's words. A failing chunk is retried once; if it still
|
|
9
|
+
* fails, the better attempt is kept and the problem is reported.
|
|
10
|
+
*/
|
|
11
|
+
/** Target chunk size in characters: big enough for context, small enough for any provider. */
|
|
12
|
+
export declare const DEFAULT_CHUNK_CHARS = 6000;
|
|
13
|
+
/** Split `text` into paragraphs (blank-line separated) and group them into chunks of about `maxChars`. */
|
|
14
|
+
export declare function chunkSource(text: string, maxChars?: number): string[];
|
|
15
|
+
/** Words for the content comparison: letters/marks/digits, NFC, case-folded. Sigils and punctuation don't count. */
|
|
16
|
+
export declare function contentWords(text: string): string[];
|
|
17
|
+
export interface ContentDiff {
|
|
18
|
+
/** Words in the source missing from the result (with multiplicity). */
|
|
19
|
+
dropped: string[];
|
|
20
|
+
/** Words in the result not in the source. */
|
|
21
|
+
added: string[];
|
|
22
|
+
}
|
|
23
|
+
export declare function compareContent(source: string, result: string): ContentDiff;
|
|
24
|
+
/** Strip a code fence or stray frontmatter a model might wrap around its answer. */
|
|
25
|
+
export declare function cleanImportOutput(raw: string): string;
|
|
26
|
+
export interface ChunkResult {
|
|
27
|
+
index: number;
|
|
28
|
+
source: string;
|
|
29
|
+
output: string;
|
|
30
|
+
attempts: number;
|
|
31
|
+
/** Why the chunk's output may not be faithful; empty when it passed. */
|
|
32
|
+
problems: string[];
|
|
33
|
+
diff: ContentDiff;
|
|
34
|
+
}
|
|
35
|
+
export declare function formatChunk(ai: AIProvider, source: string, index: number, signal?: AbortSignal): Promise<ChunkResult>;
|
|
36
|
+
export interface ImportResult {
|
|
37
|
+
/** The formatted Sutra body, chunks joined with blank lines. */
|
|
38
|
+
sutra: string;
|
|
39
|
+
chunks: ChunkResult[];
|
|
40
|
+
}
|
|
41
|
+
export declare function runAiImport(ai: AIProvider, sourceText: string, opts?: {
|
|
42
|
+
signal?: AbortSignal;
|
|
43
|
+
maxChars?: number;
|
|
44
|
+
onProgress?(done: number, total: number): void;
|
|
45
|
+
}): Promise<ImportResult>;
|
|
46
|
+
export type BlockKind = 'scene_heading' | 'action' | 'character' | 'transition' | 'centered' | 'lyrics' | 'note' | 'section' | 'other';
|
|
47
|
+
/** Split Sutra text into blank-line separated blocks (multi-line comments kept whole). */
|
|
48
|
+
export declare function splitBlocks(sutra: string): string[];
|
|
49
|
+
export declare function blockKind(block: string): BlockKind;
|
|
50
|
+
/** Rewrite `block` as element `kind`, keeping its words (and a heading's metadata lines). */
|
|
51
|
+
export declare function retypeBlock(block: string, kind: BlockKind): string;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { parse } from '@sutrata/parser';
|
|
2
|
+
import { IMPORT_FORMAT_SYSTEM_PROMPT } from '../ai/prompts';
|
|
3
|
+
/**
|
|
4
|
+
* AI-assisted import of screenplays written elsewhere (OSS spec §5.5).
|
|
5
|
+
*
|
|
6
|
+
* The source is split into chunks at blank lines (preferring likely scene
|
|
7
|
+
* boundaries), each chunk is formatted by the injected AIProvider, and every
|
|
8
|
+
* result is checked: it must parse to at least one block, and its words must
|
|
9
|
+
* match the chunk's words. A failing chunk is retried once; if it still
|
|
10
|
+
* fails, the better attempt is kept and the problem is reported.
|
|
11
|
+
*/
|
|
12
|
+
/** Target chunk size in characters: big enough for context, small enough for any provider. */
|
|
13
|
+
export const DEFAULT_CHUNK_CHARS = 6000;
|
|
14
|
+
/** A line that probably starts a scene: sluglines in Latin script, "SCENE 12", or a Sutra/Markdown heading. */
|
|
15
|
+
const LIKELY_SCENE = /^\s*(?:##?\s|\.?(?:INT|EXT|INT\.?\/EXT|I\/E)[.\s]|SCENE\s+\d|\d+[A-Z]?[.)]?\s+(?:INT|EXT)[.\s])/i;
|
|
16
|
+
/** Split `text` into paragraphs (blank-line separated) and group them into chunks of about `maxChars`. */
|
|
17
|
+
export function chunkSource(text, maxChars = DEFAULT_CHUNK_CHARS) {
|
|
18
|
+
const paragraphs = text.replace(/\r\n?/g, '\n').split(/\n\s*\n/).map(p => p.replace(/^\n+|\n+$/g, '')).filter(p => p.trim());
|
|
19
|
+
const pieces = [];
|
|
20
|
+
for (const p of paragraphs) {
|
|
21
|
+
if (p.length <= maxChars) {
|
|
22
|
+
pieces.push(p);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
// An oversized paragraph is split at line boundaries.
|
|
26
|
+
let current = [];
|
|
27
|
+
let size = 0;
|
|
28
|
+
for (const line of p.split('\n')) {
|
|
29
|
+
if (size + line.length > maxChars && current.length) {
|
|
30
|
+
pieces.push(current.join('\n'));
|
|
31
|
+
current = [];
|
|
32
|
+
size = 0;
|
|
33
|
+
}
|
|
34
|
+
current.push(line);
|
|
35
|
+
size += line.length + 1;
|
|
36
|
+
}
|
|
37
|
+
if (current.length)
|
|
38
|
+
pieces.push(current.join('\n'));
|
|
39
|
+
}
|
|
40
|
+
const chunks = [];
|
|
41
|
+
let current = [];
|
|
42
|
+
let size = 0;
|
|
43
|
+
for (const piece of pieces) {
|
|
44
|
+
const full = size + piece.length > maxChars;
|
|
45
|
+
// Past half the budget, break early at a scene boundary rather than mid-scene.
|
|
46
|
+
const sceneBreak = size > maxChars / 2 && LIKELY_SCENE.test(piece);
|
|
47
|
+
if (current.length && (full || sceneBreak)) {
|
|
48
|
+
chunks.push(current.join('\n\n'));
|
|
49
|
+
current = [];
|
|
50
|
+
size = 0;
|
|
51
|
+
}
|
|
52
|
+
current.push(piece);
|
|
53
|
+
size += piece.length + 2;
|
|
54
|
+
}
|
|
55
|
+
if (current.length)
|
|
56
|
+
chunks.push(current.join('\n\n'));
|
|
57
|
+
return chunks;
|
|
58
|
+
}
|
|
59
|
+
/** Words for the content comparison: letters/marks/digits, NFC, case-folded. Sigils and punctuation don't count. */
|
|
60
|
+
export function contentWords(text) {
|
|
61
|
+
// Attribute blocks ({#12}, {lang=hi}) are Sutra markup, not content.
|
|
62
|
+
const stripped = text.normalize('NFC').replace(/\{[^{}\n]*\}/g, ' ');
|
|
63
|
+
return (stripped.match(/[\p{L}\p{M}\p{N}]+/gu) ?? []).map(w => w.toLocaleLowerCase());
|
|
64
|
+
}
|
|
65
|
+
export function compareContent(source, result) {
|
|
66
|
+
const count = (words) => {
|
|
67
|
+
const m = new Map();
|
|
68
|
+
for (const w of words)
|
|
69
|
+
m.set(w, (m.get(w) ?? 0) + 1);
|
|
70
|
+
return m;
|
|
71
|
+
};
|
|
72
|
+
const a = count(contentWords(source));
|
|
73
|
+
const b = count(contentWords(result));
|
|
74
|
+
const diff = (x, y) => [...x].flatMap(([w, n]) => Array(Math.max(0, n - (y.get(w) ?? 0))).fill(w));
|
|
75
|
+
return { dropped: diff(a, b), added: diff(b, a) };
|
|
76
|
+
}
|
|
77
|
+
/** Page furniture a formatter may legitimately drop. */
|
|
78
|
+
const FURNITURE = new Set(['continued', 'cont', 'd', 'more']);
|
|
79
|
+
/** Whether a diff is small enough to accept: page numbers and furniture aside, nothing changed. */
|
|
80
|
+
function acceptable(diff) {
|
|
81
|
+
const significant = (w) => !FURNITURE.has(w) && !/^\d+$/.test(w);
|
|
82
|
+
return !diff.dropped.some(significant) && !diff.added.some(significant);
|
|
83
|
+
}
|
|
84
|
+
/** Strip a code fence or stray frontmatter a model might wrap around its answer. */
|
|
85
|
+
export function cleanImportOutput(raw) {
|
|
86
|
+
let out = raw.trim().replace(/^```[\w-]*\n/, '').replace(/\n```\s*$/, '');
|
|
87
|
+
if (out.startsWith('---\n')) {
|
|
88
|
+
const end = out.indexOf('\n---', 4);
|
|
89
|
+
if (end !== -1)
|
|
90
|
+
out = out.slice(end + 4);
|
|
91
|
+
}
|
|
92
|
+
return out.replace(/\r\n?/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
93
|
+
}
|
|
94
|
+
function problemsOf(output, diff) {
|
|
95
|
+
const problems = [];
|
|
96
|
+
if (!output)
|
|
97
|
+
problems.push('The AI returned nothing for this part.');
|
|
98
|
+
else if (parse(output).children.length === 0)
|
|
99
|
+
problems.push('The AI output is not a Sutra screenplay.');
|
|
100
|
+
const shown = (ws) => [...new Set(ws)].slice(0, 8).join(', ');
|
|
101
|
+
if (!acceptable(diff)) {
|
|
102
|
+
if (diff.dropped.length)
|
|
103
|
+
problems.push(`Missing from the result (${diff.dropped.length}): ${shown(diff.dropped)}`);
|
|
104
|
+
if (diff.added.length)
|
|
105
|
+
problems.push(`Not in the source (${diff.added.length}): ${shown(diff.added)}`);
|
|
106
|
+
}
|
|
107
|
+
return problems;
|
|
108
|
+
}
|
|
109
|
+
export async function formatChunk(ai, source, index, signal) {
|
|
110
|
+
let best = null;
|
|
111
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
112
|
+
signal?.throwIfAborted();
|
|
113
|
+
const output = cleanImportOutput(await ai.complete({ system: IMPORT_FORMAT_SYSTEM_PROMPT, prompt: source, signal }));
|
|
114
|
+
const diff = compareContent(source, output);
|
|
115
|
+
const result = { index, source, output, attempts: attempt, problems: problemsOf(output, diff), diff };
|
|
116
|
+
if (result.problems.length === 0)
|
|
117
|
+
return result;
|
|
118
|
+
const size = (r) => r.diff.dropped.length + r.diff.added.length + (r.output ? 0 : 1e9);
|
|
119
|
+
if (!best || size(result) < size(best))
|
|
120
|
+
best = { ...result };
|
|
121
|
+
best.attempts = attempt;
|
|
122
|
+
}
|
|
123
|
+
return best;
|
|
124
|
+
}
|
|
125
|
+
export async function runAiImport(ai, sourceText, opts = {}) {
|
|
126
|
+
const sources = chunkSource(sourceText, opts.maxChars);
|
|
127
|
+
const chunks = [];
|
|
128
|
+
opts.onProgress?.(0, sources.length);
|
|
129
|
+
for (const [i, source] of sources.entries()) {
|
|
130
|
+
chunks.push(await formatChunk(ai, source, i, opts.signal));
|
|
131
|
+
opts.onProgress?.(i + 1, sources.length);
|
|
132
|
+
}
|
|
133
|
+
return { sutra: chunks.map(c => c.output).filter(Boolean).join('\n\n'), chunks };
|
|
134
|
+
}
|
|
135
|
+
/** Split Sutra text into blank-line separated blocks (multi-line comments kept whole). */
|
|
136
|
+
export function splitBlocks(sutra) {
|
|
137
|
+
const blocks = [];
|
|
138
|
+
let current = [];
|
|
139
|
+
let inComment = false;
|
|
140
|
+
for (const line of sutra.split('\n')) {
|
|
141
|
+
if (!line.trim() && !inComment) {
|
|
142
|
+
if (current.length)
|
|
143
|
+
blocks.push(current.join('\n'));
|
|
144
|
+
current = [];
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
current.push(line);
|
|
148
|
+
if (line.includes('<!--') && !line.includes('-->'))
|
|
149
|
+
inComment = true;
|
|
150
|
+
else if (inComment && line.includes('-->'))
|
|
151
|
+
inComment = false;
|
|
152
|
+
}
|
|
153
|
+
if (current.length)
|
|
154
|
+
blocks.push(current.join('\n'));
|
|
155
|
+
return blocks;
|
|
156
|
+
}
|
|
157
|
+
export function blockKind(block) {
|
|
158
|
+
const node = parse(block + '\n').children[0];
|
|
159
|
+
switch (node?.type) {
|
|
160
|
+
case 'scene-heading': return 'scene_heading';
|
|
161
|
+
case 'section': return 'section';
|
|
162
|
+
case 'action': return 'action';
|
|
163
|
+
case 'character':
|
|
164
|
+
case 'dual-dialogue': return 'character';
|
|
165
|
+
case 'transition': return 'transition';
|
|
166
|
+
case 'centered': return 'centered';
|
|
167
|
+
case 'lyrics': return 'lyrics';
|
|
168
|
+
case 'note': return 'note';
|
|
169
|
+
default: return 'other';
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** A block's text without its Sutra sigils, one entry per line. */
|
|
173
|
+
function plainLines(block) {
|
|
174
|
+
return block.split('\n').map(l => l.trim()
|
|
175
|
+
.replace(/^(?:##|#|@|>>|~)\s*/, '')
|
|
176
|
+
.replace(/\s*<<$/, '')
|
|
177
|
+
.replace(/^\[\[\s*(.*?)\s*\]\]$/, '$1'))
|
|
178
|
+
.filter(Boolean);
|
|
179
|
+
}
|
|
180
|
+
/** Rewrite `block` as element `kind`, keeping its words (and a heading's metadata lines). */
|
|
181
|
+
export function retypeBlock(block, kind) {
|
|
182
|
+
if (kind === 'other')
|
|
183
|
+
return block;
|
|
184
|
+
const lines = plainLines(block);
|
|
185
|
+
const [first = '', ...rest] = lines;
|
|
186
|
+
switch (kind) {
|
|
187
|
+
case 'scene_heading': {
|
|
188
|
+
const meta = rest.filter(l => l.startsWith('& '));
|
|
189
|
+
const body = rest.filter(l => !l.startsWith('& '));
|
|
190
|
+
return [`## ${[first, ...body].join(' ')}`, ...meta].join('\n');
|
|
191
|
+
}
|
|
192
|
+
case 'section': return `# ${lines.join(' ')}`;
|
|
193
|
+
case 'action': return lines.join('\n');
|
|
194
|
+
case 'character': return [`@${first}`, ...rest].join('\n');
|
|
195
|
+
case 'transition': return lines.map(l => `>> ${l}`).join('\n');
|
|
196
|
+
case 'centered': return lines.map(l => `>> ${l} <<`).join('\n');
|
|
197
|
+
case 'lyrics': return lines.map(l => `~ ${l}`).join('\n');
|
|
198
|
+
case 'note': return `[[ ${lines.join(' ')} ]]`;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Put imported Sutra into the document as ONE edit (a single undo step):
|
|
3
|
+
* `replace` swaps the body and keeps the title page; `append` adds after the
|
|
4
|
+
* last block. Goes through whichever editor is mounted; without one, sets
|
|
5
|
+
* the text directly.
|
|
6
|
+
*/
|
|
7
|
+
export declare function applyImport(body: string, how: 'replace' | 'append', text: string, setText: (t: string) => void): void;
|