@webship.co/workspace 0.0.0-stage → 1.0.0-beta1

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 (307) hide show
  1. package/PLUGINS.md +249 -0
  2. package/README.md +379 -2
  3. package/WORKSPACES.md +69 -0
  4. package/actions/ai-items.js +293 -0
  5. package/actions/audio.js +48 -0
  6. package/actions/build-order.js +79 -0
  7. package/actions/checks.js +95 -0
  8. package/actions/claude-session.js +104 -0
  9. package/actions/commands-center.js +258 -0
  10. package/actions/composer.js +267 -0
  11. package/actions/ddev-addons.js +90 -0
  12. package/actions/ddev-mail.js +80 -0
  13. package/actions/ddev.js +237 -0
  14. package/actions/folders.js +165 -0
  15. package/actions/images-svg.js +33 -0
  16. package/actions/images.js +145 -0
  17. package/actions/navigation.js +190 -0
  18. package/actions/node.js +222 -0
  19. package/actions/notes.js +55 -0
  20. package/actions/plugins.js +36 -0
  21. package/actions/project-make.js +28 -0
  22. package/actions/rag.js +202 -0
  23. package/actions/row-actions.js +262 -0
  24. package/actions/screens.js +270 -0
  25. package/actions/testing.js +121 -0
  26. package/actions/video-edit.js +54 -0
  27. package/ai-inventory.js +182 -0
  28. package/ai-items.js +202 -0
  29. package/assistant-cast.js +176 -0
  30. package/assistant-suggestions.js +91 -0
  31. package/assistant.js +484 -0
  32. package/audio-voice.js +528 -0
  33. package/auth-google.js +260 -0
  34. package/build-ai.js +52 -0
  35. package/build-order-page.js +191 -0
  36. package/build-order.js +313 -0
  37. package/builder-args.js +117 -0
  38. package/changelog.js +302 -0
  39. package/chat-threads.js +134 -0
  40. package/checks.js +105 -0
  41. package/claude-args.js +282 -0
  42. package/claude-sessions.js +129 -0
  43. package/command-author.js +117 -0
  44. package/command-block.js +20 -0
  45. package/commands/defaults.yml +159 -0
  46. package/commands-page.js +460 -0
  47. package/commands.js +568 -0
  48. package/cucumber.js +43 -0
  49. package/ddev-addons-page.js +124 -0
  50. package/ddev-addons.js +154 -0
  51. package/ddev-custom-commands.js +96 -0
  52. package/ddev-domains.js +84 -0
  53. package/ddev-import.js +93 -0
  54. package/ddev-mail-page.js +167 -0
  55. package/ddev-mail.js +110 -0
  56. package/ddev-options.js +145 -0
  57. package/ddev-snapshots.js +126 -0
  58. package/ddev.js +145 -0
  59. package/docs/DEVELOPER-GUIDE.md +224 -0
  60. package/docs/README.md +252 -0
  61. package/docs/REFERENCE.md +684 -0
  62. package/docs/USER-GUIDE.md +697 -0
  63. package/docs/screenshots/assistant/avatar-board.png +0 -0
  64. package/docs/screenshots/assistant/avatar-coffee.png +0 -0
  65. package/docs/screenshots/assistant/avatar-colours.png +0 -0
  66. package/docs/screenshots/assistant/avatar-icons.png +0 -0
  67. package/docs/screenshots/assistant/avatar-moves.png +0 -0
  68. package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
  69. package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
  70. package/docs/screenshots/assistant/avatar-status.png +0 -0
  71. package/docs/screenshots/create-card/create-card.png +0 -0
  72. package/docs/screenshots/create-card/start-ai.png +0 -0
  73. package/docs/screenshots/guide-clone-address.png +0 -0
  74. package/docs/screenshots/guide-clone-worktree-off.png +0 -0
  75. package/docs/screenshots/guide-clone-worktree-on.png +0 -0
  76. package/docs/screenshots/guide-ddev-menu.png +0 -0
  77. package/docs/screenshots/guide-domains.png +0 -0
  78. package/docs/screenshots/guide-home.png +0 -0
  79. package/docs/screenshots/guide-ide-menu.png +0 -0
  80. package/docs/screenshots/guide-remove-refusal.png +0 -0
  81. package/docs/screenshots/guide-rows-worktree.png +0 -0
  82. package/docs/screenshots/guide-screens.png +0 -0
  83. package/docs/screenshots/guide-settings.png +0 -0
  84. package/docs/screenshots/guide-workspace-page.png +0 -0
  85. package/docs/screenshots/home/card-settings.png +0 -0
  86. package/docs/screenshots/home/drag-reorder.png +0 -0
  87. package/docs/screenshots/home/edit-card.png +0 -0
  88. package/docs/screenshots/images/claude-code-image.png +0 -0
  89. package/docs/screenshots/images/edit-with-ai.png +0 -0
  90. package/docs/screenshots/images/gallery.png +0 -0
  91. package/docs/screenshots/images/generate-description.png +0 -0
  92. package/docs/screenshots/images/generate-plan.png +0 -0
  93. package/docs/screenshots/images/providers.png +0 -0
  94. package/docs/screenshots/images/svg-form.png +0 -0
  95. package/docs/screenshots/images/svg-result.png +0 -0
  96. package/docs/screenshots/images/upload.png +0 -0
  97. package/docs/screenshots/new-folder/2-form.png +0 -0
  98. package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
  99. package/docs/screenshots/new-folder/files-folder.png +0 -0
  100. package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
  101. package/docs/screenshots/projects/state-chip-stop.png +0 -0
  102. package/docs/screenshots/readme/dashboard-dark.png +0 -0
  103. package/docs/screenshots/readme/dashboard-light.png +0 -0
  104. package/docs/screenshots/readme/dev-dark.png +0 -0
  105. package/docs/screenshots/readme/dev-light.png +0 -0
  106. package/docs/screenshots/readme/docs-dark.png +0 -0
  107. package/docs/screenshots/readme/docs-light.png +0 -0
  108. package/docs/screenshots/readme/icons/ai.svg +4 -0
  109. package/docs/screenshots/readme/icons/code.svg +4 -0
  110. package/docs/screenshots/readme/icons/docs.svg +4 -0
  111. package/docs/screenshots/readme/icons/services.svg +4 -0
  112. package/docs/screenshots/readme/icons/sites.svg +4 -0
  113. package/docs/screenshots/readme/test-report-dark.png +0 -0
  114. package/docs/screenshots/readme/test-report-light.png +0 -0
  115. package/docs/screenshots/readme/test-scenarios.png +0 -0
  116. package/docs/screenshots/readme/test-steps.png +0 -0
  117. package/docs/screenshots/readme/videos-dark.png +0 -0
  118. package/docs/screenshots/readme/videos-light.png +0 -0
  119. package/docs/screenshots/screens-menu.png +0 -0
  120. package/docs/screenshots/screens-new.png +0 -0
  121. package/docs/screenshots/settings/auth-google-lists.png +0 -0
  122. package/docs/screenshots/settings/delete-1-row.png +0 -0
  123. package/docs/screenshots/settings/delete-2-confirm.png +0 -0
  124. package/docs/screenshots/settings/delete-3-type-name.png +0 -0
  125. package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
  126. package/event-log.js +59 -0
  127. package/files-browser.js +247 -0
  128. package/folders.js +121 -0
  129. package/format.js +73 -0
  130. package/git-info.js +81 -0
  131. package/github-auth.js +62 -0
  132. package/html.js +10 -0
  133. package/icons.js +83 -0
  134. package/images-ai.js +447 -0
  135. package/images-create.js +226 -0
  136. package/images-page.js +417 -0
  137. package/images-records.js +39 -0
  138. package/images-render.js +36 -0
  139. package/images-svg-core.js +214 -0
  140. package/images-svg-sanitize.js +188 -0
  141. package/images-svg.js +241 -0
  142. package/images.js +270 -0
  143. package/jobs.js +430 -0
  144. package/linger.js +21 -0
  145. package/lists.js +292 -0
  146. package/media-info.js +136 -0
  147. package/navigation-page.js +264 -0
  148. package/navigation.js +394 -0
  149. package/notes.js +0 -0
  150. package/package.json +47 -4
  151. package/palette-index.js +46 -0
  152. package/playwright.config.js +23 -0
  153. package/plugin-install.js +239 -0
  154. package/plugin-loader.js +466 -0
  155. package/plugin-manifest.js +83 -0
  156. package/plugin-settings.js +44 -0
  157. package/plugins/workspace-notes/index.js +59 -0
  158. package/plugins-page.js +103 -0
  159. package/project-make.js +241 -0
  160. package/project-name.js +210 -0
  161. package/public/assistant-avatar-moves.js +646 -0
  162. package/public/assistant-avatar-ui.js +1223 -0
  163. package/public/assistant-avatar.js +530 -0
  164. package/public/avatar/druplicon.vrm +0 -0
  165. package/public/css/base.css +260 -0
  166. package/public/css/components/ai-assistant.css +263 -0
  167. package/public/css/components/assistant-avatar.css +279 -0
  168. package/public/css/components/assistant-views.css +198 -0
  169. package/public/css/components/build-order.css +74 -0
  170. package/public/css/components/clone-as-a-git-worktree.css +30 -0
  171. package/public/css/components/command-palette.css +61 -0
  172. package/public/css/components/commands-center.css +139 -0
  173. package/public/css/components/confirm-dialog.css +40 -0
  174. package/public/css/components/create-card.css +8 -0
  175. package/public/css/components/ddev-menu.css +23 -0
  176. package/public/css/components/deep-chat.css +17 -0
  177. package/public/css/components/density.css +11 -0
  178. package/public/css/components/floating-assistant-workspace-pages.css +55 -0
  179. package/public/css/components/folders.css +5 -0
  180. package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
  181. package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
  182. package/public/css/components/grid-and-list.css +203 -0
  183. package/public/css/components/help-page.css +36 -0
  184. package/public/css/components/hero.css +16 -0
  185. package/public/css/components/images.css +70 -0
  186. package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
  187. package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
  188. package/public/css/components/modal-fit.css +113 -0
  189. package/public/css/components/page-width.css +23 -0
  190. package/public/css/components/plugins-page.css +9 -0
  191. package/public/css/components/project-cards.css +256 -0
  192. package/public/css/components/project-rows.css +390 -0
  193. package/public/css/components/reordering-the-workspace-cards.css +85 -0
  194. package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
  195. package/public/css/components/running-commands-as-toasts.css +327 -0
  196. package/public/css/components/settings-editor-core-config-yml.css +828 -0
  197. package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
  198. package/public/css/components/sound-player.css +84 -0
  199. package/public/css/components/suggest-agents-skills-commands.css +112 -0
  200. package/public/css/components/tab-modal.css +142 -0
  201. package/public/css/components/terminals.css +244 -0
  202. package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
  203. package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
  204. package/public/css/components/video-edit.css +6 -0
  205. package/public/css/components/web-ide-open-with.css +23 -0
  206. package/public/css/components/working-indicators.css +158 -0
  207. package/public/css/components/workspace-cards.css +79 -0
  208. package/public/favicon.ico +0 -0
  209. package/public/favicon.png +0 -0
  210. package/public/htmx.min.js +1 -0
  211. package/public/logo.png +0 -0
  212. package/public/media-player.js +215 -0
  213. package/public/mic.js +255 -0
  214. package/public/narration.js +110 -0
  215. package/public/palette.js +278 -0
  216. package/public/sortable.js +251 -0
  217. package/public/suggest.js +337 -0
  218. package/public/terminal.js +903 -0
  219. package/public/themes/default/theme.css +267 -0
  220. package/public/themes/ember/theme.css +180 -0
  221. package/public/themes/press/theme.css +290 -0
  222. package/public/themes/slate/theme.css +182 -0
  223. package/public/touch-icon.png +0 -0
  224. package/public/ui.js +2721 -0
  225. package/public/vendor/ace/LICENSE.txt +24 -0
  226. package/public/vendor/ace/ace.js +23 -0
  227. package/public/vendor/ace/ext-language_tools.js +8 -0
  228. package/public/vendor/ace/ext-searchbox.js +8 -0
  229. package/public/vendor/ace/mode-css.js +8 -0
  230. package/public/vendor/ace/mode-html.js +8 -0
  231. package/public/vendor/ace/mode-javascript.js +8 -0
  232. package/public/vendor/ace/mode-markdown.js +8 -0
  233. package/public/vendor/ace/mode-sh.js +8 -0
  234. package/public/vendor/ace/mode-yaml.js +8 -0
  235. package/public/vendor/ace/theme-textmate.js +8 -0
  236. package/public/vendor/ace/theme-tomorrow_night.js +8 -0
  237. package/public/vendor/ace/worker-yaml.js +1 -0
  238. package/public/vendor/deep-chat.LICENSE +21 -0
  239. package/public/vendor/deep-chat.bundle.js +1 -0
  240. package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
  241. package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
  242. package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
  243. package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
  244. package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
  245. package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
  246. package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
  247. package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
  248. package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
  249. package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  250. package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
  251. package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
  252. package/public/vendor/tabler-LICENSE.txt +21 -0
  253. package/public/vendor/tabler-sprite.svg +1 -0
  254. package/public/vendor/three/LICENSE +21 -0
  255. package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
  256. package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
  257. package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
  258. package/public/vendor/three/three.core.min.js +6 -0
  259. package/public/vendor/three/three.module.min.js +6 -0
  260. package/public/vendor/three-vrm/LICENSE +22 -0
  261. package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
  262. package/public/vendor/uikit-icons.min.js +1 -0
  263. package/public/vendor/uikit.min.css +1 -0
  264. package/public/vendor/uikit.min.js +1 -0
  265. package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
  266. package/public/vendor/webship-icons/ui/headset.svg +1 -0
  267. package/public/vendor/webship-icons/ui/menu.svg +1 -0
  268. package/public/vendor/webship-icons/ui/pause.svg +1 -0
  269. package/public/vendor/webship-icons/ui/play.svg +1 -0
  270. package/public/vendor/webship-icons/ui/share.svg +1 -0
  271. package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
  272. package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
  273. package/public/vendor/webship-icons/ui/stop.svg +1 -0
  274. package/public/vendor/xterm/addon-fit.js +2 -0
  275. package/public/vendor/xterm/addon-unicode11.js +2 -0
  276. package/public/vendor/xterm/addon-web-links.js +2 -0
  277. package/public/vendor/xterm/xterm.css +218 -0
  278. package/public/vendor/xterm/xterm.js +2 -0
  279. package/rag.js +169 -0
  280. package/reader.js +132 -0
  281. package/repo-blobs.js +66 -0
  282. package/report-pdf.js +337 -0
  283. package/row-layout.js +1439 -0
  284. package/screenrc +37 -0
  285. package/screens.js +1063 -0
  286. package/scripts/code-server-start.sh +190 -0
  287. package/scripts/images-claude-finish.js +107 -0
  288. package/scripts/screen-host.sh +114 -0
  289. package/scripts/screen-runner.js +362 -0
  290. package/scripts/screen-then.js +47 -0
  291. package/scripts/update.sh +350 -0
  292. package/server.js +7923 -0
  293. package/settings-file.js +423 -0
  294. package/speech.js +297 -0
  295. package/tab-modal.js +75 -0
  296. package/test-reports.js +260 -0
  297. package/themes.js +381 -0
  298. package/transcripts.js +168 -0
  299. package/urls.js +13 -0
  300. package/validate.js +18 -0
  301. package/version-check.js +94 -0
  302. package/video-edit.js +588 -0
  303. package/waiting.js +85 -0
  304. package/workspace-card-tools.js +41 -0
  305. package/workspace-delete.js +139 -0
  306. package/workspace-types.js +85 -0
  307. package/workspaces.js +553 -0
