@heycys/gallery 0.2.0 → 0.2.1
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.
- package/THIRD-PARTY-NOTICES.md +1 -1
- package/browser/editor/components/icon/page.mjs +6 -4
- package/browser/editor/components/icons.mjs +2 -2
- package/browser/editor/page/clipboard/import/read.mjs +20 -2
- package/browser/shared/document-copy.mjs +43 -0
- package/browser/shared/drafts.mjs +10 -3
- package/browser/shared/emoji/icon-value.mjs +2 -2
- package/browser/shared/extensions.mjs +83 -7
- package/browser/shared/i18n/en.mjs +10 -0
- package/browser/shared/outline-shape.mjs +6 -0
- package/browser/shared/outline.mjs +135 -10
- package/browser/shared/workspace-columns.mjs +11 -7
- package/dist/{chunk-QE5RG76S.mjs → chunk-2GKGEVMR.mjs} +11 -7
- package/dist/{chunk-HYMEEXKN.mjs → chunk-2RKIHJI2.mjs} +1 -1
- package/dist/{chunk-742D2WWJ.mjs → chunk-36FIRJIF.mjs} +2 -2
- package/dist/{chunk-EAIFITAV.mjs → chunk-3A5I2GJK.mjs} +1 -1
- package/dist/{chunk-7JDX4E65.mjs → chunk-3OFXMN6N.mjs} +1 -1
- package/dist/chunk-46ACXOU5.mjs +2 -0
- package/dist/{chunk-PUOQ65NC.mjs → chunk-4AMTKWDO.mjs} +1 -1
- package/dist/{chunk-HPOI4ZX7.mjs → chunk-4DNHHJSL.mjs} +1 -1
- package/dist/chunk-5DPABPBI.mjs +2 -0
- package/dist/{chunk-ALZ6ZENZ.mjs → chunk-5IIH335D.mjs} +1 -1
- package/dist/{chunk-EP25HDAJ.mjs → chunk-5L26JUYA.mjs} +1 -1
- package/dist/{chunk-FDMAXMQA.mjs → chunk-5MEXZ2UF.mjs} +1 -1
- package/dist/{chunk-ZHEHPMBK.mjs → chunk-6MPKCH3P.mjs} +1 -1
- package/dist/{chunk-C564NU2H.mjs → chunk-74RPP2M2.mjs} +1 -1
- package/dist/{chunk-YNAWBAYN.mjs → chunk-7M7UPWU6.mjs} +1 -1
- package/dist/{chunk-YROX2O22.mjs → chunk-7N2LPG33.mjs} +1 -1
- package/dist/{chunk-VS7JIGJW.mjs → chunk-7XDYK3UC.mjs} +1 -1
- package/dist/chunk-AEMVZYZQ.mjs +166 -0
- package/dist/{chunk-TR7TWGHB.mjs → chunk-AFGUR7SY.mjs} +1 -1
- package/dist/{chunk-K5XATEOO.mjs → chunk-AMDGVDNV.mjs} +1 -1
- package/dist/{chunk-JA7G53PC.mjs → chunk-ASAXY2DJ.mjs} +1 -1
- package/dist/{chunk-W7665ZEC.mjs → chunk-BYSF5UMM.mjs} +2 -2
- package/dist/{chunk-U3SSPL4K.mjs → chunk-CCSGFCLZ.mjs} +1 -1
- package/dist/{chunk-JLTZF4LA.mjs → chunk-CG3UHDKH.mjs} +1 -1
- package/dist/{chunk-YLPSMDIS.mjs → chunk-CPS4SRPF.mjs} +2 -2
- package/dist/{chunk-AHN3NFKJ.mjs → chunk-CZ5KJSGK.mjs} +1 -1
- package/dist/chunk-DAMVDXR3.mjs +2 -0
- package/dist/{chunk-WQDQWCX5.mjs → chunk-DH6GMDNN.mjs} +1 -1
- package/dist/{chunk-U5XL7D6A.mjs → chunk-DHB7UZ7F.mjs} +1 -1
- package/dist/{chunk-OEXWRJME.mjs → chunk-DKR7LFIY.mjs} +1 -1
- package/dist/chunk-DLFZJJEE.mjs +2 -0
- package/dist/{chunk-DJISHPUL.mjs → chunk-FCK6NXON.mjs} +1 -1
- package/dist/{chunk-IV64FFI4.mjs → chunk-FX42XU6J.mjs} +1 -1
- package/dist/{chunk-FETSREUN.mjs → chunk-FXQBADMN.mjs} +1 -1
- package/dist/{chunk-BFJGHSQN.mjs → chunk-G4YP7GVC.mjs} +1 -1
- package/dist/{chunk-V6XZ4VIR.mjs → chunk-GWOYMENX.mjs} +1 -1
- package/dist/chunk-HATA2W7X.mjs +2 -0
- package/dist/{chunk-YI43RG4P.mjs → chunk-HBAUKKQH.mjs} +1 -1
- package/dist/{chunk-MAFIGVXY.mjs → chunk-HM4E43VM.mjs} +1 -1
- package/dist/{chunk-OFHXGWOX.mjs → chunk-JI6T3A4Y.mjs} +1 -1
- package/dist/{chunk-5XA2ZBAE.mjs → chunk-JJNRIFND.mjs} +2 -2
- package/dist/chunk-JL6R4FG4.mjs +4 -0
- package/dist/{chunk-UMHUSOPJ.mjs → chunk-JXLSHVJ2.mjs} +1 -1
- package/dist/{chunk-VFX2KNHP.mjs → chunk-KFZBYDIS.mjs} +1 -1
- package/dist/{chunk-ZTHICSSF.mjs → chunk-KH24LWAT.mjs} +1 -1
- package/dist/{chunk-GHOYKMQ3.mjs → chunk-KUILYI7H.mjs} +1 -1
- package/dist/{chunk-5K44HLTZ.mjs → chunk-L7W624FD.mjs} +1 -1
- package/dist/{chunk-J5PUIV3Y.mjs → chunk-LE44MD3O.mjs} +1 -1
- package/dist/{chunk-4K5KC6V2.mjs → chunk-LV24RY2E.mjs} +1 -1
- package/dist/{chunk-43B75J5T.mjs → chunk-MKTX5PGV.mjs} +1 -1
- package/dist/{chunk-LKX55H76.mjs → chunk-NAGMRVW4.mjs} +1 -1
- package/dist/{chunk-DAZL73VK.mjs → chunk-NFABH3QW.mjs} +2 -2
- package/dist/{chunk-U7NZI6N4.mjs → chunk-NIW7F7LU.mjs} +2 -2
- package/dist/{chunk-PAREWQGB.mjs → chunk-NWRZXELO.mjs} +1 -1
- package/dist/{chunk-ZWWC4KLF.mjs → chunk-OU3S466R.mjs} +1 -1
- package/dist/{chunk-MN7BZL4R.mjs → chunk-PAFSFVYB.mjs} +1 -1
- package/dist/chunk-QCEATQC4.mjs +2 -0
- package/dist/{chunk-Y34NR5JL.mjs → chunk-QJ3TFJLQ.mjs} +1 -1
- package/dist/{chunk-IA4PSIMD.mjs → chunk-QK3VISCA.mjs} +1 -1
- package/dist/{chunk-7OG26PGF.mjs → chunk-QXVNUASA.mjs} +1 -1
- package/dist/{chunk-GRAXKKG4.mjs → chunk-QZ35UAB4.mjs} +1 -1
- package/dist/{chunk-AR5D57ZD.mjs → chunk-S2Y7VI4H.mjs} +2 -2
- package/dist/{chunk-HXG7EOPJ.mjs → chunk-SAHS56QM.mjs} +1 -1
- package/dist/{chunk-3MBRL2PM.mjs → chunk-SOD23RGL.mjs} +1 -1
- package/dist/chunk-TDWJKOLN.mjs +2 -0
- package/dist/{chunk-ITOH5EGO.mjs → chunk-TOE6DPLH.mjs} +1 -1
- package/dist/chunk-UGO5N3EI.mjs +15 -0
- package/dist/{chunk-4OKWZY5E.mjs → chunk-V5JRSGZF.mjs} +1 -1
- package/dist/{chunk-BZ5RAF7L.mjs → chunk-VA3KAQZD.mjs} +1 -1
- package/dist/{chunk-HDWTZ763.mjs → chunk-VG5JEIUQ.mjs} +1 -1
- package/dist/{chunk-POTA2FV4.mjs → chunk-VGTMLQWZ.mjs} +1 -1
- package/dist/{chunk-UYVCQK3H.mjs → chunk-VKSRM7EY.mjs} +1 -1
- package/dist/{chunk-ZDLPUOAG.mjs → chunk-VNUWREXU.mjs} +1 -1
- package/dist/{chunk-AGDILNWB.mjs → chunk-VPVOAJ7X.mjs} +1 -1
- package/dist/{chunk-CMA72MAG.mjs → chunk-WCWYNFDF.mjs} +1 -1
- package/dist/{chunk-ZQS6H473.mjs → chunk-WTEGUPFG.mjs} +1 -1
- package/dist/{chunk-R2ZDLJYT.mjs → chunk-XKOHV4H6.mjs} +1 -1
- package/dist/{chunk-MXUBKDRO.mjs → chunk-XMKHCUI6.mjs} +1 -1
- package/dist/{chunk-67N2QJYT.mjs → chunk-XQRUG4G3.mjs} +1 -1
- package/dist/{chunk-PGDE24N5.mjs → chunk-YBB5LWZK.mjs} +1 -1
- package/dist/{chunk-6N3PX4XX.mjs → chunk-YFJU3O7G.mjs} +1 -1
- package/dist/chunk-YPPJVDQI.mjs +2 -0
- package/dist/chunk-ZANMKAWS.mjs +2 -0
- package/dist/{chunk-6QBHU4DF.mjs → chunk-ZCP7BTOZ.mjs} +1 -1
- package/dist/{chunk-ERLAIBOA.mjs → chunk-ZEY2RLBT.mjs} +2 -2
- package/dist/{chunk-SQ6Y2ZMA.mjs → chunk-ZO7SFB2E.mjs} +2 -2
- package/dist/cli.mjs +1 -1
- package/dist/server.mjs +1 -1
- package/dist/worker.mjs +1 -1
- package/kit/math.js +1 -1
- package/kit/report.css +3 -3
- package/package.json +1 -1
- package/release.json +5 -5
- package/skill/SKILL.md +8 -7
- package/skill/references/extensions.md +11 -7
- package/web/assets/index-BHC5ZOMh.js +15 -0
- package/web/assets/index-xP0uw3a0.css +1 -0
- package/web/index.html +2 -2
- package/dist/chunk-6KBYRA2C.mjs +0 -2
- package/dist/chunk-F6HVDZR4.mjs +0 -2
- package/dist/chunk-J43Z6LSS.mjs +0 -2
- package/dist/chunk-JZT7I2OJ.mjs +0 -2
- package/dist/chunk-L2SUIS5S.mjs +0 -2
- package/dist/chunk-L7UP52JO.mjs +0 -2
- package/dist/chunk-LM4XFQHZ.mjs +0 -2
- package/dist/chunk-MTYYHM2G.mjs +0 -15
- package/dist/chunk-NAMN3RAF.mjs +0 -2
- package/dist/chunk-ORID3AE7.mjs +0 -166
- package/dist/chunk-SWUTTZCN.mjs +0 -2
- package/dist/chunk-UJQJKNGK.mjs +0 -2
- package/web/assets/index-BM_K-Auu.css +0 -1
- package/web/assets/index-DRslOBiv.js +0 -15
- /package/dist/{chunk-YEY3RSFQ.mjs → chunk-5MMN3N5J.mjs} +0 -0
- /package/dist/{chunk-UH4SIXTR.mjs → chunk-74UW7QSL.mjs} +0 -0
- /package/dist/{chunk-W7XHE4CT.mjs → chunk-EKGYYOSP.mjs} +0 -0
- /package/dist/{chunk-QAIHRH3I.mjs → chunk-KNZJT4ET.mjs} +0 -0
- /package/dist/{chunk-A55HN5OK.mjs → chunk-N3DSBH5S.mjs} +0 -0
- /package/dist/{chunk-QR6A54KJ.mjs → chunk-ZW2A6GBW.mjs} +0 -0
package/THIRD-PARTY-NOTICES.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Third-party notices
|
|
2
2
|
|
|
3
|
-
@heycys/gallery 0.2.
|
|
3
|
+
@heycys/gallery 0.2.1 carries the following code and data of others, bundled into dist/ and web/, built into kit/, or as files in vendor/ and browser/.
|
|
4
4
|
|
|
5
5
|
## @asamuzakjp/css-color 5.1.11
|
|
6
6
|
|
|
@@ -43,9 +43,9 @@ const STYLE = `
|
|
|
43
43
|
[data-ge-editing] main ${STEP}[data-ge-number-hover]:not(:has(> .${ICON}))::before { box-shadow: inset 0 0 0 20px light-dark(rgb(55 53 47 / 8%), rgb(255 255 255 / 8%)); }
|
|
44
44
|
.ge-add-icon { position: fixed; left: 0; top: 0; z-index: ${Z.affordance}; display: flex; align-items: center; gap: 6px; height: 28px; padding: 0 8px; border-radius: 6px;
|
|
45
45
|
color: var(--ge-tx3); font: 14px/1 ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
46
|
-
white-space: nowrap; cursor: pointer; user-select: none; opacity: 0; transition: opacity .1s ease-in, background .1s ease-in; }
|
|
46
|
+
white-space: nowrap; cursor: pointer; user-select: none; opacity: 0; visibility: hidden; pointer-events: none; transition: opacity .1s ease-in, background .1s ease-in; }
|
|
47
47
|
.ge-add-icon svg { display: block; flex: none; width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
|
|
48
|
-
.ge-add-icon[data-shown] { opacity: 1; }
|
|
48
|
+
.ge-add-icon[data-shown] { opacity: 1; visibility: visible; pointer-events: auto; }
|
|
49
49
|
.ge-add-icon:hover { background: light-dark(rgb(55 53 47 / 6%), rgb(255 255 255 / 6%)); }
|
|
50
50
|
`;
|
|
51
51
|
|
|
@@ -187,21 +187,23 @@ export function create({ struct, say, isEditing, openMediaPicker, assetUrl, impo
|
|
|
187
187
|
const add = element("div", { class: "ge-add-icon", role: "button", tabindex: "0", html: t("{0}添加图标", FACE_ICON) });
|
|
188
188
|
add.addEventListener("mousedown", (event) => event.preventDefault());
|
|
189
189
|
add.addEventListener("click", async () => {
|
|
190
|
+
if (!shown || !isEditing() || iconOf()) return;
|
|
190
191
|
await loadEmojiData();
|
|
192
|
+
if (!shown || !isEditing() || iconOf()) return;
|
|
191
193
|
setIcon(randomEmoji(), t("已添加页面图标"));
|
|
192
194
|
});
|
|
193
195
|
let shown = false;
|
|
194
196
|
function showAdd() {
|
|
195
197
|
const title = titleOf();
|
|
196
198
|
const header = title?.parentElement;
|
|
197
|
-
if (!title || !header) return hideAdd();
|
|
199
|
+
if (!isEditing() || !title || !header || iconOf()) return hideAdd();
|
|
198
200
|
if (!add.isConnected) mount(add);
|
|
199
201
|
const first = header.firstElementChild.getBoundingClientRect();
|
|
200
202
|
const range = document.createRange();
|
|
201
203
|
range.selectNodeContents(title);
|
|
202
204
|
const text = range.getClientRects()[0] ?? title.getBoundingClientRect();
|
|
203
205
|
add.style.transform = `translate(${Math.round(text.left - 8)}px, ${Math.round(first.top - 28 - 6)}px)`;
|
|
204
|
-
if (!shown) requestAnimationFrame(() => add.setAttribute("data-shown", ""));
|
|
206
|
+
if (!shown) requestAnimationFrame(() => { if (shown) add.setAttribute("data-shown", ""); });
|
|
205
207
|
shown = true;
|
|
206
208
|
}
|
|
207
209
|
function hideAdd() {
|
|
@@ -18,8 +18,8 @@ const LEVELS = {
|
|
|
18
18
|
export const FORMULA_ICON = svg('<path d="M1.6 11.6 5.5 16.25 9.5 3.75h8.75"/><path d="m10.6 7.9 6.6 8.35M17.2 7.9l-6.6 8.35"/>');
|
|
19
19
|
|
|
20
20
|
/** A link (two rings of a chain): the "/" menu's 链接, 链接视图, an empty link's chip, one drawing. */
|
|
21
|
-
/** A page (
|
|
22
|
-
export const PAGE_ICON = svg('<path d="M5 18.33a1.67 1.67 0 0 1-1.67-1.66V3.33a1.67 1.67 0 0 1 1.67-1.66h6.67a2 2 0 0 1 1.42.58l2.99 2.99a2 2 0 0 1 .59 1.43v10a1.67 1.67 0 0 1-1.67 1.66z"/><path d="M11.67 1.67v4.16c0 .46.37.84.83.84h4.17"
|
|
21
|
+
/** A page (the workbench's plain Lucide file outline, here in 20 units): the "/" menu's 页面, a page block left empty, a page with no icon in a search's rows. */
|
|
22
|
+
export const PAGE_ICON = svg('<path d="M5 18.33a1.67 1.67 0 0 1-1.67-1.66V3.33a1.67 1.67 0 0 1 1.67-1.66h6.67a2 2 0 0 1 1.42.58l2.99 2.99a2 2 0 0 1 .59 1.43v10a1.67 1.67 0 0 1-1.67 1.66z"/><path d="M11.67 1.67v4.16c0 .46.37.84.83.84h4.17"/>');
|
|
23
23
|
|
|
24
24
|
/** A section (分区): blocks gathered between brackets, the block menu's 组成新分区 and the section's line in 设置 › 组件列表. */
|
|
25
25
|
export const SECTION_ICON = svg('<path d="M6 3.75h-.75a2 2 0 0 0-2 2v8.5a2 2 0 0 0 2 2H6M14 3.75h.75a2 2 0 0 1 2 2v8.5a2 2 0 0 1-2 2H14"/><path d="M7.25 7.75h5.5M7.25 12.25h5.5"/>');
|
|
@@ -14,7 +14,8 @@ import { addressOf } from "../../links/address.mjs";
|
|
|
14
14
|
// formatting" (html.mjs containsRichFormatting; WordPress's always): spans, divs and paragraphs
|
|
15
15
|
// alone paste as their text.
|
|
16
16
|
// - Office's HTML (its namespaces) that looks like Word's goes through the Word filter (office.mjs).
|
|
17
|
-
// - A whole $$ formula $$ → a formula block;
|
|
17
|
+
// - A whole $$ formula $$ → a formula block; IDE Markdown → document blocks, other IDE code →
|
|
18
|
+
// code in its language. An unspecified IDE language can be Markdown by its parsed structure.
|
|
18
19
|
// - HTML: several lines of text, or a table → blocks (html.mjs htmlToBlocks); else one rich text.
|
|
19
20
|
// - No HTML: several lines → Markdown (markdown.mjs); one line → inline Markdown (bold, links,
|
|
20
21
|
// addresses, formulas) when it has any, else plain text. A whole line that is a display formula
|
|
@@ -89,6 +90,20 @@ function sameAddress(one, other) {
|
|
|
89
90
|
}
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
// VS Code and Cursor copy syntax-colored HTML beside the source text, even for Markdown. Use
|
|
94
|
+
// the same Markdown reader as an ordinary text paste, not the colored HTML. A Markdown language
|
|
95
|
+
// hint is enough; for an unspecified language, require some structure or inline formatting the
|
|
96
|
+
// reader actually recognizes. Unrecognized lines remain text; the whole selection need not be
|
|
97
|
+
// valid Markdown. A declared programming language wins over lookalike comments and operators.
|
|
98
|
+
function editorMarkdown({ text, language }) {
|
|
99
|
+
if (language !== "Markdown" && language !== "Plain Text") return null;
|
|
100
|
+
const specs = fromMarkdown(text);
|
|
101
|
+
if (language === "Markdown") return specs;
|
|
102
|
+
const marked = (blocks) => blocks.some((block) => block.type !== "text"
|
|
103
|
+
|| block.title.some((piece) => piece[1]?.length) || marked(block.children));
|
|
104
|
+
return !addressOf(text) && marked(specs) ? specs : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
92
107
|
/**
|
|
93
108
|
* Another program's content as { kind: "blocks", specs } | { kind: "rich", title } | { kind: "plain",
|
|
94
109
|
* text } (notion-lite readClipboard, after the editor's own formats).
|
|
@@ -98,11 +113,14 @@ export function readExternal(data, options = {}) {
|
|
|
98
113
|
const yuque = readYuque(data, options);
|
|
99
114
|
if (yuque) return yuque;
|
|
100
115
|
const external = externalContent(data, options);
|
|
101
|
-
const { text
|
|
116
|
+
const { text } = external;
|
|
102
117
|
const convert = { colors: external.colors };
|
|
103
118
|
const equation = EQUATION.exec(text)?.[1];
|
|
104
119
|
if (equation?.trim()) return { kind: "blocks", specs: [{ type: "equation", title: [[equation]], props: {}, children: [] }] };
|
|
120
|
+
const markdown = editorMarkdown(external);
|
|
121
|
+
if (markdown?.length && (multiline(text) || markdown.some((block) => block.type !== "text"))) return { kind: "blocks", specs: markdown };
|
|
105
122
|
if (multiline(text) && external.language) return { kind: "blocks", specs: [{ type: "code", title: [[text]], props: { language: external.language }, children: [] }] };
|
|
123
|
+
const html = markdown ? "" : external.html;
|
|
106
124
|
if (html) {
|
|
107
125
|
if (multiline(text) || external.isTable) {
|
|
108
126
|
const specs = mathifySpecs(htmlToBlocks(parseClipboardHTML(html, options), convert));
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse } from "parse5";
|
|
4
|
+
import { attr, walk } from "./html-tree.mjs";
|
|
5
|
+
import { escapeAttribute } from "./escape.mjs";
|
|
6
|
+
import { newNames } from "./new-names.mjs";
|
|
7
|
+
import { COMPONENTS_FOLDER, DOCUMENT_FOLDERS, SHARED_FOLDERS, documentFiles, packageFolders, readLink } from "./workspace.mjs";
|
|
8
|
+
|
|
9
|
+
/** Files belonging to a document, excluding packages/pages sharing a legacy reserved folder. */
|
|
10
|
+
export async function documentNames(root, slug) {
|
|
11
|
+
const entries = await fs.readdir(path.join(root, slug), { withFileTypes: true });
|
|
12
|
+
if (!SHARED_FOLDERS.includes(slug)) return entries.map(({ name }) => name);
|
|
13
|
+
const scopes = slug === COMPONENTS_FOLDER ? new Set(packageFolders(root).map(({ id }) => id.split("/")[0])) : new Set();
|
|
14
|
+
return entries.filter(entry => entry.isFile() || (DOCUMENT_FOLDERS.includes(entry.name) && !scopes.has(entry.name))).map(({ name }) => name);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Same copy primitive for duplicate and cross-workspace copy: local assets and metadata stay intact. */
|
|
18
|
+
export async function copyEntryFiles(root, slug, destination, copy, { names = new Map() } = {}) {
|
|
19
|
+
const source = path.join(root, slug);
|
|
20
|
+
const document = await fs.access(documentFiles(source).html).then(() => true, () => false);
|
|
21
|
+
const link = document ? null : await readLink(source);
|
|
22
|
+
const own = new Set(["", ...(await documentNames(root, slug))]);
|
|
23
|
+
await fs.cp(source, destination, { recursive: true, errorOnExist: true, force: false, filter: from => own.has(path.relative(source, from).split(path.sep)[0]) });
|
|
24
|
+
if (link) return [];
|
|
25
|
+
const file = documentFiles(destination).html;
|
|
26
|
+
const html = await fs.readFile(file, "utf8");
|
|
27
|
+
const text = newNames(html);
|
|
28
|
+
const edits = new Map(), packages = new Set();
|
|
29
|
+
walk(parse(text.replace(/^\uFEFF/, " "), { sourceCodeLocationInfo: true }), node => {
|
|
30
|
+
const component = attr(node, "data-doc-component");
|
|
31
|
+
if (component) packages.add(component);
|
|
32
|
+
for (const { name, value } of node.attrs ?? []) {
|
|
33
|
+
const at = node.sourceCodeLocation?.attrs?.[name];
|
|
34
|
+
if (!at) continue;
|
|
35
|
+
const relative = /^(\.\.\/)([a-z0-9]+(?:-[a-z0-9]+)*)(\/.*)$/.exec(value);
|
|
36
|
+
const next = name === "data-doc-id" && value === slug ? copy : ["href", "src", "poster"].includes(name) && relative && names.has(relative[2]) ? `../${names.get(relative[2])}${relative[3]}` : value;
|
|
37
|
+
if (next !== value) edits.set(at.startOffset, { to: at.endOffset, text: `${name}="${escapeAttribute(next)}"` });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
const next = [...edits].sort(([a], [b]) => b - a).reduce((text, [from, edit]) => text.slice(0, from) + edit.text + text.slice(edit.to), text);
|
|
41
|
+
await fs.writeFile(file, next);
|
|
42
|
+
return [...packages];
|
|
43
|
+
}
|
|
@@ -2,13 +2,13 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { descendantsIn, outlineAndFile, parentIn, placeIn, readOutline, withOutlineLock, writeOutline, writtenAs } from "./outline.mjs";
|
|
5
|
+
import { descendantsIn, outlineAndFile, parentIn, placeIn, readOutline, sharedOutline, withOutlineLock, writeOutline, writtenAs } from "./outline.mjs";
|
|
6
6
|
import { DOCS, GROUP_LIMITS, isForeign, isTop, newGroupId, topIn, topOf } from "./outline-shape.mjs";
|
|
7
7
|
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
13
|
// The drafts of other workspaces in a workspace's sidebar (notes/plans/workspaces-links-plan-2026-11-08.md
|
|
14
14
|
// §2.2, §2.5, §7.2; docs/workbench.md "Drafts of other workspaces"): an entry of the outline written
|
|
@@ -76,6 +76,8 @@ export async function draftFacts(entry, ids = registeredIds()) {
|
|
|
76
76
|
const none = { entry, ws, slug, root: null, folder: null, path: null, title: slug, description: "", icon: null };
|
|
77
77
|
if (!found.root) return { ...none, missing: found.refused === "ambiguous" ? "ambiguous" : "workspace" };
|
|
78
78
|
const folder = path.join(found.root, slug);
|
|
79
|
+
const link = existsSync(documentFiles(folder).html) ? null : await readLink(folder);
|
|
80
|
+
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
81
|
const { files, html, metadata } = await metadataAt(folder);
|
|
80
82
|
const missing = !isWorkspace(found.root) ? "workspace" : !html || metadata?.instructions === true ? "draft" : null;
|
|
81
83
|
// An untitled document says so (its title empty: the workbench names it 未命名文档); one with no doc.json to read, its slug.
|
|
@@ -226,6 +228,11 @@ export async function addDraft(treeRoot, draftRoot, slug, { changed = () => {},
|
|
|
226
228
|
return { parent, column: top === null || top === "" ? DOCS : top.slice(1), under: parent !== null && !isTop(parent) ? parent : null };
|
|
227
229
|
};
|
|
228
230
|
const columnOf = (key) => outline.groups.find((each) => topOf(each.id) === topIn(outline.children, key)) ?? null;
|
|
231
|
+
if (sharedOutline(treeRoot) && holds(entry)) {
|
|
232
|
+
if (!writtenAs(outline, stored)) { await writeOutline(treeRoot, outline); changed(); }
|
|
233
|
+
const existing = pick || storedHolds;
|
|
234
|
+
return { entry, existing, added: existing ? [] : [entry], shown: false, ...where() };
|
|
235
|
+
}
|
|
229
236
|
let shown = false;
|
|
230
237
|
let column = outline.groups.find((each) => each.workspace === id) ?? null;
|
|
231
238
|
const put = () => {
|
|
@@ -425,7 +432,7 @@ export function joinedLine(joined) {
|
|
|
425
432
|
if (joined.failed) return `${where}:没有加进去(${joined.failed})`;
|
|
426
433
|
if (joined.existing) return `${where}里已经有它(${joined.names.under ? `在「${joined.names.under}」下面` : `「${joined.names.column}」栏`})`;
|
|
427
434
|
const more = joined.added.length > 1 ? `,连同它下面的 ${joined.added.length - 1} 篇` : "";
|
|
428
|
-
return `${where}:已加进${joined.names.under ? `「${joined.names.under}」下面` : `「${joined.names.column}」栏`}${more}
|
|
435
|
+
return `${where}:已加进${joined.names.under ? `「${joined.names.under}」下面` : `「${joined.names.column}」栏`}${more}(工作区内排序全局同步,文件位置不变)`;
|
|
429
436
|
}
|
|
430
437
|
|
|
431
438
|
/** Whether a gallery.json at `folder` would be another program's or not readable (what draft-add refuses to make a workspace of). */
|
|
@@ -73,12 +73,12 @@ export const iconUrl = (value, slug = null, { tab = false } = {}) => `/__gallery
|
|
|
73
73
|
const [TAB_LIGHT, TAB_DARK] = ICON_COLOR_VALUES.gray;
|
|
74
74
|
/**
|
|
75
75
|
* 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
|
|
76
|
+
* the workbench's tree shows for one (its "page" icon, components/icons.ts: Lucide's plain file outline,
|
|
77
77
|
* FinHarness's 文档), its box cropped to `1 1 22 22` so it fills the tab as an emoji does, in the tab
|
|
78
78
|
* glyphs' gray, light or dark as the system is. An image, never none: a tab given no image, or one that does not
|
|
79
79
|
* load, keeps the one it showed (Chrome), so a page with no icon opened after one with one showed that one's.
|
|
80
80
|
*/
|
|
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"
|
|
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"/></svg>`)}`;
|
|
82
82
|
|
|
83
83
|
/** The value of an icon's element as the file (or a page) has it, or null. */
|
|
84
84
|
export function iconValueOf(html) {
|
|
@@ -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 { writeAtomic } from "./files.mjs";
|
|
7
9
|
|
|
8
|
-
// The extensions
|
|
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,32 @@ 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(root) {
|
|
36
|
+
const home = defaultWorkspace();
|
|
37
|
+
const roots = sameDirectory(home, root) ? [root] : [home, root];
|
|
38
|
+
const found = new Map();
|
|
39
|
+
for (const owner of roots) {
|
|
40
|
+
for (const entry of await extensionFolders(owner)) {
|
|
41
|
+
const file = path.join(entry.folder, SIDEBAR_FILE);
|
|
42
|
+
const present = await fs.lstat(file).then(() => true, () => false);
|
|
43
|
+
if (present || !found.has(entry.id)) found.set(entry.id, { ...entry, root: owner, file, present });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return [...found.values()].sort((a, b) => a.id < b.id ? -1 : 1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Resolve an id, never a client-supplied directory, to the file this workspace actually shows. */
|
|
50
|
+
export async function extensionSource(root, id) {
|
|
51
|
+
if (!isExtensionId(id)) return null;
|
|
52
|
+
return (await visibleExtensionFolders(root)).find(entry => entry.id === id) ?? null;
|
|
53
|
+
}
|
|
54
|
+
|
|
29
55
|
/** The ids of the extensions that have a sidebar.json (the columns the sidebar has), sorted. */
|
|
30
56
|
export async function extensionColumnIds(root) {
|
|
31
|
-
|
|
32
|
-
return found.filter(Boolean);
|
|
57
|
+
return (await visibleExtensionFolders(root)).filter(entry => entry.present).map(entry => entry.id);
|
|
33
58
|
}
|
|
34
59
|
|
|
35
60
|
/**
|
|
@@ -69,11 +94,62 @@ export async function readExtension(root, id) {
|
|
|
69
94
|
}
|
|
70
95
|
|
|
71
96
|
/**
|
|
72
|
-
* The extensions
|
|
97
|
+
* The extensions visible in workspace `root`, by id: those with a sidebar.json (the sidebar's columns), or with `all`
|
|
73
98
|
* every folder that may be one (gallery extension list). Each as readExtension says it.
|
|
74
99
|
*/
|
|
75
100
|
export async function listExtensions(root, { all = false } = {}) {
|
|
76
|
-
const folders = await
|
|
77
|
-
const read = await Promise.all(folders.map(({ id }) => readExtension(
|
|
101
|
+
const folders = await visibleExtensionFolders(root);
|
|
102
|
+
const read = await Promise.all(folders.map(({ id, root: owner }) => readExtension(owner, id)));
|
|
78
103
|
return all ? read : read.filter((extension) => !extension.missing);
|
|
79
104
|
}
|
|
105
|
+
|
|
106
|
+
/** Edit only a column's title or the order of its root entries. Called under the workspace lock.
|
|
107
|
+
* Read the latest source, keep unknown fields and whole subtrees, and refuse stale UI changes.
|
|
108
|
+
* Atomic replacement checks the original text again so an external writer is not silently overwritten.
|
|
109
|
+
*/
|
|
110
|
+
export async function editExtension(root, id, change) {
|
|
111
|
+
const refused = (status, reason, error) => ({ ok: false, status, reason, error });
|
|
112
|
+
const bad = () => refused(400, "body", said("请求格式不对"));
|
|
113
|
+
const stale = () => refused(409, "stale", said("扩展目录刚在别处改过,请重试"));
|
|
114
|
+
if (!isExtensionId(id) || !change || typeof change !== "object") return bad();
|
|
115
|
+
const rename = Object.hasOwn(change, "title");
|
|
116
|
+
if (rename ? (typeof change.title !== "string" || typeof change.baseTitle !== "string" || change.move !== undefined)
|
|
117
|
+
: (!change.move || !["before", "after"].includes(change.move.side) || typeof change.move.entry !== "string" || typeof change.move.ref !== "string" || !Array.isArray(change.baseOrder))) return bad();
|
|
118
|
+
const name = rename ? change.title.replace(/\s+/g, " ").trim() : null;
|
|
119
|
+
if (rename && (!name || name.length > SIDEBAR_LIMITS.column)) return bad();
|
|
120
|
+
const folder = path.join(layoutOf(root).extensions, id);
|
|
121
|
+
const file = path.join(folder, SIDEBAR_FILE);
|
|
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
|
+
const read = readSidebarText(source);
|
|
131
|
+
if (read.error) return refused(409, "extension", read.error);
|
|
132
|
+
const value = JSON.parse(source.replace(/^\uFEFF/, ""));
|
|
133
|
+
if (rename) {
|
|
134
|
+
if (read.sidebar.title !== change.baseTitle) return stale();
|
|
135
|
+
if (read.sidebar.title === name) return { ok: true, written: false };
|
|
136
|
+
value.title = name;
|
|
137
|
+
} else {
|
|
138
|
+
const order = value.entries.map(entry => entry.id);
|
|
139
|
+
if (JSON.stringify(order) !== JSON.stringify(change.baseOrder)) return stale();
|
|
140
|
+
const from = order.indexOf(change.move.entry), to = order.indexOf(change.move.ref);
|
|
141
|
+
if (from < 0 || to < 0) return bad(); // Root entries only, in this file; no nesting or cross-column move.
|
|
142
|
+
if (from === to) return { ok: true, written: false };
|
|
143
|
+
const [entry] = value.entries.splice(from, 1);
|
|
144
|
+
const at = value.entries.findIndex(entry => entry.id === change.move.ref);
|
|
145
|
+
value.entries.splice(at + (change.move.side === "after" ? 1 : 0), 0, entry);
|
|
146
|
+
if (JSON.stringify(order) === JSON.stringify(value.entries.map(entry => entry.id))) return { ok: true, written: false };
|
|
147
|
+
}
|
|
148
|
+
const indent = source.match(/\n([ \t]+)"/)?.[1] ?? 2;
|
|
149
|
+
let next = JSON.stringify(value, null, indent) + "\n";
|
|
150
|
+
if (source.includes("\r\n")) next = next.replaceAll("\n", "\r\n");
|
|
151
|
+
if (source.startsWith("\uFEFF")) next = "\uFEFF" + next;
|
|
152
|
+
if (Buffer.byteLength(next) > SIDEBAR_LIMITS.bytes) return refused(409, "extension", said("文件超过 1MB"));
|
|
153
|
+
if (!(await writeAtomic(file, next, { expect: source }))) return stale();
|
|
154
|
+
return { ok: true, written: true };
|
|
155
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
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
|
+
"扩展目录刚在别处改过,请重试": "The extension directory changed elsewhere. Please try again.",
|
|
5
|
+
"找不到这个扩展": "This extension could not be found",
|
|
6
|
+
"扩展路径不是普通文件或文件夹": "The extension path is not a regular file or directory",
|
|
4
7
|
"正在读取文档…": "Loading documents…",
|
|
5
8
|
"没有连上本地服务": "Not connected to the local service",
|
|
6
9
|
"重试": "Retry",
|
|
@@ -153,6 +156,8 @@ export default {
|
|
|
153
156
|
"撤销": "Undo",
|
|
154
157
|
"已恢复「{0}」": "Restored “{0}”",
|
|
155
158
|
"永久删除「{0}」?": "Delete “{0}” forever?",
|
|
159
|
+
"永久删除回收区中的全部 {0} 项?": "Permanently delete all {0} items in the trash?",
|
|
160
|
+
"删除全部": "Delete all",
|
|
156
161
|
"这份文档": "this document",
|
|
157
162
|
"删除后无法恢复。": "This can't be undone.",
|
|
158
163
|
"正在导出…": "Exporting…",
|
|
@@ -1523,4 +1528,9 @@ export default {
|
|
|
1523
1528
|
"选的文件不是 zip": "The file chosen is not a zip",
|
|
1524
1529
|
"没有这个块包": "No such block pack",
|
|
1525
1530
|
"粘贴块包的文件夹或 zip 的路径": "Paste the path of the pack's folder or zip",
|
|
1531
|
+
"复制到另一个工作区?": "Copy to another workspace?",
|
|
1532
|
+
"将「{0}」及其子页面复制到「{1}」,原项保留。": "Copy “{0}” and its child pages to “{1}”, keeping the originals.",
|
|
1533
|
+
"已复制 {0} 项,原项保留": "Copied {0} items; originals kept",
|
|
1534
|
+
"跨工作区请复制,不能只移动目录位置": "Copy items to another workspace instead of moving their tree positions",
|
|
1535
|
+
"自定义块 {0} 缺失或版本冲突,未复制": "Custom block {0} is missing or has a version conflict; nothing was copied",
|
|
1526
1536
|
};
|
|
@@ -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
|
}
|
|
@@ -3,11 +3,53 @@ import fs from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { writeAtomic } from "./files.mjs";
|
|
5
5
|
import { extensionColumnIds } from "./extensions.mjs";
|
|
6
|
-
import { DOCS, entryPattern, extensionGroupId, GROUP_LIMITS, groupOf, isForeign, isTop, newGroupId, topIn, topOf, validGroups } from "./outline-shape.mjs";
|
|
6
|
+
import { DOCS, columnWorkspaceId, entryWorkspace, entryPattern, extensionGroupId, GROUP_LIMITS, groupOf, groupOfTop, isForeign, isTop, newGroupId, topIn, topOf, validGroups } from "./outline-shape.mjs";
|
|
7
7
|
import { storedHead } from "./sidebar-head.mjs";
|
|
8
|
-
import { shownWorkspaces, snapshotOf } from "./workspace-columns.mjs";
|
|
9
|
-
import { layoutOf, listDocumentSlugs, listLinkSlugs, pageSlug } from "./workspace.mjs";
|
|
8
|
+
import { forgetSnapshot, shownWorkspaces, snapshotOf } from "./workspace-columns.mjs";
|
|
9
|
+
import { defaultWorkspace, layoutOf, listDocumentSlugs, listLinkSlugs, pageSlug } from "./workspace.mjs";
|
|
10
|
+
import { readRegistry, workspaceId, workspaceKey } from "./workspace-registry.mjs";
|
|
10
11
|
|
|
12
|
+
/** Registered workspaces share the default workspace's existing tree. Unregistered/temporary
|
|
13
|
+
* workspaces remain standalone; no registry discovery or copying of document files is needed. */
|
|
14
|
+
export function outlineRoot(root) {
|
|
15
|
+
const home = defaultWorkspace();
|
|
16
|
+
return workspaceKey(root) === workspaceKey(home) || readRegistry().workspaces.some(entry => workspaceKey(entry.root) === workspaceKey(root)) ? home : root;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const sharedOutline = root => workspaceKey(outlineRoot(root)) === workspaceKey(defaultWorkspace());
|
|
20
|
+
|
|
21
|
+
/** Moving a reference across owners must use the confirmed copy command. */
|
|
22
|
+
export function ownsOutline(root, value) {
|
|
23
|
+
const self = workspaceId(root);
|
|
24
|
+
const available = new Set([self, ...shownWorkspaces(root).map(workspace => workspace.id)]);
|
|
25
|
+
return Object.entries(value.children).every(([parent, list]) => {
|
|
26
|
+
const owner = isTop(parent) ? columnWorkspaceId(value.groups?.find(group => topOf(group.id) === parent), self) : entryWorkspace(parent, self);
|
|
27
|
+
// Missing legacy references remain removable and must not block edits to healthy documents.
|
|
28
|
+
return list.every(entry => !available.has(entryWorkspace(entry, self)) || entryWorkspace(entry, self) === owner);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Earlier versions allowed mixed-workspace nesting. Return misplaced roots to their owner,
|
|
33
|
+
// retaining same-owner order and descendants. This changes directory positions, never files.
|
|
34
|
+
function ownedTree(tree, self) {
|
|
35
|
+
const children = Object.fromEntries(tree.groups.map(group => [topOf(group.id), []]));
|
|
36
|
+
const ownTop = owner => { const group = tree.groups.find(group => owner === self ? group.id === DOCS : group.workspace === owner); return group ? topOf(group.id) : null; };
|
|
37
|
+
const walk = (parent, owner) => {
|
|
38
|
+
for (const entry of tree.children[parent] ?? []) {
|
|
39
|
+
const belongs = entryWorkspace(entry, self);
|
|
40
|
+
const at = belongs === owner ? parent : ownTop(belongs) ?? parent;
|
|
41
|
+
(children[at] ??= []).push(entry);
|
|
42
|
+
walk(entry, belongs);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
for (const group of tree.groups) if (!group.extension) walk(topOf(group.id), columnWorkspaceId(group, self));
|
|
46
|
+
return { ...tree, children };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Registered workspaces project one shared tree, partitioned by file owner. Their former local files
|
|
50
|
+
// only seed new entries; the default workspace stores subsequent order/hierarchy edits. The format
|
|
51
|
+
// below is also the standalone/legacy reader. Its picked/hidden/left rules are not applied to shared
|
|
52
|
+
// trees, which list every registered document and link. Project-only extensions keep local positions.
|
|
11
53
|
// The workspace's outline: the sidebar's columns and which entry sits under which, in what order
|
|
12
54
|
// (<workspace>/.gallery/outline.json, docs/workbench.md "Workspace commands"; its shape: ./outline-shape.mjs).
|
|
13
55
|
// The file, version 2:
|
|
@@ -56,10 +98,10 @@ const LOCK_WAIT = 15_000;
|
|
|
56
98
|
/**
|
|
57
99
|
* Run `task` holding the outline's lock of workspace `root`: a file made with "wx", which one
|
|
58
100
|
* process alone can make, removed after; one older than 10 s is a crashed writer's and is taken over.
|
|
59
|
-
*
|
|
60
|
-
* nothing any more).
|
|
101
|
+
* A live writer refreshes its lock. Waits 15 s at most and then refuses rather than stealing it.
|
|
61
102
|
*/
|
|
62
|
-
export async function withOutlineLock(root, task) {
|
|
103
|
+
export async function withOutlineLock(root, task, { local = false } = {}) {
|
|
104
|
+
if (!local) root = outlineRoot(root);
|
|
63
105
|
const { gallery } = layoutOf(root);
|
|
64
106
|
const lock = path.join(gallery, "outline.lock");
|
|
65
107
|
const mine = `${process.pid} ${randomBytes(4).toString("hex")}`;
|
|
@@ -71,13 +113,17 @@ export async function withOutlineLock(root, task) {
|
|
|
71
113
|
} catch (error) {
|
|
72
114
|
if (error.code !== "EEXIST") throw error;
|
|
73
115
|
const age = await fs.stat(lock).then(({ mtimeMs }) => Date.now() - mtimeMs, () => 0);
|
|
74
|
-
if (age > LOCK_STALE
|
|
116
|
+
if (age > LOCK_STALE) await fs.rm(lock, { force: true });
|
|
117
|
+
else if (Date.now() > until) throw Object.assign(new Error("文档目录刚在别处改过"), { code: "stale" });
|
|
75
118
|
else await new Promise((resolve) => setTimeout(resolve, 20));
|
|
76
119
|
}
|
|
77
120
|
}
|
|
121
|
+
const heartbeat = setInterval(() => fs.utimes(lock, new Date(), new Date()).catch(() => {}), LOCK_STALE / 3);
|
|
122
|
+
heartbeat.unref();
|
|
78
123
|
try {
|
|
79
124
|
return await task();
|
|
80
125
|
} finally {
|
|
126
|
+
clearInterval(heartbeat);
|
|
81
127
|
// Removed only while it is still this writer's (one taken over meanwhile is the next writer's).
|
|
82
128
|
if ((await fs.readFile(lock, "utf8").catch(() => null)) === mine) await fs.rm(lock, { force: true });
|
|
83
129
|
}
|
|
@@ -98,7 +144,8 @@ export async function readOutline(root, { others = true } = {}) {
|
|
|
98
144
|
*/
|
|
99
145
|
export async function otherWorkspaces(root, { env = process.env } = {}) {
|
|
100
146
|
const shown = shownWorkspaces(root, { env });
|
|
101
|
-
|
|
147
|
+
// Project-local files seed entries not yet in the shared tree; never recurse into its projection.
|
|
148
|
+
const readOwn = async (other) => (await localOutlineAndFile(other, { others: false })).outline;
|
|
102
149
|
const unread = () => ({ documents: new Set(), order: [], parentOf: new Map(), facts: new Map(), failed: true });
|
|
103
150
|
return Promise.all(shown.map(async (each) => ({ ...each, failed: false, ...(await snapshotOf(each.root, readOwn).catch(unread)) })));
|
|
104
151
|
}
|
|
@@ -113,14 +160,16 @@ export async function otherWorkspaces(root, { env = process.env } = {}) {
|
|
|
113
160
|
* place a command or the workbench gave. `others: false`: the workspace's own tree alone (no column of
|
|
114
161
|
* another workspace made, none filled).
|
|
115
162
|
*/
|
|
116
|
-
|
|
163
|
+
async function localOutlineAndFile(root, { others: withOthers = true, shared = false } = {}) {
|
|
117
164
|
const instructions = await pageSlug(root);
|
|
118
165
|
const documents = (await listDocumentSlugs(root)).filter((slug) => slug !== instructions);
|
|
119
166
|
const links = await listLinkSlugs(root);
|
|
120
167
|
const extensions = await extensionColumnIds(root);
|
|
121
168
|
const stored = storedOf(await readJson(layoutOf(root).outline));
|
|
122
169
|
const others = withOthers ? await otherWorkspaces(root) : [];
|
|
123
|
-
const
|
|
170
|
+
const base = shared && stored ? { ...stored, groups: stored.groups.map(({ picked, hidden, left, ...group }) => group.workspace || group.extension || group.id === DOCS ? group : { ...group, owner: group.owner ?? workspaceId(root) }) } : stored;
|
|
171
|
+
let shown = ordered(base, documents, links, extensions, others, { drawn: withOthers });
|
|
172
|
+
if (shared) shown = ownedTree(shown, workspaceId(root));
|
|
124
173
|
const outline = {
|
|
125
174
|
version: 2, ...shown, links, drafts: Object.values(shown.children).flat().filter(isForeign),
|
|
126
175
|
shown: others.map(({ id, root: other, isDefault }) => ({ id, root: other, isDefault })),
|
|
@@ -128,6 +177,54 @@ export async function outlineAndFile(root, { others: withOthers = true } = {}) {
|
|
|
128
177
|
return { outline, stored, others };
|
|
129
178
|
}
|
|
130
179
|
|
|
180
|
+
/** Translate the same tree between the default workspace's ids and a project's local ids.
|
|
181
|
+
* No second ordering is kept: only the current workspace's column is displayed first. */
|
|
182
|
+
function projectTree(value, from, to, { first = false } = {}) {
|
|
183
|
+
if (!value || workspaceKey(from) === workspaceKey(to)) return value;
|
|
184
|
+
const fromId = workspaceId(from), toId = workspaceId(to);
|
|
185
|
+
const own = value.groups.find(group => group.workspace === toId);
|
|
186
|
+
const ownId = own?.id ?? toId;
|
|
187
|
+
const groupId = id => id === DOCS ? fromId : id === ownId ? DOCS : id;
|
|
188
|
+
const entry = key => key.startsWith(`~${toId}/`) ? key.slice(toId.length + 2) : isForeign(key) ? key : `~${fromId}/${key}`;
|
|
189
|
+
const key = value => isTop(value) ? topOf(groupId(groupOfTop(value))) : entry(value);
|
|
190
|
+
let groups = value.groups.map(group => {
|
|
191
|
+
if (group.id === DOCS) return { ...group, id: fromId, workspace: fromId };
|
|
192
|
+
if (group.id === ownId) {
|
|
193
|
+
const { workspace, picked, hidden, left, ...rest } = group;
|
|
194
|
+
return { ...rest, id: DOCS };
|
|
195
|
+
}
|
|
196
|
+
return group;
|
|
197
|
+
});
|
|
198
|
+
if (first) groups = [...groups.filter(group => group.id === DOCS), ...groups.filter(group => group.id !== DOCS)];
|
|
199
|
+
return { ...value, groups, children: Object.fromEntries(Object.entries(value.children).map(([parent, list]) => [key(parent), list.map(entry)])) };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function outlineAndFile(root, { others: withOthers = true } = {}) {
|
|
203
|
+
const home = outlineRoot(root);
|
|
204
|
+
if (workspaceKey(home) === workspaceKey(root)) return localOutlineAndFile(root, { others: withOthers, shared: sharedOutline(root) });
|
|
205
|
+
// A command may just have created/trashed a local item before placing it in the shared tree.
|
|
206
|
+
forgetSnapshot(root);
|
|
207
|
+
const shared = await localOutlineAndFile(home, { shared: true });
|
|
208
|
+
let outline = projectTree(shared.outline, home, root, { first: true });
|
|
209
|
+
// A project's extensions remain private; public ones still come from the default workspace.
|
|
210
|
+
outline.groups = withExtensions(outline.groups, await extensionColumnIds(root));
|
|
211
|
+
const publicIds = new Set(await extensionColumnIds(home));
|
|
212
|
+
const positions = (await readJson(layoutOf(root).outline))?.extensionPositions;
|
|
213
|
+
for (const position of Array.isArray(positions) ? [...positions].reverse() : []) {
|
|
214
|
+
const group = outline.groups.find(group => group.id === position.id && group.extension && !publicIds.has(group.extension));
|
|
215
|
+
if (!group) continue;
|
|
216
|
+
outline.groups = outline.groups.filter(item => item !== group);
|
|
217
|
+
const at = outline.groups.findIndex(item => item.id === position.before);
|
|
218
|
+
outline.groups.splice(at < 0 ? outline.groups.length : at, 0, group);
|
|
219
|
+
}
|
|
220
|
+
outline.children = Object.fromEntries(Object.entries(outline.children).filter(([key]) => !isTop(key) || outline.groups.some(group => topOf(group.id) === key)));
|
|
221
|
+
for (const group of outline.groups) outline.children[topOf(group.id)] ??= [];
|
|
222
|
+
const ownLinks = await listLinkSlugs(root);
|
|
223
|
+
const others = await otherWorkspaces(root);
|
|
224
|
+
outline = { ...outline, links: ownLinks, drafts: Object.values(outline.children).flat().filter(isForeign), shown: others.map(({ id, root: other, isDefault }) => ({ id, root: other, isDefault })) };
|
|
225
|
+
return { outline, stored: projectTree(shared.stored, home, root, { first: true }), others };
|
|
226
|
+
}
|
|
227
|
+
|
|
131
228
|
// The own column's name before it was 默认文档 or the workspace's (文档, Documents in English), which a
|
|
132
229
|
// rename of it confirmed unchanged wrote as its title (the field opens with the name shown): read as no
|
|
133
230
|
// title, so that the name it goes by now shows. A title of another column, or any other, is the user's.
|
|
@@ -378,6 +475,33 @@ export function fileOf(value) {
|
|
|
378
475
|
* the head's order the file holds kept as it is (writeHead's).
|
|
379
476
|
*/
|
|
380
477
|
export async function writeOutline(root, value) {
|
|
478
|
+
const home = outlineRoot(root);
|
|
479
|
+
if (sharedOutline(root) && !ownsOutline(root, value)) throw Object.assign(new Error("跨工作区请复制,不能只移动目录位置"), { code: "CROSS_WORKSPACE" });
|
|
480
|
+
if (sharedOutline(root)) value = { ...value, groups: (value.groups ?? [{ id: DOCS }]).map(group => group.id === DOCS || group.workspace || group.extension ? group : { ...group, owner: group.owner ?? workspaceId(root) }) };
|
|
481
|
+
if (workspaceKey(home) !== workspaceKey(root)) {
|
|
482
|
+
const current = (await localOutlineAndFile(home)).outline;
|
|
483
|
+
const translated = projectTree(value, root, home);
|
|
484
|
+
// The current-column-first projection is presentation only. Keep the shared column order;
|
|
485
|
+
// deliberate moves of other columns use the relative order submitted by the client.
|
|
486
|
+
const ownId = workspaceId(root);
|
|
487
|
+
const at = current.groups.findIndex(group => group.workspace === ownId);
|
|
488
|
+
const own = translated.groups.find(group => group.workspace === ownId);
|
|
489
|
+
translated.groups = translated.groups.filter(group => group !== own);
|
|
490
|
+
if (own) translated.groups.splice(Math.max(0, Math.min(at, translated.groups.length)), 0, own);
|
|
491
|
+
const publicIds = new Set(await extensionColumnIds(home));
|
|
492
|
+
const privateGroup = group => group.extension && !publicIds.has(group.extension);
|
|
493
|
+
const positions = value.groups.filter(privateGroup).map(group => ({ id: group.id, before: value.groups.slice(value.groups.indexOf(group) + 1).find(group => !privateGroup(group))?.id ?? null }));
|
|
494
|
+
const localFile = layoutOf(root).outline;
|
|
495
|
+
const local = await readJson(localFile) ?? {};
|
|
496
|
+
if ((positions.length || local.extensionPositions?.length) && JSON.stringify(positions) !== JSON.stringify(local.extensionPositions)) {
|
|
497
|
+
await fs.mkdir(path.dirname(localFile), { recursive: true });
|
|
498
|
+
await writeAtomic(localFile, `${JSON.stringify({ ...local, extensionPositions: positions }, null, 2)}\n`);
|
|
499
|
+
}
|
|
500
|
+
translated.groups = translated.groups.filter(group => !group.extension || publicIds.has(group.extension));
|
|
501
|
+
for (const group of current.groups) if (group.extension && !translated.groups.some(item => item.id === group.id)) translated.groups.push(group);
|
|
502
|
+
value = translated;
|
|
503
|
+
root = home;
|
|
504
|
+
}
|
|
381
505
|
const file = layoutOf(root).outline;
|
|
382
506
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
383
507
|
const head = storedHead((await readJson(file))?.head);
|
|
@@ -465,6 +589,7 @@ export function moveDocument(root, slug, where) {
|
|
|
465
589
|
return withOutlineLock(root, async () => {
|
|
466
590
|
const { outline, stored } = await outlineAndFile(root);
|
|
467
591
|
const result = moveIn(outline.children, slug, where);
|
|
592
|
+
if (result.ok && sharedOutline(root) && !ownsOutline(root, outline)) return { ok: false, reason: "cross-workspace" };
|
|
468
593
|
const written = result.ok && !writtenAs(outline, stored);
|
|
469
594
|
if (written) await writeOutline(root, outline);
|
|
470
595
|
return { ...result, ...(result.ok ? { written } : {}) };
|