@sutrata/editor 0.1.2 → 0.2.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.
Files changed (118) hide show
  1. package/dist/ai/VoiceConfirmDialog.d.ts +1 -0
  2. package/dist/ai/VoiceConfirmDialog.js +6 -6
  3. package/dist/ai/VoiceToolbar.d.ts +1 -0
  4. package/dist/ai/VoiceToolbar.js +22 -16
  5. package/dist/ai/clean-response.d.ts +7 -0
  6. package/dist/ai/clean-response.js +58 -0
  7. package/dist/ai/format-transcript.d.ts +3 -0
  8. package/dist/ai/format-transcript.js +7 -0
  9. package/dist/ai/prompts.d.ts +8 -1
  10. package/dist/ai/prompts.js +34 -7
  11. package/dist/autocomplete/suggestions.js +1 -1
  12. package/dist/context/DocumentContext.d.ts +28 -4
  13. package/dist/context/DocumentContext.js +67 -37
  14. package/dist/editor/EditorView.js +32 -4
  15. package/dist/editor/SelectionMenu.js +5 -3
  16. package/dist/editor/external-decorations-plugin.d.ts +23 -0
  17. package/dist/editor/external-decorations-plugin.js +151 -0
  18. package/dist/editor/omitted-scene-plugin.d.ts +9 -0
  19. package/dist/editor/omitted-scene-plugin.js +44 -0
  20. package/dist/editor/plugins.js +4 -0
  21. package/dist/editor/private-metadata-plugin.d.ts +10 -0
  22. package/dist/editor/private-metadata-plugin.js +60 -0
  23. package/dist/editor/prosemirror-to-sutra.js +179 -74
  24. package/dist/editor/scene-heading-view.js +2 -0
  25. package/dist/editor/schema.js +69 -12
  26. package/dist/editor/sutra-to-prosemirror.d.ts +2 -0
  27. package/dist/editor/sutra-to-prosemirror.js +32 -34
  28. package/dist/extensions/ai-provider.d.ts +44 -0
  29. package/dist/extensions/ai-provider.js +27 -0
  30. package/dist/extensions/collab.d.ts +21 -0
  31. package/dist/extensions/collab.js +6 -0
  32. package/dist/extensions/command-registry.d.ts +49 -0
  33. package/dist/extensions/command-registry.js +28 -0
  34. package/dist/extensions/decorations.d.ts +48 -0
  35. package/dist/extensions/decorations.js +6 -0
  36. package/dist/extensions/export-registry.d.ts +59 -0
  37. package/dist/extensions/export-registry.js +10 -0
  38. package/dist/extensions/panel-registry.d.ts +30 -0
  39. package/dist/extensions/panel-registry.js +16 -0
  40. package/dist/extensions/registries.d.ts +15 -0
  41. package/dist/extensions/registries.js +12 -0
  42. package/dist/extensions/registry-store.d.ts +26 -0
  43. package/dist/extensions/registry-store.js +35 -0
  44. package/dist/extensions/session.d.ts +29 -0
  45. package/dist/extensions/session.js +20 -0
  46. package/dist/extensions/speech-provider.d.ts +35 -0
  47. package/dist/extensions/speech-provider.js +17 -0
  48. package/dist/file/builtin-exporters.d.ts +9 -0
  49. package/dist/file/builtin-exporters.js +72 -0
  50. package/dist/file/docx-exporter.js +7 -4
  51. package/dist/file/docx-importer.d.ts +9 -0
  52. package/dist/file/docx-importer.js +47 -19
  53. package/dist/file/scene-number.d.ts +5 -0
  54. package/dist/file/scene-number.js +8 -0
  55. package/dist/file/workflow-reports.js +13 -7
  56. package/dist/findreplace/FindReplace.js +5 -2
  57. package/dist/import/AIImportDialog.d.ts +11 -0
  58. package/dist/import/AIImportDialog.js +112 -0
  59. package/dist/import/ai-import.d.ts +51 -0
  60. package/dist/import/ai-import.js +200 -0
  61. package/dist/import/apply-import.d.ts +7 -0
  62. package/dist/import/apply-import.js +35 -0
  63. package/dist/import/extract.d.ts +17 -0
  64. package/dist/import/extract.js +18 -0
  65. package/dist/index.d.ts +17 -2
  66. package/dist/index.js +7 -0
  67. package/dist/locales/bn.json +8 -0
  68. package/dist/locales/en.json +8 -0
  69. package/dist/locales/gu.json +8 -0
  70. package/dist/locales/hi.json +8 -0
  71. package/dist/locales/kn.json +8 -0
  72. package/dist/locales/ml.json +8 -0
  73. package/dist/locales/mr.json +8 -0
  74. package/dist/locales/or.json +8 -0
  75. package/dist/locales/pa.json +8 -0
  76. package/dist/locales/si.json +8 -0
  77. package/dist/locales/ta.json +8 -0
  78. package/dist/locales/te.json +8 -0
  79. package/dist/navigator/SceneNavigator.js +125 -131
  80. package/dist/navigator/StatisticsDialog.js +5 -1
  81. package/dist/navigator/scene-edits.d.ts +18 -0
  82. package/dist/navigator/scene-edits.js +143 -0
  83. package/dist/navigator/scene-list.js +4 -4
  84. package/dist/navigator/scene-numbering.d.ts +37 -0
  85. package/dist/navigator/scene-numbering.js +88 -0
  86. package/dist/settings/SettingsDialog.js +8 -120
  87. package/dist/shell/AppBar.js +21 -2
  88. package/dist/shell/AppShell.js +18 -4
  89. package/dist/shell/ElementToolbar.d.ts +5 -0
  90. package/dist/shell/ElementToolbar.js +35 -25
  91. package/dist/shell/LanguageStatus.js +6 -0
  92. package/dist/shell/StatusBar.js +11 -6
  93. package/dist/shell/builtin-commands.d.ts +10 -0
  94. package/dist/shell/builtin-commands.js +43 -0
  95. package/dist/shell/icons.d.ts +6 -0
  96. package/dist/shell/icons.js +6 -0
  97. package/dist/shell/use-commands.d.ts +7 -0
  98. package/dist/shell/use-commands.js +24 -0
  99. package/dist/shell/use-panel-context.d.ts +3 -0
  100. package/dist/shell/use-panel-context.js +39 -0
  101. package/dist/source/SourceView.js +18 -1
  102. package/dist/styles/screenplay.css +113 -1
  103. package/dist/titlepage/ExportDialog.d.ts +5 -0
  104. package/dist/titlepage/ExportDialog.js +59 -96
  105. package/package.json +4 -4
  106. package/dist/ai/OnboardingDialog.d.ts +0 -6
  107. package/dist/ai/OnboardingDialog.js +0 -115
  108. package/dist/ai/ai-client.d.ts +0 -71
  109. package/dist/ai/ai-client.js +0 -494
  110. package/dist/ai/generated-providers.json +0 -3696
  111. package/dist/ai/voice-service.d.ts +0 -15
  112. package/dist/ai/voice-service.js +0 -81
  113. package/dist/editor/character-view.d.ts +0 -35
  114. package/dist/editor/character-view.js +0 -112
  115. package/dist/editor/transliterate-input.d.ts +0 -10
  116. package/dist/editor/transliterate-input.js +0 -88
  117. package/dist/extensions/types.d.ts +0 -73
  118. package/dist/extensions/types.js +0 -12
