offcourse 0.0.2 → 1.0.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 (284) hide show
  1. package/.github/workflows/ci.yml +50 -0
  2. package/.husky/commit-msg +2 -0
  3. package/.husky/pre-commit +1 -0
  4. package/.husky/pre-push +3 -0
  5. package/.prettierrc +8 -0
  6. package/.release-it.json +23 -0
  7. package/ARCHITECTURE.md +233 -0
  8. package/CHANGELOG.md +78 -0
  9. package/README.md +255 -20
  10. package/commitlint.config.js +4 -0
  11. package/dist/ai/openRouter.d.ts +47 -0
  12. package/dist/ai/openRouter.d.ts.map +1 -0
  13. package/dist/ai/openRouter.js +116 -0
  14. package/dist/ai/openRouter.js.map +1 -0
  15. package/dist/ai/transcriptPolisher.d.ts +24 -0
  16. package/dist/ai/transcriptPolisher.d.ts.map +1 -0
  17. package/dist/ai/transcriptPolisher.js +89 -0
  18. package/dist/ai/transcriptPolisher.js.map +1 -0
  19. package/dist/cli/commands/config.d.ts +13 -0
  20. package/dist/cli/commands/config.d.ts.map +1 -0
  21. package/dist/cli/commands/config.js +66 -0
  22. package/dist/cli/commands/config.js.map +1 -0
  23. package/dist/cli/commands/enrich.d.ts +14 -0
  24. package/dist/cli/commands/enrich.d.ts.map +1 -0
  25. package/dist/cli/commands/enrich.js +271 -0
  26. package/dist/cli/commands/enrich.js.map +1 -0
  27. package/dist/cli/commands/inspect.d.ts +11 -0
  28. package/dist/cli/commands/inspect.d.ts.map +1 -0
  29. package/dist/cli/commands/inspect.js +365 -0
  30. package/dist/cli/commands/inspect.js.map +1 -0
  31. package/dist/cli/commands/login.d.ts +12 -0
  32. package/dist/cli/commands/login.d.ts.map +1 -0
  33. package/dist/cli/commands/login.js +55 -0
  34. package/dist/cli/commands/login.js.map +1 -0
  35. package/dist/cli/commands/status.d.ts +15 -0
  36. package/dist/cli/commands/status.d.ts.map +1 -0
  37. package/dist/cli/commands/status.js +118 -0
  38. package/dist/cli/commands/status.js.map +1 -0
  39. package/dist/cli/commands/sync.d.ts +16 -0
  40. package/dist/cli/commands/sync.d.ts.map +1 -0
  41. package/dist/cli/commands/sync.js +922 -0
  42. package/dist/cli/commands/sync.js.map +1 -0
  43. package/dist/cli/commands/syncGhl.d.ts +20 -0
  44. package/dist/cli/commands/syncGhl.d.ts.map +1 -0
  45. package/dist/cli/commands/syncGhl.js +483 -0
  46. package/dist/cli/commands/syncGhl.js.map +1 -0
  47. package/dist/cli/commands/syncHighLevel.d.ts +24 -0
  48. package/dist/cli/commands/syncHighLevel.d.ts.map +1 -0
  49. package/dist/cli/commands/syncHighLevel.js +483 -0
  50. package/dist/cli/commands/syncHighLevel.js.map +1 -0
  51. package/dist/cli/commands/syncHighLevel.test.d.ts +2 -0
  52. package/dist/cli/commands/syncHighLevel.test.d.ts.map +1 -0
  53. package/dist/cli/commands/syncHighLevel.test.js +102 -0
  54. package/dist/cli/commands/syncHighLevel.test.js.map +1 -0
  55. package/dist/cli/index.d.ts +3 -0
  56. package/dist/cli/index.d.ts.map +1 -0
  57. package/dist/cli/index.js +106 -0
  58. package/dist/cli/index.js.map +1 -0
  59. package/dist/config/configManager.d.ts +31 -0
  60. package/dist/config/configManager.d.ts.map +1 -0
  61. package/dist/config/configManager.js +64 -0
  62. package/dist/config/configManager.js.map +1 -0
  63. package/dist/config/paths.d.ts +21 -0
  64. package/dist/config/paths.d.ts.map +1 -0
  65. package/dist/config/paths.js +33 -0
  66. package/dist/config/paths.js.map +1 -0
  67. package/dist/config/paths.test.d.ts +2 -0
  68. package/dist/config/paths.test.d.ts.map +1 -0
  69. package/dist/config/paths.test.js +70 -0
  70. package/dist/config/paths.test.js.map +1 -0
  71. package/dist/config/schema.d.ts +60 -0
  72. package/dist/config/schema.d.ts.map +1 -0
  73. package/dist/config/schema.js +50 -0
  74. package/dist/config/schema.js.map +1 -0
  75. package/dist/config/schema.test.d.ts +2 -0
  76. package/dist/config/schema.test.d.ts.map +1 -0
  77. package/dist/config/schema.test.js +151 -0
  78. package/dist/config/schema.test.js.map +1 -0
  79. package/dist/downloader/hlsDownloader.d.ts +58 -0
  80. package/dist/downloader/hlsDownloader.d.ts.map +1 -0
  81. package/dist/downloader/hlsDownloader.js +254 -0
  82. package/dist/downloader/hlsDownloader.js.map +1 -0
  83. package/dist/downloader/hlsDownloader.test.d.ts +2 -0
  84. package/dist/downloader/hlsDownloader.test.d.ts.map +1 -0
  85. package/dist/downloader/hlsDownloader.test.js +116 -0
  86. package/dist/downloader/hlsDownloader.test.js.map +1 -0
  87. package/dist/downloader/hlsValidator.d.ts +35 -0
  88. package/dist/downloader/hlsValidator.d.ts.map +1 -0
  89. package/dist/downloader/hlsValidator.js +148 -0
  90. package/dist/downloader/hlsValidator.js.map +1 -0
  91. package/dist/downloader/index.d.ts +26 -0
  92. package/dist/downloader/index.d.ts.map +1 -0
  93. package/dist/downloader/index.js +52 -0
  94. package/dist/downloader/index.js.map +1 -0
  95. package/dist/downloader/loomDownloader.d.ts +56 -0
  96. package/dist/downloader/loomDownloader.d.ts.map +1 -0
  97. package/dist/downloader/loomDownloader.js +559 -0
  98. package/dist/downloader/loomDownloader.js.map +1 -0
  99. package/dist/downloader/loomDownloader.test.d.ts +2 -0
  100. package/dist/downloader/loomDownloader.test.d.ts.map +1 -0
  101. package/dist/downloader/loomDownloader.test.js +36 -0
  102. package/dist/downloader/loomDownloader.test.js.map +1 -0
  103. package/dist/downloader/queue.d.ts +56 -0
  104. package/dist/downloader/queue.d.ts.map +1 -0
  105. package/dist/downloader/queue.js +88 -0
  106. package/dist/downloader/queue.js.map +1 -0
  107. package/dist/downloader/queue.test.d.ts +2 -0
  108. package/dist/downloader/queue.test.d.ts.map +1 -0
  109. package/dist/downloader/queue.test.js +158 -0
  110. package/dist/downloader/queue.test.js.map +1 -0
  111. package/dist/downloader/videoDownloader.d.ts +32 -0
  112. package/dist/downloader/videoDownloader.d.ts.map +1 -0
  113. package/dist/downloader/videoDownloader.js +173 -0
  114. package/dist/downloader/videoDownloader.js.map +1 -0
  115. package/dist/downloader/vimeoDownloader.d.ts +52 -0
  116. package/dist/downloader/vimeoDownloader.d.ts.map +1 -0
  117. package/dist/downloader/vimeoDownloader.js +565 -0
  118. package/dist/downloader/vimeoDownloader.js.map +1 -0
  119. package/dist/downloader/vimeoDownloader.test.d.ts +2 -0
  120. package/dist/downloader/vimeoDownloader.test.d.ts.map +1 -0
  121. package/dist/downloader/vimeoDownloader.test.js +51 -0
  122. package/dist/downloader/vimeoDownloader.test.js.map +1 -0
  123. package/dist/scraper/auth.d.ts +29 -0
  124. package/dist/scraper/auth.d.ts.map +1 -0
  125. package/dist/scraper/auth.js +115 -0
  126. package/dist/scraper/auth.js.map +1 -0
  127. package/dist/scraper/extractor.d.ts +49 -0
  128. package/dist/scraper/extractor.d.ts.map +1 -0
  129. package/dist/scraper/extractor.js +627 -0
  130. package/dist/scraper/extractor.js.map +1 -0
  131. package/dist/scraper/extractor.test.d.ts +2 -0
  132. package/dist/scraper/extractor.test.d.ts.map +1 -0
  133. package/dist/scraper/extractor.test.js +65 -0
  134. package/dist/scraper/extractor.test.js.map +1 -0
  135. package/dist/scraper/ghl/auth.d.ts +25 -0
  136. package/dist/scraper/ghl/auth.d.ts.map +1 -0
  137. package/dist/scraper/ghl/auth.js +187 -0
  138. package/dist/scraper/ghl/auth.js.map +1 -0
  139. package/dist/scraper/ghl/extractor.d.ts +96 -0
  140. package/dist/scraper/ghl/extractor.d.ts.map +1 -0
  141. package/dist/scraper/ghl/extractor.js +345 -0
  142. package/dist/scraper/ghl/extractor.js.map +1 -0
  143. package/dist/scraper/ghl/index.d.ts +4 -0
  144. package/dist/scraper/ghl/index.d.ts.map +1 -0
  145. package/dist/scraper/ghl/index.js +4 -0
  146. package/dist/scraper/ghl/index.js.map +1 -0
  147. package/dist/scraper/ghl/navigator.d.ts +93 -0
  148. package/dist/scraper/ghl/navigator.d.ts.map +1 -0
  149. package/dist/scraper/ghl/navigator.js +447 -0
  150. package/dist/scraper/ghl/navigator.js.map +1 -0
  151. package/dist/scraper/highlevel/auth.d.ts +25 -0
  152. package/dist/scraper/highlevel/auth.d.ts.map +1 -0
  153. package/dist/scraper/highlevel/auth.js +189 -0
  154. package/dist/scraper/highlevel/auth.js.map +1 -0
  155. package/dist/scraper/highlevel/extractor.d.ts +97 -0
  156. package/dist/scraper/highlevel/extractor.d.ts.map +1 -0
  157. package/dist/scraper/highlevel/extractor.js +386 -0
  158. package/dist/scraper/highlevel/extractor.js.map +1 -0
  159. package/dist/scraper/highlevel/extractor.test.d.ts +2 -0
  160. package/dist/scraper/highlevel/extractor.test.d.ts.map +1 -0
  161. package/dist/scraper/highlevel/extractor.test.js +101 -0
  162. package/dist/scraper/highlevel/extractor.test.js.map +1 -0
  163. package/dist/scraper/highlevel/index.d.ts +3 -0
  164. package/dist/scraper/highlevel/index.d.ts.map +1 -0
  165. package/dist/scraper/highlevel/index.js +3 -0
  166. package/dist/scraper/highlevel/index.js.map +1 -0
  167. package/dist/scraper/highlevel/navigator.d.ts +93 -0
  168. package/dist/scraper/highlevel/navigator.d.ts.map +1 -0
  169. package/dist/scraper/highlevel/navigator.js +492 -0
  170. package/dist/scraper/highlevel/navigator.js.map +1 -0
  171. package/dist/scraper/highlevel/navigator.test.d.ts +2 -0
  172. package/dist/scraper/highlevel/navigator.test.d.ts.map +1 -0
  173. package/dist/scraper/highlevel/navigator.test.js +78 -0
  174. package/dist/scraper/highlevel/navigator.test.js.map +1 -0
  175. package/dist/scraper/navigator.d.ts +65 -0
  176. package/dist/scraper/navigator.d.ts.map +1 -0
  177. package/dist/scraper/navigator.js +300 -0
  178. package/dist/scraper/navigator.js.map +1 -0
  179. package/dist/scraper/navigator.test.d.ts +2 -0
  180. package/dist/scraper/navigator.test.d.ts.map +1 -0
  181. package/dist/scraper/navigator.test.js +63 -0
  182. package/dist/scraper/navigator.test.js.map +1 -0
  183. package/dist/scraper/skoolApi.d.ts +17 -0
  184. package/dist/scraper/skoolApi.d.ts.map +1 -0
  185. package/dist/scraper/skoolApi.js +72 -0
  186. package/dist/scraper/skoolApi.js.map +1 -0
  187. package/dist/scraper/videoInterceptor.d.ts +19 -0
  188. package/dist/scraper/videoInterceptor.d.ts.map +1 -0
  189. package/dist/scraper/videoInterceptor.js +315 -0
  190. package/dist/scraper/videoInterceptor.js.map +1 -0
  191. package/dist/shared/auth.d.ts +58 -0
  192. package/dist/shared/auth.d.ts.map +1 -0
  193. package/dist/shared/auth.js +211 -0
  194. package/dist/shared/auth.js.map +1 -0
  195. package/dist/shared/fs.d.ts +31 -0
  196. package/dist/shared/fs.d.ts.map +1 -0
  197. package/dist/shared/fs.js +73 -0
  198. package/dist/shared/fs.js.map +1 -0
  199. package/dist/shared/http.d.ts +15 -0
  200. package/dist/shared/http.d.ts.map +1 -0
  201. package/dist/shared/http.js +31 -0
  202. package/dist/shared/http.js.map +1 -0
  203. package/dist/shared/index.d.ts +4 -0
  204. package/dist/shared/index.d.ts.map +1 -0
  205. package/dist/shared/index.js +4 -0
  206. package/dist/shared/index.js.map +1 -0
  207. package/dist/state/database.d.ts +245 -0
  208. package/dist/state/database.d.ts.map +1 -0
  209. package/dist/state/database.js +676 -0
  210. package/dist/state/database.js.map +1 -0
  211. package/dist/state/database.test.d.ts +2 -0
  212. package/dist/state/database.test.d.ts.map +1 -0
  213. package/dist/state/database.test.js +34 -0
  214. package/dist/state/database.test.js.map +1 -0
  215. package/dist/state/index.d.ts +2 -0
  216. package/dist/state/index.d.ts.map +1 -0
  217. package/dist/state/index.js +2 -0
  218. package/dist/state/index.js.map +1 -0
  219. package/dist/storage/fileSystem.d.ts +56 -0
  220. package/dist/storage/fileSystem.d.ts.map +1 -0
  221. package/dist/storage/fileSystem.js +121 -0
  222. package/dist/storage/fileSystem.js.map +1 -0
  223. package/dist/transcription/whisperService.d.ts +27 -0
  224. package/dist/transcription/whisperService.d.ts.map +1 -0
  225. package/dist/transcription/whisperService.js +102 -0
  226. package/dist/transcription/whisperService.js.map +1 -0
  227. package/eslint.config.js +55 -0
  228. package/package.json +68 -11
  229. package/src/__fixtures__/highlevel-post-response.json +68 -0
  230. package/src/__fixtures__/hls-master-playlist.m3u8 +24 -0
  231. package/src/cli/commands/__snapshots__/syncHighLevel.test.ts.snap +38 -0
  232. package/src/cli/commands/config.ts +74 -0
  233. package/src/cli/commands/inspect.ts +441 -0
  234. package/src/cli/commands/login.ts +68 -0
  235. package/src/cli/commands/status.ts +147 -0
  236. package/src/cli/commands/sync.ts +1235 -0
  237. package/src/cli/commands/syncHighLevel.test.ts +144 -0
  238. package/src/cli/commands/syncHighLevel.ts +639 -0
  239. package/src/cli/index.ts +121 -0
  240. package/src/config/configManager.ts +75 -0
  241. package/src/config/paths.test.ts +83 -0
  242. package/src/config/paths.ts +36 -0
  243. package/src/config/schema.test.ts +173 -0
  244. package/src/config/schema.ts +65 -0
  245. package/src/downloader/hlsDownloader.test.ts +148 -0
  246. package/src/downloader/hlsDownloader.ts +327 -0
  247. package/src/downloader/hlsValidator.ts +196 -0
  248. package/src/downloader/index.ts +122 -0
  249. package/src/downloader/loomDownloader.test.ts +43 -0
  250. package/src/downloader/loomDownloader.ts +742 -0
  251. package/src/downloader/queue.test.ts +199 -0
  252. package/src/downloader/queue.ts +118 -0
  253. package/src/downloader/vimeoDownloader.test.ts +62 -0
  254. package/src/downloader/vimeoDownloader.ts +722 -0
  255. package/src/scraper/extractor.test.ts +124 -0
  256. package/src/scraper/extractor.ts +757 -0
  257. package/src/scraper/highlevel/__snapshots__/extractor.test.ts.snap +41 -0
  258. package/src/scraper/highlevel/extractor.test.ts +134 -0
  259. package/src/scraper/highlevel/extractor.ts +537 -0
  260. package/src/scraper/highlevel/index.ts +2 -0
  261. package/src/scraper/highlevel/navigator.test.ts +110 -0
  262. package/src/scraper/highlevel/navigator.ts +668 -0
  263. package/src/scraper/highlevel/schemas.ts +183 -0
  264. package/src/scraper/navigator.test.ts +122 -0
  265. package/src/scraper/navigator.ts +355 -0
  266. package/src/scraper/schemas.ts +177 -0
  267. package/src/scraper/videoInterceptor.ts +435 -0
  268. package/src/shared/auth.test.ts +58 -0
  269. package/src/shared/auth.ts +251 -0
  270. package/src/shared/firebase.ts +151 -0
  271. package/src/shared/fs.ts +80 -0
  272. package/src/shared/http.ts +34 -0
  273. package/src/shared/index.ts +6 -0
  274. package/src/shared/slug.ts +26 -0
  275. package/src/shared/url.test.ts +122 -0
  276. package/src/shared/url.ts +57 -0
  277. package/src/state/database.test.ts +49 -0
  278. package/src/state/database.ts +919 -0
  279. package/src/state/index.ts +14 -0
  280. package/src/storage/fileSystem.test.ts +64 -0
  281. package/src/storage/fileSystem.ts +175 -0
  282. package/tsconfig.json +28 -0
  283. package/vitest.config.ts +29 -0
  284. package/cli.js +0 -45
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from "commander";
4
+ import { configGetCommand, configSetCommand, configShowCommand } from "./commands/config.js";
5
+ import { inspectCommand } from "./commands/inspect.js";
6
+ import { loginCommand, logoutCommand } from "./commands/login.js";
7
+ import { statusCommand, statusListCommand, type StatusOptions } from "./commands/status.js";
8
+ import { syncCommand, type SyncOptions } from "./commands/sync.js";
9
+ import {
10
+ syncHighLevelCommand,
11
+ isHighLevelPortal,
12
+ type SyncHighLevelOptions,
13
+ } from "./commands/syncHighLevel.js";
14
+
15
+ const program = new Command();
16
+
17
+ program
18
+ .name("offcourse")
19
+ .description("Download online courses for offline access – of course!")
20
+ .version("0.1.0");
21
+
22
+ // Login command
23
+ program
24
+ .command("login")
25
+ .description("Log in to a learning platform (opens browser)")
26
+ .option("-f, --force", "Force re-login even if session exists")
27
+ .action(loginCommand);
28
+
29
+ // Logout command
30
+ program.command("logout").description("Clear saved session").action(logoutCommand);
31
+
32
+ // Sync command - auto-detects platform
33
+ program
34
+ .command("sync <url>")
35
+ .description("Download a course for offline access (auto-detects platform)")
36
+ .option("--skip-videos", "Skip video downloads (only save text content)")
37
+ .option("--skip-content", "Skip text content (only download videos)")
38
+ .option("--dry-run", "Scan course structure without downloading")
39
+ .option("--limit <n>", "Limit to first N lessons (for testing)", parseInt)
40
+ .option("-f, --force", "Force full rescan of all lessons")
41
+ .option("--retry-failed", "Retry failed lessons with detailed diagnostics")
42
+ .option("--visible", "Show browser window (default: headless)")
43
+ .option("-q, --quality <quality>", "Preferred video quality (e.g., 720p, 1080p)")
44
+ .option("--course-name <name>", "Override detected course name")
45
+ .action((url: string, options: SyncOptions & SyncHighLevelOptions) => {
46
+ // Auto-detect platform
47
+ if (url.includes("skool.com")) {
48
+ return syncCommand(url, options);
49
+ } else if (isHighLevelPortal(url)) {
50
+ return syncHighLevelCommand(url, options);
51
+ } else {
52
+ // Default: try HighLevel (most generic)
53
+ console.log("Platform not recognized, trying as HighLevel portal...");
54
+ return syncHighLevelCommand(url, options);
55
+ }
56
+ });
57
+
58
+ // Explicit Skool sync command
59
+ program
60
+ .command("sync-skool <url>")
61
+ .description("Download a Skool course for offline access")
62
+ .option("--skip-videos", "Skip video downloads (only save text content)")
63
+ .option("--skip-content", "Skip text content (only download videos)")
64
+ .option("--dry-run", "Scan course structure without downloading")
65
+ .option("--limit <n>", "Limit to first N lessons (for testing)", parseInt)
66
+ .option("-f, --force", "Force full rescan of all lessons")
67
+ .option("--retry-failed", "Retry failed lessons with detailed diagnostics")
68
+ .option("--visible", "Show browser window (default: headless)")
69
+ .action(syncCommand);
70
+
71
+ // Explicit HighLevel sync command
72
+ program
73
+ .command("sync-highlevel <url>")
74
+ .description("Download a HighLevel (GoHighLevel) course for offline access")
75
+ .option("--skip-videos", "Skip video downloads (only save text content)")
76
+ .option("--skip-content", "Skip text content (only download videos)")
77
+ .option("--dry-run", "Scan course structure without downloading")
78
+ .option("--limit <n>", "Limit to first N lessons (for testing)", parseInt)
79
+ .option("--visible", "Show browser window (default: headless)")
80
+ .option("-q, --quality <quality>", "Preferred video quality (e.g., 720p, 1080p)")
81
+ .option("--course-name <name>", "Override detected course name")
82
+ .action(syncHighLevelCommand);
83
+
84
+ // Status command
85
+ program
86
+ .command("status [url]")
87
+ .description("Show sync status for a course (or list all if no URL)")
88
+ .option("--errors", "Show details for failed downloads")
89
+ .option("--pending", "Show not-yet-scanned lessons")
90
+ .option("-a, --all", "Show all details")
91
+ .action((url: string | undefined, options: StatusOptions) => {
92
+ if (url) {
93
+ statusCommand(url, options);
94
+ } else {
95
+ void statusListCommand();
96
+ }
97
+ });
98
+
99
+ // Inspect command (debugging)
100
+ program
101
+ .command("inspect <url>")
102
+ .description("Analyze page structure for debugging")
103
+ .option("-o, --output <dir>", "Save analysis to directory")
104
+ .option("--full", "Save complete HTML as well")
105
+ .option("--click", "Try to click video preview to trigger lazy loading")
106
+ .action(inspectCommand);
107
+
108
+ // Config commands
109
+ const configCmd = program.command("config").description("Manage configuration");
110
+
111
+ configCmd.command("show").description("Show all configuration values").action(configShowCommand);
112
+
113
+ configCmd.command("get <key>").description("Get a configuration value").action(configGetCommand);
114
+
115
+ configCmd
116
+ .command("set <key> <value>")
117
+ .description("Set a configuration value")
118
+ .action(configSetCommand);
119
+
120
+ // Parse and run
121
+ program.parse();
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Application configuration using the `conf` package.
3
+ * Excluded from coverage - testing would just test the conf package.
4
+ */
5
+ import Conf from "conf";
6
+ import { APP_DIR, SESSIONS_DIR } from "./paths.js";
7
+ import { Config, configSchema } from "./schema.js";
8
+ import { ensureDir } from "../shared/fs.js";
9
+
10
+ /**
11
+ * Application configuration store using conf package.
12
+ * Provides atomic writes, dot-notation access, and safe defaults.
13
+ */
14
+ const store = new Conf<Config>({
15
+ projectName: "offcourse",
16
+ cwd: APP_DIR,
17
+ configName: "config",
18
+ defaults: configSchema.parse({}),
19
+ });
20
+
21
+ /**
22
+ * Ensures all required application directories exist.
23
+ */
24
+ export async function ensureAppDirectories(): Promise<void> {
25
+ const dirs = [APP_DIR, SESSIONS_DIR, `${APP_DIR}/sync-state`];
26
+ await Promise.all(dirs.map((dir) => ensureDir(dir)));
27
+ }
28
+
29
+ /**
30
+ * Loads the application configuration.
31
+ * Returns validated config with defaults applied.
32
+ */
33
+ export function loadConfig(): Config {
34
+ // Validate with zod to ensure type safety
35
+ return configSchema.parse(store.store);
36
+ }
37
+
38
+ /**
39
+ * Saves the configuration.
40
+ */
41
+ export function saveConfig(config: Config): void {
42
+ const validated = configSchema.parse(config);
43
+ store.store = validated;
44
+ }
45
+
46
+ /**
47
+ * Updates specific config values.
48
+ */
49
+ export function updateConfig(updates: Partial<Config>): Config {
50
+ const current = loadConfig();
51
+ const updated = configSchema.parse({ ...current, ...updates });
52
+ store.store = updated;
53
+ return updated;
54
+ }
55
+
56
+ /**
57
+ * Gets a specific config value.
58
+ */
59
+ export function getConfigValue<K extends keyof Config>(key: K): Config[K] {
60
+ return store.get(key);
61
+ }
62
+
63
+ /**
64
+ * Clears all configuration (for testing or reset).
65
+ */
66
+ export function clearConfig(): void {
67
+ store.clear();
68
+ }
69
+
70
+ /**
71
+ * Gets the path to the config file.
72
+ */
73
+ export function getConfigPath(): string {
74
+ return store.path;
75
+ }
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { expandPath, getSessionPath, getSyncStatePath, APP_DIR } from "./paths.js";
5
+
6
+ describe("expandPath", () => {
7
+ it("expands ~ to home directory", () => {
8
+ const result = expandPath("~/Downloads/offcourse");
9
+ expect(result).toBe(join(homedir(), "Downloads/offcourse"));
10
+ });
11
+
12
+ it("expands ~/nested/path correctly", () => {
13
+ const result = expandPath("~/foo/bar/baz");
14
+ expect(result).toBe(join(homedir(), "foo/bar/baz"));
15
+ });
16
+
17
+ it("returns absolute paths unchanged", () => {
18
+ const result = expandPath("/usr/local/bin");
19
+ expect(result).toBe("/usr/local/bin");
20
+ });
21
+
22
+ it("returns relative paths unchanged", () => {
23
+ const result = expandPath("relative/path");
24
+ expect(result).toBe("relative/path");
25
+ });
26
+
27
+ it("handles just ~ correctly", () => {
28
+ // untildify expands ~ to home directory
29
+ const result = expandPath("~");
30
+ expect(result).toBe(homedir());
31
+ });
32
+
33
+ it("handles empty string", () => {
34
+ const result = expandPath("");
35
+ expect(result).toBe("");
36
+ });
37
+ });
38
+
39
+ describe("getSessionPath", () => {
40
+ it("generates correct session path for simple domain", () => {
41
+ const result = getSessionPath("example.com");
42
+ expect(result).toMatch(/\.offcourse\/sessions\/example\.com\.json$/);
43
+ });
44
+
45
+ it("sanitizes domains with special characters", () => {
46
+ const result = getSessionPath("sub.domain.com");
47
+ expect(result).toMatch(/sub\.domain\.com\.json$/);
48
+ });
49
+
50
+ it("replaces invalid filesystem characters with underscores", () => {
51
+ const result = getSessionPath("example.com/path?query");
52
+ expect(result).toMatch(/example\.com_path_query\.json$/);
53
+ });
54
+
55
+ it("handles domains with ports", () => {
56
+ const result = getSessionPath("localhost:3000");
57
+ expect(result).toMatch(/localhost_3000\.json$/);
58
+ });
59
+ });
60
+
61
+ describe("getSyncStatePath", () => {
62
+ it("generates correct sync state path for simple slug", () => {
63
+ const result = getSyncStatePath("my-course");
64
+ expect(result).toMatch(/\.offcourse\/sync-state\/my-course\.json$/);
65
+ });
66
+
67
+ it("sanitizes slugs with special characters", () => {
68
+ const result = getSyncStatePath("Course Name: Special!");
69
+ expect(result).toMatch(/Course_Name__Special_\.json$/);
70
+ });
71
+
72
+ it("handles slugs with only valid characters", () => {
73
+ const result = getSyncStatePath("valid-slug-123");
74
+ expect(result).toMatch(/valid-slug-123\.json$/);
75
+ });
76
+ });
77
+
78
+ describe("APP_DIR", () => {
79
+ it("is defined and contains offcourse", () => {
80
+ expect(APP_DIR).toBeDefined();
81
+ expect(APP_DIR).toContain("offcourse");
82
+ });
83
+ });
@@ -0,0 +1,36 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import untildify from "untildify";
4
+
5
+ /**
6
+ * Application directory paths.
7
+ * Uses ~/.offcourse/ for easy access and visibility.
8
+ */
9
+ export const APP_DIR = join(homedir(), ".offcourse");
10
+ export const SESSIONS_DIR = join(APP_DIR, "sessions");
11
+ export const CONFIG_FILE = join(APP_DIR, "config.json");
12
+ export const CACHE_DIR = join(APP_DIR, "cache");
13
+
14
+ /**
15
+ * Get the session file path for a specific domain.
16
+ */
17
+ export function getSessionPath(domain: string): string {
18
+ // Sanitize domain for filesystem
19
+ const safeDomain = domain.replace(/[^a-zA-Z0-9.-]/g, "_");
20
+ return join(SESSIONS_DIR, `${safeDomain}.json`);
21
+ }
22
+
23
+ /**
24
+ * Get the sync state file path for a course.
25
+ */
26
+ export function getSyncStatePath(courseSlug: string): string {
27
+ const safeSlug = courseSlug.replace(/[^a-zA-Z0-9-]/g, "_");
28
+ return join(APP_DIR, "sync-state", `${safeSlug}.json`);
29
+ }
30
+
31
+ /**
32
+ * Expand ~ to home directory in paths.
33
+ */
34
+ export function expandPath(path: string): string {
35
+ return untildify(path);
36
+ }
@@ -0,0 +1,173 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { configSchema, courseSyncStateSchema, sessionInfoSchema } from "./schema.js";
3
+
4
+ describe("configSchema", () => {
5
+ it("parses empty object with defaults", () => {
6
+ const result = configSchema.parse({});
7
+ expect(result).toEqual({
8
+ outputDir: "~/Downloads/offcourse",
9
+ videoQuality: "highest",
10
+ concurrency: 2,
11
+ retryAttempts: 3,
12
+ headless: true,
13
+ });
14
+ });
15
+
16
+ it("accepts valid config values", () => {
17
+ const input = {
18
+ outputDir: "/custom/path",
19
+ videoQuality: "720p" as const,
20
+ concurrency: 4,
21
+ retryAttempts: 5,
22
+ headless: false,
23
+ };
24
+ const result = configSchema.parse(input);
25
+ expect(result).toEqual(input);
26
+ });
27
+
28
+ it("rejects invalid video quality", () => {
29
+ expect(() => configSchema.parse({ videoQuality: "4k" })).toThrow();
30
+ });
31
+
32
+ it("rejects concurrency outside valid range", () => {
33
+ expect(() => configSchema.parse({ concurrency: 0 })).toThrow();
34
+ expect(() => configSchema.parse({ concurrency: 6 })).toThrow();
35
+ });
36
+
37
+ it("rejects retry attempts outside valid range", () => {
38
+ expect(() => configSchema.parse({ retryAttempts: -1 })).toThrow();
39
+ expect(() => configSchema.parse({ retryAttempts: 11 })).toThrow();
40
+ });
41
+
42
+ it("accepts all valid video quality values", () => {
43
+ const qualities = ["highest", "lowest", "1080p", "720p", "480p"] as const;
44
+ for (const quality of qualities) {
45
+ const result = configSchema.parse({ videoQuality: quality });
46
+ expect(result.videoQuality).toBe(quality);
47
+ }
48
+ });
49
+ });
50
+
51
+ describe("courseSyncStateSchema", () => {
52
+ it("validates minimal course sync state", () => {
53
+ const input = {
54
+ url: "https://example.com/course",
55
+ name: "My Course",
56
+ modules: [],
57
+ };
58
+ const result = courseSyncStateSchema.parse(input);
59
+ expect(result.url).toBe(input.url);
60
+ expect(result.name).toBe(input.name);
61
+ expect(result.modules).toEqual([]);
62
+ });
63
+
64
+ it("validates complete course sync state", () => {
65
+ const input = {
66
+ url: "https://example.com/course",
67
+ name: "My Course",
68
+ lastSyncedAt: "2024-01-15T10:30:00.000Z",
69
+ modules: [
70
+ {
71
+ name: "Module 1",
72
+ slug: "abc12345",
73
+ lessons: [
74
+ {
75
+ name: "Lesson 1",
76
+ slug: "def67890",
77
+ url: "https://example.com/lesson/1",
78
+ isCompleted: true,
79
+ videoDownloaded: true,
80
+ contentSaved: true,
81
+ },
82
+ ],
83
+ },
84
+ ],
85
+ };
86
+ const result = courseSyncStateSchema.parse(input);
87
+ expect(result).toEqual(input);
88
+ });
89
+
90
+ it("applies default values for lesson flags", () => {
91
+ const input = {
92
+ url: "https://example.com/course",
93
+ name: "My Course",
94
+ modules: [
95
+ {
96
+ name: "Module 1",
97
+ slug: "abc12345",
98
+ lessons: [
99
+ {
100
+ name: "Lesson 1",
101
+ slug: "def67890",
102
+ url: "https://example.com/lesson/1",
103
+ },
104
+ ],
105
+ },
106
+ ],
107
+ };
108
+ const result = courseSyncStateSchema.parse(input);
109
+ const lesson = result.modules[0]?.lessons[0];
110
+ expect(lesson?.isCompleted).toBe(false);
111
+ expect(lesson?.videoDownloaded).toBe(false);
112
+ expect(lesson?.contentSaved).toBe(false);
113
+ });
114
+
115
+ it("rejects invalid URL", () => {
116
+ expect(() =>
117
+ courseSyncStateSchema.parse({
118
+ url: "not-a-url",
119
+ name: "Course",
120
+ modules: [],
121
+ })
122
+ ).toThrow();
123
+ });
124
+
125
+ it("rejects invalid datetime format", () => {
126
+ expect(() =>
127
+ courseSyncStateSchema.parse({
128
+ url: "https://example.com/course",
129
+ name: "Course",
130
+ lastSyncedAt: "invalid-date",
131
+ modules: [],
132
+ })
133
+ ).toThrow();
134
+ });
135
+ });
136
+
137
+ describe("sessionInfoSchema", () => {
138
+ it("validates complete session info", () => {
139
+ const input = {
140
+ domain: "example.com",
141
+ createdAt: "2024-01-15T10:30:00.000Z",
142
+ expiresAt: "2024-02-15T10:30:00.000Z",
143
+ };
144
+ const result = sessionInfoSchema.parse(input);
145
+ expect(result).toEqual(input);
146
+ });
147
+
148
+ it("accepts session without expiry", () => {
149
+ const input = {
150
+ domain: "example.com",
151
+ createdAt: "2024-01-15T10:30:00.000Z",
152
+ };
153
+ const result = sessionInfoSchema.parse(input);
154
+ expect(result.expiresAt).toBeUndefined();
155
+ });
156
+
157
+ it("rejects invalid createdAt datetime", () => {
158
+ expect(() =>
159
+ sessionInfoSchema.parse({
160
+ domain: "example.com",
161
+ createdAt: "not-a-date",
162
+ })
163
+ ).toThrow();
164
+ });
165
+
166
+ it("requires domain field", () => {
167
+ expect(() =>
168
+ sessionInfoSchema.parse({
169
+ createdAt: "2024-01-15T10:30:00.000Z",
170
+ })
171
+ ).toThrow();
172
+ });
173
+ });
@@ -0,0 +1,65 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Video quality preferences for downloads.
5
+ */
6
+ export const VIDEO_QUALITY = {
7
+ highest: "highest",
8
+ lowest: "lowest",
9
+ "1080p": "1080p",
10
+ "720p": "720p",
11
+ "480p": "480p",
12
+ } as const;
13
+
14
+ export type VideoQuality = keyof typeof VIDEO_QUALITY;
15
+
16
+ /**
17
+ * Global application configuration schema.
18
+ */
19
+ export const configSchema = z.object({
20
+ outputDir: z.string().default("~/Downloads/offcourse"),
21
+ videoQuality: z.enum(["highest", "lowest", "1080p", "720p", "480p"]).default("highest"),
22
+ concurrency: z.number().int().min(1).max(5).default(2),
23
+ retryAttempts: z.number().int().min(0).max(10).default(3),
24
+ headless: z.boolean().default(true),
25
+ });
26
+
27
+ export type Config = z.infer<typeof configSchema>;
28
+
29
+ /**
30
+ * Course sync state to track progress and enable resume.
31
+ */
32
+ export const courseSyncStateSchema = z.object({
33
+ url: z.url(),
34
+ name: z.string(),
35
+ lastSyncedAt: z.iso.datetime().optional(),
36
+ modules: z.array(
37
+ z.object({
38
+ name: z.string(),
39
+ slug: z.string(),
40
+ lessons: z.array(
41
+ z.object({
42
+ name: z.string(),
43
+ slug: z.string(),
44
+ url: z.url(),
45
+ isCompleted: z.boolean().default(false),
46
+ videoDownloaded: z.boolean().default(false),
47
+ contentSaved: z.boolean().default(false),
48
+ })
49
+ ),
50
+ })
51
+ ),
52
+ });
53
+
54
+ export type CourseSyncState = z.infer<typeof courseSyncStateSchema>;
55
+
56
+ /**
57
+ * Session info for a specific domain.
58
+ */
59
+ export const sessionInfoSchema = z.object({
60
+ domain: z.string(),
61
+ createdAt: z.iso.datetime(),
62
+ expiresAt: z.iso.datetime().optional(),
63
+ });
64
+
65
+ export type SessionInfo = z.infer<typeof sessionInfoSchema>;