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

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 (311) 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/graphs.js +107 -0
  16. package/actions/images-svg.js +33 -0
  17. package/actions/images.js +145 -0
  18. package/actions/navigation.js +190 -0
  19. package/actions/node.js +222 -0
  20. package/actions/notes.js +55 -0
  21. package/actions/obsidian.js +59 -0
  22. package/actions/plugins.js +36 -0
  23. package/actions/project-make.js +28 -0
  24. package/actions/rag.js +202 -0
  25. package/actions/row-actions.js +262 -0
  26. package/actions/screens.js +270 -0
  27. package/actions/testing.js +121 -0
  28. package/actions/video-edit.js +54 -0
  29. package/ai-inventory.js +182 -0
  30. package/ai-items.js +202 -0
  31. package/assistant-cast.js +176 -0
  32. package/assistant-suggestions.js +91 -0
  33. package/assistant.js +484 -0
  34. package/audio-voice.js +528 -0
  35. package/auth-google.js +260 -0
  36. package/build-ai.js +52 -0
  37. package/build-order-page.js +191 -0
  38. package/build-order.js +313 -0
  39. package/builder-args.js +117 -0
  40. package/changelog.js +302 -0
  41. package/chat-threads.js +134 -0
  42. package/checks.js +105 -0
  43. package/claude-args.js +282 -0
  44. package/claude-sessions.js +129 -0
  45. package/command-author.js +117 -0
  46. package/command-block.js +20 -0
  47. package/commands/defaults.yml +159 -0
  48. package/commands-page.js +460 -0
  49. package/commands.js +568 -0
  50. package/cucumber.js +43 -0
  51. package/ddev-addons-page.js +124 -0
  52. package/ddev-addons.js +154 -0
  53. package/ddev-custom-commands.js +96 -0
  54. package/ddev-domains.js +84 -0
  55. package/ddev-import.js +93 -0
  56. package/ddev-mail-page.js +167 -0
  57. package/ddev-mail.js +110 -0
  58. package/ddev-options.js +145 -0
  59. package/ddev-snapshots.js +126 -0
  60. package/ddev.js +145 -0
  61. package/docs/DEVELOPER-GUIDE.md +224 -0
  62. package/docs/README.md +252 -0
  63. package/docs/REFERENCE.md +684 -0
  64. package/docs/USER-GUIDE.md +697 -0
  65. package/docs/screenshots/assistant/avatar-board.png +0 -0
  66. package/docs/screenshots/assistant/avatar-coffee.png +0 -0
  67. package/docs/screenshots/assistant/avatar-colours.png +0 -0
  68. package/docs/screenshots/assistant/avatar-icons.png +0 -0
  69. package/docs/screenshots/assistant/avatar-moves.png +0 -0
  70. package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
  71. package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
  72. package/docs/screenshots/assistant/avatar-status.png +0 -0
  73. package/docs/screenshots/create-card/create-card.png +0 -0
  74. package/docs/screenshots/create-card/start-ai.png +0 -0
  75. package/docs/screenshots/guide-clone-address.png +0 -0
  76. package/docs/screenshots/guide-clone-worktree-off.png +0 -0
  77. package/docs/screenshots/guide-clone-worktree-on.png +0 -0
  78. package/docs/screenshots/guide-ddev-menu.png +0 -0
  79. package/docs/screenshots/guide-domains.png +0 -0
  80. package/docs/screenshots/guide-home.png +0 -0
  81. package/docs/screenshots/guide-ide-menu.png +0 -0
  82. package/docs/screenshots/guide-remove-refusal.png +0 -0
  83. package/docs/screenshots/guide-rows-worktree.png +0 -0
  84. package/docs/screenshots/guide-screens.png +0 -0
  85. package/docs/screenshots/guide-settings.png +0 -0
  86. package/docs/screenshots/guide-workspace-page.png +0 -0
  87. package/docs/screenshots/home/card-settings.png +0 -0
  88. package/docs/screenshots/home/drag-reorder.png +0 -0
  89. package/docs/screenshots/home/edit-card.png +0 -0
  90. package/docs/screenshots/images/claude-code-image.png +0 -0
  91. package/docs/screenshots/images/edit-with-ai.png +0 -0
  92. package/docs/screenshots/images/gallery.png +0 -0
  93. package/docs/screenshots/images/generate-description.png +0 -0
  94. package/docs/screenshots/images/generate-plan.png +0 -0
  95. package/docs/screenshots/images/providers.png +0 -0
  96. package/docs/screenshots/images/svg-form.png +0 -0
  97. package/docs/screenshots/images/svg-result.png +0 -0
  98. package/docs/screenshots/images/upload.png +0 -0
  99. package/docs/screenshots/new-folder/2-form.png +0 -0
  100. package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
  101. package/docs/screenshots/new-folder/files-folder.png +0 -0
  102. package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
  103. package/docs/screenshots/projects/state-chip-stop.png +0 -0
  104. package/docs/screenshots/readme/dashboard-dark.png +0 -0
  105. package/docs/screenshots/readme/dashboard-light.png +0 -0
  106. package/docs/screenshots/readme/dev-dark.png +0 -0
  107. package/docs/screenshots/readme/dev-light.png +0 -0
  108. package/docs/screenshots/readme/docs-dark.png +0 -0
  109. package/docs/screenshots/readme/docs-light.png +0 -0
  110. package/docs/screenshots/readme/icons/ai.svg +4 -0
  111. package/docs/screenshots/readme/icons/code.svg +4 -0
  112. package/docs/screenshots/readme/icons/docs.svg +4 -0
  113. package/docs/screenshots/readme/icons/services.svg +4 -0
  114. package/docs/screenshots/readme/icons/sites.svg +4 -0
  115. package/docs/screenshots/readme/test-report-dark.png +0 -0
  116. package/docs/screenshots/readme/test-report-light.png +0 -0
  117. package/docs/screenshots/readme/test-scenarios.png +0 -0
  118. package/docs/screenshots/readme/test-steps.png +0 -0
  119. package/docs/screenshots/readme/videos-dark.png +0 -0
  120. package/docs/screenshots/readme/videos-light.png +0 -0
  121. package/docs/screenshots/screens-menu.png +0 -0
  122. package/docs/screenshots/screens-new.png +0 -0
  123. package/docs/screenshots/settings/auth-google-lists.png +0 -0
  124. package/docs/screenshots/settings/delete-1-row.png +0 -0
  125. package/docs/screenshots/settings/delete-2-confirm.png +0 -0
  126. package/docs/screenshots/settings/delete-3-type-name.png +0 -0
  127. package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
  128. package/event-log.js +59 -0
  129. package/files-browser.js +247 -0
  130. package/folders.js +121 -0
  131. package/format.js +73 -0
  132. package/git-info.js +81 -0
  133. package/github-auth.js +62 -0
  134. package/graphs.js +104 -0
  135. package/html.js +10 -0
  136. package/icons.js +83 -0
  137. package/images-ai.js +447 -0
  138. package/images-create.js +226 -0
  139. package/images-page.js +417 -0
  140. package/images-records.js +39 -0
  141. package/images-render.js +36 -0
  142. package/images-svg-core.js +214 -0
  143. package/images-svg-sanitize.js +188 -0
  144. package/images-svg.js +241 -0
  145. package/images.js +270 -0
  146. package/jobs.js +430 -0
  147. package/linger.js +21 -0
  148. package/lists.js +292 -0
  149. package/media-info.js +136 -0
  150. package/navigation-page.js +264 -0
  151. package/navigation.js +394 -0
  152. package/notes.js +0 -0
  153. package/obsidian.js +405 -0
  154. package/package.json +47 -4
  155. package/palette-index.js +46 -0
  156. package/playwright.config.js +23 -0
  157. package/plugin-install.js +239 -0
  158. package/plugin-loader.js +448 -0
  159. package/plugin-manifest.js +83 -0
  160. package/plugin-settings.js +44 -0
  161. package/plugins/workspace-notes/index.js +59 -0
  162. package/plugins-page.js +103 -0
  163. package/project-make.js +241 -0
  164. package/project-name.js +210 -0
  165. package/public/assistant-avatar-moves.js +646 -0
  166. package/public/assistant-avatar-ui.js +1223 -0
  167. package/public/assistant-avatar.js +530 -0
  168. package/public/avatar/druplicon.vrm +0 -0
  169. package/public/css/base.css +260 -0
  170. package/public/css/components/ai-assistant.css +263 -0
  171. package/public/css/components/assistant-avatar.css +279 -0
  172. package/public/css/components/assistant-views.css +198 -0
  173. package/public/css/components/build-order.css +74 -0
  174. package/public/css/components/clone-as-a-git-worktree.css +30 -0
  175. package/public/css/components/command-palette.css +61 -0
  176. package/public/css/components/commands-center.css +139 -0
  177. package/public/css/components/confirm-dialog.css +40 -0
  178. package/public/css/components/create-card.css +8 -0
  179. package/public/css/components/ddev-menu.css +23 -0
  180. package/public/css/components/deep-chat.css +17 -0
  181. package/public/css/components/density.css +11 -0
  182. package/public/css/components/floating-assistant-workspace-pages.css +55 -0
  183. package/public/css/components/folders.css +5 -0
  184. package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
  185. package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
  186. package/public/css/components/grid-and-list.css +203 -0
  187. package/public/css/components/help-page.css +36 -0
  188. package/public/css/components/hero.css +16 -0
  189. package/public/css/components/images.css +70 -0
  190. package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
  191. package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
  192. package/public/css/components/modal-fit.css +113 -0
  193. package/public/css/components/page-width.css +23 -0
  194. package/public/css/components/plugins-page.css +9 -0
  195. package/public/css/components/project-cards.css +256 -0
  196. package/public/css/components/project-rows.css +390 -0
  197. package/public/css/components/reordering-the-workspace-cards.css +85 -0
  198. package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
  199. package/public/css/components/running-commands-as-toasts.css +327 -0
  200. package/public/css/components/settings-editor-core-config-yml.css +828 -0
  201. package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
  202. package/public/css/components/sound-player.css +84 -0
  203. package/public/css/components/suggest-agents-skills-commands.css +112 -0
  204. package/public/css/components/tab-modal.css +142 -0
  205. package/public/css/components/terminals.css +244 -0
  206. package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
  207. package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
  208. package/public/css/components/video-edit.css +6 -0
  209. package/public/css/components/web-ide-open-with.css +23 -0
  210. package/public/css/components/working-indicators.css +158 -0
  211. package/public/css/components/workspace-cards.css +79 -0
  212. package/public/favicon.ico +0 -0
  213. package/public/favicon.png +0 -0
  214. package/public/htmx.min.js +1 -0
  215. package/public/logo.png +0 -0
  216. package/public/media-player.js +215 -0
  217. package/public/mic.js +255 -0
  218. package/public/narration.js +110 -0
  219. package/public/palette.js +278 -0
  220. package/public/sortable.js +251 -0
  221. package/public/suggest.js +337 -0
  222. package/public/terminal.js +903 -0
  223. package/public/themes/default/theme.css +267 -0
  224. package/public/themes/ember/theme.css +180 -0
  225. package/public/themes/press/theme.css +290 -0
  226. package/public/themes/slate/theme.css +182 -0
  227. package/public/touch-icon.png +0 -0
  228. package/public/ui.js +2721 -0
  229. package/public/vendor/ace/LICENSE.txt +24 -0
  230. package/public/vendor/ace/ace.js +23 -0
  231. package/public/vendor/ace/ext-language_tools.js +8 -0
  232. package/public/vendor/ace/ext-searchbox.js +8 -0
  233. package/public/vendor/ace/mode-css.js +8 -0
  234. package/public/vendor/ace/mode-html.js +8 -0
  235. package/public/vendor/ace/mode-javascript.js +8 -0
  236. package/public/vendor/ace/mode-markdown.js +8 -0
  237. package/public/vendor/ace/mode-sh.js +8 -0
  238. package/public/vendor/ace/mode-yaml.js +8 -0
  239. package/public/vendor/ace/theme-textmate.js +8 -0
  240. package/public/vendor/ace/theme-tomorrow_night.js +8 -0
  241. package/public/vendor/ace/worker-yaml.js +1 -0
  242. package/public/vendor/deep-chat.LICENSE +21 -0
  243. package/public/vendor/deep-chat.bundle.js +1 -0
  244. package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
  245. package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
  246. package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
  247. package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
  248. package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
  249. package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
  250. package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
  251. package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
  252. package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
  253. package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  254. package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
  255. package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
  256. package/public/vendor/tabler-LICENSE.txt +21 -0
  257. package/public/vendor/tabler-sprite.svg +1 -0
  258. package/public/vendor/three/LICENSE +21 -0
  259. package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
  260. package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
  261. package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
  262. package/public/vendor/three/three.core.min.js +6 -0
  263. package/public/vendor/three/three.module.min.js +6 -0
  264. package/public/vendor/three-vrm/LICENSE +22 -0
  265. package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
  266. package/public/vendor/uikit-icons.min.js +1 -0
  267. package/public/vendor/uikit.min.css +1 -0
  268. package/public/vendor/uikit.min.js +1 -0
  269. package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
  270. package/public/vendor/webship-icons/ui/headset.svg +1 -0
  271. package/public/vendor/webship-icons/ui/menu.svg +1 -0
  272. package/public/vendor/webship-icons/ui/pause.svg +1 -0
  273. package/public/vendor/webship-icons/ui/play.svg +1 -0
  274. package/public/vendor/webship-icons/ui/share.svg +1 -0
  275. package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
  276. package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
  277. package/public/vendor/webship-icons/ui/stop.svg +1 -0
  278. package/public/vendor/xterm/addon-fit.js +2 -0
  279. package/public/vendor/xterm/addon-unicode11.js +2 -0
  280. package/public/vendor/xterm/addon-web-links.js +2 -0
  281. package/public/vendor/xterm/xterm.css +218 -0
  282. package/public/vendor/xterm/xterm.js +2 -0
  283. package/rag.js +169 -0
  284. package/reader.js +132 -0
  285. package/repo-blobs.js +66 -0
  286. package/report-pdf.js +337 -0
  287. package/row-layout.js +1440 -0
  288. package/screenrc +37 -0
  289. package/screens.js +1063 -0
  290. package/scripts/code-server-start.sh +190 -0
  291. package/scripts/images-claude-finish.js +107 -0
  292. package/scripts/screen-host.sh +114 -0
  293. package/scripts/screen-runner.js +362 -0
  294. package/scripts/screen-then.js +47 -0
  295. package/scripts/update.sh +350 -0
  296. package/server.js +8140 -0
  297. package/settings-file.js +423 -0
  298. package/speech.js +297 -0
  299. package/tab-modal.js +75 -0
  300. package/test-reports.js +260 -0
  301. package/themes.js +381 -0
  302. package/transcripts.js +168 -0
  303. package/urls.js +13 -0
  304. package/validate.js +18 -0
  305. package/version-check.js +94 -0
  306. package/video-edit.js +588 -0
  307. package/waiting.js +85 -0
  308. package/workspace-card-tools.js +41 -0
  309. package/workspace-delete.js +139 -0
  310. package/workspace-types.js +85 -0
  311. package/workspaces.js +557 -0
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Making images in the images workspace: upload, a screenshot of a web page, and an edit saved as a new
3
+ * file. Everything runs in the dashboard container, which has ImageMagick and chromium.
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const { execFile } = require('child_process');
9
+
10
+ const { esc } = require('./html');
11
+ const { workspaceDir } = require('./workspaces');
12
+ const images = require('./images');
13
+
14
+ const MAX_UPLOAD = 25 * 1024 * 1024;
15
+ const WIDTHS = ['1280', '1440', '1600', '1920'];
16
+ const FORMATS = ['png', 'jpg', 'webp'];
17
+ const CROPS = { '1:1': [1, 1], '16:9': [16, 9], '4:3': [4, 3], '3:2': [3, 2], '9:16': [9, 16] };
18
+ const URL_RE = /^https?:\/\/[a-zA-Z0-9.:\/_~%?#&=+-]+$/;
19
+
20
+ /** The images folder a request names, made if missing, or null when it leads out. */
21
+ function targetDir(rel) {
22
+ const root = workspaceDir('images');
23
+ const clean = String(rel || '').replace(/^\/+|\/+$/g, '');
24
+ if (clean && !/^[A-Za-z0-9._ -]+(\/[A-Za-z0-9._ -]+)*$/.test(clean)) return null;
25
+ if (clean.split('/').some((s) => s === '..' || s.startsWith('.'))) return null;
26
+ fs.mkdirSync(path.join(root, clean), { recursive: true });
27
+ const where = images.safeIn(root, clean);
28
+ return where ? where.abs : null;
29
+ }
30
+
31
+ /** What the first bytes say a file is, so an upload is an image whatever it is called. */
32
+ function sniff(buf) {
33
+ if (buf.length >= 8 && buf.readUInt32BE(0) === 0x89504e47) return 'png';
34
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'jpg';
35
+ if (buf.length >= 6 && buf.toString('ascii', 0, 3) === 'GIF') return 'gif';
36
+ if (buf.length >= 12 && buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') return 'webp';
37
+ if (buf.length >= 12 && buf.toString('ascii', 4, 8) === 'ftyp' && /^avi[fs]/.test(buf.toString('ascii', 8, 12))) return 'avif';
38
+ if (/^\s*(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-->\s*)*(<!DOCTYPE svg[^>]*>\s*)?<svg\b/i.test(buf.toString('utf8', 0, Math.min(buf.length, 4096)))) return 'svg';
39
+ return '';
40
+ }
41
+
42
+ /** POST /actions/image-upload: one file per request, its bytes as the body. */
43
+ function upload(req, res) {
44
+ const q = new URL(req.url, 'http://localhost').searchParams;
45
+ const reply = (status, html) => { res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(html); };
46
+ const dir = targetDir(q.get('path'));
47
+ if (!dir) return reply(400, '<div class="msg error">That folder is not in the images workspace.</div>');
48
+ const declared = Number(req.headers['content-length'] || 0);
49
+ if (declared > MAX_UPLOAD) return reply(413, '<div class="msg error">An image can be 25 MB at most.</div>');
50
+ const chunks = [];
51
+ let size = 0;
52
+ let tooBig = false;
53
+ req.on('data', (d) => { size += d.length; if (size > MAX_UPLOAD) { tooBig = true; req.destroy(); } else chunks.push(d); });
54
+ req.on('end', () => {
55
+ if (tooBig) return reply(413, '<div class="msg error">An image can be 25 MB at most.</div>');
56
+ const buf = Buffer.concat(chunks);
57
+ const type = sniff(buf);
58
+ if (!type) return reply(415, '<div class="msg error">That is not a PNG, JPEG, GIF, WebP, AVIF or SVG image.</div>');
59
+ const stem = images.safeName(q.get('name') || 'image').replace(/\.[^.]*$/, '') || 'image';
60
+ const name = images.freeName(dir, stem, type);
61
+ fs.writeFileSync(path.join(dir, name), buf);
62
+ images.forgetScan();
63
+ return reply(200, `<div class="msg assistant">Uploaded <strong>${esc(name)}</strong>.</div>`);
64
+ });
65
+ return undefined;
66
+ }
67
+
68
+ /** A screenshot of a web page into images/, with the container's chromium. */
69
+ async function screenshot(form) {
70
+ const url = String(form.url || '').trim();
71
+ if (!URL_RE.test(url)) return { ok: false, status: 400, error: 'Give a full http or https address.' };
72
+ const width = WIDTHS.includes(String(form.width)) ? Number(form.width) : 1600;
73
+ const height = width === 1600 ? 1000 : Math.round(width * 9 / 16);
74
+ const dir = targetDir(form.folder || 'screenshots');
75
+ if (!dir) return { ok: false, status: 400, error: 'That folder is not in the images workspace.' };
76
+ const stem = images.safeName(form.name || '') || `${new URL(url).hostname.replace(/[^a-z0-9]+/gi, '-')}-${new Date().toISOString().slice(0, 10)}`;
77
+ const name = images.freeName(dir, stem, 'png');
78
+ const { chromium } = require('playwright');
79
+ let browser;
80
+ try {
81
+ browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium', args: ['--no-sandbox'] });
82
+ const page = await browser.newPage({ viewport: { width, height }, colorScheme: form.scheme === 'dark' ? 'dark' : 'light', ignoreHTTPSErrors: true });
83
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 45000 }).catch(() => page.waitForTimeout(3000));
84
+ await page.screenshot({ path: path.join(dir, name), fullPage: form.full === '1' });
85
+ } catch (e) {
86
+ return { ok: false, status: 502, error: `The page could not be captured: ${String(e.message || e).split('\n')[0].slice(0, 200)}` };
87
+ } finally {
88
+ if (browser) await browser.close().catch(() => {});
89
+ }
90
+ images.forgetScan();
91
+ const rel = path.relative(fs.realpathSync(workspaceDir('images')), path.join(dir, name)).split(path.sep).join('/');
92
+ return { ok: true, rel, name };
93
+ }
94
+
95
+ /**
96
+ * The ImageMagick arguments for an edit, from fixed lists and checked numbers only, and the new file's name.
97
+ * `dims` is the source's { width, height }, which a crop needs.
98
+ */
99
+ function editArgs(src, dir, form, dims) {
100
+ const ext = images.typeOf(src);
101
+ const format = FORMATS.includes(form.format) ? form.format : (FORMATS.includes(ext) ? ext : 'png');
102
+ const args = [`${src}[0]`, '-auto-orient'];
103
+ const crop = CROPS[form.crop];
104
+ if (crop && dims && dims.width && dims.height) {
105
+ const [rw, rh] = crop;
106
+ let w = dims.width;
107
+ let h = Math.round(w * rh / rw);
108
+ if (h > dims.height) { h = dims.height; w = Math.round(h * rw / rh); }
109
+ args.push('-gravity', 'center', '-crop', `${w}x${h}+0+0`, '+repage');
110
+ }
111
+ const width = parseInt(form.width, 10);
112
+ if (Number.isInteger(width) && width >= 16 && width <= 8000) args.push('-resize', `${width}x`);
113
+ if (format !== 'png') {
114
+ const quality = parseInt(form.quality, 10);
115
+ args.push('-quality', String(Number.isInteger(quality) && quality >= 1 && quality <= 100 ? quality : 85));
116
+ }
117
+ const name = images.versionName(dir, path.basename(src), format);
118
+ args.push(`${format}:${path.join(dir, name)}`);
119
+ return { args, name, changed: !!crop || (Number.isInteger(width) && width >= 16 && width <= 8000) || format !== ext };
120
+ }
121
+
122
+ /** Run an edit; never touches the original. */
123
+ function edit(form) {
124
+ return new Promise((resolve) => {
125
+ const root = workspaceDir('images');
126
+ const where = images.safeIn(root, String(form.file || ''));
127
+ if (!where || !images.IMAGE_RE.test(where.abs)) return resolve({ ok: false, status: 404, error: 'That image is not in the images workspace.' });
128
+ const dims = images.dimensions(where.abs);
129
+ const { args, name, changed } = editArgs(where.abs, path.dirname(where.abs), form, dims);
130
+ if (!changed) return resolve({ ok: false, status: 400, error: 'Choose a width, a crop or another format.' });
131
+ execFile('convert', args, { timeout: 60000 }, (err, _o, stderr) => {
132
+ if (err) return resolve({ ok: false, status: 500, error: `ImageMagick refused it: ${String(stderr || err.message).slice(0, 200)}` });
133
+ images.forgetScan();
134
+ const rel = path.posix.join(path.posix.dirname(where.rel) === '.' ? '' : path.posix.dirname(where.rel), name);
135
+ return resolve({ ok: true, rel, name });
136
+ });
137
+ return undefined;
138
+ });
139
+ }
140
+
141
+ // ------------------------------------------------------------------- forms --
142
+
143
+ const folderOptions = (current) => {
144
+ const root = workspaceDir('images');
145
+ const out = [''];
146
+ const walk = (dir, rel, depth) => {
147
+ if (depth > 3) return;
148
+ let entries = [];
149
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
150
+ for (const e of entries) {
151
+ if (!e.isDirectory() || e.name.startsWith('.')) continue;
152
+ const r = rel ? `${rel}/${e.name}` : e.name;
153
+ out.push(r);
154
+ walk(path.join(dir, e.name), r, depth + 1);
155
+ }
156
+ };
157
+ walk(root, '', 0);
158
+ return out.sort().map((r) => `<option value="${esc(r)}"${r === current ? ' selected' : ''}>${esc(r ? `images/${r}` : 'images/ (the top)')}</option>`).join('');
159
+ };
160
+
161
+ function uploadFormHtml(rel) {
162
+ return `
163
+ <div class="editor-card img-upload" data-img-upload>
164
+ <h3 class="uk-margin-small-bottom">Upload images</h3>
165
+ <label class="uk-form-label" for="img-up-folder">Into</label>
166
+ <select class="uk-select uk-form-small uk-margin-small-bottom" id="img-up-folder" name="path" data-img-upload-folder>${folderOptions(rel)}</select>
167
+ <label class="img-drop" data-img-drop>
168
+ <input type="file" accept="image/png,image/jpeg,image/gif,image/webp,image/avif,image/svg+xml" multiple hidden data-img-upload-input>
169
+ <span class="img-drop-text">Drop images here, or <u>choose files</u></span>
170
+ <span class="uk-text-meta">PNG, JPEG, GIF, WebP, AVIF or SVG, 25 MB each. A name that is taken gets a number.</span>
171
+ </label>
172
+ <ul class="uk-list img-upload-log" data-img-upload-log></ul>
173
+ </div>`;
174
+ }
175
+
176
+ function screenshotFormHtml() {
177
+ return `
178
+ <div class="editor-card">
179
+ <h3 class="uk-margin-small-bottom">Screenshot a page</h3>
180
+ <form hx-post="/actions/image-screenshot" hx-target="#editor-modal-body" hx-swap="innerHTML">
181
+ <input type="hidden" name="workspace" value="images">
182
+ <label class="uk-form-label" for="img-shot-url">Address</label>
183
+ <input class="uk-input uk-margin-small-bottom" id="img-shot-url" name="url" type="url" required>
184
+ <div class="uk-grid-small uk-child-width-1-2@s" uk-grid>
185
+ <div><label class="uk-form-label" for="img-shot-name">File name</label>
186
+ <input class="uk-input uk-form-small" id="img-shot-name" name="name" maxlength="80"></div>
187
+ <div><label class="uk-form-label" for="img-shot-folder">Into</label>
188
+ <select class="uk-select uk-form-small" id="img-shot-folder" name="folder">${folderOptions('screenshots')}${
189
+ fs.existsSync(path.join(workspaceDir('images'), 'screenshots')) ? '' : '<option value="screenshots" selected>images/screenshots</option>'}</select></div>
190
+ <div><label class="uk-form-label" for="img-shot-width">Window width</label>
191
+ <select class="uk-select uk-form-small" id="img-shot-width" name="width">${WIDTHS.map((w) => `<option value="${w}"${w === '1600' ? ' selected' : ''}>${w} px</option>`).join('')}</select></div>
192
+ <div><label class="uk-form-label" for="img-shot-scheme">Colour scheme</label>
193
+ <select class="uk-select uk-form-small" id="img-shot-scheme" name="scheme"><option value="light">Light</option><option value="dark">Dark</option></select></div>
194
+ </div>
195
+ <label class="uk-display-block uk-margin-small-top"><input class="uk-checkbox" type="checkbox" name="full" value="1"> The whole page, not only the window</label>
196
+ <p class="uk-margin-small-top"><button class="uk-button uk-button-primary" type="submit">Take the screenshot</button></p>
197
+ </form>
198
+ </div>`;
199
+ }
200
+
201
+ /** The edit form, under an image in the viewer. */
202
+ function editFormHtml(rel) {
203
+ return `
204
+ <details class="img-edit">
205
+ <summary>Edit as a new file</summary>
206
+ <form hx-post="/actions/image-edit" hx-target="#editor-modal-body" hx-swap="innerHTML">
207
+ <input type="hidden" name="workspace" value="images">
208
+ <input type="hidden" name="file" value="${esc(rel)}">
209
+ <div class="uk-grid-small uk-child-width-1-4@s" uk-grid>
210
+ <div><label class="uk-form-label" for="img-ed-width">Width (px)</label>
211
+ <input class="uk-input uk-form-small" id="img-ed-width" name="width" type="number" min="16" max="8000"></div>
212
+ <div><label class="uk-form-label" for="img-ed-crop">Crop</label>
213
+ <select class="uk-select uk-form-small" id="img-ed-crop" name="crop"><option value="">No crop</option>${Object.keys(CROPS).map((c) => `<option value="${c}">${c}, centred</option>`).join('')}</select></div>
214
+ <div><label class="uk-form-label" for="img-ed-format">Format</label>
215
+ <select class="uk-select uk-form-small" id="img-ed-format" name="format"><option value="">Keep it</option>${FORMATS.map((f) => `<option value="${f}">${f.toUpperCase()}</option>`).join('')}</select></div>
216
+ <div><label class="uk-form-label" for="img-ed-quality">Quality (JPEG, WebP)</label>
217
+ <input class="uk-input uk-form-small" id="img-ed-quality" name="quality" type="number" min="1" max="100" value="85"></div>
218
+ </div>
219
+ <p class="uk-margin-small-top"><button class="uk-button uk-button-primary uk-button-small" type="submit">Save as a new file</button>
220
+ <span class="uk-text-meta">The original stays as it is; the new one is named <code>…-v2</code>.</span></p>
221
+ </form>
222
+ </details>`;
223
+ }
224
+
225
+ module.exports = { MAX_UPLOAD, WIDTHS, FORMATS, CROPS, targetDir, sniff, upload, screenshot, editArgs, edit,
226
+ uploadFormHtml, screenshotFormHtml, editFormHtml };