eprec 0.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +122 -29
- package/app/assets/styles.css +129 -0
- package/app/client/app.tsx +37 -0
- package/app/client/counter.tsx +22 -0
- package/app/client/entry.tsx +8 -0
- package/app/components/layout.tsx +37 -0
- package/app/config/env.ts +31 -0
- package/app/config/import-map.ts +9 -0
- package/app/config/init-env.ts +3 -0
- package/app/config/routes.ts +5 -0
- package/app/helpers/render.ts +6 -0
- package/app/router.tsx +102 -0
- package/app/routes/index.tsx +50 -0
- package/app-server.ts +60 -0
- package/cli.ts +173 -0
- package/package.json +46 -7
- package/process-course/chapter-processor.ts +1037 -0
- package/process-course/cli.ts +236 -0
- package/process-course/config.ts +50 -0
- package/process-course/edits/cli.ts +167 -0
- package/process-course/edits/combined-video-editor.ts +316 -0
- package/process-course/edits/edit-workspace.ts +90 -0
- package/process-course/edits/index.ts +20 -0
- package/process-course/edits/regenerate-transcript.ts +84 -0
- package/process-course/edits/remove-ranges.test.ts +36 -0
- package/process-course/edits/remove-ranges.ts +287 -0
- package/process-course/edits/timestamp-refinement.test.ts +25 -0
- package/process-course/edits/timestamp-refinement.ts +172 -0
- package/process-course/edits/transcript-diff.test.ts +105 -0
- package/process-course/edits/transcript-diff.ts +214 -0
- package/process-course/edits/transcript-output.test.ts +50 -0
- package/process-course/edits/transcript-output.ts +36 -0
- package/process-course/edits/types.ts +26 -0
- package/process-course/edits/video-editor.ts +246 -0
- package/process-course/errors.test.ts +63 -0
- package/process-course/errors.ts +82 -0
- package/process-course/ffmpeg.ts +449 -0
- package/process-course/jarvis-commands/handlers.ts +71 -0
- package/process-course/jarvis-commands/index.ts +14 -0
- package/process-course/jarvis-commands/parser.test.ts +348 -0
- package/process-course/jarvis-commands/parser.ts +257 -0
- package/process-course/jarvis-commands/types.ts +46 -0
- package/process-course/jarvis-commands/windows.ts +254 -0
- package/process-course/logging.ts +24 -0
- package/process-course/paths.test.ts +59 -0
- package/process-course/paths.ts +53 -0
- package/process-course/summary.test.ts +209 -0
- package/process-course/summary.ts +210 -0
- package/process-course/types.ts +85 -0
- package/process-course/utils/audio-analysis.test.ts +348 -0
- package/process-course/utils/audio-analysis.ts +463 -0
- package/process-course/utils/chapter-selection.test.ts +307 -0
- package/process-course/utils/chapter-selection.ts +136 -0
- package/process-course/utils/file-utils.test.ts +83 -0
- package/process-course/utils/file-utils.ts +57 -0
- package/process-course/utils/filename.test.ts +27 -0
- package/process-course/utils/filename.ts +12 -0
- package/process-course/utils/time-ranges.test.ts +221 -0
- package/process-course/utils/time-ranges.ts +86 -0
- package/process-course/utils/transcript.test.ts +257 -0
- package/process-course/utils/transcript.ts +86 -0
- package/process-course/utils/video-editing.ts +44 -0
- package/process-course-video.ts +389 -0
- package/public/robots.txt +2 -0
- package/server/bundling.ts +210 -0
- package/speech-detection.ts +355 -0
- package/utils.ts +138 -0
- package/whispercpp-transcribe.ts +343 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { test, expect } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
parseChapterSelection,
|
|
4
|
+
resolveChapterSelection,
|
|
5
|
+
} from './chapter-selection'
|
|
6
|
+
import type { ChapterSelection } from '../types'
|
|
7
|
+
|
|
8
|
+
// Factory for creating expected selection results
|
|
9
|
+
function createSelection(
|
|
10
|
+
base: 0 | 1,
|
|
11
|
+
...ranges: Array<[number, number | null]>
|
|
12
|
+
): ChapterSelection {
|
|
13
|
+
return {
|
|
14
|
+
base,
|
|
15
|
+
ranges: ranges.map(([start, end]) => ({ start, end })),
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// parseChapterSelection - single values
|
|
20
|
+
test('parseChapterSelection parses single string number', () => {
|
|
21
|
+
expect(parseChapterSelection('4')).toEqual(createSelection(1, [4, 4]))
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('parseChapterSelection parses numeric input', () => {
|
|
25
|
+
expect(parseChapterSelection(4)).toEqual(createSelection(1, [4, 4]))
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('parseChapterSelection parses zero as base-0', () => {
|
|
29
|
+
expect(parseChapterSelection('0')).toEqual(createSelection(0, [0, 0]))
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('parseChapterSelection parses zero numeric as base-0', () => {
|
|
33
|
+
expect(parseChapterSelection(0)).toEqual(createSelection(0, [0, 0]))
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// parseChapterSelection - ranges
|
|
37
|
+
test('parseChapterSelection parses simple range', () => {
|
|
38
|
+
expect(parseChapterSelection('4-6')).toEqual(createSelection(1, [4, 6]))
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('parseChapterSelection parses range with spaces', () => {
|
|
42
|
+
expect(parseChapterSelection('4 - 6')).toEqual(createSelection(1, [4, 6]))
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('parseChapterSelection parses open-ended wildcard range', () => {
|
|
46
|
+
expect(parseChapterSelection('4-*')).toEqual(createSelection(1, [4, null]))
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('parseChapterSelection parses range starting at zero as base-0', () => {
|
|
50
|
+
expect(parseChapterSelection('0-5')).toEqual(createSelection(0, [0, 5]))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('parseChapterSelection parses zero-to-zero range', () => {
|
|
54
|
+
expect(parseChapterSelection('0-0')).toEqual(createSelection(0, [0, 0]))
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// parseChapterSelection - comma-separated
|
|
58
|
+
test('parseChapterSelection parses comma-separated values', () => {
|
|
59
|
+
expect(parseChapterSelection('4,6,9')).toEqual(
|
|
60
|
+
createSelection(1, [4, 4], [6, 6], [9, 9]),
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('parseChapterSelection parses comma-separated with spaces', () => {
|
|
65
|
+
expect(parseChapterSelection('4, 6, 9')).toEqual(
|
|
66
|
+
createSelection(1, [4, 4], [6, 6], [9, 9]),
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('parseChapterSelection parses mixed values and ranges', () => {
|
|
71
|
+
expect(parseChapterSelection('4,6,9-12')).toEqual(
|
|
72
|
+
createSelection(1, [4, 4], [6, 6], [9, 12]),
|
|
73
|
+
)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('parseChapterSelection parses complex selection with wildcard', () => {
|
|
77
|
+
expect(parseChapterSelection('1,3-5,8-*')).toEqual(
|
|
78
|
+
createSelection(1, [1, 1], [3, 5], [8, null]),
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// parseChapterSelection - array input
|
|
83
|
+
test('parseChapterSelection parses array of numbers', () => {
|
|
84
|
+
expect(parseChapterSelection([4, 6, 9])).toEqual(
|
|
85
|
+
createSelection(1, [4, 4], [6, 6], [9, 9]),
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('parseChapterSelection parses array of strings', () => {
|
|
90
|
+
expect(parseChapterSelection(['4', '6-8'])).toEqual(
|
|
91
|
+
createSelection(1, [4, 4], [6, 8]),
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('parseChapterSelection parses mixed array', () => {
|
|
96
|
+
expect(parseChapterSelection([4, '6-8', 10])).toEqual(
|
|
97
|
+
createSelection(1, [4, 4], [6, 8], [10, 10]),
|
|
98
|
+
)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('parseChapterSelection skips null and undefined in array', () => {
|
|
102
|
+
expect(parseChapterSelection([4, null, 6, undefined, 8])).toEqual(
|
|
103
|
+
createSelection(1, [4, 4], [6, 6], [8, 8]),
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('parseChapterSelection skips empty strings in array', () => {
|
|
108
|
+
expect(parseChapterSelection(['4', '', '6'])).toEqual(
|
|
109
|
+
createSelection(1, [4, 4], [6, 6]),
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// parseChapterSelection - edge cases
|
|
114
|
+
test('parseChapterSelection trims whitespace', () => {
|
|
115
|
+
expect(parseChapterSelection(' 4 ')).toEqual(createSelection(1, [4, 4]))
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('parseChapterSelection handles mixed zero and non-zero as base-0', () => {
|
|
119
|
+
expect(parseChapterSelection('0,5')).toEqual(
|
|
120
|
+
createSelection(0, [0, 0], [5, 5]),
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// parseChapterSelection - error cases
|
|
125
|
+
test('parseChapterSelection throws for empty string', () => {
|
|
126
|
+
expect(() => parseChapterSelection('')).toThrow(
|
|
127
|
+
'chapter must include at least one value',
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('parseChapterSelection throws for whitespace-only', () => {
|
|
132
|
+
expect(() => parseChapterSelection(' ')).toThrow(
|
|
133
|
+
'chapter must include at least one value',
|
|
134
|
+
)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('parseChapterSelection throws for empty array', () => {
|
|
138
|
+
expect(() => parseChapterSelection([])).toThrow(
|
|
139
|
+
'chapter must include at least one value',
|
|
140
|
+
)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('parseChapterSelection throws for array of nulls', () => {
|
|
144
|
+
expect(() => parseChapterSelection([null, null])).toThrow(
|
|
145
|
+
'chapter must include at least one value',
|
|
146
|
+
)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('parseChapterSelection throws for invalid string', () => {
|
|
150
|
+
expect(() => parseChapterSelection('abc')).toThrow(
|
|
151
|
+
'Invalid chapter value: "abc"',
|
|
152
|
+
)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('parseChapterSelection throws for negative value', () => {
|
|
156
|
+
expect(() => parseChapterSelection('-5')).toThrow(
|
|
157
|
+
'Invalid chapter value: "-5"',
|
|
158
|
+
)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('parseChapterSelection throws for reversed range', () => {
|
|
162
|
+
expect(() => parseChapterSelection('10-5')).toThrow(
|
|
163
|
+
'chapter ranges must be low-to-high: "10-5"',
|
|
164
|
+
)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('parseChapterSelection throws for object input', () => {
|
|
168
|
+
expect(() => parseChapterSelection({ value: 5 })).toThrow(
|
|
169
|
+
'chapter must be a number or range',
|
|
170
|
+
)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('parseChapterSelection throws for boolean input', () => {
|
|
174
|
+
expect(() => parseChapterSelection(true)).toThrow(
|
|
175
|
+
'chapter must be a number or range',
|
|
176
|
+
)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// resolveChapterSelection - 1-based
|
|
180
|
+
test('resolveChapterSelection resolves single 1-based chapter to index', () => {
|
|
181
|
+
const selection = parseChapterSelection('4')
|
|
182
|
+
expect(resolveChapterSelection(selection, 10)).toEqual([3])
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('resolveChapterSelection resolves 1-based range to indexes', () => {
|
|
186
|
+
const selection = parseChapterSelection('4-6')
|
|
187
|
+
expect(resolveChapterSelection(selection, 10)).toEqual([3, 4, 5])
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('resolveChapterSelection resolves open-ended range to end', () => {
|
|
191
|
+
const selection = parseChapterSelection('8-*')
|
|
192
|
+
expect(resolveChapterSelection(selection, 10)).toEqual([7, 8, 9])
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('resolveChapterSelection resolves comma-separated to indexes', () => {
|
|
196
|
+
const selection = parseChapterSelection('2,5,8')
|
|
197
|
+
expect(resolveChapterSelection(selection, 10)).toEqual([1, 4, 7])
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test('resolveChapterSelection resolves complex selection', () => {
|
|
201
|
+
const selection = parseChapterSelection('1,3-5,9')
|
|
202
|
+
expect(resolveChapterSelection(selection, 10)).toEqual([0, 2, 3, 4, 8])
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('resolveChapterSelection deduplicates overlapping ranges', () => {
|
|
206
|
+
const selection = parseChapterSelection('3-5,4-6')
|
|
207
|
+
expect(resolveChapterSelection(selection, 10)).toEqual([2, 3, 4, 5])
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('resolveChapterSelection sorts results', () => {
|
|
211
|
+
const selection = parseChapterSelection('8,2,5')
|
|
212
|
+
expect(resolveChapterSelection(selection, 10)).toEqual([1, 4, 7])
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
// resolveChapterSelection - 0-based
|
|
216
|
+
test('resolveChapterSelection resolves 0-based single chapter', () => {
|
|
217
|
+
const selection = parseChapterSelection('0')
|
|
218
|
+
expect(resolveChapterSelection(selection, 10)).toEqual([0])
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('resolveChapterSelection resolves 0-based range', () => {
|
|
222
|
+
const selection = parseChapterSelection('0-3')
|
|
223
|
+
expect(resolveChapterSelection(selection, 10)).toEqual([0, 1, 2, 3])
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('resolveChapterSelection resolves 0-based open-ended range', () => {
|
|
227
|
+
const selection = parseChapterSelection('0-*')
|
|
228
|
+
expect(resolveChapterSelection(selection, 10)).toEqual([
|
|
229
|
+
0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
|
|
230
|
+
])
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test('resolveChapterSelection resolves 0-based mixed values', () => {
|
|
234
|
+
const selection = parseChapterSelection('0,5,9')
|
|
235
|
+
expect(resolveChapterSelection(selection, 10)).toEqual([0, 5, 9])
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
// resolveChapterSelection - boundary cases
|
|
239
|
+
test('resolveChapterSelection resolves first chapter (1-based)', () => {
|
|
240
|
+
const selection = parseChapterSelection('1')
|
|
241
|
+
expect(resolveChapterSelection(selection, 5)).toEqual([0])
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('resolveChapterSelection resolves last chapter (1-based)', () => {
|
|
245
|
+
const selection = parseChapterSelection('5')
|
|
246
|
+
expect(resolveChapterSelection(selection, 5)).toEqual([4])
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
test('resolveChapterSelection resolves all chapters with 1-*', () => {
|
|
250
|
+
const selection = parseChapterSelection('1-*')
|
|
251
|
+
expect(resolveChapterSelection(selection, 5)).toEqual([0, 1, 2, 3, 4])
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('resolveChapterSelection handles single chapter count', () => {
|
|
255
|
+
const selection = parseChapterSelection('1')
|
|
256
|
+
expect(resolveChapterSelection(selection, 1)).toEqual([0])
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
// resolveChapterSelection - error cases
|
|
260
|
+
test('resolveChapterSelection throws for zero chapter count', () => {
|
|
261
|
+
const selection = parseChapterSelection('1')
|
|
262
|
+
expect(() => resolveChapterSelection(selection, 0)).toThrow(
|
|
263
|
+
'Chapter count must be a positive number',
|
|
264
|
+
)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
test('resolveChapterSelection throws for negative chapter count', () => {
|
|
268
|
+
const selection = parseChapterSelection('1')
|
|
269
|
+
expect(() => resolveChapterSelection(selection, -5)).toThrow(
|
|
270
|
+
'Chapter count must be a positive number',
|
|
271
|
+
)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('resolveChapterSelection throws for NaN chapter count', () => {
|
|
275
|
+
const selection = parseChapterSelection('1')
|
|
276
|
+
expect(() => resolveChapterSelection(selection, NaN)).toThrow(
|
|
277
|
+
'Chapter count must be a positive number',
|
|
278
|
+
)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
test('resolveChapterSelection throws for chapter beyond count', () => {
|
|
282
|
+
const selection = parseChapterSelection('15')
|
|
283
|
+
expect(() => resolveChapterSelection(selection, 10)).toThrow(
|
|
284
|
+
'chapter range starts at 15, but only 10 chapters exist',
|
|
285
|
+
)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test('resolveChapterSelection throws for range starting beyond count', () => {
|
|
289
|
+
const selection = parseChapterSelection('12-15')
|
|
290
|
+
expect(() => resolveChapterSelection(selection, 10)).toThrow(
|
|
291
|
+
'chapter range starts at 12, but only 10 chapters exist',
|
|
292
|
+
)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
test('resolveChapterSelection throws for range ending beyond count', () => {
|
|
296
|
+
const selection = parseChapterSelection('8-15')
|
|
297
|
+
expect(() => resolveChapterSelection(selection, 10)).toThrow(
|
|
298
|
+
'chapter range ends at 15, but only 10 chapters exist',
|
|
299
|
+
)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
test('resolveChapterSelection throws for 0-based chapter beyond count', () => {
|
|
303
|
+
const selection = parseChapterSelection('0,10')
|
|
304
|
+
expect(() => resolveChapterSelection(selection, 10)).toThrow(
|
|
305
|
+
'chapter range starts at 10, but only 10 chapters exist',
|
|
306
|
+
)
|
|
307
|
+
})
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { ChapterRange, ChapterSelection } from '../types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse a chapter selection string (e.g., "4", "4-6", "4,6,9-12", "4-*").
|
|
5
|
+
*/
|
|
6
|
+
export function parseChapterSelection(rawSelection: unknown): ChapterSelection {
|
|
7
|
+
const rawList = Array.isArray(rawSelection) ? rawSelection : [rawSelection]
|
|
8
|
+
const parts: string[] = []
|
|
9
|
+
|
|
10
|
+
for (const value of rawList) {
|
|
11
|
+
if (value === undefined || value === null) {
|
|
12
|
+
continue
|
|
13
|
+
}
|
|
14
|
+
if (typeof value === 'number') {
|
|
15
|
+
parts.push(String(value))
|
|
16
|
+
continue
|
|
17
|
+
}
|
|
18
|
+
if (typeof value === 'string') {
|
|
19
|
+
const chunk = value.trim()
|
|
20
|
+
if (chunk.length === 0) {
|
|
21
|
+
continue
|
|
22
|
+
}
|
|
23
|
+
parts.push(
|
|
24
|
+
...chunk
|
|
25
|
+
.split(',')
|
|
26
|
+
.map((item) => item.trim())
|
|
27
|
+
.filter(Boolean),
|
|
28
|
+
)
|
|
29
|
+
continue
|
|
30
|
+
}
|
|
31
|
+
throw new Error('chapter must be a number or range (e.g. 4, 4-6, 4-*)')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (parts.length === 0) {
|
|
35
|
+
throw new Error('chapter must include at least one value.')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const ranges: ChapterRange[] = []
|
|
39
|
+
let hasZero = false
|
|
40
|
+
|
|
41
|
+
for (const part of parts) {
|
|
42
|
+
const rangeMatch = part.match(/^(\d+)\s*-\s*(\*|\d+)$/)
|
|
43
|
+
if (rangeMatch) {
|
|
44
|
+
const startToken = rangeMatch[1]
|
|
45
|
+
const endToken = rangeMatch[2]
|
|
46
|
+
if (!startToken || !endToken) {
|
|
47
|
+
throw new Error(`Invalid chapter range: "${part}".`)
|
|
48
|
+
}
|
|
49
|
+
const start = Number.parseInt(startToken, 10)
|
|
50
|
+
const end = endToken === '*' ? null : Number.parseInt(endToken, 10)
|
|
51
|
+
|
|
52
|
+
if (!Number.isFinite(start)) {
|
|
53
|
+
throw new Error(`Invalid chapter range start: "${part}".`)
|
|
54
|
+
}
|
|
55
|
+
if (end !== null && !Number.isFinite(end)) {
|
|
56
|
+
throw new Error(`Invalid chapter range end: "${part}".`)
|
|
57
|
+
}
|
|
58
|
+
if (start < 0 || (end !== null && end < 0)) {
|
|
59
|
+
throw new Error(`chapter values must be >= 0: "${part}".`)
|
|
60
|
+
}
|
|
61
|
+
if (end !== null && end < start) {
|
|
62
|
+
throw new Error(`chapter ranges must be low-to-high: "${part}".`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (start === 0 || end === 0) {
|
|
66
|
+
hasZero = true
|
|
67
|
+
}
|
|
68
|
+
ranges.push({ start, end })
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const singleMatch = part.match(/^\d+$/)
|
|
73
|
+
if (singleMatch) {
|
|
74
|
+
const value = Number.parseInt(part, 10)
|
|
75
|
+
if (!Number.isFinite(value)) {
|
|
76
|
+
throw new Error(`Invalid chapter value: "${part}".`)
|
|
77
|
+
}
|
|
78
|
+
if (value < 0) {
|
|
79
|
+
throw new Error(`chapter values must be >= 0: "${part}".`)
|
|
80
|
+
}
|
|
81
|
+
if (value === 0) {
|
|
82
|
+
hasZero = true
|
|
83
|
+
}
|
|
84
|
+
ranges.push({ start: value, end: value })
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
throw new Error(`Invalid chapter value: "${part}".`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { base: hasZero ? 0 : 1, ranges }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resolve a chapter selection to an array of zero-based chapter indexes.
|
|
96
|
+
*/
|
|
97
|
+
export function resolveChapterSelection(
|
|
98
|
+
selection: ChapterSelection,
|
|
99
|
+
chapterCount: number,
|
|
100
|
+
): number[] {
|
|
101
|
+
if (!Number.isFinite(chapterCount) || chapterCount <= 0) {
|
|
102
|
+
throw new Error('Chapter count must be a positive number.')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const maxIndex = chapterCount - 1
|
|
106
|
+
const maxValue = selection.base === 0 ? maxIndex : chapterCount
|
|
107
|
+
const indexes: number[] = []
|
|
108
|
+
|
|
109
|
+
for (const range of selection.ranges) {
|
|
110
|
+
const startValue = range.start
|
|
111
|
+
const endValue = range.end === null ? maxValue : range.end
|
|
112
|
+
|
|
113
|
+
if (startValue > maxValue) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`chapter range starts at ${startValue}, but only ${chapterCount} chapters exist.`,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
if (endValue > maxValue) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`chapter range ends at ${endValue}, but only ${chapterCount} chapters exist.`,
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (let value = startValue; value <= endValue; value += 1) {
|
|
125
|
+
const index = selection.base === 0 ? value : value - 1
|
|
126
|
+
if (index < 0 || index > maxIndex) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`chapter selection ${value} is out of range for ${chapterCount} chapters.`,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
indexes.push(index)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return Array.from(new Set(indexes)).sort((a, b) => a - b)
|
|
136
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { test, expect } from 'bun:test'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import { mkdtemp, rm, mkdir, stat } from 'node:fs/promises'
|
|
5
|
+
import { removeDirIfEmpty, safeUnlink } from './file-utils'
|
|
6
|
+
|
|
7
|
+
async function createTempDir(): Promise<string> {
|
|
8
|
+
return mkdtemp(path.join(os.tmpdir(), 'file-utils-'))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
test('safeUnlink removes existing file', async () => {
|
|
12
|
+
const tmpDir = await createTempDir()
|
|
13
|
+
const filePath = path.join(tmpDir, 'sample.txt')
|
|
14
|
+
try {
|
|
15
|
+
await Bun.write(filePath, 'hello')
|
|
16
|
+
expect(await Bun.file(filePath).exists()).toBe(true)
|
|
17
|
+
await safeUnlink(filePath)
|
|
18
|
+
expect(await Bun.file(filePath).exists()).toBe(false)
|
|
19
|
+
} finally {
|
|
20
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('safeUnlink ignores missing files', async () => {
|
|
25
|
+
const tmpDir = await createTempDir()
|
|
26
|
+
const filePath = path.join(tmpDir, 'missing.txt')
|
|
27
|
+
try {
|
|
28
|
+
await expect(safeUnlink(filePath)).resolves.toBeUndefined()
|
|
29
|
+
} finally {
|
|
30
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('safeUnlink does not remove directories', async () => {
|
|
35
|
+
const tmpDir = await createTempDir()
|
|
36
|
+
const nestedDir = path.join(tmpDir, 'nested')
|
|
37
|
+
try {
|
|
38
|
+
await mkdir(nestedDir)
|
|
39
|
+
await expect(safeUnlink(nestedDir)).resolves.toBeUndefined()
|
|
40
|
+
const stats = await stat(nestedDir)
|
|
41
|
+
expect(stats.isDirectory()).toBe(true)
|
|
42
|
+
} finally {
|
|
43
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('removeDirIfEmpty removes empty directories', async () => {
|
|
48
|
+
const tmpDir = await createTempDir()
|
|
49
|
+
const emptyDir = path.join(tmpDir, 'empty')
|
|
50
|
+
try {
|
|
51
|
+
await mkdir(emptyDir)
|
|
52
|
+
const removed = await removeDirIfEmpty(emptyDir)
|
|
53
|
+
expect(removed).toBe(true)
|
|
54
|
+
await expect(stat(emptyDir)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
55
|
+
} finally {
|
|
56
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('removeDirIfEmpty keeps directories with files', async () => {
|
|
61
|
+
const tmpDir = await createTempDir()
|
|
62
|
+
const filledDir = path.join(tmpDir, 'filled')
|
|
63
|
+
try {
|
|
64
|
+
await mkdir(filledDir)
|
|
65
|
+
await Bun.write(path.join(filledDir, 'sample.txt'), 'data')
|
|
66
|
+
const removed = await removeDirIfEmpty(filledDir)
|
|
67
|
+
expect(removed).toBe(false)
|
|
68
|
+
const stats = await stat(filledDir)
|
|
69
|
+
expect(stats.isDirectory()).toBe(true)
|
|
70
|
+
} finally {
|
|
71
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('removeDirIfEmpty ignores missing directories', async () => {
|
|
76
|
+
const tmpDir = await createTempDir()
|
|
77
|
+
const missingDir = path.join(tmpDir, 'missing')
|
|
78
|
+
try {
|
|
79
|
+
await expect(removeDirIfEmpty(missingDir)).resolves.toBe(false)
|
|
80
|
+
} finally {
|
|
81
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
82
|
+
}
|
|
83
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readdir, rmdir, unlink } from 'node:fs/promises'
|
|
2
|
+
import { logInfo } from '../logging'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Safely delete a file, ignoring ENOENT errors.
|
|
6
|
+
*/
|
|
7
|
+
export async function safeUnlink(filePath: string): Promise<void> {
|
|
8
|
+
try {
|
|
9
|
+
await unlink(filePath)
|
|
10
|
+
} catch (error) {
|
|
11
|
+
if (error && typeof error === 'object' && 'code' in error) {
|
|
12
|
+
if (error.code === 'ENOENT') {
|
|
13
|
+
return
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
logInfo(
|
|
17
|
+
`Failed to delete ${filePath}: ${error instanceof Error ? error.message : error}`,
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Remove a directory if it exists and is empty.
|
|
24
|
+
*/
|
|
25
|
+
export async function removeDirIfEmpty(dirPath: string): Promise<boolean> {
|
|
26
|
+
try {
|
|
27
|
+
const entries = await readdir(dirPath)
|
|
28
|
+
if (entries.length > 0) {
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error && typeof error === 'object' && 'code' in error) {
|
|
33
|
+
if (error.code === 'ENOENT') {
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
logInfo(
|
|
38
|
+
`Failed to read directory ${dirPath}: ${error instanceof Error ? error.message : error}`,
|
|
39
|
+
)
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
await rmdir(dirPath)
|
|
45
|
+
return true
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error && typeof error === 'object' && 'code' in error) {
|
|
48
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTEMPTY') {
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
logInfo(
|
|
53
|
+
`Failed to remove directory ${dirPath}: ${error instanceof Error ? error.message : error}`,
|
|
54
|
+
)
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { test, expect } from 'bun:test'
|
|
2
|
+
import type { Chapter } from '../types'
|
|
3
|
+
import { formatChapterFilename } from './filename'
|
|
4
|
+
|
|
5
|
+
function createChapter(index: number, title?: string): Chapter {
|
|
6
|
+
return {
|
|
7
|
+
index,
|
|
8
|
+
start: 0,
|
|
9
|
+
end: 10,
|
|
10
|
+
title: title as string,
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test('formatChapterFilename uses padded index and kebab-case title', () => {
|
|
15
|
+
const chapter = createChapter(0, 'Intro to React')
|
|
16
|
+
expect(formatChapterFilename(chapter)).toBe('chapter-01-intro-to-react')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('formatChapterFilename falls back to chapter-N when title missing', () => {
|
|
20
|
+
const chapter = createChapter(2, undefined)
|
|
21
|
+
expect(formatChapterFilename(chapter)).toBe('chapter-03-chapter-3')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('formatChapterFilename normalizes number words and dots', () => {
|
|
25
|
+
const chapter = createChapter(0, 'Lesson One point Five')
|
|
26
|
+
expect(formatChapterFilename(chapter)).toBe('chapter-01-lesson-0105')
|
|
27
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Chapter } from '../types'
|
|
2
|
+
import { normalizeFilename, toKebabCase } from '../../utils'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Format a chapter into a filename-safe string.
|
|
6
|
+
*/
|
|
7
|
+
export function formatChapterFilename(chapter: Chapter): string {
|
|
8
|
+
const title = chapter.title ?? `chapter-${chapter.index + 1}`
|
|
9
|
+
const normalized = normalizeFilename(title)
|
|
10
|
+
const slug = toKebabCase(normalized)
|
|
11
|
+
return `chapter-${String(chapter.index + 1).padStart(2, '0')}-${slug}`
|
|
12
|
+
}
|