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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +122 -29
  3. package/app/assets/styles.css +129 -0
  4. package/app/client/app.tsx +37 -0
  5. package/app/client/counter.tsx +22 -0
  6. package/app/client/entry.tsx +8 -0
  7. package/app/components/layout.tsx +37 -0
  8. package/app/config/env.ts +31 -0
  9. package/app/config/import-map.ts +9 -0
  10. package/app/config/init-env.ts +3 -0
  11. package/app/config/routes.ts +5 -0
  12. package/app/helpers/render.ts +6 -0
  13. package/app/router.tsx +102 -0
  14. package/app/routes/index.tsx +50 -0
  15. package/app-server.ts +60 -0
  16. package/cli.ts +173 -0
  17. package/package.json +46 -7
  18. package/process-course/chapter-processor.ts +1037 -0
  19. package/process-course/cli.ts +236 -0
  20. package/process-course/config.ts +50 -0
  21. package/process-course/edits/cli.ts +167 -0
  22. package/process-course/edits/combined-video-editor.ts +316 -0
  23. package/process-course/edits/edit-workspace.ts +90 -0
  24. package/process-course/edits/index.ts +20 -0
  25. package/process-course/edits/regenerate-transcript.ts +84 -0
  26. package/process-course/edits/remove-ranges.test.ts +36 -0
  27. package/process-course/edits/remove-ranges.ts +287 -0
  28. package/process-course/edits/timestamp-refinement.test.ts +25 -0
  29. package/process-course/edits/timestamp-refinement.ts +172 -0
  30. package/process-course/edits/transcript-diff.test.ts +105 -0
  31. package/process-course/edits/transcript-diff.ts +214 -0
  32. package/process-course/edits/transcript-output.test.ts +50 -0
  33. package/process-course/edits/transcript-output.ts +36 -0
  34. package/process-course/edits/types.ts +26 -0
  35. package/process-course/edits/video-editor.ts +246 -0
  36. package/process-course/errors.test.ts +63 -0
  37. package/process-course/errors.ts +82 -0
  38. package/process-course/ffmpeg.ts +449 -0
  39. package/process-course/jarvis-commands/handlers.ts +71 -0
  40. package/process-course/jarvis-commands/index.ts +14 -0
  41. package/process-course/jarvis-commands/parser.test.ts +348 -0
  42. package/process-course/jarvis-commands/parser.ts +257 -0
  43. package/process-course/jarvis-commands/types.ts +46 -0
  44. package/process-course/jarvis-commands/windows.ts +254 -0
  45. package/process-course/logging.ts +24 -0
  46. package/process-course/paths.test.ts +59 -0
  47. package/process-course/paths.ts +53 -0
  48. package/process-course/summary.test.ts +209 -0
  49. package/process-course/summary.ts +210 -0
  50. package/process-course/types.ts +85 -0
  51. package/process-course/utils/audio-analysis.test.ts +348 -0
  52. package/process-course/utils/audio-analysis.ts +463 -0
  53. package/process-course/utils/chapter-selection.test.ts +307 -0
  54. package/process-course/utils/chapter-selection.ts +136 -0
  55. package/process-course/utils/file-utils.test.ts +83 -0
  56. package/process-course/utils/file-utils.ts +57 -0
  57. package/process-course/utils/filename.test.ts +27 -0
  58. package/process-course/utils/filename.ts +12 -0
  59. package/process-course/utils/time-ranges.test.ts +221 -0
  60. package/process-course/utils/time-ranges.ts +86 -0
  61. package/process-course/utils/transcript.test.ts +257 -0
  62. package/process-course/utils/transcript.ts +86 -0
  63. package/process-course/utils/video-editing.ts +44 -0
  64. package/process-course-video.ts +389 -0
  65. package/public/robots.txt +2 -0
  66. package/server/bundling.ts +210 -0
  67. package/speech-detection.ts +355 -0
  68. package/utils.ts +138 -0
  69. 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
+ }