@heycys/gallery 0.2.0 → 0.2.2

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 (144) hide show
  1. package/THIRD-PARTY-NOTICES.md +1 -1
  2. package/browser/editor/components/icon/page.mjs +6 -4
  3. package/browser/editor/components/icons.mjs +2 -2
  4. package/browser/editor/page/annotate/index.mjs +3 -2
  5. package/browser/editor/page/board/index.mjs +2 -1
  6. package/browser/editor/page/board/settings.mjs +2 -1
  7. package/browser/editor/page/clipboard/import/read.mjs +20 -2
  8. package/browser/editor/page/core.mjs +3 -2
  9. package/browser/editor/page/custom-page.mjs +4 -3
  10. package/browser/editor/page/editor.mjs +9 -5
  11. package/browser/editor/page/emoji-picker/support.mjs +3 -2
  12. package/browser/editor/page/links/index.mjs +1 -12
  13. package/browser/editor/page/links/pages.mjs +3 -2
  14. package/browser/editor/page/outline/folds.mjs +3 -0
  15. package/browser/editor/page/post.mjs +2 -1
  16. package/browser/editor/page/present-links.mjs +4 -3
  17. package/browser/editor/page/present-shell.mjs +3 -58
  18. package/browser/editor/page/session.mjs +3 -37
  19. package/browser/editor/page/storage.mjs +5 -5
  20. package/browser/shared/component-catalog.mjs +2 -2
  21. package/browser/shared/document-copy.mjs +43 -0
  22. package/browser/shared/drafts.mjs +32 -50
  23. package/browser/shared/emoji/icon-value.mjs +4 -3
  24. package/browser/shared/extension-manifest.mjs +90 -0
  25. package/browser/shared/extensions.mjs +93 -7
  26. package/browser/shared/http.mjs +1 -1
  27. package/browser/shared/i18n/en.mjs +55 -14
  28. package/browser/shared/media.mjs +2 -1
  29. package/browser/shared/outline-shape.mjs +6 -0
  30. package/browser/shared/outline.mjs +95 -10
  31. package/browser/shared/page-address.mjs +8 -15
  32. package/browser/shared/paths.mjs +2 -1
  33. package/browser/shared/preferences.mjs +3 -1
  34. package/browser/shared/preview-server.mjs +51 -63
  35. package/browser/shared/service-location.mjs +18 -0
  36. package/browser/shared/session.mjs +3 -6
  37. package/browser/shared/workspace-address.mjs +19 -0
  38. package/browser/shared/workspace-columns.mjs +12 -9
  39. package/browser/shared/workspace-registry.mjs +6 -40
  40. package/browser/shared/workspace.mjs +3 -6
  41. package/browser/shared/writing-preferences.mjs +6 -4
  42. package/dist/{chunk-W7665ZEC.mjs → chunk-236JJXWY.mjs} +2 -2
  43. package/dist/{chunk-EAIFITAV.mjs → chunk-2KAVXMKG.mjs} +1 -1
  44. package/dist/chunk-2T4PZQEP.mjs +2 -0
  45. package/dist/{chunk-HXG7EOPJ.mjs → chunk-3SKOU6OZ.mjs} +1 -1
  46. package/dist/{chunk-AR5D57ZD.mjs → chunk-3XZOA7AH.mjs} +2 -2
  47. package/dist/{chunk-UYVCQK3H.mjs → chunk-4B2TB5K2.mjs} +1 -1
  48. package/dist/{chunk-3MBRL2PM.mjs → chunk-4KUO7R2M.mjs} +8 -8
  49. package/dist/chunk-4SNV4HA5.mjs +2 -0
  50. package/dist/chunk-53XDTHTG.mjs +4 -0
  51. package/dist/chunk-5XV3YYLG.mjs +2 -0
  52. package/dist/{chunk-ZDLPUOAG.mjs → chunk-65NTKF4N.mjs} +1 -1
  53. package/dist/chunk-6GV3CBB2.mjs +2 -0
  54. package/dist/{chunk-WQDQWCX5.mjs → chunk-6L6V7YR2.mjs} +1 -1
  55. package/dist/{chunk-ITOH5EGO.mjs → chunk-6NLXUKAY.mjs} +1 -1
  56. package/dist/chunk-6Q7ASAXJ.mjs +3 -0
  57. package/dist/{chunk-7OG26PGF.mjs → chunk-6QWQXHAV.mjs} +1 -1
  58. package/dist/chunk-6XD6U3QA.mjs +5 -0
  59. package/dist/{chunk-YNAWBAYN.mjs → chunk-7EFHNQ5B.mjs} +1 -1
  60. package/dist/{chunk-OFHXGWOX.mjs → chunk-7ISBBDJR.mjs} +1 -1
  61. package/dist/{chunk-QE5RG76S.mjs → chunk-7UZUQQ7Q.mjs} +29 -24
  62. package/dist/chunk-BLNQUR5R.mjs +168 -0
  63. package/dist/chunk-BMX3VUHY.mjs +2 -0
  64. package/dist/chunk-BN7M3IN7.mjs +8 -0
  65. package/dist/{chunk-K5XATEOO.mjs → chunk-BQNTTFPL.mjs} +1 -1
  66. package/dist/chunk-C257H6IA.mjs +2 -0
  67. package/dist/chunk-CHQU37CA.mjs +2 -0
  68. package/dist/{chunk-CMA72MAG.mjs → chunk-E4IHAIJ7.mjs} +1 -1
  69. package/dist/{chunk-VS7JIGJW.mjs → chunk-E7656LR7.mjs} +1 -1
  70. package/dist/{chunk-VFX2KNHP.mjs → chunk-ECZBLQKK.mjs} +1 -1
  71. package/dist/{chunk-MN7BZL4R.mjs → chunk-ERE2BXYV.mjs} +1 -1
  72. package/dist/chunk-FFV733MA.mjs +2 -0
  73. package/dist/{chunk-AHN3NFKJ.mjs → chunk-GJJRBGMY.mjs} +1 -1
  74. package/dist/{chunk-BZ5RAF7L.mjs → chunk-GKOBB6DH.mjs} +1 -1
  75. package/dist/chunk-GMJD7ID3.mjs +2 -0
  76. package/dist/{chunk-OEXWRJME.mjs → chunk-IA2SIEJQ.mjs} +1 -1
  77. package/dist/chunk-JPG2LBQM.mjs +11 -0
  78. package/dist/{chunk-POTA2FV4.mjs → chunk-JXXHAXYH.mjs} +1 -1
  79. package/dist/{chunk-FDMAXMQA.mjs → chunk-K3JKPFL5.mjs} +1 -1
  80. package/dist/{chunk-J5PUIV3Y.mjs → chunk-KLQP46Y3.mjs} +1 -1
  81. package/dist/chunk-LMUZQK5O.mjs +2 -0
  82. package/dist/{chunk-JLTZF4LA.mjs → chunk-M4NRDYI5.mjs} +1 -1
  83. package/dist/{chunk-HPOI4ZX7.mjs → chunk-MKIBJTJZ.mjs} +1 -1
  84. package/dist/{chunk-5K44HLTZ.mjs → chunk-MNWJ4EZY.mjs} +1 -1
  85. package/dist/{chunk-FETSREUN.mjs → chunk-NHP66IWR.mjs} +1 -1
  86. package/dist/{chunk-PGDE24N5.mjs → chunk-OEIJLMPI.mjs} +1 -1
  87. package/dist/{chunk-YROX2O22.mjs → chunk-PDZR3L3C.mjs} +1 -1
  88. package/dist/{chunk-6N3PX4XX.mjs → chunk-PLB7L22J.mjs} +1 -1
  89. package/dist/{chunk-LM4XFQHZ.mjs → chunk-Q4MS6OFC.mjs} +1 -1
  90. package/dist/{chunk-7JDX4E65.mjs → chunk-Q7P3Q5YX.mjs} +1 -1
  91. package/dist/{chunk-ZHEHPMBK.mjs → chunk-QNECWMGR.mjs} +1 -1
  92. package/dist/{chunk-YI43RG4P.mjs → chunk-QP7BOHE4.mjs} +1 -1
  93. package/dist/chunk-QVGOPHOF.mjs +2 -0
  94. package/dist/{chunk-DAZL73VK.mjs → chunk-RRKJMHNX.mjs} +2 -2
  95. package/dist/chunk-S3SX3COT.mjs +20 -0
  96. package/dist/{chunk-HDWTZ763.mjs → chunk-TKWEX4FD.mjs} +2 -2
  97. package/dist/{chunk-YLPSMDIS.mjs → chunk-ULXSPX5H.mjs} +2 -2
  98. package/dist/chunk-USIHBCSW.mjs +15 -0
  99. package/dist/{chunk-TR7TWGHB.mjs → chunk-VPMRP2TL.mjs} +1 -1
  100. package/dist/{chunk-ZQS6H473.mjs → chunk-VRPVVMRB.mjs} +1 -1
  101. package/dist/{chunk-F6HVDZR4.mjs → chunk-VZ2RQ3AM.mjs} +1 -1
  102. package/dist/{chunk-R2ZDLJYT.mjs → chunk-W5NPH5UA.mjs} +1 -1
  103. package/dist/chunk-WFGPMCTZ.mjs +4 -0
  104. package/dist/chunk-XZCFPKGO.mjs +2 -0
  105. package/dist/{chunk-4OKWZY5E.mjs → chunk-YN37ZDLW.mjs} +1 -1
  106. package/dist/chunk-ZFGMJVJC.mjs +2 -0
  107. package/dist/cli.mjs +1 -1
  108. package/dist/server.mjs +1 -1
  109. package/dist/worker.mjs +1 -1
  110. package/kit/report.css +3 -3
  111. package/kit/runtime.js +12 -2
  112. package/kit/state.js +2 -0
  113. package/kit/ui.js +5 -1
  114. package/package.json +1 -1
  115. package/release.json +7 -7
  116. package/skill/SKILL.md +13 -12
  117. package/skill/references/building-blocks.md +1 -1
  118. package/skill/references/extensions.md +48 -8
  119. package/web/assets/index-FLb9jCfl.css +1 -0
  120. package/web/assets/index-czjrXVjf.js +15 -0
  121. package/web/index.html +2 -2
  122. package/dist/chunk-5XA2ZBAE.mjs +0 -8
  123. package/dist/chunk-67N2QJYT.mjs +0 -2
  124. package/dist/chunk-6KBYRA2C.mjs +0 -2
  125. package/dist/chunk-DJISHPUL.mjs +0 -3
  126. package/dist/chunk-EKNHM3P4.mjs +0 -2
  127. package/dist/chunk-EP25HDAJ.mjs +0 -2
  128. package/dist/chunk-GHOYKMQ3.mjs +0 -4
  129. package/dist/chunk-IV64FFI4.mjs +0 -2
  130. package/dist/chunk-J43Z6LSS.mjs +0 -2
  131. package/dist/chunk-JA7G53PC.mjs +0 -2
  132. package/dist/chunk-JZT7I2OJ.mjs +0 -2
  133. package/dist/chunk-L2SUIS5S.mjs +0 -2
  134. package/dist/chunk-L7UP52JO.mjs +0 -2
  135. package/dist/chunk-MTYYHM2G.mjs +0 -15
  136. package/dist/chunk-MXUBKDRO.mjs +0 -15
  137. package/dist/chunk-NAMN3RAF.mjs +0 -2
  138. package/dist/chunk-ORID3AE7.mjs +0 -166
  139. package/dist/chunk-SWUTTZCN.mjs +0 -2
  140. package/dist/chunk-U7NZI6N4.mjs +0 -4
  141. package/dist/chunk-UJQJKNGK.mjs +0 -2
  142. package/dist/chunk-ZWWC4KLF.mjs +0 -20
  143. package/web/assets/index-BM_K-Auu.css +0 -1
  144. package/web/assets/index-DRslOBiv.js +0 -15
