@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
package/lists.js ADDED
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Search, sort, filter and page — the controls every list on the dashboard shares.
3
+ *
4
+ * State travels in the query string and is remembered in a cookie per workspace, so a list you
5
+ * left filtered is still filtered when you come back to it, and a link you paste carries what you
6
+ * were looking at.
7
+ */
8
+
9
+ const { esc } = require('./html');
10
+ // hubDomain as well as styleSettings: setPageCookie writes its cookies against the hub domain so a
11
+ // preference set on workspace.ddev.site is still in force on dev.workspace.ddev.site. It was used
12
+ // there without being imported, which made every list 500 the moment `auto` appeared in the query —
13
+ // that is, on the first click of the auto-search checkbox, on every list in the app.
14
+ const { styleSettings, hubDomain } = require('./workspaces');
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Paging, shared by every list in the dashboard.
18
+ //
19
+ // The lists outgrew the page: 111 videos, 165 commands, 112 modules. Every row was
20
+ // rendered on every request, which is a large page whose actions sit a screenful
21
+ // below the fold. Paging is server-side because the rows are server-rendered —
22
+ // hiding them with CSS would still send all of them.
23
+ //
24
+ // `all` stays one of the choices on purpose: it is what every one of these lists did
25
+ // before, and it is the right answer when you want the browser's own find across the
26
+ // whole set. It must remain reachable rather than be replaced by paging.
27
+ // Overridable with `style: page_size:`. Read per call, not captured, so a settings edit
28
+ // applies without a restart.
29
+ // The page size a list starts at: the settings file's value, or the shipped default. A size picked
30
+ // from a pager wins over it, because that choice is in a cookie — a default is not an override.
31
+ const defaultPageSize = () => {
32
+ const v = styleSettings().pageSize;
33
+ return v === 'all' ? 'all' : Number(v);
34
+ };
35
+
36
+ const PAGE_SIZES = [20, 50, 100, 200, 'all'];
37
+
38
+ // What a project list can be narrowed to. Keyed so an unknown value falls back to 'all'
39
+ // rather than showing nothing.
40
+ const PROJECT_FILTERS = {
41
+ all: 'All',
42
+ running: 'Running',
43
+ stopped: 'Stopped',
44
+ nodddev: 'No DDEV config',
45
+ tested: 'Has a test report',
46
+ failing: 'Has failing tests',
47
+ // What is RUNNING in the project, not what the project is: a screen session, and the narrower case
48
+ // of one with Claude Code inside it. Both read the same sources the row's SCREENS and agent badges
49
+ // read, so a project that passes the filter is a project whose row says so.
50
+ screens: 'Screens running',
51
+ agents: 'Agent running',
52
+ };
53
+
54
+ const SORTS = {
55
+ newest: { label: 'Newest first', cmp: (a, b) => b.mtime - a.mtime || a.name.localeCompare(b.name) },
56
+ oldest: { label: 'Oldest first', cmp: (a, b) => a.mtime - b.mtime || a.name.localeCompare(b.name) },
57
+ name: { label: 'Name (A–Z)', cmp: (a, b) => a.name.localeCompare(b.name) },
58
+ nameDesc:{ label: 'Name (Z–A)', cmp: (a, b) => b.name.localeCompare(a.name) },
59
+ // File lists only: a project row has no size.
60
+ size: { label: 'Largest first', filesOnly: true, cmp: (a, b) => (b.size || 0) - (a.size || 0) || a.name.localeCompare(b.name) },
61
+ // Agents, then screens, then the newest — the projects somebody is working in come first. It reads
62
+ // fields only a PROJECT row carries, so it is offered on project lists alone (`projectsOnly`) and
63
+ // treats a missing count as none, which keeps a hand-typed `?sort=active` on any other list harmless.
64
+ active: { label: 'Active first', projectsOnly: true,
65
+ cmp: (a, b) => (b.agents || 0) - (a.agents || 0) || (b.screens || 0) - (a.screens || 0)
66
+ || b.mtime - a.mtime || a.name.localeCompare(b.name) },
67
+ };
68
+
69
+ const defaultSort = () => styleSettings().pageSort;
70
+
71
+ // The chosen size sticks in a COOKIE, not localStorage. The dashboard spans
72
+ // <workspace>.<hub> subdomains and localStorage is per-origin, so a choice made on
73
+ // the hub would be forgotten the moment you follow a rail link — the same limitation
74
+ // the theme and rail-collapsed preferences already have. Scoping the cookie to the
75
+ // hub domain makes every workspace subdomain read the one value.
76
+ //
77
+ // `list` names a list whose search, sort and filter STICK ("dev/projects", "ai-agents/items"): what
78
+ // was last chosen on it is read from a cookie when the request does not say, so a workspace opens the
79
+ // way it was left. A request that does say — any of q, sort, status, kind — wins and is remembered; a
80
+ // request carrying `reset` clears it. Without `list` nothing sticks, which is every other list.
81
+ function readPageState(req, list) {
82
+ const url = new URL(req.url, 'http://localhost');
83
+ const cookies = Object.fromEntries((req.headers.cookie || '').split(';')
84
+ .map((c) => c.trim().split('=')).filter((p) => p.length === 2));
85
+ const raw = url.searchParams.get('per') ?? cookies['ws-per'];
86
+ const valid = PAGE_SIZES.map(String).includes(String(raw));
87
+ const params = url.searchParams;
88
+ const views = readViews(cookies);
89
+ const saved = list ? (views[list] || {}) : {};
90
+ const reset = params.has('reset');
91
+ // What the request says, else what was last chosen, else nothing. Everything goes through the same
92
+ // checks below whichever way it arrived: a cookie is as untrusted as a query string.
93
+ const field = (k) => (params.has(k) ? params.get(k) : (reset ? '' : String(saved[k] ?? '')));
94
+ const sort = field('sort');
95
+ const status = PROJECT_FILTERS[field('status')] ? field('status') : 'all';
96
+ const state = {
97
+ per: valid ? (raw === 'all' ? 'all' : Number(raw)) : defaultPageSize(),
98
+ page: Math.max(1, Number(url.searchParams.get('page')) || 1),
99
+ fromQuery: url.searchParams.has('per'),
100
+ q: (field('q') || '').trim().slice(0, 200),
101
+ sort: SORTS[sort] ? sort : defaultSort(),
102
+ status,
103
+ // Which workspace folder a list is narrowed to. Sanitised to the shape a folder name can have
104
+ // rather than checked against the real ones — this module does not know what exists, and the
105
+ // page that does simply matches nothing for a name that is not there.
106
+ ws: (url.searchParams.get('ws') || 'all').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 40) || 'all',
107
+ // Which subfolder a file list is showing. Kept to the characters a path can hold and to a
108
+ // sane depth; whether it EXISTS, and whether it is inside the workspace, is decided by the page
109
+ // that has the workspace in hand — this module knows nothing about the filesystem.
110
+ path: (url.searchParams.get('path') || '').replace(/[^a-zA-Z0-9_./ '&()+,@\-]/g, '').slice(0, 400),
111
+ // What a file list is showing: folders first, files first, one or the other alone, or one kind of
112
+ // file. Sanitised to a slug here; which slugs MEAN anything is the file list's business.
113
+ kind: (field('kind') || '').replace(/[^a-z-]/g, '').slice(0, 20),
114
+ // The images gallery: this workspace, or every media image read-only.
115
+ scope: url.searchParams.get('scope') === 'all' ? 'all' : 'here',
116
+ // Search as you type, or only when asked. Remembered per browser like the page size; the
117
+ // hidden companion below is what makes an unchecked box mean "off" rather than "unspecified".
118
+ // getAll, not get: the checkbox posts its name twice (hidden 0, then the box) and get()
119
+ // returns the FIRST — which would read a ticked box as off. The box is emitted second, so
120
+ // the last value is the answer.
121
+ auto: url.searchParams.has('auto')
122
+ ? url.searchParams.getAll('auto').slice(-1)[0] === '1'
123
+ : (cookies['ws-autosearch'] ?? '1') === '1',
124
+ autoFromQuery: url.searchParams.has('auto'),
125
+ };
126
+ // What to write back: only when this request CHOSE something. A poll that carries none of the four
127
+ // must not rewrite the preference, exactly as a plain page-2 click must not.
128
+ const chose = reset || ['q', 'sort', 'status', 'kind'].some((k) => params.has(k));
129
+ state.view = list && chose ? { list, map: nextViews(views, list, state) } : null;
130
+ return state;
131
+ }
132
+
133
+ // The sticky choices of every list, in ONE cookie — a cookie per list would put twenty-odd of them on
134
+ // every request to every project site under the hub domain (the cookie is scoped to it), and a request
135
+ // header that grows with the number of workspaces is how a proxy ends up refusing it. Only what
136
+ // differs from the default is stored, so a list left alone costs nothing.
137
+ const VIEW_COOKIE = 'ws-view';
138
+ const VIEW_MAX_LISTS = 40;
139
+ const VIEW_MAX_BYTES = 3000;
140
+
141
+ function readViews(cookies) {
142
+ try {
143
+ const v = JSON.parse(decodeURIComponent(cookies[VIEW_COOKIE] || ''));
144
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
145
+ } catch (_) { return {}; }
146
+ }
147
+
148
+ function nextViews(views, list, state) {
149
+ const entry = {};
150
+ if (state.q) entry.q = state.q;
151
+ if (state.sort !== defaultSort()) entry.sort = state.sort;
152
+ if (state.status && state.status !== 'all') entry.status = state.status;
153
+ if (state.kind) entry.kind = state.kind;
154
+ const next = { ...views };
155
+ delete next[list]; // re-added last, so it is the newest entry
156
+ if (Object.keys(entry).length) next[list] = entry;
157
+ // Bounded: the oldest lists go first.
158
+ const keys = Object.keys(next);
159
+ while (keys.length > 1 && (keys.length > VIEW_MAX_LISTS
160
+ || encodeURIComponent(JSON.stringify(next)).length > VIEW_MAX_BYTES)) delete next[keys.shift()];
161
+ return next;
162
+ }
163
+
164
+ // Remember an explicit choice for the next request. Only when it was explicit: a
165
+ // plain page-2 click must not rewrite the preference.
166
+ function setPageCookie(res, state) {
167
+ const cookies = [];
168
+ if (state.fromQuery) {
169
+ cookies.push(`ws-per=${state.per}; Path=/; Domain=.${hubDomain()}; Max-Age=31536000; SameSite=Lax`);
170
+ }
171
+ if (state.autoFromQuery) {
172
+ cookies.push(`ws-autosearch=${state.auto ? '1' : '0'}; Path=/; Domain=.${hubDomain()}; Max-Age=31536000; SameSite=Lax`);
173
+ }
174
+ if (state.view) {
175
+ const domain = `Path=/; Domain=.${hubDomain()}; SameSite=Lax`;
176
+ cookies.push(Object.keys(state.view.map).length
177
+ ? `${VIEW_COOKIE}=${encodeURIComponent(JSON.stringify(state.view.map))}; ${domain}; Max-Age=31536000`
178
+ : `${VIEW_COOKIE}=; ${domain}; Max-Age=0`);
179
+ }
180
+ if (cookies.length) res.setHeader('Set-Cookie', cookies);
181
+ }
182
+
183
+ // Clamps the page rather than trusting it: ?page=99 on a 3-page list shows the last
184
+ // page, and deleting the only row on page 4 does not leave you staring at an empty one.
185
+ function paginate(list, state) {
186
+ const total = list.length;
187
+ if (!state || state.per === 'all') {
188
+ return { slice: list, page: 1, pages: 1, from: total ? 1 : 0, to: total, total };
189
+ }
190
+ const pages = Math.max(1, Math.ceil(total / state.per));
191
+ const page = Math.min(Math.max(1, state.page), pages);
192
+ const start = (page - 1) * state.per;
193
+ const slice = list.slice(start, start + state.per);
194
+ return { slice, page, pages, from: total ? start + 1 : 0, to: start + slice.length, total };
195
+ }
196
+
197
+ // Search and sort for one list. Typing re-fetches on a 220ms debounce; both controls
198
+ // send the other's value, so changing one keeps the other.
199
+ function listControlsHtml(url, target, state, noun, extra, hidden, opts) {
200
+ // Values this list carries that are not search or sort — today, the folder a file list is showing.
201
+ // They ride as hidden inputs (so `hx-include` sends them with every control) and are rebuilt into
202
+ // Reset's URL, because Reset builds its own link and would otherwise land you at the top of the
203
+ // workspace rather than clearing the search where you stand.
204
+ const carried = Object.entries(hidden || {}).filter(([, v]) => v !== undefined && v !== null && v !== '');
205
+ const carriedInputs = carried.map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`).join('');
206
+ // `reset` is what clears a list that sticks: leaving the sort and the filter out of this link used to
207
+ // mean "back to the defaults", and with a remembered choice it would mean "back to what was saved".
208
+ const resetQs = new URLSearchParams([['page', '1'], ['q', ''], ['reset', '1'], ...carried.map(([k, v]) => [k, String(v)])]);
209
+ // A sort that reads fields only a project row has is offered on project lists alone (`projectSorts`).
210
+ const sortOpts = Object.entries(SORTS)
211
+ .filter(([, v]) => !v.projectsOnly || (opts && opts.projectSorts))
212
+ .filter(([, v]) => !v.filesOnly || (opts && opts.fileSorts))
213
+ .map(([k, v]) => `<option value="${k}"${k === state.sort ? ' selected' : ''}>${esc(v.label)}</option>`).join('');
214
+ const auto = state.auto !== false;
215
+ // With auto on the wait is 700ms, not the 220ms this started at: a query is several words and
216
+ // firing per keystroke made the list flicker and re-page under the cursor. With auto off
217
+ // nothing is requested until Enter or the Search button, which is the calmer default for a
218
+ // long list on a slow tree.
219
+ const trigger = auto ? "keyup changed delay:700ms, search" : "keyup[key=='Enter'], search";
220
+ return `
221
+ <div class="list-controls">
222
+ ${carriedInputs}
223
+ <div class="lc-group lc-find">
224
+ <input class="uk-input uk-form-small list-search" type="search" name="q" value="${esc(state.q)}"
225
+ placeholder="Search ${esc(noun)}…" aria-label="Search ${esc(noun)}"
226
+ hx-get="${esc(url)}" hx-target="${esc(target)}" hx-swap="innerHTML"
227
+ hx-trigger="${trigger}"
228
+ hx-include="closest .list-controls" hx-vals='{"page":"1"}'>
229
+ <button class="uk-button uk-button-primary uk-button-small" type="button"
230
+ hx-get="${esc(url)}" hx-target="${esc(target)}" hx-swap="innerHTML"
231
+ hx-include="closest .list-controls" hx-vals='{"page":"1"}'
232
+ title="Search now"><span uk-icon="icon: search; ratio: .7"></span> Search</button>
233
+ <button class="uk-button uk-button-default uk-button-small list-reset" type="button"
234
+ hx-get="${esc(url)}?${esc(resetQs.toString())}" hx-target="${esc(target)}" hx-swap="innerHTML"
235
+ title="Clear the search and the filters"><span uk-icon="icon: close; ratio: .7"></span> Reset</button>
236
+ </div>
237
+ <div class="lc-rest">
238
+ <div class="lc-group">
239
+ <label class="uk-text-meta">Sort
240
+ <select class="uk-select uk-form-small" name="sort" aria-label="Sort ${esc(noun)}"
241
+ hx-get="${esc(url)}" hx-target="${esc(target)}" hx-swap="innerHTML"
242
+ hx-trigger="change" hx-include="closest .list-controls" hx-vals='{"page":"1"}'>${sortOpts}</select>
243
+ </label>
244
+ </div>
245
+ ${(Array.isArray(extra) ? extra : [extra]).filter(Boolean).map((x) => `<div class="lc-group">${x}</div>`).join('')}
246
+ <div class="lc-group">
247
+ <label class="list-auto uk-text-meta" title="Search while you type, after a short pause. Off means Enter or the Search button.">
248
+ <input type="hidden" name="auto" value="0">
249
+ <input class="uk-checkbox" type="checkbox" name="auto" value="1" ${auto ? 'checked' : ''}
250
+ hx-get="${esc(url)}" hx-target="${esc(target)}" hx-swap="innerHTML"
251
+ hx-trigger="change" hx-include="closest .list-controls" hx-vals='{"page":"1"}'>
252
+ Auto-search
253
+ </label>
254
+ </div>
255
+ </div>
256
+ </div>`;
257
+ }
258
+
259
+ // The pager and the size chooser for one list. `url` is the fragment the list came
260
+ // from, so paging refetches exactly the list on screen and leaves the rest of the
261
+ // page alone.
262
+ function listPagerHtml(url, target, p, state, noun) {
263
+ if (!p.total) return '';
264
+ const per = state ? state.per : 'all';
265
+ const step = (n, glyph, disabled, title) => disabled
266
+ ? `<span class="uk-button uk-button-default uk-button-small" aria-hidden="true" disabled>${glyph}</span>`
267
+ : `<button class="uk-button uk-button-default uk-button-small" title="${esc(title)}"
268
+ hx-get="${esc(url)}${url.includes('?') ? '&' : '?'}page=${n}&per=${per}"
269
+ hx-target="${esc(target)}" hx-swap="innerHTML">${glyph}</button>`;
270
+ // The select carries name="per", so HTMX sends its value; hx-vals adds page=1 —
271
+ // landing on page 7 of a list that now has 2 pages would otherwise show nothing.
272
+ const sizer = `
273
+ <label class="list-pager-size uk-text-meta">Per page
274
+ <select class="uk-select uk-form-small" name="per" aria-label="How many rows per page"
275
+ hx-get="${esc(url)}" hx-target="${esc(target)}" hx-swap="innerHTML"
276
+ hx-trigger="change" hx-vals='{"page":"1"}'>${PAGE_SIZES.map((s) => `<option value="${s}"${String(s) === String(per) ? ' selected' : ''}>${s === 'all' ? 'All' : s}</option>`).join('')}</select>
277
+ </label>`;
278
+ if (p.pages === 1) {
279
+ return `<div class="list-pager">${sizer}<span class="uk-text-meta">${p.total} ${esc(noun)}</span></div>`;
280
+ }
281
+ return `
282
+ <div class="list-pager">
283
+ ${sizer}
284
+ ${step(1, '«', p.page === 1, 'First page')}
285
+ ${step(p.page - 1, '‹', p.page === 1, 'Previous page')}
286
+ <span class="uk-text-meta">${p.from}–${p.to} of ${p.total} ${esc(noun)} · page ${p.page} of ${p.pages}</span>
287
+ ${step(p.page + 1, '›', p.page === p.pages, 'Next page')}
288
+ ${step(p.pages, '»', p.page === p.pages, 'Last page')}
289
+ </div>`;
290
+ }
291
+
292
+ module.exports = { PAGE_SIZES, defaultPageSize, PROJECT_FILTERS, SORTS, defaultSort, readPageState, setPageCookie, paginate, listControlsHtml, listPagerHtml };
package/media-info.js ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * How long a recording or a sound runs, read from the file itself.
3
+ *
4
+ * The dashboard's container has no ffprobe, and a card that shows a length is worth a few kilobytes of
5
+ * reading: mp4/m4v/mov/m4a from the movie header (at the front or the end of the file), webm from its
6
+ * Duration element, wav from its header, and mp3 from its first frame, which is exact for the constant
7
+ * bit rate files the tooling makes. Anything else is unknown, and unknown is shown as nothing.
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ const MP4_RE = /\.(mp4|m4v|mov|m4a)$/i;
14
+ const cache = new Map();
15
+
16
+ function read(fd, start, length) {
17
+ const buf = Buffer.alloc(length);
18
+ const got = fs.readSync(fd, buf, 0, length, start);
19
+ return got < length ? buf.subarray(0, got) : buf;
20
+ }
21
+
22
+ function mvhdSeconds(buf) {
23
+ const at = buf.indexOf('mvhd');
24
+ if (at < 0) return null;
25
+ try {
26
+ if (buf[at + 4] === 1) {
27
+ const scale = buf.readUInt32BE(at + 8 + 16);
28
+ return scale ? Number(buf.readBigUInt64BE(at + 8 + 20)) / scale : null;
29
+ }
30
+ const scale = buf.readUInt32BE(at + 8 + 8);
31
+ return scale ? buf.readUInt32BE(at + 8 + 12) / scale : null;
32
+ } catch (_) { return null; }
33
+ }
34
+
35
+ /** The movie header of an mp4-family file, wherever the encoder put it: walk the top-level boxes. */
36
+ function mp4Seconds(fd, size) {
37
+ let pos = 0;
38
+ for (let hops = 0; pos + 16 <= size && hops < 64; hops += 1) {
39
+ const head = read(fd, pos, 16);
40
+ let box = head.readUInt32BE(0);
41
+ const type = head.toString('latin1', 4, 8);
42
+ if (box === 1) box = Number(head.readBigUInt64BE(8));
43
+ else if (box === 0) box = size - pos;
44
+ if (type === 'moov') return mvhdSeconds(read(fd, pos, Math.min(box, 512)));
45
+ if (box < 8) return null;
46
+ pos += box;
47
+ }
48
+ return null;
49
+ }
50
+
51
+ function webmSeconds(fd, size) {
52
+ const buf = read(fd, 0, Math.min(262144, size));
53
+ const i = buf.indexOf(Buffer.from([0x44, 0x89]));
54
+ if (i < 0) return null;
55
+ const len = buf[i + 2] & 0x7f;
56
+ let value;
57
+ try {
58
+ if (len === 4) value = buf.readFloatBE(i + 3);
59
+ else if (len === 8) value = buf.readDoubleBE(i + 3);
60
+ else return null;
61
+ } catch (_) { return null; }
62
+ let scale = 1000000;
63
+ const k = buf.indexOf(Buffer.from([0x2a, 0xd7, 0xb1]));
64
+ if (k >= 0) {
65
+ const n = buf[k + 3] & 0x7f;
66
+ if (n > 0 && n <= 8) scale = Number(buf.readUIntBE(k + 4, n));
67
+ }
68
+ return (value * scale) / 1e9;
69
+ }
70
+
71
+ function wavSeconds(fd, size) {
72
+ const buf = read(fd, 0, Math.min(4096, size));
73
+ if (buf.toString('latin1', 0, 4) !== 'RIFF') return null;
74
+ const fmt = buf.indexOf('fmt ');
75
+ const data = buf.indexOf('data');
76
+ if (fmt < 0 || data < 0) return null;
77
+ const rate = buf.readUInt32LE(fmt + 8 + 8);
78
+ const bytes = Math.min(buf.readUInt32LE(data + 4), size - data - 8);
79
+ return rate ? bytes / rate : null;
80
+ }
81
+
82
+ const MP3_RATES = {
83
+ 1: [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320],
84
+ 2: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160],
85
+ };
86
+
87
+ function mp3Seconds(fd, size) {
88
+ const head = read(fd, 0, Math.min(65536, size));
89
+ let at = 0;
90
+ if (head.toString('latin1', 0, 3) === 'ID3') at = 10 + ((head[6] & 0x7f) << 21 | (head[7] & 0x7f) << 14 | (head[8] & 0x7f) << 7 | (head[9] & 0x7f));
91
+ for (let i = at; i + 4 < head.length; i += 1) {
92
+ if (head[i] !== 0xff || (head[i + 1] & 0xe0) !== 0xe0) continue;
93
+ const version = (head[i + 1] >> 3) & 3;
94
+ const layer = (head[i + 1] >> 1) & 3;
95
+ const index = head[i + 2] >> 4;
96
+ if (version === 1 || layer !== 1 || index === 0 || index === 15) continue;
97
+ const table = MP3_RATES[version === 3 ? 1 : 2];
98
+ const kbps = table[index];
99
+ return kbps ? ((size - i) * 8) / (kbps * 1000) : null;
100
+ }
101
+ return null;
102
+ }
103
+
104
+ /** Seconds, or null when the file cannot be read or is of a kind not understood. Cached on path, size and time. */
105
+ function seconds(full) {
106
+ let st;
107
+ try { st = fs.statSync(full); } catch (_) { return null; }
108
+ const key = `${full}|${st.size}|${st.mtimeMs}`;
109
+ if (cache.has(key)) return cache.get(key);
110
+ let found = null;
111
+ let fd;
112
+ try {
113
+ fd = fs.openSync(full, 'r');
114
+ const ext = path.extname(full).toLowerCase();
115
+ if (MP4_RE.test(ext)) found = mp4Seconds(fd, st.size);
116
+ else if (ext === '.webm' || ext === '.weba') found = webmSeconds(fd, st.size);
117
+ else if (ext === '.wav') found = wavSeconds(fd, st.size);
118
+ else if (ext === '.mp3') found = mp3Seconds(fd, st.size);
119
+ } catch (_) { found = null; } finally { if (fd !== undefined) fs.closeSync(fd); }
120
+ if (!(found > 0) || !Number.isFinite(found)) found = null;
121
+ cache.set(key, found);
122
+ if (cache.size > 4000) cache.delete(cache.keys().next().value);
123
+ return found;
124
+ }
125
+
126
+ /** 7:56, or 1:02:03: the way a player says it, with no leading zero on the largest unit. */
127
+ function clock(sec) {
128
+ if (!(sec > 0)) return '';
129
+ const s = Math.round(sec);
130
+ const h = Math.floor(s / 3600);
131
+ const m = Math.floor((s % 3600) / 60);
132
+ const pad = (n) => String(n).padStart(2, '0');
133
+ return h ? `${h}:${pad(m)}:${pad(s % 60)}` : `${m}:${pad(s % 60)}`;
134
+ }
135
+
136
+ module.exports = { seconds, clock };