@sutrata/editor 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -0
- package/dist/ai/generated-providers.json +1 -1
- package/package.json +30 -3
- package/scripts/copy-assets.mjs +0 -16
- package/scripts/generate-providers.mjs +0 -127
- package/src/ai/OnboardingDialog.tsx +0 -390
- package/src/ai/VoiceConfirmDialog.tsx +0 -137
- package/src/ai/VoiceToolbar.tsx +0 -321
- package/src/ai/ai-client.ts +0 -596
- package/src/ai/commands.ts +0 -164
- package/src/ai/generated-providers.json +0 -3696
- package/src/ai/prompts.ts +0 -62
- package/src/ai/voice-service.ts +0 -96
- package/src/autocomplete/AutocompletePlugin.ts +0 -21
- package/src/autocomplete/suggestions.ts +0 -147
- package/src/context/DocumentContext.tsx +0 -648
- package/src/desktop-api.d.ts +0 -40
- package/src/editor/EditorView.tsx +0 -82
- package/src/editor/SelectionMenu.tsx +0 -152
- package/src/editor/attr-pencil-plugin.ts +0 -172
- package/src/editor/character-decoration-plugin.ts +0 -104
- package/src/editor/editor-bus.ts +0 -51
- package/src/editor/find-highlight-plugin.ts +0 -68
- package/src/editor/frontmatter-field-view.ts +0 -52
- package/src/editor/frontmatter-field.ts +0 -115
- package/src/editor/grapheme-cursor.ts +0 -99
- package/src/editor/ime-plugin.ts +0 -85
- package/src/editor/insert-helper.ts +0 -43
- package/src/editor/keymap.ts +0 -124
- package/src/editor/language-class-plugin.ts +0 -91
- package/src/editor/plugins.ts +0 -26
- package/src/editor/prosemirror-to-sutra.ts +0 -192
- package/src/editor/scene-heading-view.ts +0 -147
- package/src/editor/schema.ts +0 -156
- package/src/editor/sutra-to-prosemirror.ts +0 -156
- package/src/extensions/storage-adapter.ts +0 -65
- package/src/extensions/types.ts +0 -60
- package/src/file/docx-exporter.ts +0 -585
- package/src/file/docx-importer.ts +0 -313
- package/src/file/print-paginator.ts +0 -301
- package/src/file/templates.ts +0 -73
- package/src/file/workflow-reports.ts +0 -820
- package/src/findreplace/FindReplace.tsx +0 -242
- package/src/findreplace/find-engine.ts +0 -97
- package/src/findreplace/scroll-to-match.ts +0 -89
- package/src/fonts/tofu-detect.ts +0 -53
- package/src/i18n/LanguageContext.tsx +0 -207
- package/src/i18n/language-detect.ts +0 -73
- package/src/i18n/useTranslation.tsx +0 -74
- package/src/index.ts +0 -27
- package/src/locales/bn.json +0 -143
- package/src/locales/en.json +0 -143
- package/src/locales/gu.json +0 -143
- package/src/locales/hi.json +0 -143
- package/src/locales/kn.json +0 -143
- package/src/locales/ml.json +0 -143
- package/src/locales/mr.json +0 -143
- package/src/locales/or.json +0 -143
- package/src/locales/pa.json +0 -143
- package/src/locales/si.json +0 -143
- package/src/locales/ta.json +0 -143
- package/src/locales/te.json +0 -143
- package/src/navigator/SceneNavigator.tsx +0 -844
- package/src/navigator/StatisticsDialog.tsx +0 -249
- package/src/navigator/scene-list.ts +0 -84
- package/src/settings/SettingsDialog.tsx +0 -303
- package/src/shell/AppBar.tsx +0 -354
- package/src/shell/AppShell.tsx +0 -71
- package/src/shell/ConfirmDialog.tsx +0 -71
- package/src/shell/ElementToolbar.tsx +0 -175
- package/src/shell/LanguageStatus.tsx +0 -101
- package/src/shell/StatusBar.tsx +0 -135
- package/src/shell/SutrataLogo.tsx +0 -89
- package/src/shell/Toast.tsx +0 -46
- package/src/shell/ToolbarButton.tsx +0 -30
- package/src/shell/VersionHistory.tsx +0 -212
- package/src/shell/Watermark.tsx +0 -9
- package/src/shell/fonts/fonts.css +0 -1133
- package/src/shell/icons.tsx +0 -335
- package/src/shell/toolbar-actions.ts +0 -59
- package/src/source/SourceView.tsx +0 -111
- package/src/source/find-highlight-source.ts +0 -57
- package/src/source/sutra-lang.ts +0 -121
- package/src/spellcheck/dictionaries.ts +0 -21
- package/src/spellcheck/hunspell.ts +0 -110
- package/src/spellcheck/spellcheck-plugin.ts +0 -83
- package/src/styles/StyleDialog.tsx +0 -148
- package/src/styles/builtin-styles.ts +0 -139
- package/src/styles/css-adapter.ts +0 -76
- package/src/styles/docx-adapter.ts +0 -109
- package/src/styles/embed-fonts.ts +0 -81
- package/src/styles/pagination.ts +0 -27
- package/src/styles/print-adapter.ts +0 -288
- package/src/styles/registry.ts +0 -20
- package/src/styles/screenplay.css +0 -3006
- package/src/styles/script-ranges.ts +0 -17
- package/src/styles/types.ts +0 -104
- package/src/styles/validate.ts +0 -114
- package/src/templates/templates.ts +0 -194
- package/src/titlepage/ExportDialog.tsx +0 -494
- package/src/titlepage/TitlePageDialog.tsx +0 -312
- package/src/titlepage/frontmatter-form.ts +0 -280
- package/src/titlepage/languages.ts +0 -37
- package/src/types.ts +0 -7
- package/tests/AppBar.test.tsx +0 -35
- package/tests/AppShell.test.tsx +0 -22
- package/tests/ElementToolbar.test.tsx +0 -36
- package/tests/LanguageStatus.test.tsx +0 -129
- package/tests/SelectionMenu.test.tsx +0 -71
- package/tests/ToolbarButton.test.tsx +0 -24
- package/tests/ai-onboarding.test.ts +0 -102
- package/tests/ai-status.test.tsx +0 -72
- package/tests/ai-structured.test.ts +0 -94
- package/tests/autocomplete/AutocompletePlugin.test.ts +0 -20
- package/tests/autocomplete/suggestions.test.ts +0 -106
- package/tests/document-context.test.tsx +0 -565
- package/tests/docx-importer.test.ts +0 -160
- package/tests/editor/character-decoration-plugin.test.ts +0 -101
- package/tests/editor/character-extension.test.ts +0 -44
- package/tests/editor/grapheme-cursor.test.ts +0 -118
- package/tests/editor/ime-plugin.test.ts +0 -72
- package/tests/editor/inline-note-bridge.test.ts +0 -32
- package/tests/editor/language-class-plugin.test.ts +0 -265
- package/tests/editor/title-page.test.ts +0 -124
- package/tests/embed-fonts.test.ts +0 -17
- package/tests/exporters.test.ts +0 -365
- package/tests/findreplace/FindReplace.test.tsx +0 -391
- package/tests/findreplace/find-engine.test.ts +0 -86
- package/tests/findreplace/scroll-to-match.test.ts +0 -50
- package/tests/fonts/tofu-detect.test.ts +0 -122
- package/tests/i18n/LanguageContext.test.tsx +0 -146
- package/tests/i18n/language-detect.test.ts +0 -54
- package/tests/i18n/locale-parity.test.ts +0 -25
- package/tests/i18n/useTranslation.test.tsx +0 -45
- package/tests/keymap.test.ts +0 -18
- package/tests/navigator/SceneNavigator.test.tsx +0 -177
- package/tests/navigator/StatisticsDialog.test.tsx +0 -63
- package/tests/navigator/scene-list.test.ts +0 -71
- package/tests/print-paginator.test.ts +0 -82
- package/tests/shell/VersionHistory.test.tsx +0 -129
- package/tests/spellcheck/hunspell.test.ts +0 -82
- package/tests/spellcheck/spellcheck-plugin.test.ts +0 -56
- package/tests/styles.test.ts +0 -315
- package/tests/templates/templates.test.ts +0 -83
- package/tests/titlepage/TitlePageDialog.test.tsx +0 -90
- package/tests/titlepage/apply-title-page.test.ts +0 -61
- package/tests/titlepage/frontmatter-form.test.ts +0 -115
- package/tests/toolbar-actions.test.ts +0 -93
- package/tests/voice-commands.test.ts +0 -58
- package/tsconfig.json +0 -17
- package/vitest.config.ts +0 -11
- package/vitest.setup.ts +0 -65
|
@@ -1,249 +0,0 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import { useDocument } from '../context/DocumentContext'
|
|
3
|
-
import { extractWorkflowData } from '../file/workflow-reports'
|
|
4
|
-
import { getEditorView } from '../editor/editor-bus'
|
|
5
|
-
import { schema } from '../editor/schema'
|
|
6
|
-
|
|
7
|
-
interface Props {
|
|
8
|
-
onClose: () => void
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function StatisticsDialog({ onClose }: Props) {
|
|
12
|
-
const { ast, text, setText } = useDocument()
|
|
13
|
-
const { scenes, characters } = extractWorkflowData(ast)
|
|
14
|
-
|
|
15
|
-
const handleUpdateActor = (charName: string, actorName: string) => {
|
|
16
|
-
const cleanChar = charName.trim().toUpperCase()
|
|
17
|
-
if (!cleanChar) return
|
|
18
|
-
|
|
19
|
-
const view = getEditorView()
|
|
20
|
-
const key = `characters.${cleanChar}`
|
|
21
|
-
|
|
22
|
-
if (view) {
|
|
23
|
-
let tr = view.state.tr
|
|
24
|
-
let titlePagePos: number | null = null
|
|
25
|
-
view.state.doc.forEach((node, offset) => {
|
|
26
|
-
if (node.type.name === 'title_page') titlePagePos = offset
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
if (titlePagePos !== null) {
|
|
30
|
-
const tpNode = view.state.doc.nodeAt(titlePagePos)!
|
|
31
|
-
let existingPos: number | null = null
|
|
32
|
-
let pos = titlePagePos + 1 // inside title_page
|
|
33
|
-
|
|
34
|
-
tpNode.forEach((field) => {
|
|
35
|
-
if (field.attrs['fmKey'] === key) {
|
|
36
|
-
existingPos = pos
|
|
37
|
-
}
|
|
38
|
-
pos += field.nodeSize
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
if (existingPos !== null) {
|
|
42
|
-
const fieldNode = view.state.doc.nodeAt(existingPos)!
|
|
43
|
-
if (actorName.trim() === '') {
|
|
44
|
-
tr = tr.delete(existingPos, existingPos + fieldNode.nodeSize)
|
|
45
|
-
} else {
|
|
46
|
-
tr = tr.replaceWith(
|
|
47
|
-
existingPos + 1,
|
|
48
|
-
existingPos + fieldNode.nodeSize - 1,
|
|
49
|
-
schema.text(actorName)
|
|
50
|
-
)
|
|
51
|
-
}
|
|
52
|
-
} else if (actorName.trim() !== '') {
|
|
53
|
-
const insertPos = titlePagePos + tpNode.nodeSize - 1 // end of title_page
|
|
54
|
-
const newField = schema.nodes['frontmatter_field']!.create(
|
|
55
|
-
{ fmKey: key },
|
|
56
|
-
schema.text(actorName)
|
|
57
|
-
)
|
|
58
|
-
tr = tr.insert(insertPos, newField)
|
|
59
|
-
}
|
|
60
|
-
view.dispatch(tr)
|
|
61
|
-
} else {
|
|
62
|
-
const freshText = updateActorInText(text, cleanChar, actorName)
|
|
63
|
-
setText(freshText)
|
|
64
|
-
}
|
|
65
|
-
} else {
|
|
66
|
-
const freshText = updateActorInText(text, cleanChar, actorName)
|
|
67
|
-
setText(freshText)
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Word count helper
|
|
72
|
-
const wordCount = text.split(/\s+/).filter(Boolean).length
|
|
73
|
-
|
|
74
|
-
// Sum up estimated duration. Each scene's est-duration is "MM:SS" — sum
|
|
75
|
-
// in seconds and only convert to minutes/hours once, at the end. Summing
|
|
76
|
-
// the MM parts alone (the previous approach) silently dropped every
|
|
77
|
-
// scene's seconds component, undercounting the total by tens of minutes
|
|
78
|
-
// on a script with many scenes.
|
|
79
|
-
let totalSeconds = 0
|
|
80
|
-
scenes.forEach(s => {
|
|
81
|
-
const match = /^(\d+):(\d{1,2})$/.exec(s.estDuration.trim())
|
|
82
|
-
if (match && match[1] && match[2]) {
|
|
83
|
-
totalSeconds += Number.parseInt(match[1], 10) * 60 + Number.parseInt(match[2], 10)
|
|
84
|
-
}
|
|
85
|
-
})
|
|
86
|
-
const totalMinutes = Math.round(totalSeconds / 60)
|
|
87
|
-
const totalDurationLabel = totalSeconds === 0
|
|
88
|
-
? 'TBD'
|
|
89
|
-
: totalMinutes >= 60
|
|
90
|
-
? `${Math.floor(totalMinutes / 60)}h ${totalMinutes % 60}m`
|
|
91
|
-
: `${totalMinutes} min`
|
|
92
|
-
|
|
93
|
-
return (
|
|
94
|
-
<div
|
|
95
|
-
className="cs-dialog-overlay"
|
|
96
|
-
role="presentation"
|
|
97
|
-
onClick={e => {
|
|
98
|
-
if (e.target === e.currentTarget) onClose()
|
|
99
|
-
}}
|
|
100
|
-
>
|
|
101
|
-
<div
|
|
102
|
-
className="cs-dialog"
|
|
103
|
-
role="dialog"
|
|
104
|
-
aria-modal="true"
|
|
105
|
-
aria-labelledby="cs-stats-dialog-title"
|
|
106
|
-
style={{ maxWidth: '640px', width: '92%', maxHeight: '85vh', display: 'flex', flexDirection: 'column' }}
|
|
107
|
-
>
|
|
108
|
-
<div className="cs-panel-header">
|
|
109
|
-
<span id="cs-stats-dialog-title" className="cs-panel-title">Script Statistics</span>
|
|
110
|
-
<button className="cs-panel-close" onClick={onClose} aria-label="Close">
|
|
111
|
-
×
|
|
112
|
-
</button>
|
|
113
|
-
</div>
|
|
114
|
-
<div className="cs-panel-body" style={{ overflowY: 'auto', flex: 1, padding: '20px' }}>
|
|
115
|
-
<div className="cs-stats-summary-grid">
|
|
116
|
-
<div className="cs-stats-card">
|
|
117
|
-
<div className="cs-stats-card-val">{scenes.length}</div>
|
|
118
|
-
<div className="cs-stats-card-lbl">Scenes</div>
|
|
119
|
-
</div>
|
|
120
|
-
<div className="cs-stats-card">
|
|
121
|
-
<div className="cs-stats-card-val">{wordCount.toLocaleString()}</div>
|
|
122
|
-
<div className="cs-stats-card-lbl">Words</div>
|
|
123
|
-
</div>
|
|
124
|
-
<div className="cs-stats-card">
|
|
125
|
-
<div className="cs-stats-card-val">
|
|
126
|
-
{totalDurationLabel}
|
|
127
|
-
</div>
|
|
128
|
-
<div className="cs-stats-card-lbl">Est. Duration</div>
|
|
129
|
-
</div>
|
|
130
|
-
</div>
|
|
131
|
-
|
|
132
|
-
<div className="cs-stats-section-hdr">
|
|
133
|
-
Cast List & Occurrences ({characters.length})
|
|
134
|
-
</div>
|
|
135
|
-
|
|
136
|
-
{characters.length === 0 ? (
|
|
137
|
-
<div style={{ color: 'var(--cs-ui-text-light-muted, #8c897f)', fontStyle: 'italic', fontSize: '12px' }}>
|
|
138
|
-
No character cues detected in the screenplay yet.
|
|
139
|
-
</div>
|
|
140
|
-
) : (
|
|
141
|
-
<table className="cs-stats-table">
|
|
142
|
-
<thead>
|
|
143
|
-
<tr>
|
|
144
|
-
<th style={{ textAlign: 'left', width: '35%' }}>Character</th>
|
|
145
|
-
<th style={{ textAlign: 'left' }}>Actor</th>
|
|
146
|
-
<th style={{ textAlign: 'center', width: '15%' }}>Scenes</th>
|
|
147
|
-
</tr>
|
|
148
|
-
</thead>
|
|
149
|
-
<tbody>
|
|
150
|
-
{characters.map((c, i) => (
|
|
151
|
-
<tr key={i}>
|
|
152
|
-
<td style={{ fontWeight: '600' }}>{c.name}</td>
|
|
153
|
-
<td>
|
|
154
|
-
<input
|
|
155
|
-
type="text"
|
|
156
|
-
className="cs-stats-actor-input"
|
|
157
|
-
value={c.actor}
|
|
158
|
-
onChange={(e) => handleUpdateActor(c.name, e.target.value)}
|
|
159
|
-
placeholder="Assign actor..."
|
|
160
|
-
aria-label={`Actor for ${c.name}`}
|
|
161
|
-
/>
|
|
162
|
-
</td>
|
|
163
|
-
<td style={{ textAlign: 'center', fontWeight: '500' }}>{c.sceneCount}</td>
|
|
164
|
-
</tr>
|
|
165
|
-
))}
|
|
166
|
-
</tbody>
|
|
167
|
-
</table>
|
|
168
|
-
)}
|
|
169
|
-
</div>
|
|
170
|
-
<div
|
|
171
|
-
style={{
|
|
172
|
-
borderTop: '1px solid var(--cs-ui-border-light-subtle, #eeece4)',
|
|
173
|
-
display: 'flex',
|
|
174
|
-
justifyContent: 'flex-end',
|
|
175
|
-
padding: '12px 20px',
|
|
176
|
-
background: 'var(--cs-ui-bg-panel-subtle, #f4f2ea)',
|
|
177
|
-
}}
|
|
178
|
-
>
|
|
179
|
-
<button
|
|
180
|
-
type="button"
|
|
181
|
-
className="cs-confirm-btn-primary"
|
|
182
|
-
onClick={onClose}
|
|
183
|
-
>
|
|
184
|
-
Close
|
|
185
|
-
</button>
|
|
186
|
-
</div>
|
|
187
|
-
</div>
|
|
188
|
-
</div>
|
|
189
|
-
)
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function updateActorInText(text: string, charName: string, actorName: string): string {
|
|
193
|
-
const lines = text.split('\n')
|
|
194
|
-
const fmStartIndex = lines.indexOf('---')
|
|
195
|
-
if (fmStartIndex === -1) {
|
|
196
|
-
return `---\ncharacters:\n ${charName}: ${actorName}\n---\n\n${text}`
|
|
197
|
-
}
|
|
198
|
-
const fmEndIndex = lines.indexOf('---', fmStartIndex + 1)
|
|
199
|
-
if (fmEndIndex === -1) return text
|
|
200
|
-
|
|
201
|
-
const fmLines = lines.slice(fmStartIndex + 1, fmEndIndex)
|
|
202
|
-
let charsLineIdx = -1
|
|
203
|
-
for (let i = 0; i < fmLines.length; i++) {
|
|
204
|
-
if (fmLines[i]!.trim() === 'characters:') {
|
|
205
|
-
charsLineIdx = i
|
|
206
|
-
break
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const cleanChar = charName.toUpperCase()
|
|
211
|
-
if (charsLineIdx === -1) {
|
|
212
|
-
if (actorName.trim() !== '') {
|
|
213
|
-
fmLines.push('characters:')
|
|
214
|
-
fmLines.push(` ${cleanChar}: ${actorName}`)
|
|
215
|
-
}
|
|
216
|
-
} else {
|
|
217
|
-
let insertIdx = charsLineIdx + 1
|
|
218
|
-
let charLineIdx = -1
|
|
219
|
-
while (insertIdx < fmLines.length) {
|
|
220
|
-
const line = fmLines[insertIdx]!
|
|
221
|
-
if (!line.startsWith(' ') && line.trim() !== '') {
|
|
222
|
-
break
|
|
223
|
-
}
|
|
224
|
-
const match = /^\s+([A-Za-z0-9_ -]+)\s*:(.*)/.exec(line)
|
|
225
|
-
if (match && match[1]?.trim().toUpperCase() === cleanChar) {
|
|
226
|
-
charLineIdx = insertIdx
|
|
227
|
-
break
|
|
228
|
-
}
|
|
229
|
-
insertIdx++
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
if (charLineIdx !== -1) {
|
|
233
|
-
if (actorName.trim() === '') {
|
|
234
|
-
fmLines.splice(charLineIdx, 1)
|
|
235
|
-
} else {
|
|
236
|
-
fmLines[charLineIdx] = ` ${cleanChar}: ${actorName}`
|
|
237
|
-
}
|
|
238
|
-
} else if (actorName.trim() !== '') {
|
|
239
|
-
fmLines.splice(insertIdx, 0, ` ${cleanChar}: ${actorName}`)
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
const newLines = [
|
|
244
|
-
...lines.slice(0, fmStartIndex + 1),
|
|
245
|
-
...fmLines,
|
|
246
|
-
...lines.slice(fmEndIndex)
|
|
247
|
-
]
|
|
248
|
-
return newLines.join('\n')
|
|
249
|
-
}
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
export interface SceneEntry {
|
|
2
|
-
id: string | null
|
|
3
|
-
heading: string
|
|
4
|
-
synopsis: string
|
|
5
|
-
status: string
|
|
6
|
-
tags: string[]
|
|
7
|
-
location: string
|
|
8
|
-
index: number // 1-based position (internal, used for PM navigation)
|
|
9
|
-
textOffset: number // character offset in source text where this scene starts
|
|
10
|
-
estDuration?: string
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Derive a flat scene list from Sutra source text.
|
|
15
|
-
* Scenes are identified by scene heading lines (## prefix).
|
|
16
|
-
*/
|
|
17
|
-
export function buildSceneList(text: string): SceneEntry[] {
|
|
18
|
-
const lines = text.split('\n')
|
|
19
|
-
const scenes: SceneEntry[] = []
|
|
20
|
-
let sceneIndex = 0
|
|
21
|
-
let currentOffset = 0
|
|
22
|
-
|
|
23
|
-
for (let i = 0; i < lines.length; i++) {
|
|
24
|
-
const line = lines[i] ?? ''
|
|
25
|
-
const trimmed = line.trim()
|
|
26
|
-
|
|
27
|
-
// Scene headings start with ## (Sutra sigil)
|
|
28
|
-
if (trimmed.startsWith('## ') || trimmed.startsWith('##\t')) {
|
|
29
|
-
const headingRaw = trimmed.slice(3).trim()
|
|
30
|
-
const sceneStart = currentOffset
|
|
31
|
-
|
|
32
|
-
// Extract scene id from inline {#...} on the heading line, then strip it
|
|
33
|
-
const idMatch = /\{#([^}]+)\}/.exec(headingRaw)
|
|
34
|
-
const sceneId: string | null = idMatch?.[1] ?? null
|
|
35
|
-
const heading = headingRaw.replace(/\s*\{#[^}]+\}/, '').trim()
|
|
36
|
-
|
|
37
|
-
// Collect metadata (& key: value) until next heading
|
|
38
|
-
let synopsis = ''
|
|
39
|
-
let status = ''
|
|
40
|
-
let tags: string[] = []
|
|
41
|
-
let location = ''
|
|
42
|
-
let estDuration = ''
|
|
43
|
-
|
|
44
|
-
let j = i + 1
|
|
45
|
-
while (j < lines.length) {
|
|
46
|
-
const nextLine = (lines[j] ?? '').trim()
|
|
47
|
-
if (nextLine.startsWith('## ') || nextLine.startsWith('##\t')) break
|
|
48
|
-
if (nextLine.startsWith('& synopsis:')) {
|
|
49
|
-
if (!synopsis) synopsis = nextLine.replace('& synopsis:', '').trim()
|
|
50
|
-
} else if (nextLine.startsWith('& status:')) {
|
|
51
|
-
status = nextLine.replace('& status:', '').trim()
|
|
52
|
-
} else if (nextLine.startsWith('& tags:')) {
|
|
53
|
-
tags = nextLine
|
|
54
|
-
.replace('& tags:', '')
|
|
55
|
-
.trim()
|
|
56
|
-
.split(',')
|
|
57
|
-
.map((t) => t.trim())
|
|
58
|
-
.filter(Boolean)
|
|
59
|
-
} else if (nextLine.startsWith('& location:')) {
|
|
60
|
-
location = nextLine.replace('& location:', '').trim()
|
|
61
|
-
} else if (nextLine.startsWith('& est-duration:')) {
|
|
62
|
-
estDuration = nextLine.replace('& est-duration:', '').trim()
|
|
63
|
-
}
|
|
64
|
-
j++
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
scenes.push({
|
|
68
|
-
id: sceneId,
|
|
69
|
-
heading,
|
|
70
|
-
synopsis,
|
|
71
|
-
status,
|
|
72
|
-
tags,
|
|
73
|
-
location,
|
|
74
|
-
estDuration: estDuration || undefined,
|
|
75
|
-
index: ++sceneIndex,
|
|
76
|
-
textOffset: sceneStart,
|
|
77
|
-
})
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
currentOffset += (line?.length ?? 0) + 1 // +1 for \n
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return scenes
|
|
84
|
-
}
|
|
@@ -1,303 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useRef, useState } from 'react'
|
|
2
|
-
import { useTranslation } from '../i18n/useTranslation'
|
|
3
|
-
import { useDocument } from '../context/DocumentContext'
|
|
4
|
-
import {
|
|
5
|
-
getAIConfig,
|
|
6
|
-
saveAIConfig,
|
|
7
|
-
fetchProviders,
|
|
8
|
-
getApiKey,
|
|
9
|
-
setApiKey,
|
|
10
|
-
deleteApiKey,
|
|
11
|
-
testConnection,
|
|
12
|
-
listModels,
|
|
13
|
-
ProviderConfig,
|
|
14
|
-
ModelConfig,
|
|
15
|
-
AIConfig,
|
|
16
|
-
} from '../ai/ai-client'
|
|
17
|
-
|
|
18
|
-
const UI_LANGUAGES = [
|
|
19
|
-
{ code: 'en', label: 'English' },
|
|
20
|
-
{ code: 'hi', label: 'हिन्दी' },
|
|
21
|
-
{ code: 'ta', label: 'தமிழ்' },
|
|
22
|
-
{ code: 'te', label: 'తెలుగు' },
|
|
23
|
-
{ code: 'ml', label: 'മലയാളം' },
|
|
24
|
-
{ code: 'kn', label: 'ಕನ್ನಡ' },
|
|
25
|
-
{ code: 'bn', label: 'বাংলা' },
|
|
26
|
-
{ code: 'gu', label: 'ગુજરાતી' },
|
|
27
|
-
{ code: 'mr', label: 'मराठी' },
|
|
28
|
-
{ code: 'pa', label: 'ਪੰਜਾਬੀ' },
|
|
29
|
-
{ code: 'or', label: 'ଓଡ଼ିଆ' },
|
|
30
|
-
{ code: 'si', label: 'සිංහල' },
|
|
31
|
-
]
|
|
32
|
-
|
|
33
|
-
interface Props {
|
|
34
|
-
onClose: () => void
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function SettingsDialog({ onClose }: Props) {
|
|
38
|
-
const { t, locale, setLocale } = useTranslation()
|
|
39
|
-
const { setAIOnboardingVisible } = useDocument()
|
|
40
|
-
const dialogRef = useRef<HTMLDivElement>(null)
|
|
41
|
-
|
|
42
|
-
// AI states
|
|
43
|
-
const [aiConfig, setAiConfig] = useState<AIConfig>(getAIConfig())
|
|
44
|
-
const [providers, setProviders] = useState<ProviderConfig[]>([])
|
|
45
|
-
const [activeKey, setActiveKey] = useState<string>('')
|
|
46
|
-
const [showKey, setShowKey] = useState<boolean>(false)
|
|
47
|
-
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle')
|
|
48
|
-
const [testError, setTestError] = useState<string>('')
|
|
49
|
-
const [dynamicModels, setDynamicModels] = useState<ModelConfig[] | null>(null)
|
|
50
|
-
const [modelsLoading, setModelsLoading] = useState<boolean>(false)
|
|
51
|
-
|
|
52
|
-
useEffect(() => {
|
|
53
|
-
dialogRef.current?.focus()
|
|
54
|
-
void loadProviders()
|
|
55
|
-
}, [])
|
|
56
|
-
|
|
57
|
-
const loadProviders = async () => {
|
|
58
|
-
const p = await fetchProviders(aiConfig)
|
|
59
|
-
setProviders(p)
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Loads the stored key for the selected provider, then refreshes its
|
|
63
|
-
// model list from the provider's live /models endpoint (falls back to the
|
|
64
|
-
// static list on any error — see listModels). Depends on `providers` too
|
|
65
|
-
// so it re-runs once fetchProviders() resolves on mount, instead of
|
|
66
|
-
// racing it.
|
|
67
|
-
useEffect(() => {
|
|
68
|
-
let cancelled = false
|
|
69
|
-
const run = async () => {
|
|
70
|
-
setTestStatus('idle')
|
|
71
|
-
setTestError('')
|
|
72
|
-
const key = await getApiKey(aiConfig.preferredProvider)
|
|
73
|
-
if (cancelled) return
|
|
74
|
-
setActiveKey(key || '')
|
|
75
|
-
setDynamicModels(null)
|
|
76
|
-
|
|
77
|
-
const provider = providers.find(p => p.id === aiConfig.preferredProvider)
|
|
78
|
-
if (key && provider) {
|
|
79
|
-
setModelsLoading(true)
|
|
80
|
-
const models = await listModels(provider, key)
|
|
81
|
-
if (!cancelled) {
|
|
82
|
-
setDynamicModels(models)
|
|
83
|
-
setModelsLoading(false)
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
void run()
|
|
88
|
-
return () => { cancelled = true }
|
|
89
|
-
}, [aiConfig.preferredProvider, providers])
|
|
90
|
-
|
|
91
|
-
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
92
|
-
if (e.key === 'Escape') onClose()
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const handleProviderChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
96
|
-
const nextProvider = e.target.value
|
|
97
|
-
const provider = providers.find(p => p.id === nextProvider)
|
|
98
|
-
const nextModel = provider?.models[0]?.id || ''
|
|
99
|
-
|
|
100
|
-
const updated = {
|
|
101
|
-
...aiConfig,
|
|
102
|
-
preferredProvider: nextProvider,
|
|
103
|
-
preferredModel: nextModel,
|
|
104
|
-
}
|
|
105
|
-
setAiConfig(updated)
|
|
106
|
-
saveAIConfig(updated)
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const handleModelChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
110
|
-
const updated = {
|
|
111
|
-
...aiConfig,
|
|
112
|
-
preferredModel: e.target.value,
|
|
113
|
-
}
|
|
114
|
-
setAiConfig(updated)
|
|
115
|
-
saveAIConfig(updated)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const handleKeyChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
119
|
-
const val = e.target.value
|
|
120
|
-
setActiveKey(val)
|
|
121
|
-
if (val.trim()) {
|
|
122
|
-
await setApiKey(aiConfig.preferredProvider, val.trim())
|
|
123
|
-
} else {
|
|
124
|
-
await deleteApiKey(aiConfig.preferredProvider)
|
|
125
|
-
}
|
|
126
|
-
setTestStatus('idle')
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const handleTestKey = async () => {
|
|
130
|
-
if (!activeKey.trim()) {
|
|
131
|
-
setTestStatus('error')
|
|
132
|
-
setTestError('Please enter an API key to test.')
|
|
133
|
-
return
|
|
134
|
-
}
|
|
135
|
-
setTestStatus('testing')
|
|
136
|
-
setTestError('')
|
|
137
|
-
try {
|
|
138
|
-
const ok = await testConnection(aiConfig.preferredProvider, activeKey.trim(), aiConfig)
|
|
139
|
-
if (ok) {
|
|
140
|
-
setTestStatus('success')
|
|
141
|
-
const provider = providers.find(p => p.id === aiConfig.preferredProvider)
|
|
142
|
-
if (provider) {
|
|
143
|
-
setModelsLoading(true)
|
|
144
|
-
setDynamicModels(await listModels(provider, activeKey.trim()))
|
|
145
|
-
setModelsLoading(false)
|
|
146
|
-
}
|
|
147
|
-
} else {
|
|
148
|
-
setTestStatus('error')
|
|
149
|
-
setTestError('Connection failed: empty or invalid response.')
|
|
150
|
-
}
|
|
151
|
-
} catch (err: any) {
|
|
152
|
-
setTestStatus('error')
|
|
153
|
-
setTestError(err.message || String(err))
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const currentProvider = providers.find(p => p.id === aiConfig.preferredProvider)
|
|
158
|
-
const models = dynamicModels ?? currentProvider?.models ?? []
|
|
159
|
-
|
|
160
|
-
return (
|
|
161
|
-
<div className="cs-dialog-overlay" onClick={onClose}>
|
|
162
|
-
<div
|
|
163
|
-
ref={dialogRef}
|
|
164
|
-
className="cs-dialog cs-settings-dialog"
|
|
165
|
-
role="dialog"
|
|
166
|
-
aria-modal="true"
|
|
167
|
-
aria-label={t('settings.title')}
|
|
168
|
-
tabIndex={-1}
|
|
169
|
-
onClick={e => e.stopPropagation()}
|
|
170
|
-
onKeyDown={handleKeyDown}
|
|
171
|
-
style={{ maxWidth: '480px' }}
|
|
172
|
-
>
|
|
173
|
-
<div className="cs-dialog-header">
|
|
174
|
-
<span className="cs-dialog-title">{t('settings.title')}</span>
|
|
175
|
-
<button className="cs-fr-close" aria-label={t('settings.close')} onClick={onClose}>×</button>
|
|
176
|
-
</div>
|
|
177
|
-
|
|
178
|
-
<div className="cs-settings-body" style={{ maxHeight: '80vh', overflowY: 'auto' }}>
|
|
179
|
-
<div className="cs-settings-section-label">{t('settings.general')}</div>
|
|
180
|
-
|
|
181
|
-
<div className="cs-settings-row">
|
|
182
|
-
<label className="cs-settings-label" htmlFor="cs-ui-lang-select">
|
|
183
|
-
{t('settings.uiLanguage')}
|
|
184
|
-
</label>
|
|
185
|
-
<select
|
|
186
|
-
id="cs-ui-lang-select"
|
|
187
|
-
className="cs-settings-select"
|
|
188
|
-
value={locale}
|
|
189
|
-
onChange={e => setLocale(e.target.value)}
|
|
190
|
-
>
|
|
191
|
-
{UI_LANGUAGES.map(lang => (
|
|
192
|
-
<option key={lang.code} value={lang.code}>{lang.label}</option>
|
|
193
|
-
))}
|
|
194
|
-
</select>
|
|
195
|
-
</div>
|
|
196
|
-
|
|
197
|
-
<div style={{ margin: '14px 0 6px 0', borderTop: '1px solid var(--cs-ui-border-light, #e8e8e4)', paddingTop: '14px' }} className="cs-settings-section-label">
|
|
198
|
-
Intelligence & Voice Settings
|
|
199
|
-
</div>
|
|
200
|
-
|
|
201
|
-
<div className="cs-settings-row">
|
|
202
|
-
<label className="cs-settings-label" htmlFor="cs-ai-provider-select">
|
|
203
|
-
Preferred Provider
|
|
204
|
-
</label>
|
|
205
|
-
<select
|
|
206
|
-
id="cs-ai-provider-select"
|
|
207
|
-
className="cs-settings-select"
|
|
208
|
-
value={aiConfig.preferredProvider}
|
|
209
|
-
onChange={handleProviderChange}
|
|
210
|
-
>
|
|
211
|
-
{providers.map(p => (
|
|
212
|
-
<option key={p.id} value={p.id}>{p.name}</option>
|
|
213
|
-
))}
|
|
214
|
-
</select>
|
|
215
|
-
</div>
|
|
216
|
-
|
|
217
|
-
<div className="cs-settings-row">
|
|
218
|
-
<label className="cs-settings-label" htmlFor="cs-ai-model-select">
|
|
219
|
-
Preferred Model{modelsLoading ? ' (refreshing...)' : ''}
|
|
220
|
-
</label>
|
|
221
|
-
<select
|
|
222
|
-
id="cs-ai-model-select"
|
|
223
|
-
className="cs-settings-select"
|
|
224
|
-
value={aiConfig.preferredModel}
|
|
225
|
-
onChange={handleModelChange}
|
|
226
|
-
disabled={models.length === 0}
|
|
227
|
-
>
|
|
228
|
-
{models.map(m => (
|
|
229
|
-
<option key={m.id} value={m.id}>{m.name}</option>
|
|
230
|
-
))}
|
|
231
|
-
</select>
|
|
232
|
-
</div>
|
|
233
|
-
|
|
234
|
-
<div className="cs-settings-row" style={{ flexDirection: 'column', alignItems: 'stretch', gap: '6px' }}>
|
|
235
|
-
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
|
|
236
|
-
<label className="cs-settings-label" htmlFor="cs-ai-key-input">
|
|
237
|
-
API Key for {currentProvider?.name || aiConfig.preferredProvider}
|
|
238
|
-
</label>
|
|
239
|
-
<button
|
|
240
|
-
type="button"
|
|
241
|
-
style={{ background: 'none', border: 'none', color: 'var(--cs-ui-accent, #c4760a)', cursor: 'pointer', fontSize: '11px', padding: 0 }}
|
|
242
|
-
onClick={() => setShowKey(!showKey)}
|
|
243
|
-
>
|
|
244
|
-
{showKey ? 'Hide' : 'Show'}
|
|
245
|
-
</button>
|
|
246
|
-
</div>
|
|
247
|
-
<input
|
|
248
|
-
id="cs-ai-key-input"
|
|
249
|
-
type={showKey ? 'text' : 'password'}
|
|
250
|
-
style={{
|
|
251
|
-
width: '100%',
|
|
252
|
-
padding: '7px 10px',
|
|
253
|
-
border: '1px solid var(--cs-ui-border-light, #ccc)',
|
|
254
|
-
borderRadius: '4px',
|
|
255
|
-
boxSizing: 'border-box',
|
|
256
|
-
fontFamily: 'monospace',
|
|
257
|
-
fontSize: '12px',
|
|
258
|
-
}}
|
|
259
|
-
value={activeKey}
|
|
260
|
-
onChange={handleKeyChange}
|
|
261
|
-
placeholder={`Enter API Key for ${aiConfig.preferredProvider}`}
|
|
262
|
-
/>
|
|
263
|
-
<div style={{ display: 'flex', gap: '8px', marginTop: '4px' }}>
|
|
264
|
-
<button
|
|
265
|
-
type="button"
|
|
266
|
-
className="cs-voice-btn-secondary"
|
|
267
|
-
style={{ padding: '4px 10px', fontSize: '12px' }}
|
|
268
|
-
onClick={handleTestKey}
|
|
269
|
-
disabled={testStatus === 'testing'}
|
|
270
|
-
>
|
|
271
|
-
{testStatus === 'testing' ? 'Testing...' : 'Test Connection'}
|
|
272
|
-
</button>
|
|
273
|
-
</div>
|
|
274
|
-
{testStatus === 'success' && (
|
|
275
|
-
<span style={{ color: '#10b981', fontSize: '12px', marginTop: '2px' }}>
|
|
276
|
-
✓ Key verified! Connection succeeded.
|
|
277
|
-
</span>
|
|
278
|
-
)}
|
|
279
|
-
{testStatus === 'error' && (
|
|
280
|
-
<span style={{ color: '#ef4444', fontSize: '12px', marginTop: '2px', whiteSpace: 'pre-wrap' }}>
|
|
281
|
-
✗ Error: {testError}
|
|
282
|
-
</span>
|
|
283
|
-
)}
|
|
284
|
-
</div>
|
|
285
|
-
|
|
286
|
-
<div style={{ marginTop: '16px', display: 'flex', justifyContent: 'flex-start' }}>
|
|
287
|
-
<button
|
|
288
|
-
type="button"
|
|
289
|
-
className="cs-voice-btn-primary"
|
|
290
|
-
style={{ padding: '6px 12px', fontSize: '12px' }}
|
|
291
|
-
onClick={() => {
|
|
292
|
-
onClose()
|
|
293
|
-
setAIOnboardingVisible(true)
|
|
294
|
-
}}
|
|
295
|
-
>
|
|
296
|
-
Open Setup Wizard
|
|
297
|
-
</button>
|
|
298
|
-
</div>
|
|
299
|
-
</div>
|
|
300
|
-
</div>
|
|
301
|
-
</div>
|
|
302
|
-
)
|
|
303
|
-
}
|