@@ -8,20 +8,13 @@ import { findPreviewServer } from "./preview-server.mjs";
8
8
  import { idsOf, shownIcon } from "./workspace-columns.mjs";
9
9
  import { nameStops, workspaceLabels, workspaceName } from "./workspace-name.mjs";
10
10
  import { isWorkspace, readRegistry, workspaceId, workspaceKey } from "./workspace-registry.mjs";
11
- import { defaultWorkspace, documentFiles, isSlugName, listDocumentSlugs } from "./workspace.mjs";
11
+ import { defaultWorkspace, documentFiles, isSlugName, listDocumentSlugs, readLink } from "./workspace.mjs";
12
12
 
13
- // The drafts of other workspaces in a workspace's sidebar (notes/plans/workspaces-links-plan-2026-11-08.md
14
- // §2.2, §2.5, §7.2; docs/workbench.md "Drafts of other workspaces"): an entry of the outline written
15
- // "~<ws id>/<slug>" (./outline-shape.mjs), the workspace's id its path's hash on the machine's registry
16
- // (./workspace-registry.mjs), the draft its document <root>/<slug>/doc.html there. Only where the sidebar
17
- // shows it is this workspace's to keep: the draft's folder never moves, and its own workspace's tree is
18
- // never written (its server shows it as it was). Every registered workspace has a column of its own
19
- // (./workspace-columns.mjs, ./outline.mjs), "whole" (all its documents) unless the user picked a document
20
- // of it in 添加其他 Gallery 项目… (POST draft-add) while it had none shown: then it is `picked`, holding that
21
- // document and those under it alone, and what an Agent makes or opens there later (gallery new, gallery
22
- // open in another workspace than the default one: joinDefaultTree). The column the user removed is
23
- // `hidden`; an Agent's gallery new or open there, or the user's pick, shows it again. A draft the user took
24
- // out of a whole column (从文档树移除) is in its `left`, not filled in again until it is put back here.
13
+ // Registered document folders share the default home's sidebar and outline. A foreign entry is
14
+ // ~<workspace-id>/<slug>; its files, history, trash and exports remain in that folder.
15
+ // Every registered folder contributes its documents. Ordering and hierarchy have one global owner.
16
+ // addDraft/addWorkspace register or reveal a folder through the existing outline operations;
17
+ // joinDefaultTree places documents created by CLI commands in that same tree.
25
18
  // A draft whose workspace is not on the registry, two registered folders sharing its id (six hex digits
