@sutrata/editor 0.1.1 → 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 (268) hide show
  1. package/README.md +87 -0
  2. package/dist/ai/VoiceConfirmDialog.d.ts +1 -0
  3. package/dist/ai/VoiceConfirmDialog.js +6 -6
  4. package/dist/ai/VoiceToolbar.d.ts +1 -0
  5. package/dist/ai/VoiceToolbar.js +22 -16
  6. package/dist/ai/clean-response.d.ts +7 -0
  7. package/dist/ai/clean-response.js +58 -0
  8. package/dist/ai/format-transcript.d.ts +3 -0
  9. package/dist/ai/format-transcript.js +7 -0
  10. package/dist/ai/prompts.d.ts +8 -1
  11. package/dist/ai/prompts.js +34 -7
  12. package/dist/autocomplete/suggestions.js +1 -1
  13. package/dist/context/DocumentContext.d.ts +28 -4
  14. package/dist/context/DocumentContext.js +67 -37
  15. package/dist/editor/EditorView.js +32 -4
  16. package/dist/editor/SelectionMenu.js +5 -3
  17. package/dist/editor/external-decorations-plugin.d.ts +23 -0
  18. package/dist/editor/external-decorations-plugin.js +151 -0
  19. package/dist/editor/omitted-scene-plugin.d.ts +9 -0
  20. package/dist/editor/omitted-scene-plugin.js +44 -0
  21. package/dist/editor/plugins.js +4 -0
  22. package/dist/editor/private-metadata-plugin.d.ts +10 -0
  23. package/dist/editor/private-metadata-plugin.js +60 -0
  24. package/dist/editor/prosemirror-to-sutra.js +179 -74
  25. package/dist/editor/scene-heading-view.js +2 -0
  26. package/dist/editor/schema.js +69 -12
  27. package/dist/editor/sutra-to-prosemirror.d.ts +2 -0
  28. package/dist/editor/sutra-to-prosemirror.js +32 -34
  29. package/dist/extensions/ai-provider.d.ts +44 -0
  30. package/dist/extensions/ai-provider.js +27 -0
  31. package/dist/extensions/collab.d.ts +21 -0
  32. package/dist/extensions/collab.js +6 -0
  33. package/dist/extensions/command-registry.d.ts +49 -0
  34. package/dist/extensions/command-registry.js +28 -0
  35. package/dist/extensions/decorations.d.ts +48 -0
  36. package/dist/extensions/decorations.js +6 -0
  37. package/dist/extensions/export-registry.d.ts +59 -0
  38. package/dist/extensions/export-registry.js +10 -0
  39. package/dist/extensions/panel-registry.d.ts +30 -0
  40. package/dist/extensions/panel-registry.js +16 -0
  41. package/dist/extensions/registries.d.ts +15 -0
  42. package/dist/extensions/registries.js +12 -0
  43. package/dist/extensions/registry-store.d.ts +26 -0
  44. package/dist/extensions/registry-store.js +35 -0
  45. package/dist/extensions/session.d.ts +29 -0
  46. package/dist/extensions/session.js +20 -0
  47. package/dist/extensions/speech-provider.d.ts +35 -0
  48. package/dist/extensions/speech-provider.js +17 -0
  49. package/dist/file/builtin-exporters.d.ts +9 -0
  50. package/dist/file/builtin-exporters.js +72 -0
  51. package/dist/file/docx-exporter.js +7 -4
  52. package/dist/file/docx-importer.d.ts +9 -0
  53. package/dist/file/docx-importer.js +47 -19
  54. package/dist/file/scene-number.d.ts +5 -0
  55. package/dist/file/scene-number.js +8 -0
  56. package/dist/file/workflow-reports.js +13 -7
  57. package/dist/findreplace/FindReplace.js +5 -2
  58. package/dist/import/AIImportDialog.d.ts +11 -0
  59. package/dist/import/AIImportDialog.js +112 -0
  60. package/dist/import/ai-import.d.ts +51 -0
  61. package/dist/import/ai-import.js +200 -0
  62. package/dist/import/apply-import.d.ts +7 -0
  63. package/dist/import/apply-import.js +35 -0
  64. package/dist/import/extract.d.ts +17 -0
  65. package/dist/import/extract.js +18 -0
  66. package/dist/index.d.ts +17 -2
  67. package/dist/index.js +7 -0
  68. package/dist/locales/bn.json +8 -0
  69. package/dist/locales/en.json +8 -0
  70. package/dist/locales/gu.json +8 -0
  71. package/dist/locales/hi.json +8 -0
  72. package/dist/locales/kn.json +8 -0
  73. package/dist/locales/ml.json +8 -0
  74. package/dist/locales/mr.json +8 -0
  75. package/dist/locales/or.json +8 -0
  76. package/dist/locales/pa.json +8 -0
  77. package/dist/locales/si.json +8 -0
  78. package/dist/locales/ta.json +8 -0
  79. package/dist/locales/te.json +8 -0
  80. package/dist/navigator/SceneNavigator.js +125 -131
  81. package/dist/navigator/StatisticsDialog.js +5 -1
  82. package/dist/navigator/scene-edits.d.ts +18 -0
  83. package/dist/navigator/scene-edits.js +143 -0
  84. package/dist/navigator/scene-list.js +4 -4
  85. package/dist/navigator/scene-numbering.d.ts +37 -0
  86. package/dist/navigator/scene-numbering.js +88 -0
  87. package/dist/settings/SettingsDialog.js +8 -120
  88. package/dist/shell/AppBar.js +21 -2
  89. package/dist/shell/AppShell.js +18 -4
  90. package/dist/shell/ElementToolbar.d.ts +5 -0
  91. package/dist/shell/ElementToolbar.js +35 -25
  92. package/dist/shell/LanguageStatus.js +6 -0
  93. package/dist/shell/StatusBar.js +11 -6
  94. package/dist/shell/builtin-commands.d.ts +10 -0
  95. package/dist/shell/builtin-commands.js +43 -0
  96. package/dist/shell/icons.d.ts +6 -0
  97. package/dist/shell/icons.js +6 -0
  98. package/dist/shell/use-commands.d.ts +7 -0
  99. package/dist/shell/use-commands.js +24 -0
  100. package/dist/shell/use-panel-context.d.ts +3 -0
  101. package/dist/shell/use-panel-context.js +39 -0
  102. package/dist/source/SourceView.js +18 -1
  103. package/dist/styles/screenplay.css +113 -1
  104. package/dist/titlepage/ExportDialog.d.ts +5 -0
  105. package/dist/titlepage/ExportDialog.js +59 -96
  106. package/package.json +32 -5
  107. package/dist/ai/OnboardingDialog.d.ts +0 -6
  108. package/dist/ai/OnboardingDialog.js +0 -115
  109. package/dist/ai/ai-client.d.ts +0 -71
  110. package/dist/ai/ai-client.js +0 -494
  111. package/dist/ai/generated-providers.json +0 -3696
  112. package/dist/ai/voice-service.d.ts +0 -15
  113. package/dist/ai/voice-service.js +0 -81
  114. package/dist/editor/character-view.d.ts +0 -35
  115. package/dist/editor/character-view.js +0 -112
  116. package/dist/editor/transliterate-input.d.ts +0 -10
  117. package/dist/editor/transliterate-input.js +0 -88
  118. package/dist/extensions/types.d.ts +0 -73
  119. package/dist/extensions/types.js +0 -12
  120. package/scripts/copy-assets.mjs +0 -16
  121. package/scripts/generate-providers.mjs +0 -127
  122. package/src/ai/OnboardingDialog.tsx +0 -390
  123. package/src/ai/VoiceConfirmDialog.tsx +0 -137
  124. package/src/ai/VoiceToolbar.tsx +0 -321
  125. package/src/ai/ai-client.ts +0 -596
  126. package/src/ai/commands.ts +0 -164
  127. package/src/ai/generated-providers.json +0 -3696
  128. package/src/ai/prompts.ts +0 -62
  129. package/src/ai/voice-service.ts +0 -96
  130. package/src/autocomplete/AutocompletePlugin.ts +0 -21
  131. package/src/autocomplete/suggestions.ts +0 -147
  132. package/src/context/DocumentContext.tsx +0 -648
  133. package/src/desktop-api.d.ts +0 -40
  134. package/src/editor/EditorView.tsx +0 -82
  135. package/src/editor/SelectionMenu.tsx +0 -152
  136. package/src/editor/attr-pencil-plugin.ts +0 -172
  137. package/src/editor/character-decoration-plugin.ts +0 -104
  138. package/src/editor/editor-bus.ts +0 -51
  139. package/src/editor/find-highlight-plugin.ts +0 -68
  140. package/src/editor/frontmatter-field-view.ts +0 -52
  141. package/src/editor/frontmatter-field.ts +0 -115
  142. package/src/editor/grapheme-cursor.ts +0 -99
  143. package/src/editor/ime-plugin.ts +0 -85
  144. package/src/editor/insert-helper.ts +0 -43
  145. package/src/editor/keymap.ts +0 -124
  146. package/src/editor/language-class-plugin.ts +0 -91
  147. package/src/editor/plugins.ts +0 -26
  148. package/src/editor/prosemirror-to-sutra.ts +0 -192
  149. package/src/editor/scene-heading-view.ts +0 -147
  150. package/src/editor/schema.ts +0 -156
  151. package/src/editor/sutra-to-prosemirror.ts +0 -156
  152. package/src/extensions/storage-adapter.ts +0 -65
  153. package/src/extensions/types.ts +0 -60
  154. package/src/file/docx-exporter.ts +0 -585
  155. package/src/file/docx-importer.ts +0 -313
  156. package/src/file/print-paginator.ts +0 -301
  157. package/src/file/templates.ts +0 -73
  158. package/src/file/workflow-reports.ts +0 -820
  159. package/src/findreplace/FindReplace.tsx +0 -242
  160. package/src/findreplace/find-engine.ts +0 -97
  161. package/src/findreplace/scroll-to-match.ts +0 -89
  162. package/src/fonts/tofu-detect.ts +0 -53
  163. package/src/i18n/LanguageContext.tsx +0 -207
  164. package/src/i18n/language-detect.ts +0 -73
  165. package/src/i18n/useTranslation.tsx +0 -74
  166. package/src/index.ts +0 -27
  167. package/src/locales/bn.json +0 -143
  168. package/src/locales/en.json +0 -143
  169. package/src/locales/gu.json +0 -143
  170. package/src/locales/hi.json +0 -143
  171. package/src/locales/kn.json +0 -143
  172. package/src/locales/ml.json +0 -143
  173. package/src/locales/mr.json +0 -143
  174. package/src/locales/or.json +0 -143
  175. package/src/locales/pa.json +0 -143
  176. package/src/locales/si.json +0 -143
  177. package/src/locales/ta.json +0 -143
  178. package/src/locales/te.json +0 -143
  179. package/src/navigator/SceneNavigator.tsx +0 -844
  180. package/src/navigator/StatisticsDialog.tsx +0 -249
  181. package/src/navigator/scene-list.ts +0 -84
  182. package/src/settings/SettingsDialog.tsx +0 -303
  183. package/src/shell/AppBar.tsx +0 -354
  184. package/src/shell/AppShell.tsx +0 -71
  185. package/src/shell/ConfirmDialog.tsx +0 -71
  186. package/src/shell/ElementToolbar.tsx +0 -175
  187. package/src/shell/LanguageStatus.tsx +0 -101
  188. package/src/shell/StatusBar.tsx +0 -135
  189. package/src/shell/SutrataLogo.tsx +0 -89
  190. package/src/shell/Toast.tsx +0 -46
  191. package/src/shell/ToolbarButton.tsx +0 -30
  192. package/src/shell/VersionHistory.tsx +0 -212
  193. package/src/shell/Watermark.tsx +0 -9
  194. package/src/shell/fonts/fonts.css +0 -1133
  195. package/src/shell/icons.tsx +0 -335
  196. package/src/shell/toolbar-actions.ts +0 -59
  197. package/src/source/SourceView.tsx +0 -111
  198. package/src/source/find-highlight-source.ts +0 -57
  199. package/src/source/sutra-lang.ts +0 -121
  200. package/src/spellcheck/dictionaries.ts +0 -21
  201. package/src/spellcheck/hunspell.ts +0 -110
  202. package/src/spellcheck/spellcheck-plugin.ts +0 -83
  203. package/src/styles/StyleDialog.tsx +0 -148
  204. package/src/styles/builtin-styles.ts +0 -139
  205. package/src/styles/css-adapter.ts +0 -76
  206. package/src/styles/docx-adapter.ts +0 -109
  207. package/src/styles/embed-fonts.ts +0 -81
  208. package/src/styles/pagination.ts +0 -27
  209. package/src/styles/print-adapter.ts +0 -288
  210. package/src/styles/registry.ts +0 -20
  211. package/src/styles/screenplay.css +0 -3006
  212. package/src/styles/script-ranges.ts +0 -17
  213. package/src/styles/types.ts +0 -104
  214. package/src/styles/validate.ts +0 -114
  215. package/src/templates/templates.ts +0 -194
  216. package/src/titlepage/ExportDialog.tsx +0 -494
  217. package/src/titlepage/TitlePageDialog.tsx +0 -312
  218. package/src/titlepage/frontmatter-form.ts +0 -280
  219. package/src/titlepage/languages.ts +0 -37
  220. package/src/types.ts +0 -7
  221. package/tests/AppBar.test.tsx +0 -35
  222. package/tests/AppShell.test.tsx +0 -22
  223. package/tests/ElementToolbar.test.tsx +0 -36
  224. package/tests/LanguageStatus.test.tsx +0 -129
  225. package/tests/SelectionMenu.test.tsx +0 -71
  226. package/tests/ToolbarButton.test.tsx +0 -24
  227. package/tests/ai-onboarding.test.ts +0 -102
  228. package/tests/ai-status.test.tsx +0 -72
  229. package/tests/ai-structured.test.ts +0 -94
  230. package/tests/autocomplete/AutocompletePlugin.test.ts +0 -20
  231. package/tests/autocomplete/suggestions.test.ts +0 -106
  232. package/tests/document-context.test.tsx +0 -565
  233. package/tests/docx-importer.test.ts +0 -160
  234. package/tests/editor/character-decoration-plugin.test.ts +0 -101
  235. package/tests/editor/character-extension.test.ts +0 -44
  236. package/tests/editor/grapheme-cursor.test.ts +0 -118
  237. package/tests/editor/ime-plugin.test.ts +0 -72
  238. package/tests/editor/inline-note-bridge.test.ts +0 -32
  239. package/tests/editor/language-class-plugin.test.ts +0 -265
  240. package/tests/editor/title-page.test.ts +0 -124
  241. package/tests/embed-fonts.test.ts +0 -17
  242. package/tests/exporters.test.ts +0 -365
  243. package/tests/findreplace/FindReplace.test.tsx +0 -391
  244. package/tests/findreplace/find-engine.test.ts +0 -86
  245. package/tests/findreplace/scroll-to-match.test.ts +0 -50
  246. package/tests/fonts/tofu-detect.test.ts +0 -122
  247. package/tests/i18n/LanguageContext.test.tsx +0 -146
  248. package/tests/i18n/language-detect.test.ts +0 -54
  249. package/tests/i18n/locale-parity.test.ts +0 -25
  250. package/tests/i18n/useTranslation.test.tsx +0 -45
  251. package/tests/keymap.test.ts +0 -18
  252. package/tests/navigator/SceneNavigator.test.tsx +0 -177
  253. package/tests/navigator/StatisticsDialog.test.tsx +0 -63
  254. package/tests/navigator/scene-list.test.ts +0 -71
  255. package/tests/print-paginator.test.ts +0 -82
  256. package/tests/shell/VersionHistory.test.tsx +0 -129
  257. package/tests/spellcheck/hunspell.test.ts +0 -82
  258. package/tests/spellcheck/spellcheck-plugin.test.ts +0 -56
  259. package/tests/styles.test.ts +0 -315
  260. package/tests/templates/templates.test.ts +0 -83
  261. package/tests/titlepage/TitlePageDialog.test.tsx +0 -90
  262. package/tests/titlepage/apply-title-page.test.ts +0 -61
  263. package/tests/titlepage/frontmatter-form.test.ts +0 -115
  264. package/tests/toolbar-actions.test.ts +0 -93
  265. package/tests/voice-commands.test.ts +0 -58
  266. package/tsconfig.json +0 -17
  267. package/vitest.config.ts +0 -11
  268. package/vitest.setup.ts +0 -65
@@ -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>;