@@ -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 tr = tbl.getElementsByTagNameNS(W_NS, 'tr')[0];
60
- const cells = [];
61
- if (tr) {
62
- for (const tc of Array.from(tr.children)) {
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
- /** Parses a .docx file (as produced by exportToDocx, possibly re-saved from Word with
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 numMeta = node.metadata.find(m => m.key === 'number')?.value;
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 all shot metadata lines
35
- const shotLines = node.metadata.filter(m => m.key === 'shots').map(m => m.value);
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
- const sceneLabel = node.id || String(sceneOrdinal);
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">${textHtml(node.text.toUpperCase())}</span>
620
- ${node.id ? `<span class="print-scene-num">${escapeHtml(node.id)}</span>` : ''}
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
- const isReplace = findMode === 'replace';
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;
@@ -0,0 +1,35 @@
1
+ import { parse } from '@sutrata/parser';
2
+ import { getEditorView, getSourceView } from '../editor/editor-bus';
3
+ import { sutraToProsemirror } from '../editor/sutra-to-prosemirror';
4
+ /**
5
+ * Put imported Sutra into the document as ONE edit (a single undo step):
6
+ * `replace` swaps the body and keeps the title page; `append` adds after the
7
+ * last block. Goes through whichever editor is mounted; without one, sets
8
+ * the text directly.
9
+ */
10
+ export function applyImport(body, how, text, setText) {
11
+ const view = getEditorView();
12
+ if (view) {
13
+ const nodes = [];
14
+ sutraToProsemirror(parse(body + '\n')).forEach(n => { if (n.type.name !== 'title_page')
15
+ nodes.push(n); });
16
+ const doc = view.state.doc;
17
+ const first = doc.firstChild;
18
+ const bodyStart = first && first.type.name === 'title_page' ? first.nodeSize : 0;
19
+ const tr = how === 'replace'
20
+ ? view.state.tr.replaceWith(bodyStart, doc.content.size, nodes)
21
+ : view.state.tr.insert(doc.content.size, nodes);
22
+ view.dispatch(tr.scrollIntoView());
23
+ return;
24
+ }
25
+ const frontmatter = parse(text).frontmatter?.raw ?? '';
26
+ const next = how === 'replace'
27
+ ? `${frontmatter}${frontmatter ? '\n' : ''}${body.trim()}\n`
28
+ : `${text.trimEnd()}\n\n${body.trim()}\n`;
29
+ const source = getSourceView();
30
+ if (source) {
31
+ source.dispatch({ changes: { from: 0, to: source.state.doc.length, insert: next } });
32
+ return;
33
+ }
34
+ setText(next);
35
+ }
@@ -0,0 +1,17 @@
1
+ export type ImportSource =
2
+ /** A DOCX exported by Sutrata: imported losslessly, no AI needed. */
3
+ {
4
+ kind: 'sutra';
5
+ name: string;
6
+ sutra: string;
7
+ warnings: string[];
8
+ }
9
+ /** Anything else: plain text (with emphasis markup) for the AI formatter. */
10
+ | {
11
+ kind: 'text';
12
+ name: string;
13
+ text: string;
14
+ };
15
+ export declare const IMPORT_ACCEPT = ".docx,.txt,.text,.md,.markdown";
16
+ /** Read a picked file for import. Word documents Sutrata wrote keep the lossless importer. */
17
+ export declare function readImportFile(file: File): Promise<ImportSource>;