26
19
  // of a hash: a clash is refused, never guessed), or whose folder is gone stays in the tree, shown grey
27
20
  // (找不到底稿) until the user removes it. Reads other workspaces' files only; writes this one's outline.
@@ -76,6 +69,8 @@ export async function draftFacts(entry, ids = registeredIds()) {
76
69
  const none = { entry, ws, slug, root: null, folder: null, path: null, title: slug, description: "", icon: null };
77
70
  if (!found.root) return { ...none, missing: found.refused === "ambiguous" ? "ambiguous" : "workspace" };
78
71
  const folder = path.join(found.root, slug);
72
+ const link = existsSync(documentFiles(folder).html) ? null : await readLink(folder);
73
+ if (link) return { entry, ws, slug, root: found.root, folder, path: path.join(folder, 'link.json'), title: link.title, description: '', kind: 'link', url: link.url, icon: link.icon, siteIcon: link.siteIcon, missing: null };
79
74
  const { files, html, metadata } = await metadataAt(folder);
80
75
  const missing = !isWorkspace(found.root) ? "workspace" : !html || metadata?.instructions === true ? "draft" : null;
81
76
  // An untitled document says so (its title empty: the workbench names it 未命名文档); one with no doc.json to read, its slug.
@@ -191,7 +186,7 @@ const shownColumn = ({ hidden, ...column }) => column;
191
186
  * (documents: a web link of that workspace is none, nor a draft of a third its tree shows, the drafts under
192
187
  * one going under the draft above it), those the tree does not hold yet. Its workspace's column is shown
193
188
  * again when the user had removed it (hidden). How, `pick` says:
194
- * - false (an Agent's gallery new or open there: joinDefaultTree, POST draft-join): one the outline holds
189
+ * - false (an Agent's gallery new or open there: joinDefaultTree, POST draft-add): one the outline holds
195
190
  * (the file, or a whole column as it is read) stays where it is; else (a picked column) under its own
196
191
  * parent's entry when the tree holds that, else last in its workspace's column;
197
192
  * - true (the user's pick in 添加其他 Gallery 项目…, `after`: the column whose menu it came from): one shown
@@ -226,6 +221,15 @@ export async function addDraft(treeRoot, draftRoot, slug, { changed = () => {},
226
221
  return { parent, column: top === null || top === "" ? DOCS : top.slice(1), under: parent !== null && !isTop(parent) ? parent : null };
227
222
  };
228
223
  const columnOf = (key) => outline.groups.find((each) => topOf(each.id) === topIn(outline.children, key)) ?? null;
224
+ if (holds(entry)) {
225
+ if (pick && fresh) {
226
+ const column = columnOf(entry);
227
+ if (column) moveAfter(outline, column.id, after);
228
+ }
229
+ if (!writtenAs(outline, stored)) { await writeOutline(treeRoot, outline); changed(); }
230
+ const existing = !fresh && (pick || storedHolds);
231
+ return { entry, existing, added: existing ? [] : [entry], shown: false, ...where() };
232
+ }
229
233
  let shown = false;
230
234
  let column = outline.groups.find((each) => each.workspace === id) ?? null;
231
235
  const put = () => {
@@ -349,23 +353,9 @@ export async function projectChoices(treeRoot, { ids = registeredIds(), stops =
349
353
  }
350
354
 
351
355
 
352
- /** The environment variable that keeps gallery new and gallery open from putting their draft in the default workspace's sidebar (the tests set it). */
353
- export const NO_DEFAULT_TREE = "GALLERY_NO_DEFAULT_TREE";
354
- const set = (value) => !["", "0", undefined].includes(value);
355
-
356
- /**
357
- * gallery new and gallery open in a workspace other than the default one (src/shared/workspace.mjs
358
- * defaultWorkspace): draft `slug` of workspace `root` put in the default workspace's sidebar (addDraft,
359
- * with the drafts under it), through the default workspace's server when one runs (POST
360
- * /__gallery/workspace/draft-join: in its queue, its workbench told at once), else here under the
361
- * outline's lock. Null where it is not done: NO_DEFAULT_TREE set, `root` the default workspace, no
362
- * default workspace there yet (nothing is made for it), or `root` not on the registry under its id (a
363
- * workspace in the system's temporary folder, or a clash of ids: an entry would name none). Else
364
- * addDraft's answer with `home` (the default workspace) and `names`: { column: the name of the column
365
- * it is in, under: the title of the draft it is under, or null }; or { failed: why } (nothing written).
366
- */
356
+ /** Put a project document in home's shared outline through the single service's draft-add
357
+ * command when running, otherwise through the same outline lock offline. */
367
358
  export async function joinDefaultTree(root, slug, { env = process.env } = {}) {
368
- if (set(env[NO_DEFAULT_TREE])) return null;
369
359
  const home = defaultWorkspace({ env });
370
360
  if (workspaceKey(home) === workspaceKey(root) || !isWorkspace(home)) return null;
371
361
  const ids = registeredIds();
@@ -373,8 +363,17 @@ export async function joinDefaultTree(root, slug, { env = process.env } = {}) {
373
363
  if (!found.root || workspaceKey(found.root) !== workspaceKey(root)) return null;
374
364
  let done = null;
375
365
  try {
376
- const server = await findPreviewServer({ reportsDirectory: home });
377
- if (server) done = await joinThrough(server, root, slug);
366
+ const server = await findPreviewServer();
367
+ if (server) {
368
+ const origin = `http://127.0.0.1:${server.port}`;
369
+ const response = await fetch(`${origin}/__gallery/workspace/draft-add`, {
370
+ method: "POST", headers: { "Content-Type": "application/json", Origin: origin, "X-Gallery-Token": server.token },
371
+ body: JSON.stringify({ path: path.join(root, slug) }), signal: AbortSignal.timeout(30000),
372
+ });
373
+ const answer = await response.json();
374
+ if (!answer.ok) throw new Error(answer.message ?? "Could not register the document");
375
+ done = answer;
376
+ }
378
377
  done ??= await addDraft(home, root, slug);
379
378
  } catch (error) {
380
379
  return { failed: error.message, home };
@@ -382,23 +381,6 @@ export async function joinDefaultTree(root, slug, { env = process.env } = {}) {
382
381
  return { ...done, home, names: await placeNames(home, done, ids) };
383
382
  }
384
383
 
385
- // POST draft-join to the default workspace's server; null when no server of it takes it (gone, or an older Gallery's).
386
- async function joinThrough(server, root, slug) {
387
- const host = `127.0.0.1:${server.port}`;
388
- try {
389
- const response = await fetch(`http://${host}/__gallery/workspace/draft-join`, {
390
- method: "POST",
391
- headers: { "Content-Type": "application/json", Origin: `http://${host}`, "X-Gallery-Token": server.token },
392
- body: JSON.stringify({ root, slug }),
393
- signal: AbortSignal.timeout(30000),
394
- });
395
- const answer = await response.json().catch(() => null);
396
- return answer?.ok === true && typeof answer.entry === "string" ? answer : null;
397
- } catch {
398
- return null;
399
- }
400
- }
401
-
402
384
  // The names a line says of where a draft went: its column's (the user's title, else its workspace's
403
385
  // name, else 默认文档, the default workspace's own column, or 链接) and the title of the draft or document it is under.
404
386
  async function placeNames(home, { column, under }, ids) {
@@ -425,7 +407,7 @@ export function joinedLine(joined) {
425
407
  if (joined.failed) return `${where}:没有加进去(${joined.failed})`;
426
408
  if (joined.existing) return `${where}里已经有它(${joined.names.under ? `在「${joined.names.under}」下面` : `「${joined.names.column}」栏`})`;
427
409
  const more = joined.added.length > 1 ? `,连同它下面的 ${joined.added.length - 1} 篇` : "";
428
- return `${where}:已加进${joined.names.under ? `「${joined.names.under}」下面` : `「${joined.names.column}」栏`}${more}(拖动只改那里的显示位置,文件不动)`;
410
+ return `${where}:已加进${joined.names.under ? `「${joined.names.under}」下面` : `「${joined.names.column}」栏`}${more}(工作区内排序全局同步,文件位置不变)`;
429
411
  }
430
412
 
431
413
  /** Whether a gallery.json at `folder` would be another program's or not readable (what draft-add refuses to make a workspace of). */
@@ -1,3 +1,4 @@
1
+ import { workspaceAddress } from "../workspace-address.mjs";
1
2
  import { iconImageFile } from "../media.mjs";
2
3
 
3
4
  // A page's icon (the kit's "icon" component): what its value may be, its markup in a report and
@@ -68,17 +69,17 @@ export function iconMarkup(value, id = "") {
68
69
  * @param {string | null} [slug]
69
70
  * @param {{ tab?: boolean }} [options]
70
71
  */
71
- export const iconUrl = (value, slug = null, { tab = false } = {}) => `/__gallery/edit/icon?${new URLSearchParams({ value, ...(slug ? { slug } : {}), ...(tab ? { tab: "1" } : {}) })}`;
72
+ export const iconUrl = (value, slug = null, { tab = false } = {}) => workspaceAddress(`/__gallery/edit/icon?${new URLSearchParams({ value, ...(slug ? { slug } : {}), ...(tab ? { tab: "1" } : {}) })}`);
72
73
 
73
74
  const [TAB_LIGHT, TAB_DARK] = ICON_COLOR_VALUES.gray;
74
75
  /**
75
76
  * The browser tab's image of a page with no icon (the workbench's and the preview page's): the page
76
- * the workbench's tree shows for one (its "page" icon, components/icons.ts: Lucide's file-text,
77
+ * the workbench's tree shows for one (its "page" icon, components/icons.ts: Lucide's plain file outline,
77
78
  * FinHarness's 文档), its box cropped to `1 1 22 22` so it fills the tab as an emoji does, in the tab
78
79
  * glyphs' gray, light or dark as the system is. An image, never none: a tab given no image, or one that does not
79
80
  * load, keeps the one it showed (Chrome), so a page with no icon opened after one with one showed that one's.
80
81
  */
81
- export const DEFAULT_TAB_ICON = `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="1 1 22 22" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><style>svg{color:${TAB_LIGHT}}@media (prefers-color-scheme:dark){svg{color:${TAB_DARK}}}</style><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></svg>`)}`;
82
+ export const DEFAULT_TAB_ICON = `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="1 1 22 22" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><style>svg{color:${TAB_LIGHT}}@media (prefers-color-scheme:dark){svg{color:${TAB_DARK}}}</style><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/></svg>`)}`;
82
83
 
83
84
  /** The value of an icon's element as the file (or a page) has it, or null. */
84
85
  export function iconValueOf(html) {
@@ -0,0 +1,90 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { defaultWorkspace, layoutOf } from "./workspace.mjs";
4
+ import { extensionFolders } from "./extensions.mjs";
5
+ import { isExtensionId } from "./outline-shape.mjs";
6
+ import { sha256, writeAtomic } from "./files.mjs";
7
+ import { withOutlineLock } from "./outline.mjs";
8
+
9
+ export const extensionDirectory = id => {
10
+ if (!isExtensionId(id)) throw new Error("扩展 ID 不正确");
11
+ return path.join(layoutOf(defaultWorkspace()).extensions, id);
12
+ };
13
+
14
+ export function extensionPath(value) {
15
+ if (typeof value !== "string" || !value || value.includes("\\") || value.includes(":")) throw new Error("扩展文件必须使用相对路径");
16
+ const parts = value.split("/");
17
+ if (parts.some(part => !part || part === "." || part === ".." || /[\x00-\x1f]/.test(part))) throw new Error("扩展文件路径不正确");
18
+ return parts;
19
+ }
20
+
21
+ // Resolve every segment, including the extension directory, before serving or running a file.
22
+ export async function extensionFile(folder, name) {
23
+ const parts = extensionPath(name);
24
+ if ((await fs.lstat(path.dirname(folder))).isSymbolicLink()) throw new Error("扩展文件不能是符号链接");
25
+ let file = folder;
26
+ for (const part of [null, ...parts]) {
27
+ if (part !== null) file = path.join(file, part);
28
+ const stat = await fs.lstat(file);
29
+ if (stat.isSymbolicLink()) throw new Error("扩展文件不能是符号链接");
30
+ }
31
+ const [root, real] = await Promise.all([fs.realpath(folder), fs.realpath(file)]);
32
+ const relative = path.relative(root, real);
33
+ if (relative.startsWith("..") || path.isAbsolute(relative) || !(await fs.stat(real)).isFile()) throw new Error("扩展文件不在扩展目录内");
34
+ return real;
35
+ }
36
+
37
+ export async function readManifest(folder) {
38
+ const file = await extensionFile(folder, "extension.json");
39
+ if ((await fs.stat(file)).size > 65536) throw new Error("extension.json 超过 64KB");
40
+ const text = await fs.readFile(file, "utf8");
41
+ if (Buffer.byteLength(text) > 65536) throw new Error("extension.json 超过 64KB");
42
+ const value = JSON.parse(text);
43
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("extension.json 必须是对象");
44
+ if (value.id !== undefined && !isExtensionId(value.id)) throw new Error("扩展 ID 不正确");
45
+ if (value.workbench !== undefined && (!value.workbench || typeof value.workbench !== "object" || Array.isArray(value.workbench))) throw new Error("workbench 必须是对象");
46
+ for (const name of [value.workbench?.script, value.workbench?.style, value.agent].filter(value => value !== undefined)) await extensionFile(folder, name);
47
+ if (value.server !== undefined) {
48
+ const command = value.server?.command;
49
+ if (!Array.isArray(command) || !command.length || command.length > 32 || command.some(word => typeof word !== "string" || !word || word.includes("\0"))) throw new Error("server.command 必须是非空的参数数组");
50
+ }
51
+ return { ...value, revision: sha256(text) };
52
+ }
53
+
54
+ const configFile = () => path.join(defaultWorkspace(), "gallery.json");
55
+ const configuration = async () => JSON.parse(await fs.readFile(configFile(), "utf8"));
56
+
57
+ export async function listCapabilities() {
58
+ const config = await configuration();
59
+ return Promise.all((await extensionFolders(defaultWorkspace())).map(async ({ id, folder }) => {
60
+ try {
61
+ const manifest = await readManifest(folder);
62
+ const grant = config.extensions?.[id];
63
+ return { id, folder, manifest, enabled: grant?.enabled === true && grant.revision === manifest.revision,
64
+ confirmed: grant?.revision === manifest.revision, agent: manifest.agent ? path.join(folder, manifest.agent) : null };
65
+ } catch (error) {
66
+ return { id, folder, enabled: false, confirmed: false, manifest: null, error: error.code === "ENOENT" ? null : error.message };
67
+ }
68
+ }));
69
+ }
70
+
71
+ export async function setExtensionEnabled({ id, enabled, revision, confirmed = false }) {
72
+ const folder = extensionDirectory(id);
73
+ if (typeof enabled !== "boolean") throw new Error("扩展开关的值不正确");
74
+ return withOutlineLock(defaultWorkspace(), async () => {
75
+ const manifest = await readManifest(folder);
76
+ if (manifest.revision !== revision) throw new Error("扩展刚刚更新,请重新打开设置");
77
+ const config = await configuration();
78
+ if (enabled && config.extensions?.[id]?.revision !== revision && !confirmed) throw new Error("请先确认扩展的脚本和后台权限");
79
+ config.extensions = { ...config.extensions, [id]: { enabled, revision } };
80
+ await writeAtomic(configFile(), JSON.stringify(config, null, 2) + "\n");
81
+ });
82
+ }
83
+
84
+ export async function forgetExtensionGrant(id) {
85
+ const config = await configuration();
86
+ if (config.extensions?.[id]) {
87
+ delete config.extensions[id];
88
+ await writeAtomic(configFile(), JSON.stringify(config, null, 2) + "\n");
89
+ }
90
+ }
@@ -3,9 +3,12 @@ import path from "node:path";
3
3
  import { SIDEBAR_FILE, SIDEBAR_LIMITS, readSidebarText } from "./extension-sidebar.mjs";
4
4
  import { said } from "./i18n/index.mjs";
5
5
  import { isExtensionId } from "./outline-shape.mjs";
6
- import { DOCUMENT_FOLDERS, layoutOf } from "./workspace.mjs";
6
+ import { defaultWorkspace, DOCUMENT_FOLDERS, layoutOf } from "./workspace.mjs";
7
+ import { sameDirectory } from "./preview-server.mjs";
8
+ import { sha256, writeAtomic } from "./files.mjs";
7
9
 
8
- // The extensions of a workspace (docs/workbench.md "Extension columns"): each a folder
10
+ // The extensions visible in a workspace (docs/workbench.md "Extension columns"): public columns from
11
+ // the default workspace plus this project's own, a project sidebar.json taking precedence by id. Each a folder
9
12
  // <workspace>/extensions/<id>/ whose sidebar.json (./extension-sidebar.mjs) gives a column of the sidebar
10
13
  // its entries; what else the folder holds (the extension's own server, its code) is none of Gallery's.
11
14
  // Read from disk each time it is asked (the workbench's list, the outline's columns, gallery extension):
@@ -26,10 +29,26 @@ export async function extensionFolders(root) {
26
29
  .sort((a, b) => (a.id < b.id ? -1 : 1));
27
30
  }
28
31
 
32
+ /** Public columns live in the default workspace; a project's own sidebar.json overrides the same id.
33
+ * Empty local folders do not hide a public column. Keep one source for listing, outline and writes.
34
+ */
35
+ export async function visibleExtensionFolders() {
36
+ const root = defaultWorkspace();
37
+ return Promise.all((await extensionFolders(root)).map(async entry => {
38
+ const file = path.join(entry.folder, SIDEBAR_FILE);
39
+ return { ...entry, root, file, present: await fs.lstat(file).then(() => true, () => false) };
40
+ }));
41
+ }
42
+
43
+ /** Resolve an id, never a client-supplied directory, to the file this workspace actually shows. */
44
+ export async function extensionSource(root, id) {
45
+ if (!isExtensionId(id)) return null;
46
+ return (await visibleExtensionFolders(root)).find(entry => entry.id === id) ?? null;
47
+ }
48
+
29
49
  /** The ids of the extensions that have a sidebar.json (the columns the sidebar has), sorted. */
30
50
  export async function extensionColumnIds(root) {
31
- const found = await Promise.all((await extensionFolders(root)).map(async ({ id, folder }) => ((await fs.stat(path.join(folder, SIDEBAR_FILE)).then(() => true, () => false)) ? id : null)));
32
- return found.filter(Boolean);
51
+ return (await visibleExtensionFolders(root)).filter(entry => entry.present).map(entry => entry.id);
33
52
  }
34
53
 
35
54
  /**
@@ -56,6 +75,7 @@ export async function readExtension(root, id) {
56
75
  return { ...empty, error: said("读不出这个文件:{0}", error.code ?? "error") };
57
76
  }
58
77
  const read = readSidebarText(text);
78
+ empty.revision = sha256(text);
59
79
  if (read.error) {
60
80
  // What can still be said of a file that does not read: its name, when it has one.
61
81
  let title = id;
@@ -69,11 +89,77 @@ export async function readExtension(root, id) {
69
89
  }
70
90
 
71
91
  /**
72
- * The extensions of workspace `root`, by id: those with a sidebar.json (the sidebar's columns), or with `all`
92
+ * The extensions visible in workspace `root`, by id: those with a sidebar.json (the sidebar's columns), or with `all`
73
93
  * every folder that may be one (gallery extension list). Each as readExtension says it.
74
94
  */
75
95
  export async function listExtensions(root, { all = false } = {}) {
76
- const folders = await extensionFolders(root);
77
- const read = await Promise.all(folders.map(({ id }) => readExtension(root, id)));
96
+ const folders = await visibleExtensionFolders(root);
97
+ const read = await Promise.all(folders.map(({ id, root: owner }) => readExtension(owner, id)));
78
98
  return all ? read : read.filter((extension) => !extension.missing);
79
99
  }
100
+
101
+ /** Edit a column's title/root order, remove a root subtree or delete its sidebar.json. Called under the workspace lock.
102
+ * Read the latest source, keep unknown fields and whole subtrees, and refuse stale UI changes.
103
+ * Atomic replacement checks the original text again so an external writer is not silently overwritten.
104
+ */
105
+ export async function editExtension(root, id, change) {
106
+ const refused = (status, reason, error) => ({ ok: false, status, reason, error });
107
+ const bad = () => refused(400, "body", said("请求格式不对"));
108
+ const stale = () => refused(409, "stale", said("扩展目录刚在别处改过,请重试"));
109
+ if (!isExtensionId(id) || !change || typeof change !== "object") return bad();
110
+ const rename = Object.hasOwn(change, "title");
111
+ const remove = Object.hasOwn(change, "remove");
112
+ if ([rename, remove, Object.hasOwn(change, "move")].filter(Boolean).length !== 1) return bad();
113
+ if (remove ? (change.remove !== "column" && (typeof change.remove?.entry !== "string" || !change.remove.entry)
114
+ || typeof change.baseFile !== "string" || typeof change.baseRevision !== "string")
115
+ : rename ? (typeof change.title !== "string" || typeof change.baseTitle !== "string")
116
+ : (!change.move || !["before", "after"].includes(change.move.side) || typeof change.move.entry !== "string" || typeof change.move.ref !== "string" || !Array.isArray(change.baseOrder))) return bad();
117
+ const name = rename ? change.title.replace(/\s+/g, " ").trim() : null;
118
+ if (rename && (!name || name.length > SIDEBAR_LIMITS.column)) return bad();
119
+ const folder = path.join(layoutOf(root).extensions, id);
120
+ const file = path.join(folder, SIDEBAR_FILE);
121
+ if (remove && change.baseFile !== file) return stale(); // An override must not redirect a confirmed deletion.
122
+ // Never turn an extension path supplied by the client or a symlink into an arbitrary-file write.
123
+ for (const [target, directory] of [[layoutOf(root).extensions, true], [folder, true], [file, false]]) {
124
+ const stat = await fs.lstat(target).catch(() => null);
125
+ if (!stat) return refused(404, "missing", said("找不到这个扩展"));
126
+ if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile())) return refused(409, "path", said("扩展路径不是普通文件或文件夹"));
127
+ if (!directory && stat.size > SIDEBAR_LIMITS.bytes) return refused(409, "extension", said("文件超过 1MB"));
128
+ }
129
+ const source = await fs.readFile(file, "utf8");
130
+ if (remove && sha256(source) !== change.baseRevision) return stale();
131
+ if (remove && change.remove === "column") {
132
+ if ((await fs.readFile(file, "utf8")) !== source) return stale();
133
+ await fs.unlink(file); // Only the validated sidebar file; the extension's other files stay.
134
+ return { ok: true, written: true };
135
+ }
136
+ const read = readSidebarText(source);
137
+ if (read.error) return refused(409, "extension", read.error);
138
+ const value = JSON.parse(source.replace(/^\uFEFF/, ""));
139
+ if (rename) {
140
+ if (read.sidebar.title !== change.baseTitle) return stale();
141
+ if (read.sidebar.title === name) return { ok: true, written: false };
142
+ value.title = name;
143
+ } else if (remove) {
144
+ const index = value.entries.findIndex(entry => entry.id === change.remove.entry);
145
+ if (index < 0) return bad(); // Roots only; a root's entire subtree goes with it.
146
+ value.entries.splice(index, 1);
147
+ } else {
148
+ const order = value.entries.map(entry => entry.id);
149
+ if (JSON.stringify(order) !== JSON.stringify(change.baseOrder)) return stale();
150
+ const from = order.indexOf(change.move.entry), to = order.indexOf(change.move.ref);
151
+ if (from < 0 || to < 0) return bad(); // Root entries only, in this file; no nesting or cross-column move.
152
+ if (from === to) return { ok: true, written: false };
153
+ const [entry] = value.entries.splice(from, 1);
154
+ const at = value.entries.findIndex(entry => entry.id === change.move.ref);
155
+ value.entries.splice(at + (change.move.side === "after" ? 1 : 0), 0, entry);
156
+ if (JSON.stringify(order) === JSON.stringify(value.entries.map(entry => entry.id))) return { ok: true, written: false };
157
+ }
158
+ const indent = source.match(/\n([ \t]+)"/)?.[1] ?? 2;
159
+ let next = JSON.stringify(value, null, indent) + "\n";
160
+ if (source.includes("\r\n")) next = next.replaceAll("\n", "\r\n");
161
+ if (source.startsWith("\uFEFF")) next = "\uFEFF" + next;
162
+ if (Buffer.byteLength(next) > SIDEBAR_LIMITS.bytes) return refused(409, "extension", said("文件超过 1MB"));
163
+ if (!(await writeAtomic(file, next, { expect: source }))) return stale();
164
+ return { ok: true, written: true };
165
+ }
@@ -8,7 +8,7 @@
8
8
  // could lay an editable page under a decoy, the reader's clicks and keys then editing the files (the security
9
9
  // review of 0.2.0), nor localhost, which may lead to another program on [::1] (no workbench is shown there:
10
10
  // src/server/routes.mjs toLoopback).
11
- export const FRAMED_BY = { "Content-Security-Policy": "frame-ancestors 'self' http://127.0.0.1:*" };
11
+ export const FRAMED_BY = { "Content-Security-Policy": "frame-ancestors 'self'" };
12
12
 
13
13
  // A JSON answer.
14
14
  export function sendJson(response, status, body) {
@@ -1,6 +1,49 @@
1
1
  // English for Gallery's UI, by the Chinese text the source writes (index.mjs t()), in the order the
2
2
  // sources use them. tests/unit/i18n.mjs holds every string to an entry here and every entry to a string.
3
3
  export default {
4
+ "启用扩展「{0}」?": "Enable extension “{0}”?",
5
+ "此扩展可以在工作台运行脚本;后台程序拥有 Gallery 的本地文件和网络访问权限。仅启用你信任的扩展。": "This extension can run workbench scripts. Its backend has Gallery’s local file and network permissions. Only enable extensions you trust.",
6
+ "启用": "Enable",
7
+ "扩展设置保存失败": "Could not save extension settings",
8
+ "扩展设置已保存。重新打开 Gallery 后生效。": "Extension settings saved. Reopen Gallery to apply them.",
9
+ "来自默认工作区,所有文档共用。": "From the default workspace, shared by all documents.",
10
+ "还没有安装扩展。": "No extensions installed yet.",
11
+ "工作台脚本": "Workbench script",
12
+ "工作台样式": "Workbench style",
13
+ "后台程序": "Backend process",
14
+ "侧栏目录": "Sidebar directory",
15
+ "扩展列表读取失败": "Could not load extensions",
16
+ "扩展 {0} 加载失败": "Could not load extension {0}",
17
+ "扩展正在更新,请稍后再试": "The extension is being updated. Try again shortly.",
18
+ "扩展后台暂未就绪,请查看扩展日志": "The extension backend is not ready. Check its log.",
19
+ "扩展后台请求超时": "The extension backend request timed out",
20
+ "扩展操作失败": "The extension operation failed",
21
+ "扩展目录不能是符号链接": "The extensions directory cannot be a symbolic link",
22
+ "扩展超过 100MB 或 10000 个文件": "The extension exceeds 100MB or 10000 files",
23
+ "安装源不能是符号链接": "The installation source cannot be a symbolic link",
24
+ "扩展只能包含普通文件和文件夹": "Extensions may contain only regular files and directories",
25
+ "扩展 ZIP 超过 100MB": "The extension ZIP exceeds 100MB",
26
+ "ZIP 中没有 extension.json": "The ZIP has no extension.json",
27
+ "ZIP 的文件必须在同一个扩展文件夹内": "All ZIP files must belong to one extension directory",
28
+ "ZIP 中存在重名文件": "The ZIP contains duplicate file names",
29
+ "请选择扩展文件夹或 ZIP": "Choose an extension directory or ZIP",
30
+ "已安装的扩展不能是符号链接": "An installed extension cannot be a symbolic link",
31
+ "扩展不能是符号链接": "An extension cannot be a symbolic link",
32
+ "扩展 ID 不正确": "Invalid extension ID",
33
+ "扩展文件必须使用相对路径": "Extension files must use relative paths",
34
+ "扩展文件路径不正确": "Invalid extension file path",
35
+ "扩展文件不能是符号链接": "Extension files cannot be symbolic links",
36
+ "扩展文件不在扩展目录内": "The file is not inside the extension directory",
37
+ "extension.json 超过 64KB": "extension.json exceeds 64KB",
38
+ "extension.json 必须是对象": "extension.json must be an object",
39
+ "workbench 必须是对象": "workbench must be an object",
40
+ "server.command 必须是非空的参数数组": "server.command must be a nonempty array of arguments",
41
+ "扩展开关的值不正确": "Invalid extension enabled value",
42
+ "扩展刚刚更新,请重新打开设置": "The extension was updated. Reopen settings.",
43
+ "请先确认扩展的脚本和后台权限": "Confirm the extension’s script and backend permissions first",
44
+ "扩展目录刚在别处改过,请重试": "The extension directory changed elsewhere. Please try again.",
45
+ "找不到这个扩展": "This extension could not be found",
46
+ "扩展路径不是普通文件或文件夹": "The extension path is not a regular file or directory",
4
47
  "正在读取文档…": "Loading documents…",
5
48
  "没有连上本地服务": "Not connected to the local service",
6
49
  "重试": "Retry",
@@ -153,6 +196,14 @@ export default {
153
196
  "撤销": "Undo",
154
197
  "已恢复「{0}」": "Restored “{0}”",
155
198
  "永久删除「{0}」?": "Delete “{0}” forever?",
199
+ "永久删除回收区中的全部 {0} 项?": "Permanently delete all {0} items in the trash?",
200
+ "删除全部": "Delete all",
201
+ "删除分栏": "Delete column",
202
+ "删除页面": "Delete page",
203
+ "删除分栏「{0}」?": "Delete column “{0}”?",
204
+ "删除页面「{0}」?": "Delete page “{0}”?",
205
+ "将从 sidebar.json 删除这个条目及其全部子项,共用此配置的工作区会同步更新。不会删除网站上的原文档,此操作无法撤销。": "Remove this entry and all its children from sidebar.json in every workspace sharing this configuration. The original web documents stay. This cannot be undone.",
206
+ "将删除此分栏的 sidebar.json,共用此配置的工作区会同步移除分栏。其他文件和网站上的原文档不受影响,此操作无法撤销。": "Delete this column's sidebar.json in every workspace sharing this configuration. Other files and the original web documents stay. This cannot be undone.",
156
207
  "这份文档": "this document",
157
208
  "删除后无法恢复。": "This can't be undone.",
158
209
  "正在导出…": "Exporting…",
@@ -1399,15 +1450,12 @@ export default {
1399
1450
  "网址太长了(最多 2048 个字符)": "The address is too long (2048 characters at most)",
1400
1451
  "名称不能是空的,也不能超过 200 个字": "The name can't be empty or longer than 200 characters",
1401
1452
  "图标只能是表情或图标页签里的图标": "The icon must be an emoji or an icon of the Icons tab",
1402
- "正在启动它所在工作区的服务…": "Starting the server of its workspace…",
1403
- "没能打开这份底稿:{0}": "Couldn't open this draft: {0}",
1404
1453
  "正在读取别的工作区…": "Reading the other workspaces…",
1405
1454
  "别处的底稿 · {0}": "Draft from elsewhere · {0}",
1406
1455
  "别处的底稿": "Draft from elsewhere",
1407
1456
  "改的是那篇底稿的标题": "Changes the title of that draft",
1408
1457
  "只改它在这里的显示位置,文件不动": "Changes only where it shows here: no file moves",
1409
1458
  "在文件夹中显示": "Show in folder",
1410
- "在它的工作区里打开": "Open in its workspace",
1411
1459
  "「{0}」已移至它所在工作区的回收区": "“{0}” moved to the trash of its workspace",
1412
1460
  "副本在它所在的工作区里": "The copy is made in its workspace",
1413
1461
  "导入的页面在它所在的工作区里": "The imported pages are made in its workspace",
@@ -1417,7 +1465,6 @@ export default {
1417
1465
  "找不到底稿:本机登记的两个工作区编号相同": "Draft not found: two registered workspaces share one id",
1418
1466
  "找不到底稿:{0}": "Draft not found: {0}",
1419
1467
  "找不到底稿": "Draft not found",
1420
- "这份文档在它自己的工作区里,不在这里的侧边栏中": "This document is in its own workspace, not in this sidebar",
1421
1468
  "找不到它所在的工作区": "Its workspace was not found",
1422
1469
  "没有打开文件夹:{0}": "The folder was not opened: {0}",
1423
1470
  "已从文档树移除「{0}」,底稿没有删除": "Removed “{0}” from the sidebar; the draft was not deleted",
@@ -1431,17 +1478,7 @@ export default {
1431
1478
  "会把 {0} 登记成一个工作区(在里面放 gallery.json 和 .gallery/)": "{0} will be registered as a workspace (a gallery.json and a .gallery/ put in it)",
1432
1479
  "没能把它的工作区记进本机的登记表": "Its workspace could not be added to this machine's registry",
1433
1480
  "本机登记的 {0} 个工作区编号相同({1}),先用 gallery workspaces --forget 去掉其中一个": "{0} registered workspaces share one id ({1}): take one off with gallery workspaces --forget first",
1434
- "这是当前的工作区": "This is the current workspace",
1435
- "这个工作区不在本机的登记表里": "This workspace is not on this machine's registry",
1436
- "本机登记的工作区里有两个用的是同一个编号,先用 gallery workspaces --forget 去掉其中一个": "Two registered workspaces share this id: take one off with gallery workspaces --forget first",
1437
- "找不到它所在的工作区:它不在本机的登记表里": "Its workspace was not found: it is not on this machine's registry",
1438
- "找不到它所在的工作区:{0} 不在了": "Its workspace was not found: {0} is gone",
1439
1481
  "找不到这个文件夹": "Folder not found",
1440
- "它所在工作区的服务没有在运行": "The server of its workspace is not running",
1441
- "它所在工作区的服务没有回答": "The server of its workspace did not answer",
1442
- "没能启动它所在工作区的服务:{0}": "The server of its workspace did not start: {0}",
1443
- "这个命令不能转给别的工作区": "This command can't be passed on to another workspace",
1444
- "它所在工作区的服务没有给出结果": "The server of its workspace gave no answer",
1445
1482
  // Extension columns (src/shared/extension-sidebar.mjs, extensions.mjs, workbench/app/features/sidebar/SidebarExtension.tsx)
1446
1483
  "这个条目已被扩展移除": "This entry was removed by its extension",
1447
1484
  "条目读不出来:{0}": "Entries can't be read: {0}",
@@ -1523,4 +1560,8 @@ export default {
1523
1560
  "选的文件不是 zip": "The file chosen is not a zip",
1524
1561
  "没有这个块包": "No such block pack",
1525
1562
  "粘贴块包的文件夹或 zip 的路径": "Paste the path of the pack's folder or zip",
1563
+ "复制到另一个工作区?": "Copy to another workspace?",
1564
+ "将「{0}」及其子页面复制到「{1}」,原项保留。": "Copy “{0}” and its child pages to “{1}”, keeping the originals.",
1565
+ "已复制 {0} 项,原项保留": "Copied {0} items; originals kept",
1566
+ "跨工作区请复制,不能只移动目录位置": "Copy items to another workspace instead of moving their tree positions",
1526
1567
  };
@@ -1,3 +1,4 @@
1
+ import { workspaceAddress } from "./workspace-address.mjs";
1
2
  // The media of a report, one place for what every system asks about it: the files its media
2
3
  // blocks, its page icon and any other reference of its markup keep in the report's folder
3
4
  // (assets/<file>), where the preview serves them, and what the editor may upload. Shared by the
@@ -127,7 +128,7 @@ export function referencedFile(value) {
127
128
  export const MEDIA_ROUTES = { asset: imageFile, file: assetFile, embed: embedFile };
128
129
 
129
130
  /** The address the preview serves a file of a report's folder at (`value`: assets/<file>). */
130
- export const mediaUrl = (route, slug, value) => `/__gallery/edit/${route}/${slug}/${value}`;
131
+ export const mediaUrl = (route, slug, value) => workspaceAddress(`/__gallery/edit/${route}/${slug}/${value}`);
131
132
 
132
133
  // ---------- what may be uploaded ----------
133
134
 
@@ -55,6 +55,10 @@ export const groupOfTop = (key) => (key === "" ? DOCS : key.slice(1));
55
55
  /** Whether `entry` names a draft of another workspace. */
56
56
  export const isForeign = (entry) => entry.startsWith("~");
57
57
 
58
+ /** File ownership is independent of which workspace's window is open. */
59
+ export const entryWorkspace = (entry, self) => /^~(ws-[0-9a-f]{6})\//.exec(entry)?.[1] ?? self;
60
+ export const columnWorkspaceId = (column, self) => column?.extension ? null : column?.workspace ?? column?.owner ?? self;
61
+
58
62
  const tidy = (value) => (typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "");
59
63
 
60
64
  /** A whole column's `left` as it is kept: the slugs once each, 1000 at most; none when there are none. */
@@ -80,6 +84,7 @@ export function groupOf(value) {
80
84
  ...(title ? { title } : {}),
81
85
  ...(GROUP_ROLES.includes(role) && id !== DOCS ? { role } : {}),
82
86
  ...(isWorkspaceColumn ? { workspace } : {}),
87
+ ...(typeof value.owner === "string" && /^ws-[0-9a-f]{6}$/.test(value.owner) && !isWorkspaceColumn && id !== DOCS ? { owner: value.owner } : {}),
83
88
  // A workspace's column alone is picked or hidden (the user's columns and 链接 are removed, never hidden).
84
89
  ...(isWorkspaceColumn && value.picked === true ? { picked: true } : {}),
85
90
  ...(isWorkspaceColumn && value.hidden === true ? { hidden: true } : {}),
@@ -98,6 +103,7 @@ export function validGroups(groups) {
98
103
  if (value.title !== undefined && (typeof value.title !== "string" || tidy(value.title).length > GROUP_LIMITS.title)) return false;
99
104
  if (value.role !== undefined && !GROUP_ROLES.includes(value.role)) return false;
100
105
  if (value.extension !== undefined && group.extension !== value.extension) return false;
106
+ if (value.owner !== undefined && group.owner !== value.owner) return false;
101
107
  if (value.left !== undefined && (!Array.isArray(value.left) || value.left.length > GROUP_LIMITS.left || !value.left.every((slug) => typeof slug === "string" && slugPattern.test(slug)))) return false;
102
108
  ids.add(group.id);
103
109
  }