package/images.js ADDED
@@ -0,0 +1,270 @@
1
+ /**
2
+ * The images workspace: what an image file is, how big it is, and where images live.
3
+ *
4
+ * Dimensions are read from the file's own header, a few kilobytes of it, so a folder of three hundred
5
+ * screenshots lists without starting a process per file.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ const { ROOT, workspaceDir, isValidWorkspace } = require('./workspaces');
12
+
13
+ const IMAGE_RE = /\.(png|jpe?g|gif|webp|svg|avif)$/i;
14
+ const TYPES = ['png', 'jpg', 'gif', 'webp', 'svg', 'avif'];
15
+ const typeOf = (name) => {
16
+ const ext = path.extname(name).slice(1).toLowerCase();
17
+ return ext === 'jpeg' ? 'jpg' : ext;
18
+ };
19
+ const NOISE_DIRS = new Set(['node_modules', '.git', 'dev-ai-agents', 'workspace-repo', 'vendor']);
20
+
21
+ // ------------------------------------------------------------- dimensions --
22
+
23
+ function readHead(file, bytes) {
24
+ let fd;
25
+ try {
26
+ fd = fs.openSync(file, 'r');
27
+ const buf = Buffer.alloc(bytes);
28
+ const n = fs.readSync(fd, buf, 0, bytes, 0);
29
+ return buf.subarray(0, n);
30
+ } catch (_) {
31
+ return Buffer.alloc(0);
32
+ } finally {
33
+ if (fd !== undefined) try { fs.closeSync(fd); } catch (_) { /* closed */ }
34
+ }
35
+ }
36
+
37
+ function pngSize(b) {
38
+ if (b.length < 24 || b.readUInt32BE(0) !== 0x89504e47) return null;
39
+ return { width: b.readUInt32BE(16), height: b.readUInt32BE(20) };
40
+ }
41
+
42
+ function gifSize(b) {
43
+ if (b.length < 10 || b.toString('ascii', 0, 3) !== 'GIF') return null;
44
+ return { width: b.readUInt16LE(6), height: b.readUInt16LE(8) };
45
+ }
46
+
47
+ function jpegSize(b) {
48
+ if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) return null;
49
+ let i = 2;
50
+ while (i + 9 < b.length) {
51
+ if (b[i] !== 0xff) { i += 1; continue; }
52
+ const marker = b[i + 1];
53
+ if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01) { i += 2; continue; }
54
+ const len = b.readUInt16BE(i + 2);
55
+ // SOF0..SOF15, less the ones that are not frames (DHT, JPG, DAC).
56
+ if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
57
+ return { width: b.readUInt16BE(i + 7), height: b.readUInt16BE(i + 5) };
58
+ }
59
+ i += 2 + len;
60
+ }
61
+ return null;
62
+ }
63
+
64
+ function webpSize(b) {
65
+ if (b.length < 30 || b.toString('ascii', 0, 4) !== 'RIFF' || b.toString('ascii', 8, 12) !== 'WEBP') return null;
66
+ const chunk = b.toString('ascii', 12, 16);
67
+ if (chunk === 'VP8 ') return { width: b.readUInt16LE(26) & 0x3fff, height: b.readUInt16LE(28) & 0x3fff };
68
+ if (chunk === 'VP8L') {
69
+ const bits = b.readUInt32LE(21);
70
+ return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };
71
+ }
72
+ if (chunk === 'VP8X') return { width: b.readUIntLE(24, 3) + 1, height: b.readUIntLE(27, 3) + 1 };
73
+ return null;
74
+ }
75
+
76
+ function avifSize(b) {
77
+ const at = b.indexOf('ispe');
78
+ if (at < 0 || at + 16 > b.length) return null;
79
+ return { width: b.readUInt32BE(at + 8), height: b.readUInt32BE(at + 12) };
80
+ }
81
+
82
+ function svgSize(b) {
83
+ const text = b.toString('utf8');
84
+ const tag = (text.match(/<svg\b[^>]*>/i) || [])[0];
85
+ if (!tag) return null;
86
+ const attr = (n) => { const m = tag.match(new RegExp(`\\s${n}\\s*=\\s*["']([\\d.]+)(px)?["']`, 'i')); return m ? Math.round(Number(m[1])) : 0; };
87
+ let width = attr('width');
88
+ let height = attr('height');
89
+ const vb = tag.match(/viewBox\s*=\s*["'][-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)["']/i);
90
+ if ((!width || !height) && vb) { width = width || Math.round(Number(vb[1])); height = height || Math.round(Number(vb[2])); }
91
+ return width && height ? { width, height } : null;
92
+ }
93
+
94
+ const dimCache = new Map();
95
+ const DIM_CACHE_MAX = 5000;
96
+
97
+ /** { width, height } of an image, or null when its header does not say. */
98
+ function dimensions(file, stat) {
99
+ const st = stat || (() => { try { return fs.statSync(file); } catch (_) { return null; } })();
100
+ if (!st) return null;
101
+ const key = `${file}|${st.size}|${st.mtimeMs}`;
102
+ if (dimCache.has(key)) return dimCache.get(key);
103
+ const type = typeOf(file);
104
+ // A JPEG's frame header can sit after a large EXIF block, so it gets a longer read.
105
+ const head = readHead(file, type === 'jpg' ? 262144 : 65536);
106
+ const parse = { png: pngSize, gif: gifSize, jpg: jpegSize, webp: webpSize, avif: avifSize, svg: svgSize }[type];
107
+ let out = null;
108
+ try { out = parse ? parse(head) : null; } catch (_) { out = null; }
109
+ if (dimCache.size >= DIM_CACHE_MAX) dimCache.delete(dimCache.keys().next().value);
110
+ dimCache.set(key, out);
111
+ return out;
112
+ }
113
+
114
+ // ---------------------------------------------------------------- listing --
115
+
116
+ /** Resolve rel inside root, following links, or null when it leads out. */
117
+ function safeIn(root, rel) {
118
+ const clean = String(rel || '').replace(/^\/+|\/+$/g, '');
119
+ if (clean.includes('\0') || clean.split('/').some((s) => s === '..' || s.startsWith('.'))) return null;
120
+ try {
121
+ const base = fs.realpathSync(root);
122
+ const abs = fs.realpathSync(path.join(base, clean));
123
+ if (abs !== base && !abs.startsWith(base + path.sep)) return null;
124
+ return { base, abs, rel: path.relative(base, abs).split(path.sep).join('/') };
125
+ } catch (_) {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ /** The sidecar that says how an image was made, when there is one. */
131
+ function sidecarOf(abs) {
132
+ try {
133
+ const j = JSON.parse(fs.readFileSync(`${abs}.json`, 'utf8'));
134
+ return j && typeof j === 'object' ? j : null;
135
+ } catch (_) {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ /** One image, as the gallery and the viewer use it. */
141
+ function describe(abs, rel, stat) {
142
+ const st = stat || fs.statSync(abs);
143
+ const dims = dimensions(abs, st);
144
+ return {
145
+ name: path.basename(abs), rel, type: typeOf(abs), size: st.size, mtime: st.mtime,
146
+ width: dims ? dims.width : 0, height: dims ? dims.height : 0,
147
+ };
148
+ }
149
+
150
+ /** The folders and images in one folder of a workspace. */
151
+ function listFolder(root, rel) {
152
+ const where = safeIn(root, rel);
153
+ if (!where) return { ok: false, folders: [], images: [], rel: '' };
154
+ const folders = [];
155
+ const images = [];
156
+ let entries = [];
157
+ try { entries = fs.readdirSync(where.abs, { withFileTypes: true }); } catch (_) { /* unreadable */ }
158
+ for (const e of entries) {
159
+ if (e.name.startsWith('.')) continue;
160
+ const childRel = where.rel ? `${where.rel}/${e.name}` : e.name;
161
+ const abs = path.join(where.abs, e.name);
162
+ if (e.isDirectory()) {
163
+ if (NOISE_DIRS.has(e.name)) continue;
164
+ let count = 0;
165
+ let subs = 0;
166
+ try {
167
+ for (const c of fs.readdirSync(abs, { withFileTypes: true })) {
168
+ if (c.name.startsWith('.')) continue;
169
+ if (c.isDirectory() && !NOISE_DIRS.has(c.name)) subs += 1;
170
+ else if (c.isFile() && IMAGE_RE.test(c.name)) count += 1;
171
+ }
172
+ } catch (_) { /* unreadable */ }
173
+ let mtime = new Date(0);
174
+ try { mtime = fs.statSync(abs).mtime; } catch (_) { /* gone */ }
175
+ folders.push({ folder: true, name: e.name, rel: childRel, count, folders: subs, mtime, size: 0 });
176
+ } else if (e.isFile() && IMAGE_RE.test(e.name)) {
177
+ try { images.push(describe(abs, childRel)); } catch (_) { /* vanished */ }
178
+ }
179
+ }
180
+ return { ok: true, folders, images, rel: where.rel };
181
+ }
182
+
183
+ // ------------------------------------------------------------- everywhere --
184
+
185
+ // Read-only sources for "All media images". Workspaces of documents and media, never of sites.
186
+ const EVERYWHERE = ['images', 'docs', 'videos', 'audio', 'specs', 'designs'];
187
+ const APP_SHOTS = 'dashboard';
188
+ const appShotsDir = () => path.join(ROOT, 'core', 'workspace-app', 'docs', 'screenshots');
189
+ const SCAN_DEPTH = 4;
190
+ // Each source gets its own share, so one large folder of docs cannot crowd out the rest.
191
+ const SCAN_MAX = 1000;
192
+ const SCAN_TTL = 60 * 1000;
193
+ let scanMemo = { at: 0, list: null };
194
+
195
+ function walk(root, src, out) {
196
+ const start = out.length;
197
+ const full = () => out.length - start >= SCAN_MAX;
198
+ const go = (dir, rel, depth) => {
199
+ if (depth > SCAN_DEPTH || full()) return;
200
+ let entries = [];
201
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
202
+ for (const e of entries) {
203
+ if (full()) return;
204
+ if (e.name.startsWith('.') || NOISE_DIRS.has(e.name)) continue;
205
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
206
+ const abs = path.join(dir, e.name);
207
+ if (e.isDirectory()) go(abs, childRel, depth + 1);
208
+ else if (e.isFile() && IMAGE_RE.test(e.name)) {
209
+ try { out.push({ ...describe(abs, childRel), src }); } catch (_) { /* vanished */ }
210
+ }
211
+ }
212
+ };
213
+ go(root, '', 0);
214
+ return full();
215
+ }
216
+
217
+ /** Every image in the media and document workspaces, and the dashboard's own screenshots. */
218
+ function scanEverywhere({ fresh = false } = {}) {
219
+ if (!fresh && scanMemo.list && Date.now() - scanMemo.at < SCAN_TTL) return scanMemo.list;
220
+ const out = [];
221
+ const capped = [];
222
+ for (const ws of EVERYWHERE) if (isValidWorkspace(ws) && walk(workspaceDir(ws), ws, out)) capped.push(ws);
223
+ if (fs.existsSync(appShotsDir()) && walk(appShotsDir(), APP_SHOTS, out)) capped.push(APP_SHOTS);
224
+ out.capped = capped;
225
+ scanMemo = { at: Date.now(), list: out };
226
+ return out;
227
+ }
228
+
229
+ function forgetScan() { scanMemo = { at: 0, list: null }; }
230
+
231
+ /** Where an image of a source lives on disk, or null. */
232
+ function sourceRoot(src) {
233
+ if (src === APP_SHOTS) return appShotsDir();
234
+ return EVERYWHERE.includes(src) && isValidWorkspace(src) ? workspaceDir(src) : null;
235
+ }
236
+
237
+ // ------------------------------------------------------------------ names --
238
+
239
+ /** A file name made safe: letters, digits, dot, dash and underscore, and an image extension kept. */
240
+ function safeName(name) {
241
+ const base = path.basename(String(name || '')).replace(/\s+/g, '-').replace(/[^A-Za-z0-9._-]/g, '').replace(/^[.-]+/, '');
242
+ return base.slice(0, 120);
243
+ }
244
+
245
+ /** The first free name for <stem>.<ext> in dir: stem, stem-2, stem-3… */
246
+ function freeName(dir, stem, ext) {
247
+ const clean = safeName(stem).replace(/\.[^.]*$/, '') || 'image';
248
+ for (let n = 1; n < 10000; n += 1) {
249
+ const name = n === 1 ? `${clean}.${ext}` : `${clean}-${n}.${ext}`;
250
+ if (!fs.existsSync(path.join(dir, name))) return name;
251
+ }
252
+ return `${clean}-${Date.now()}.${ext}`;
253
+ }
254
+
255
+ /** The next version of a file: <stem>-v2.<ext>, -v3… never the original. */
256
+ function versionName(dir, file, ext) {
257
+ const stem = path.basename(file).replace(/\.[^.]*$/, '').replace(/-v\d+$/, '');
258
+ for (let n = 2; n < 10000; n += 1) {
259
+ const name = `${stem}-v${n}.${ext}`;
260
+ if (!fs.existsSync(path.join(dir, name))) return name;
261
+ }
262
+ return `${stem}-v${Date.now()}.${ext}`;
263
+ }
264
+
265
+ module.exports = {
266
+ IMAGE_RE, TYPES, typeOf, dimensions, safeIn, sidecarOf, describe, listFolder,
267
+ EVERYWHERE, APP_SHOTS, SCAN_MAX, appShotsDir, scanEverywhere, forgetScan, sourceRoot,
268
+ safeName, freeName, versionName,
269
+ _parsers: { pngSize, gifSize, jpegSize, webpSize, avifSize, svgSize },
270
+ };