@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.
- 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/annotate/index.mjs +3 -2
- package/browser/editor/page/board/index.mjs +2 -1
- package/browser/editor/page/board/settings.mjs +2 -1
- package/browser/editor/page/clipboard/import/read.mjs +20 -2
- package/browser/editor/page/core.mjs +3 -2
- package/browser/editor/page/custom-page.mjs +4 -3
- package/browser/editor/page/editor.mjs +9 -5
- package/browser/editor/page/emoji-picker/support.mjs +3 -2
- package/browser/editor/page/links/index.mjs +1 -12
- package/browser/editor/page/links/pages.mjs +3 -2
- package/browser/editor/page/outline/folds.mjs +3 -0
- package/browser/editor/page/post.mjs +2 -1
- package/browser/editor/page/present-links.mjs +4 -3
- package/browser/editor/page/present-shell.mjs +3 -58
- package/browser/editor/page/session.mjs +3 -37
- package/browser/editor/page/storage.mjs +5 -5
- package/browser/shared/component-catalog.mjs +2 -2
- package/browser/shared/document-copy.mjs +43 -0
- package/browser/shared/drafts.mjs +32 -50
- package/browser/shared/emoji/icon-value.mjs +4 -3
- package/browser/shared/extension-manifest.mjs +90 -0
- package/browser/shared/extensions.mjs +93 -7
- package/browser/shared/http.mjs +1 -1
- package/browser/shared/i18n/en.mjs +55 -14
- package/browser/shared/media.mjs +2 -1
- package/browser/shared/outline-shape.mjs +6 -0
- package/browser/shared/outline.mjs +95 -10
- package/browser/shared/page-address.mjs +8 -15
- package/browser/shared/paths.mjs +2 -1
- package/browser/shared/preferences.mjs +3 -1
- package/browser/shared/preview-server.mjs +51 -63
- package/browser/shared/service-location.mjs +18 -0
- package/browser/shared/session.mjs +3 -6
- package/browser/shared/workspace-address.mjs +19 -0
- package/browser/shared/workspace-columns.mjs +12 -9
- package/browser/shared/workspace-registry.mjs +6 -40
- package/browser/shared/workspace.mjs +3 -6
- package/browser/shared/writing-preferences.mjs +6 -4
- package/dist/{chunk-W7665ZEC.mjs → chunk-236JJXWY.mjs} +2 -2
- package/dist/{chunk-EAIFITAV.mjs → chunk-2KAVXMKG.mjs} +1 -1
- package/dist/chunk-2T4PZQEP.mjs +2 -0
- package/dist/{chunk-HXG7EOPJ.mjs → chunk-3SKOU6OZ.mjs} +1 -1
- package/dist/{chunk-AR5D57ZD.mjs → chunk-3XZOA7AH.mjs} +2 -2
- package/dist/{chunk-UYVCQK3H.mjs → chunk-4B2TB5K2.mjs} +1 -1
- package/dist/{chunk-3MBRL2PM.mjs → chunk-4KUO7R2M.mjs} +8 -8
- package/dist/chunk-4SNV4HA5.mjs +2 -0
- package/dist/chunk-53XDTHTG.mjs +4 -0
- package/dist/chunk-5XV3YYLG.mjs +2 -0
- package/dist/{chunk-ZDLPUOAG.mjs → chunk-65NTKF4N.mjs} +1 -1
- package/dist/chunk-6GV3CBB2.mjs +2 -0
- package/dist/{chunk-WQDQWCX5.mjs → chunk-6L6V7YR2.mjs} +1 -1
- package/dist/{chunk-ITOH5EGO.mjs → chunk-6NLXUKAY.mjs} +1 -1
- package/dist/chunk-6Q7ASAXJ.mjs +3 -0
- package/dist/{chunk-7OG26PGF.mjs → chunk-6QWQXHAV.mjs} +1 -1
- package/dist/chunk-6XD6U3QA.mjs +5 -0
- package/dist/{chunk-YNAWBAYN.mjs → chunk-7EFHNQ5B.mjs} +1 -1
- package/dist/{chunk-OFHXGWOX.mjs → chunk-7ISBBDJR.mjs} +1 -1
- package/dist/{chunk-QE5RG76S.mjs → chunk-7UZUQQ7Q.mjs} +29 -24
- package/dist/chunk-BLNQUR5R.mjs +168 -0
- package/dist/chunk-BMX3VUHY.mjs +2 -0
- package/dist/chunk-BN7M3IN7.mjs +8 -0
- package/dist/{chunk-K5XATEOO.mjs → chunk-BQNTTFPL.mjs} +1 -1
- package/dist/chunk-C257H6IA.mjs +2 -0
- package/dist/chunk-CHQU37CA.mjs +2 -0
- package/dist/{chunk-CMA72MAG.mjs → chunk-E4IHAIJ7.mjs} +1 -1
- package/dist/{chunk-VS7JIGJW.mjs → chunk-E7656LR7.mjs} +1 -1
- package/dist/{chunk-VFX2KNHP.mjs → chunk-ECZBLQKK.mjs} +1 -1
- package/dist/{chunk-MN7BZL4R.mjs → chunk-ERE2BXYV.mjs} +1 -1
- package/dist/chunk-FFV733MA.mjs +2 -0
- package/dist/{chunk-AHN3NFKJ.mjs → chunk-GJJRBGMY.mjs} +1 -1
- package/dist/{chunk-BZ5RAF7L.mjs → chunk-GKOBB6DH.mjs} +1 -1
- package/dist/chunk-GMJD7ID3.mjs +2 -0
- package/dist/{chunk-OEXWRJME.mjs → chunk-IA2SIEJQ.mjs} +1 -1
- package/dist/chunk-JPG2LBQM.mjs +11 -0
- package/dist/{chunk-POTA2FV4.mjs → chunk-JXXHAXYH.mjs} +1 -1
- package/dist/{chunk-FDMAXMQA.mjs → chunk-K3JKPFL5.mjs} +1 -1
- package/dist/{chunk-J5PUIV3Y.mjs → chunk-KLQP46Y3.mjs} +1 -1
- package/dist/chunk-LMUZQK5O.mjs +2 -0
- package/dist/{chunk-JLTZF4LA.mjs → chunk-M4NRDYI5.mjs} +1 -1
- package/dist/{chunk-HPOI4ZX7.mjs → chunk-MKIBJTJZ.mjs} +1 -1
- package/dist/{chunk-5K44HLTZ.mjs → chunk-MNWJ4EZY.mjs} +1 -1
- package/dist/{chunk-FETSREUN.mjs → chunk-NHP66IWR.mjs} +1 -1
- package/dist/{chunk-PGDE24N5.mjs → chunk-OEIJLMPI.mjs} +1 -1
- package/dist/{chunk-YROX2O22.mjs → chunk-PDZR3L3C.mjs} +1 -1
- package/dist/{chunk-6N3PX4XX.mjs → chunk-PLB7L22J.mjs} +1 -1
- package/dist/{chunk-LM4XFQHZ.mjs → chunk-Q4MS6OFC.mjs} +1 -1
- package/dist/{chunk-7JDX4E65.mjs → chunk-Q7P3Q5YX.mjs} +1 -1
- package/dist/{chunk-ZHEHPMBK.mjs → chunk-QNECWMGR.mjs} +1 -1
- package/dist/{chunk-YI43RG4P.mjs → chunk-QP7BOHE4.mjs} +1 -1
- package/dist/chunk-QVGOPHOF.mjs +2 -0
- package/dist/{chunk-DAZL73VK.mjs → chunk-RRKJMHNX.mjs} +2 -2
- package/dist/chunk-S3SX3COT.mjs +20 -0
- package/dist/{chunk-HDWTZ763.mjs → chunk-TKWEX4FD.mjs} +2 -2
- package/dist/{chunk-YLPSMDIS.mjs → chunk-ULXSPX5H.mjs} +2 -2
- package/dist/chunk-USIHBCSW.mjs +15 -0
- package/dist/{chunk-TR7TWGHB.mjs → chunk-VPMRP2TL.mjs} +1 -1
- package/dist/{chunk-ZQS6H473.mjs → chunk-VRPVVMRB.mjs} +1 -1
- package/dist/{chunk-F6HVDZR4.mjs → chunk-VZ2RQ3AM.mjs} +1 -1
- package/dist/{chunk-R2ZDLJYT.mjs → chunk-W5NPH5UA.mjs} +1 -1
- package/dist/chunk-WFGPMCTZ.mjs +4 -0
- package/dist/chunk-XZCFPKGO.mjs +2 -0
- package/dist/{chunk-4OKWZY5E.mjs → chunk-YN37ZDLW.mjs} +1 -1
- package/dist/chunk-ZFGMJVJC.mjs +2 -0
- package/dist/cli.mjs +1 -1
- package/dist/server.mjs +1 -1
- package/dist/worker.mjs +1 -1
- package/kit/report.css +3 -3
- package/kit/runtime.js +12 -2
- package/kit/state.js +2 -0
- package/kit/ui.js +5 -1
- package/package.json +1 -1
- package/release.json +7 -7
- package/skill/SKILL.md +13 -12
- package/skill/references/building-blocks.md +1 -1
- package/skill/references/extensions.md +48 -8
- package/web/assets/index-FLb9jCfl.css +1 -0
- package/web/assets/index-czjrXVjf.js +15 -0
- package/web/index.html +2 -2
- package/dist/chunk-5XA2ZBAE.mjs +0 -8
- package/dist/chunk-67N2QJYT.mjs +0 -2
- package/dist/chunk-6KBYRA2C.mjs +0 -2
- package/dist/chunk-DJISHPUL.mjs +0 -3
- package/dist/chunk-EKNHM3P4.mjs +0 -2
- package/dist/chunk-EP25HDAJ.mjs +0 -2
- package/dist/chunk-GHOYKMQ3.mjs +0 -4
- package/dist/chunk-IV64FFI4.mjs +0 -2
- package/dist/chunk-J43Z6LSS.mjs +0 -2
- package/dist/chunk-JA7G53PC.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-MTYYHM2G.mjs +0 -15
- package/dist/chunk-MXUBKDRO.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-U7NZI6N4.mjs +0 -4
- package/dist/chunk-UJQJKNGK.mjs +0 -2
- package/dist/chunk-ZWWC4KLF.mjs +0 -20
- package/web/assets/index-BM_K-Auu.css +0 -1
- package/web/assets/index-DRslOBiv.js +0 -15
|
@@ -3,11 +3,47 @@ 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
|
+
/** All document folders share the default workspace’s outline. */
|
|
13
|
+
export const outlineRoot = () => defaultWorkspace();
|
|
14
|
+
|
|
15
|
+
/** Moving a reference across owners must use the confirmed copy command. */
|
|
16
|
+
export function ownsOutline(root, value) {
|
|
17
|
+
const self = workspaceId(root);
|
|
18
|
+
const available = new Set([self, ...shownWorkspaces(root).map(workspace => workspace.id)]);
|
|
19
|
+
return Object.entries(value.children).every(([parent, list]) => {
|
|
20
|
+
const owner = isTop(parent) ? columnWorkspaceId(value.groups?.find(group => topOf(group.id) === parent), self) : entryWorkspace(parent, self);
|
|
21
|
+
// Missing legacy references remain removable and must not block edits to healthy documents.
|
|
22
|
+
return list.every(entry => !available.has(entryWorkspace(entry, self)) || entryWorkspace(entry, self) === owner);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Earlier versions allowed mixed-workspace nesting. Return misplaced roots to their owner,
|
|
27
|
+
// retaining same-owner order and descendants. This changes directory positions, never files.
|
|
28
|
+
function ownedTree(tree, self) {
|
|
29
|
+
const children = Object.fromEntries(tree.groups.map(group => [topOf(group.id), []]));
|
|
30
|
+
const ownTop = owner => { const group = tree.groups.find(group => owner === self ? group.id === DOCS : group.workspace === owner); return group ? topOf(group.id) : null; };
|
|
31
|
+
const walk = (parent, owner) => {
|
|
32
|
+
for (const entry of tree.children[parent] ?? []) {
|
|
33
|
+
const belongs = entryWorkspace(entry, self);
|
|
34
|
+
const at = belongs === owner ? parent : ownTop(belongs) ?? parent;
|
|
35
|
+
(children[at] ??= []).push(entry);
|
|
36
|
+
walk(entry, belongs);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
for (const group of tree.groups) if (!group.extension) walk(topOf(group.id), columnWorkspaceId(group, self));
|
|
40
|
+
return { ...tree, children };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Registered workspaces project one shared tree, partitioned by file owner. Their former local files
|
|
44
|
+
// only seed new entries; the default workspace stores subsequent order/hierarchy edits. The format
|
|
45
|
+
// below is also the standalone/legacy reader. Its picked/hidden/left rules are not applied to shared
|
|
46
|
+
// trees, which list every registered document and link. Extensions are read from home alone.
|
|
11
47
|
// The workspace's outline: the sidebar's columns and which entry sits under which, in what order
|
|
12
48
|
// (<workspace>/.gallery/outline.json, docs/workbench.md "Workspace commands"; its shape: ./outline-shape.mjs).
|
|
13
49
|
// The file, version 2:
|
|
@@ -56,10 +92,10 @@ const LOCK_WAIT = 15_000;
|
|
|
56
92
|
/**
|
|
57
93
|
* Run `task` holding the outline's lock of workspace `root`: a file made with "wx", which one
|
|
58
94
|
* 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).
|
|
95
|
+
* A live writer refreshes its lock. Waits 15 s at most and then refuses rather than stealing it.
|
|
61
96
|
*/
|
|
62
|
-
export async function withOutlineLock(root, task) {
|
|
97
|
+
export async function withOutlineLock(root, task, { local = false } = {}) {
|
|
98
|
+
if (!local) root = outlineRoot(root);
|
|
63
99
|
const { gallery } = layoutOf(root);
|
|
64
100
|
const lock = path.join(gallery, "outline.lock");
|
|
65
101
|
const mine = `${process.pid} ${randomBytes(4).toString("hex")}`;
|
|
@@ -71,13 +107,17 @@ export async function withOutlineLock(root, task) {
|
|
|
71
107
|
} catch (error) {
|
|
72
108
|
if (error.code !== "EEXIST") throw error;
|
|
73
109
|
const age = await fs.stat(lock).then(({ mtimeMs }) => Date.now() - mtimeMs, () => 0);
|
|
74
|
-
if (age > LOCK_STALE
|
|
110
|
+
if (age > LOCK_STALE) await fs.rm(lock, { force: true });
|
|
111
|
+
else if (Date.now() > until) throw Object.assign(new Error("文档目录刚在别处改过"), { code: "stale" });
|
|
75
112
|
else await new Promise((resolve) => setTimeout(resolve, 20));
|
|
76
113
|
}
|
|
77
114
|
}
|
|
115
|
+
const heartbeat = setInterval(() => fs.utimes(lock, new Date(), new Date()).catch(() => {}), LOCK_STALE / 3);
|
|
116
|
+
heartbeat.unref();
|
|
78
117
|
try {
|
|
79
118
|
return await task();
|
|
80
119
|
} finally {
|
|
120
|
+
clearInterval(heartbeat);
|
|
81
121
|
// Removed only while it is still this writer's (one taken over meanwhile is the next writer's).
|
|
82
122
|
if ((await fs.readFile(lock, "utf8").catch(() => null)) === mine) await fs.rm(lock, { force: true });
|
|
83
123
|
}
|
|
@@ -98,7 +138,8 @@ export async function readOutline(root, { others = true } = {}) {
|
|
|
98
138
|
*/
|
|
99
139
|
export async function otherWorkspaces(root, { env = process.env } = {}) {
|
|
100
140
|
const shown = shownWorkspaces(root, { env });
|
|
101
|
-
|
|
141
|
+
// Project-local files seed entries not yet in the shared tree; never recurse into its projection.
|
|
142
|
+
const readOwn = async (other) => (await localOutlineAndFile(other, { others: false })).outline;
|
|
102
143
|
const unread = () => ({ documents: new Set(), order: [], parentOf: new Map(), facts: new Map(), failed: true });
|
|
103
144
|
return Promise.all(shown.map(async (each) => ({ ...each, failed: false, ...(await snapshotOf(each.root, readOwn).catch(unread)) })));
|
|
104
145
|
}
|
|
@@ -113,14 +154,16 @@ export async function otherWorkspaces(root, { env = process.env } = {}) {
|
|
|
113
154
|
* place a command or the workbench gave. `others: false`: the workspace's own tree alone (no column of
|
|
114
155
|
* another workspace made, none filled).
|
|
115
156
|
*/
|
|
116
|
-
|
|
157
|
+
async function localOutlineAndFile(root, { others: withOthers = true, shared = false } = {}) {
|
|
117
158
|
const instructions = await pageSlug(root);
|
|
118
159
|
const documents = (await listDocumentSlugs(root)).filter((slug) => slug !== instructions);
|
|
119
160
|
const links = await listLinkSlugs(root);
|
|
120
161
|
const extensions = await extensionColumnIds(root);
|
|
121
162
|
const stored = storedOf(await readJson(layoutOf(root).outline));
|
|
122
163
|
const others = withOthers ? await otherWorkspaces(root) : [];
|
|
123
|
-
const
|
|
164
|
+
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;
|
|
165
|
+
let shown = ordered(base, documents, links, extensions, others, { drawn: withOthers });
|
|
166
|
+
if (shared) shown = ownedTree(shown, workspaceId(root));
|
|
124
167
|
const outline = {
|
|
125
168
|
version: 2, ...shown, links, drafts: Object.values(shown.children).flat().filter(isForeign),
|
|
126
169
|
shown: others.map(({ id, root: other, isDefault }) => ({ id, root: other, isDefault })),
|
|
@@ -128,6 +171,40 @@ export async function outlineAndFile(root, { others: withOthers = true } = {}) {
|
|
|
128
171
|
return { outline, stored, others };
|
|
129
172
|
}
|
|
130
173
|
|
|
174
|
+
/** Translate the same tree between the default workspace's ids and a project's local ids.
|
|
175
|
+
* No second ordering is kept: only the current workspace's column is displayed first. */
|
|
176
|
+
function projectTree(value, from, to) {
|
|
177
|
+
if (!value || workspaceKey(from) === workspaceKey(to)) return value;
|
|
178
|
+
const fromId = workspaceId(from), toId = workspaceId(to);
|
|
179
|
+
const own = value.groups.find(group => group.workspace === toId);
|
|
180
|
+
const ownId = own?.id ?? toId;
|
|
181
|
+
const groupId = id => id === DOCS ? fromId : id === ownId ? DOCS : id;
|
|
182
|
+
const entry = key => key.startsWith(`~${toId}/`) ? key.slice(toId.length + 2) : isForeign(key) ? key : `~${fromId}/${key}`;
|
|
183
|
+
const key = value => isTop(value) ? topOf(groupId(groupOfTop(value))) : entry(value);
|
|
184
|
+
let groups = value.groups.map(group => {
|
|
185
|
+
if (group.id === DOCS) return { ...group, id: fromId, workspace: fromId };
|
|
186
|
+
if (group.id === ownId) {
|
|
187
|
+
const { workspace, picked, hidden, left, ...rest } = group;
|
|
188
|
+
return { ...rest, id: DOCS };
|
|
189
|
+
}
|
|
190
|
+
return group;
|
|
191
|
+
});
|
|
192
|
+
return { ...value, groups, children: Object.fromEntries(Object.entries(value.children).map(([parent, list]) => [key(parent), list.map(entry)])) };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function outlineAndFile(root, { others: withOthers = true } = {}) {
|
|
196
|
+
const home = outlineRoot(root);
|
|
197
|
+
if (workspaceKey(home) === workspaceKey(root)) return localOutlineAndFile(root, { others: withOthers, shared: true });
|
|
198
|
+
// A command may just have created/trashed a local item before placing it in the shared tree.
|
|
199
|
+
forgetSnapshot(root);
|
|
200
|
+
const shared = await localOutlineAndFile(home, { shared: true });
|
|
201
|
+
let outline = projectTree(shared.outline, home, root);
|
|
202
|
+
const ownLinks = await listLinkSlugs(root);
|
|
203
|
+
const others = await otherWorkspaces(root);
|
|
204
|
+
outline = { ...outline, links: ownLinks, drafts: Object.values(outline.children).flat().filter(isForeign), shown: others.map(({ id, root: other, isDefault }) => ({ id, root: other, isDefault })) };
|
|
205
|
+
return { outline, stored: projectTree(shared.stored, home, root), others };
|
|
206
|
+
}
|
|
207
|
+
|
|
131
208
|
// The own column's name before it was 默认文档 or the workspace's (文档, Documents in English), which a
|
|
132
209
|
// rename of it confirmed unchanged wrote as its title (the field opens with the name shown): read as no
|
|
133
210
|
// title, so that the name it goes by now shows. A title of another column, or any other, is the user's.
|
|
@@ -378,6 +455,13 @@ export function fileOf(value) {
|
|
|
378
455
|
* the head's order the file holds kept as it is (writeHead's).
|
|
379
456
|
*/
|
|
380
457
|
export async function writeOutline(root, value) {
|
|
458
|
+
const home = outlineRoot(root);
|
|
459
|
+
if (!ownsOutline(root, value)) throw Object.assign(new Error("跨工作区请复制,不能只移动目录位置"), { code: "CROSS_WORKSPACE" });
|
|
460
|
+
value = { ...value, groups: (value.groups ?? [{ id: DOCS }]).map(group => group.id === DOCS || group.workspace || group.extension ? group : { ...group, owner: group.owner ?? workspaceId(root) }) };
|
|
461
|
+
if (workspaceKey(home) !== workspaceKey(root)) {
|
|
462
|
+
value = projectTree(value, root, home);
|
|
463
|
+
root = home;
|
|
464
|
+
}
|
|
381
465
|
const file = layoutOf(root).outline;
|
|
382
466
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
383
467
|
const head = storedHead((await readJson(file))?.head);
|
|
@@ -465,6 +549,7 @@ export function moveDocument(root, slug, where) {
|
|
|
465
549
|
return withOutlineLock(root, async () => {
|
|
466
550
|
const { outline, stored } = await outlineAndFile(root);
|
|
467
551
|
const result = moveIn(outline.children, slug, where);
|
|
552
|
+
if (result.ok && !ownsOutline(root, outline)) return { ok: false, reason: "cross-workspace" };
|
|
468
553
|
const written = result.ok && !writtenAs(outline, stored);
|
|
469
554
|
if (written) await writeOutline(root, outline);
|
|
470
555
|
return { ...result, ...(result.ok ? { written } : {}) };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { localPath } from "./workspace-address.mjs";
|
|
2
|
+
import { pageLink, pageOfLink } from "./page-links.mjs";
|
|
2
3
|
|
|
3
4
|
// A document's address, as a link, a paste or the address bar may write it (docs/workbench.md, "A
|
|
4
5
|
// document's addresses"), read in one place: { slug, block }, block null for the page itself. Pure: the
|
|
@@ -16,11 +17,8 @@ import { earlierLink, pageLink, pageOfLink } from "./page-links.mjs";
|
|
|
16
17
|
// one form that needs `links` (pageLinks: a Map of slug to link), for only the list can say which
|
|
17
18
|
// document an id is or whether a name is a slug at all; given, `links` also turns away every slug it
|
|
18
19
|
// does not hold. Without it such an address is none here, and workbenchHash hands it to the workbench,
|
|
19
|
-
// which has the list. pageAddress
|
|
20
|
-
//
|
|
21
|
-
// on another port (elsewhere) is a document's only if the list holds it, by its folder name or its
|
|
22
|
-
// link of now (addressOn), or for a folder named doc- and eight characters, the link it had before
|
|
23
|
-
// those eight were its id (shared/page-links.mjs earlierId).
|
|
20
|
+
// which has the list. pageAddress parses a shape; addressOn additionally requires the current
|
|
21
|
+
// service’s port. An address of another port is always external, even when its slug matches.
|
|
24
22
|
|
|
25
23
|
const SLUG = "[a-z0-9]+(?:-[a-z0-9]+)*"; // shared/workspace.mjs slugPattern, which needs Node
|
|
26
24
|
const FILE = "(?:doc|report)\\.html";
|
|
@@ -71,7 +69,7 @@ export function pageAddress(text, links = null) {
|
|
|
71
69
|
if (inFile) return known(inFile[1], decoded(inFile[2] ?? ""));
|
|
72
70
|
const url = ownAddress(address);
|
|
73
71
|
if (!url) return null;
|
|
74
|
-
const path = PAGE.exec(url.pathname) ?? OWN_FILE.exec(url.pathname);
|
|
72
|
+
const path = PAGE.exec(localPath(url.pathname)) ?? OWN_FILE.exec(localPath(url.pathname));
|
|
75
73
|
if (path) return known(path[1], decoded(url.hash.slice(1)));
|
|
76
74
|
return url.pathname === "/" && url.hash.length > 1 ? hashAddress(decoded(url.hash.slice(1)), links) : null;
|
|
77
75
|
}
|
|
@@ -87,19 +85,14 @@ export function workbenchHash(text) {
|
|
|
87
85
|
|
|
88
86
|
/**
|
|
89
87
|
* The document and block `text` names of those in `links` (a Map, or null with no list) as read on the
|
|
90
|
-
* server of `origin`, as pageAddress reads it
|
|
91
|
-
*
|
|
92
|
-
* another workspace's page. With no `origin`, as a file is read, every port is another.
|
|
88
|
+
* server of `origin`, as pageAddress reads it. Another port is always external; with no origin
|
|
89
|
+
* supplied, no running service’s port is inferred from matching titles or folder names.
|
|
93
90
|
* @param {string} text
|
|
94
91
|
* @param {Map<string, string> | null} links
|
|
95
92
|
* @param {string} [origin]
|
|
96
93
|
*/
|
|
97
94
|
export function addressOn(text, links, origin = "http://127.0.0.1") {
|
|
98
|
-
|
|
99
|
-
const name = place && elsewhere(text, origin) ? workbenchHash(text)?.split("/")[0] : undefined;
|
|
100
|
-
if (name === undefined || links.has(name)) return place;
|
|
101
|
-
const link = links.get(place.slug);
|
|
102
|
-
return link === name || earlierLink(place.slug, link) === name ? place : null;
|
|
95
|
+
return elsewhere(text, origin) ? null : pageAddress(text, links);
|
|
103
96
|
}
|
|
104
97
|
|
|
105
98
|
/** Whether `text` is an address of a document of this workspace, as far as can be told with no list. */
|
package/browser/shared/paths.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { serviceLocation } from "./service-location.mjs";
|
|
4
5
|
import { findWorkspace, layoutOf } from "./workspace.mjs";
|
|
5
6
|
|
|
6
7
|
// Where everything is on disk, for every system, in two kinds: the workspace's (the user's documents
|
|
@@ -42,7 +43,7 @@ export const qaDirectory = workspace.qa; // gallery qa's pictures of what it fla
|
|
|
42
43
|
export const backupsDirectory = workspace.backups; // documents as they were before a migration (add-block-ids.mjs)
|
|
43
44
|
export const editStateDirectory = workspace.state; // the editor's token, snapshots, recent versions…
|
|
44
45
|
// What a server a command started says while it runs (it runs on after that command).
|
|
45
|
-
export const previewLogFile =
|
|
46
|
+
export const previewLogFile = serviceLocation().serverLog;
|
|
46
47
|
|
|
47
48
|
// Gallery's own files, which a command never writes (a global install may be read-only): the shared
|
|
48
49
|
// kit and the starter, what the preview serves, what the checks and the import read. In the release
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { documentKey } from "./workspace-address.mjs";
|
|
1
2
|
// The reader's preferences of Gallery's UI (the workbench's 设置): switches (and a width) kept in localStorage,
|
|
2
3
|
// which the workbench and every report page share (one origin), so the page editor follows a change
|
|
3
4
|
// at once (the storage event reaches every other page of the origin). Each has its default (off, but for
|
|
@@ -88,7 +89,7 @@ function openSlugs() {
|
|
|
88
89
|
|
|
89
90
|
/** Whether report `slug` has its outline on (off until it is switched on there). */
|
|
90
91
|
export function outlineOpen(slug) {
|
|
91
|
-
return typeof slug === "string" && slug !== "" && openSlugs().includes(slug);
|
|
92
|
+
return typeof slug === "string" && slug !== "" && openSlugs().includes(documentKey(slug));
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
/**
|
|
@@ -96,6 +97,7 @@ export function outlineOpen(slug) {
|
|
|
96
97
|
* reports, the one switched on longest ago let go); off, it is taken out.
|
|
97
98
|
*/
|
|
98
99
|
export function setOutlineOpen(slug, on) {
|
|
100
|
+
slug = documentKey(slug);
|
|
99
101
|
if (typeof slug !== "string" || slug === "") return;
|
|
100
102
|
try {
|
|
101
103
|
const slugs = openSlugs().filter((one) => one !== slug);
|
|
@@ -5,18 +5,11 @@ import net from "node:net";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { packageRoot } from "./paths.mjs";
|
|
7
7
|
import { compareVersions } from "./versions.mjs";
|
|
8
|
-
import {
|
|
8
|
+
import { defaultWorkspace } from "./workspace.mjs";
|
|
9
|
+
import { serviceLocation } from "./service-location.mjs";
|
|
9
10
|
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// export hands a document in through the server's edit service (POST /__gallery/edit/hand-in), in the
|
|
13
|
-
// queue its pages' commits run in, instead of writing the file beside them; gallery open, new and
|
|
14
|
-
// import give a document's address on it, or start one when none runs (src/cli/server-process.mjs).
|
|
15
|
-
// The server keeps a small file in its workspace while it listens, .gallery/server.json (one server
|
|
16
|
-
// a workspace), with its port, its process, the token its requests need, its kind and Gallery's
|
|
17
|
-
// version; a command finds it there, or does without it when there is none, or its process is gone.
|
|
18
|
-
// A port is never asked whose it is: 4317 may be another workspace's server, whose pages would write
|
|
19
|
-
// that workspace's files.
|
|
11
|
+
// One service per data directory: commands verify the announcement before reusing its port.
|
|
12
|
+
// Documents in every registered folder use the same service and retain owner-qualified paths.
|
|
20
13
|
|
|
21
14
|
// A directory as one name, however it was reached: on Windows a drive letter and the folders' names
|
|
22
15
|
// may be typed in either case (`cd /d d:\…` in cmd gives d:\), and they name the same directory.
|
|
@@ -24,10 +17,10 @@ const canonical = (directory) => {
|
|
|
24
17
|
const resolved = path.resolve(directory);
|
|
25
18
|
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
26
19
|
};
|
|
27
|
-
/** Whether two paths name the same directory (a
|
|
20
|
+
/** Whether two paths name the same directory (a service home or data directory). */
|
|
28
21
|
export const sameDirectory = (a, b) => canonical(a) === canonical(b);
|
|
29
22
|
|
|
30
|
-
const fileOf = (
|
|
23
|
+
const fileOf = () => serviceLocation().serverFile;
|
|
31
24
|
/** Gallery's version, as its package says it (a server of another version is another Gallery's). */
|
|
32
25
|
export const galleryVersion = (() => {
|
|
33
26
|
try {
|
|
@@ -47,30 +40,33 @@ const runs = (pid) => {
|
|
|
47
40
|
}
|
|
48
41
|
};
|
|
49
42
|
|
|
50
|
-
/**
|
|
51
|
-
*
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
* beside (which would take the announcement: the pages' commits in one queue, an export's hand-in in
|
|
55
|
-
* another). Every `every` ms (0: never) it is written again when its file is gone (taken away by
|
|
56
|
-
* hand, or by a server of these reports that closed as this one wrote it) or names a process that is
|
|
57
|
-
* gone (left behind). One that names another process that runs is left: a server of these reports
|
|
58
|
-
* started later (the last to start is the one the commands use), or the one Vite started again in
|
|
59
|
-
* this process. The .gallery/ folder is made again, never the workspace (a workspace deleted stays
|
|
60
|
-
* deleted). Resolves to a function that stops that and, only while the announcement is this one's
|
|
61
|
-
* (`nonce`), gives the file back to the server it was written over when that one's process runs (in
|
|
62
|
-
* another process: in this one it is Vite's earlier server), or else takes it away: for when the
|
|
63
|
-
* server closes. A server stopped without closing leaves it behind, which the commands find out (its
|
|
64
|
-
* process gone, its port answering no more).
|
|
65
|
-
*/
|
|
66
|
-
export async function announcePreviewServer({ reportsDirectory, port, token, kind = "dev", version = galleryVersion, every = 1000 }) {
|
|
67
|
-
const file = fileOf(reportsDirectory);
|
|
43
|
+
/** Acquire the data-directory lifetime lease and publish the sole service identity.
|
|
44
|
+
* The returned closer removes only this instance’s announcement and lease. */
|
|
45
|
+
export async function announcePreviewServer({ home = defaultWorkspace(), port, token, kind = "dev", version = galleryVersion, every = 1000 }) {
|
|
46
|
+
const file = fileOf();
|
|
68
47
|
const nonce = randomBytes(8).toString("hex");
|
|
69
|
-
const text = `${JSON.stringify({ port, pid: process.pid,
|
|
48
|
+
const text = `${JSON.stringify({ port, pid: process.pid, home: path.resolve(home), token, nonce, kind, version, started: new Date().toISOString() })}\n`;
|
|
70
49
|
const save = (content) => fs.writeFile(file, content, { mode: 0o600 });
|
|
71
50
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
72
|
-
const
|
|
73
|
-
|
|
51
|
+
const lease = path.join(path.dirname(file), "server.lock");
|
|
52
|
+
for (let attempt = 0; ; attempt++) {
|
|
53
|
+
try { await fs.writeFile(lease, text, { flag: "wx", mode: 0o600 }); break; }
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error.code !== "EEXIST" || attempt > 3) throw error;
|
|
56
|
+
const owner = await fs.readFile(lease, "utf8").then(JSON.parse).catch(() => null);
|
|
57
|
+
if (!owner) throw new Error("Gallery is starting; retry shortly.");
|
|
58
|
+
if (runs(owner.pid)) {
|
|
59
|
+
if (owner.pid === process.pid && owner.port === port) { await fs.writeFile(lease, text); break; }
|
|
60
|
+
throw new Error(`Gallery already runs on port ${owner.port} (pid ${owner.pid}).`);
|
|
61
|
+
}
|
|
62
|
+
await fs.rm(lease, { force: true });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
try { await save(text); } catch (error) {
|
|
66
|
+
const owner = await fs.readFile(lease, "utf8").then(JSON.parse).catch(() => null);
|
|
67
|
+
if (owner?.nonce === nonce) await fs.rm(lease, { force: true });
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
74
70
|
let closed = false;
|
|
75
71
|
const look = async () => {
|
|
76
72
|
let there = null;
|
|
@@ -91,29 +87,22 @@ export async function announcePreviewServer({ reportsDirectory, port, token, kin
|
|
|
91
87
|
closed = true;
|
|
92
88
|
clearInterval(keeping);
|
|
93
89
|
await looking;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
let earlier = null;
|
|
100
|
-
try {
|
|
101
|
-
earlier = JSON.parse(before);
|
|
102
|
-
} catch {}
|
|
103
|
-
const back = Number.isInteger(earlier?.pid) && earlier.pid !== process.pid && runs(earlier.pid);
|
|
104
|
-
await (back ? save(before) : fs.rm(file, { force: true })).catch(() => {});
|
|
90
|
+
const announcement = await fs.readFile(file, "utf8").then(JSON.parse).catch(() => null);
|
|
91
|
+
if (announcement?.nonce === nonce) await fs.rm(file, { force: true }).catch(() => {});
|
|
92
|
+
const owner = await fs.readFile(lease, "utf8").then(JSON.parse).catch(() => null);
|
|
93
|
+
if (owner?.nonce === nonce) await fs.rm(lease, { force: true }).catch(() => {});
|
|
105
94
|
};
|
|
106
95
|
}
|
|
107
96
|
|
|
108
|
-
// The
|
|
109
|
-
export async function readAnnouncement(
|
|
97
|
+
// The data-directory service announcement, while its process runs.
|
|
98
|
+
export async function readAnnouncement() {
|
|
110
99
|
let found;
|
|
111
100
|
try {
|
|
112
|
-
found = JSON.parse(await fs.readFile(fileOf(
|
|
101
|
+
found = JSON.parse(await fs.readFile(fileOf(), "utf8"));
|
|
113
102
|
} catch {
|
|
114
103
|
return null;
|
|
115
104
|
}
|
|
116
|
-
if (!Number.isInteger(found?.port) || !Number.isInteger(found.pid) || typeof found.token !== "string" || typeof found.
|
|
105
|
+
if (!Number.isInteger(found?.port) || !Number.isInteger(found.pid) || typeof found.token !== "string" || typeof found.home !== "string") return null;
|
|
117
106
|
return runs(found.pid) ? found : null;
|
|
118
107
|
}
|
|
119
108
|
|
|
@@ -122,17 +111,16 @@ export const processRuns = runs;
|
|
|
122
111
|
|
|
123
112
|
/**
|
|
124
113
|
* How the server an announcement names answers on its port, within `timeout` ms: "yes", as that
|
|
125
|
-
* server (GET /__gallery/workspace/info there names the announcement's token, its process and
|
|
126
|
-
* workspace, docs/workbench.md; there itself, not where it sends the request on to); "no", as none (a
|
|
114
|
+
* server (GET /__gallery/workspace/info there names the announcement's token, its process, home and data directory, docs/workbench.md; there itself, not where it sends the request on to); "no", as none (a
|
|
127
115
|
* server stopped without closing leaves its announcement behind, and by now its process's number may
|
|
128
116
|
* be another program's, its port another server's); or "busy": the port took the request and gave no
|
|
129
117
|
* answer in time.
|
|
130
118
|
*/
|
|
131
|
-
export async function answerOf(found,
|
|
119
|
+
export async function answerOf(found, timeout = 10_000) {
|
|
132
120
|
try {
|
|
133
121
|
const response = await fetch(`http://127.0.0.1:${found.port}/__gallery/workspace/info`, { redirect: "error", signal: AbortSignal.timeout(timeout) });
|
|
134
122
|
const info = response.ok ? await response.json() : null;
|
|
135
|
-
const same = info?.token === found.token && info.server?.pid === found.pid && typeof info.server.
|
|
123
|
+
const same = info?.token === found.token && info.server?.pid === found.pid && typeof info.server.home === "string" && sameDirectory(info.server.home, found.home) && sameDirectory(info.server.data, serviceLocation().data);
|
|
136
124
|
return same ? "yes" : "no";
|
|
137
125
|
} catch (error) {
|
|
138
126
|
return error?.name === "TimeoutError" ? "busy" : "no";
|
|
@@ -140,15 +128,15 @@ export async function answerOf(found, reportsDirectory, timeout) {
|
|
|
140
128
|
}
|
|
141
129
|
|
|
142
130
|
/**
|
|
143
|
-
* The running server
|
|
131
|
+
* The running server for this data directory: { port, token, pid }, or null when none says it runs. With
|
|
144
132
|
* `answering`, only one that answers on its port as the server its announcement names, within
|
|
145
133
|
* `timeout` ms (gallery open, which gives its address to the user); gallery export asks without it,
|
|
146
134
|
* its hand-in's own request telling whether the server takes it (a server slow to answer may still
|
|
147
135
|
* write the file).
|
|
148
136
|
*/
|
|
149
|
-
export async function findPreviewServer({
|
|
150
|
-
const found = await readAnnouncement(
|
|
151
|
-
if (!found || (answering &&
|
|
137
|
+
export async function findPreviewServer({ answering = false, timeout = 10_000 } = {}) {
|
|
138
|
+
const found = await readAnnouncement();
|
|
139
|
+
if (!found || (answering && await answerOf(found, timeout) !== "yes")) return null;
|
|
152
140
|
return { port: found.port, token: found.token, pid: found.pid };
|
|
153
141
|
}
|
|
154
142
|
|
|
@@ -199,7 +187,7 @@ export async function stopServer(found, { wait = 10_000 } = {}) {
|
|
|
199
187
|
status = response.status;
|
|
200
188
|
await response.text().catch(() => "");
|
|
201
189
|
} catch {} // it closed as it answered, or did not answer: its process says
|
|
202
|
-
if (status === 409) return { ok: false, reason: "dev", message: `
|
|
190
|
+
if (status === 409) return { ok: false, reason: "dev", message: `The shared Gallery server is npm run dev's (port ${found.port}, pid ${found.pid}): stop it where it runs.` };
|
|
203
191
|
if (await goneWithin(found.pid, wait)) return { ok: true, killed: false };
|
|
204
192
|
try {
|
|
205
193
|
process.kill(found.pid);
|
|
@@ -209,7 +197,7 @@ export async function stopServer(found, { wait = 10_000 } = {}) {
|
|
|
209
197
|
}
|
|
210
198
|
|
|
211
199
|
/**
|
|
212
|
-
*
|
|
200
|
+
* The shared service, for a command that needs one (src/cli/server-process.mjs serverFor): the
|
|
213
201
|
* one its announcement names when it answers there within `timeout` ms (as findPreviewServer with
|
|
214
202
|
* `answering`), or else one `start(port)` starts on the first free port of `ports`. A server of
|
|
215
203
|
* Gallery's of an older `version` (a command's, kind "cli": never npm run dev's) is stopped first
|
|
@@ -218,12 +206,12 @@ export async function stopServer(found, { wait = 10_000 } = {}) {
|
|
|
218
206
|
* pid, kind, version } of that one, replaced: { pid, version } of the one stopped, or null, passed:
|
|
219
207
|
* the ports passed over, as "4317 (in use)" }; `port` is null when none of `ports` is free; when the
|
|
220
208
|
* announced server's port took the request and gave no answer in time, with `busy: { port, pid,
|
|
221
|
-
* version, newer }`:
|
|
209
|
+
* version, newer }`: the shared Gallery server, busy or stuck, and none is started beside it; and when
|
|
222
210
|
* an older one would not stop, with `stuck: { port, pid, message }`.
|
|
223
211
|
*/
|
|
224
|
-
export async function previewServerFor({
|
|
225
|
-
const found = await readAnnouncement(
|
|
226
|
-
const answer = found ? await answerOf(found,
|
|
212
|
+
export async function previewServerFor({ ports, start, stop = stopServer, timeout = 10_000, version = galleryVersion }) {
|
|
213
|
+
const found = await readAnnouncement();
|
|
214
|
+
const answer = found ? await answerOf(found, timeout) : "no";
|
|
227
215
|
const kind = found?.kind === "dev" ? "dev" : "cli";
|
|
228
216
|
const order = found ? compareVersions(found.version, version) : 0;
|
|
229
217
|
if (answer === "busy") return { port: null, started: null, passed: [], replaced: null, busy: { port: found.port, pid: found.pid, version: found.version ?? null, newer: kind === "cli" && order > 0 } };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { userDirectory } from "./user-directory.mjs";
|
|
3
|
+
import { defaultWorkspace } from "./workspace.mjs";
|
|
4
|
+
import { workspaceId, workspaceKey } from "./workspace-registry.mjs";
|
|
5
|
+
import { projectBase } from "./workspace-address.mjs";
|
|
6
|
+
|
|
7
|
+
export const workspaceBase = root => workspaceKey(root) === workspaceKey(defaultWorkspace()) ? "" : projectBase(workspaceId(root));
|
|
8
|
+
|
|
9
|
+
// A service belongs to a Gallery data directory, never to a document storage directory.
|
|
10
|
+
export function serviceLocation() {
|
|
11
|
+
const data = userDirectory("data");
|
|
12
|
+
return {
|
|
13
|
+
data,
|
|
14
|
+
serverFile: path.join(data, "server.json"),
|
|
15
|
+
lastPortFile: path.join(data, "last-port"),
|
|
16
|
+
serverLog: path.join(data, "logs", "server.log"),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
+
import { documentKey } from "./workspace-address.mjs";
|
|
1
2
|
// The session between a report page and a workbench that shows it in an iframe of the same origin
|
|
2
3
|
// (docs/workbench.md): the page's side is src/editor/page/session.mjs, the workbench's
|
|
3
4
|
// src/workbench/app/session/. Every message is { protocol: PROTOCOL, v: VERSION, type, slug, ... },
|
|
4
|
-
// posted to the other window for this origin only
|
|
5
|
-
// page and a workbench of another origin each side's server vouched for (the page's ?shell=<origin>,
|
|
6
|
-
// GET /__gallery/workspace/shell; the workbench's POST elsewhere: src/workbench/elsewhere.mjs).
|
|
5
|
+
// posted to the other window for this origin only. Every document uses this service.
|
|
7
6
|
//
|
|
8
7
|
// report -> workbench ready once the editor started: { commands, title, icon, look, state, annotations, card }
|
|
9
8
|
// state when the state, the title, the page's icon, the look it shows, how many 批注 it
|
|
@@ -39,8 +38,6 @@
|
|
|
39
38
|
// annotations-removed
|
|
40
39
|
// a 批注 deleted from its card: { items }: the workbench says so, as
|
|
41
40
|
// it does for 全部删除, and its 撤销 puts them back
|
|
42
|
-
// bye the page goes (pagehide), to a workbench of another origin alone, which
|
|
43
|
-
// cannot see it go: its session ends there
|
|
44
41
|
// workbench -> report command { id, name, ... }
|
|
45
42
|
|
|
46
43
|
export const PROTOCOL = "gallery/session";
|
|
@@ -63,7 +60,7 @@ export const VERSION = 2;
|
|
|
63
60
|
* back from another document keeps): sessionStorage, which a workbench and its frames share. A page
|
|
64
61
|
* puts it there before it is shown again; the workbench before the way back comes to it.
|
|
65
62
|
*/
|
|
66
|
-
export const viewKey = (slug) => `gallery-edit:${slug}:view`;
|
|
63
|
+
export const viewKey = (slug) => `gallery-edit:${documentKey(slug)}:view`;
|
|
67
64
|
|
|
68
65
|
/**
|
|
69
66
|
* What `state` reports, in this order: editing (the editor runs: the page edits from the start),
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// All projects share one origin. Only document resources and operations carry an owner;
|
|
2
|
+
// package assets and the workbench itself stay at the root of that origin.
|
|
3
|
+
export const projectBase = (id) => `/~${id}`;
|
|
4
|
+
export function projectItem(value) {
|
|
5
|
+
const match = /^~(ws-[0-9a-f]{6})\/([^/]+)$/.exec(String(value ?? ""));
|
|
6
|
+
return match ? { ws: match[1], slug: match[2], base: projectBase(match[1]) } : null;
|
|
7
|
+
}
|
|
8
|
+
const PROJECT = /^(\/~(ws-[0-9a-f]{6}))(?=\/|$)/;
|
|
9
|
+
export const projectOfPath = (pathname) => PROJECT.exec(pathname)?.[2] ?? null;
|
|
10
|
+
export const localPath = (pathname) => pathname.replace(PROJECT, "") || "/";
|
|
11
|
+
export const currentProjectBase = () => PROJECT.exec(globalThis.location?.pathname ?? "")?.[1] ?? "";
|
|
12
|
+
export function workspaceAddress(address, base = currentProjectBase()) {
|
|
13
|
+
if (!base || typeof address !== "string" || projectOfPath(address)) return address;
|
|
14
|
+
return /^\/(?:__gallery\/|generated\/|pages\/)/.test(address) ? base + address : address;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Browser state is shared by origin, so a document's owner is part of its identity. */
|
|
18
|
+
export const documentKey = (slug, base = currentProjectBase()) => base && typeof slug === "string" && slug && !slug.startsWith("~") ? base.slice(1) + "/" + slug : slug;
|
|
19
|
+
export const workspaceStoragePrefix = (base = currentProjectBase()) => base ? "gallery-project:" + base.slice(1) + ":" : "";
|
|
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { iconKind } from "./emoji/icon-value.mjs";
|
|
4
4
|
import { isWorkspace, readRegistry, workspaceId, workspaceKey } from "./workspace-registry.mjs";
|
|
5
|
-
import { defaultWorkspace, documentFiles, isSlugName } from "./workspace.mjs";
|
|
5
|
+
import { defaultWorkspace, documentFiles, isSlugName, readLink } from "./workspace.mjs";
|
|
6
6
|
|
|
7
7
|
// The other workspaces a workspace's sidebar shows a column of (notes/plans/workspaces-links-plan-2026-11-08.md
|
|
8
8
|
// §7.2; docs/workbench.md "Columns"): every workspace of the machine's registry (./workspace-registry.mjs) but
|
|
@@ -13,13 +13,12 @@ import { defaultWorkspace, documentFiles, isSlugName } from "./workspace.mjs";
|
|
|
13
13
|
// as its own sidebar has them) and the place of its documents no column holds; the user's order of the
|
|
14
14
|
// columns is the outline's after that.
|
|
15
15
|
//
|
|
16
|
-
// What a workspace's sidebar is read with (snapshotOf): its documents in its
|
|
17
|
-
//
|
|
16
|
+
// What a workspace's sidebar is read with (snapshotOf): its documents and links in its former local order,
|
|
17
|
+
// their same-workspace parents, and what their doc.json or link.json
|
|
18
18
|
// says (title, description, an icon of the emoji or 图标 kind: no picture of another workspace's folder). It is
|
|
19
19
|
// read from its files, never written, and kept SNAPSHOT_KEPT ms at most, so that the reads of a burst (a
|
|
20
20
|
// command and the workbench's read after it, the list of 添加其他 Gallery 项目…) read each folder once while a
|
|
21
|
-
// change there shows at the next read after that; the
|
|
22
|
-
// server tells of a change (src/workbench/service.mjs, through ./elsewhere.mjs). Reads files only.
|
|
21
|
+
// change there shows at the next read after that; the cache is dropped when the storage runtime reports a change in the same process (src/server/projects.mjs). Reads files only.
|
|
23
22
|
|
|
24
23
|
/** How long a snapshot of another workspace is believed (ms). */
|
|
25
24
|
export const SNAPSHOT_KEPT = 2000;
|
|
@@ -90,19 +89,19 @@ async function read(root, readOwn) {
|
|
|
90
89
|
const documents = new Set();
|
|
91
90
|
const order = [];
|
|
92
91
|
const parentOf = new Map();
|
|
93
|
-
//
|
|
94
|
-
const
|
|
92
|
+
// Own documents and links; another workspace's reference is not a second file here.
|
|
93
|
+
const isEntry = (key) => !key.startsWith("~") && isSlugName(key);
|
|
95
94
|
const parentIn = (child) => {
|
|
96
95
|
for (const [key, list] of Object.entries(own.children)) if (list.includes(child)) return key;
|
|
97
96
|
return null;
|
|
98
97
|
};
|
|
99
98
|
const above = (slug) => {
|
|
100
99
|
let up = parentIn(slug);
|
|
101
|
-
while (up !== null && !(up === "" || up.startsWith(":")) && !
|
|
100
|
+
while (up !== null && !(up === "" || up.startsWith(":")) && !isEntry(up)) up = parentIn(up);
|
|
102
101
|
return up === null || up === "" || up.startsWith(":") ? null : up;
|
|
103
102
|
};
|
|
104
103
|
const walk = (key) => (own.children[key] ?? []).forEach((slug) => {
|
|
105
|
-
if (
|
|
104
|
+
if (isEntry(slug) && !documents.has(slug)) {
|
|
106
105
|
documents.add(slug);
|
|
107
106
|
order.push(slug);
|
|
108
107
|
parentOf.set(slug, above(slug));
|
|
@@ -111,6 +110,10 @@ async function read(root, readOwn) {
|
|
|
111
110
|
});
|
|
112
111
|
for (const { id } of own.groups) walk(id === "docs" ? "" : `:${id}`);
|
|
113
112
|
const facts = new Map(await Promise.all(order.map(async (slug) => {
|
|
113
|
+
if (own.links.includes(slug)) {
|
|
114
|
+
const link = await readLink(path.join(root, slug));
|
|
115
|
+
return [slug, { ...link, kind: "link", title: link?.title ?? slug, description: "", path: path.join(root, slug, "link.json") }];
|
|
116
|
+
}
|
|
114
117
|
const files = documentFiles(path.join(root, slug));
|
|
115
118
|
const metadata = await fs.readFile(files.json, "utf8").then((text) => JSON.parse(text.replace(/^/, "")), () => null);
|
|
116
119
|
const value = metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : {};
|