@heycys/gallery 0.2.3 → 0.2.4
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/README.md +1 -1
- package/THIRD-PARTY-NOTICES.md +1 -1
- package/assets/preview/runtime.js +156 -0
- package/assets/preview/shell.html +107 -0
- package/assets/writing-preferences.md +1 -1
- package/browser/editor/components/divider/index.mjs +1 -1
- package/browser/editor/page/annotate/card.mjs +6 -0
- package/browser/editor/page/annotate/index.mjs +22 -2
- package/browser/editor/page/columns/index.mjs +1 -1
- package/browser/editor/page/conflicts.mjs +1 -1
- package/browser/editor/page/drag/index.mjs +1 -1
- package/browser/editor/page/editor.mjs +39 -3
- package/browser/editor/page/focus.mjs +104 -0
- package/browser/editor/page/handle/index.mjs +14 -2
- package/browser/editor/page/links/index.mjs +2 -2
- package/browser/editor/page/quiet.mjs +3 -3
- package/browser/editor/page/resize/index.mjs +1 -1
- package/browser/editor/page/session.mjs +4 -1
- package/browser/editor/page/ui.mjs +1 -1
- package/browser/shared/annotations.mjs +22 -3
- package/browser/shared/block-packs.mjs +13 -4
- package/browser/shared/document-look.mjs +14 -0
- package/browser/shared/drafts.mjs +1 -1
- package/browser/shared/extension-manifest.mjs +40 -12
- package/browser/shared/favorite-merge.mjs +17 -0
- package/browser/shared/favorites.mjs +77 -0
- package/browser/shared/i18n/en.mjs +123 -24
- package/browser/shared/outline-ops.mjs +109 -0
- package/browser/shared/outline.mjs +6 -0
- package/browser/shared/pages.mjs +20 -8
- package/browser/shared/paths.mjs +5 -2
- package/browser/shared/preference-documents.mjs +137 -0
- package/browser/shared/presence.mjs +94 -0
- package/browser/shared/preview-server.mjs +8 -5
- package/browser/shared/qa-levels.mjs +9 -1
- package/browser/shared/session.mjs +4 -0
- package/browser/shared/shipped-blocks.mjs +22 -4
- package/browser/shared/startup.mjs +101 -0
- package/browser/shared/workspace-columns.mjs +1 -1
- package/browser/shared/workspace-look.mjs +21 -8
- package/browser/shared/workspace-registry.mjs +6 -3
- package/browser/shared/workspace.mjs +37 -10
- package/browser/shared/writing-preferences.mjs +2 -42
- package/dist/chunk-25LYSMAE.mjs +2 -0
- package/dist/chunk-2I6ZVC5O.mjs +2 -0
- package/dist/{chunk-M4NRDYI5.mjs → chunk-2MYTOOP6.mjs} +1 -1
- package/dist/chunk-2VNENLER.mjs +2 -0
- package/dist/chunk-3FERXZF5.mjs +4 -0
- package/dist/chunk-3M6SBBGU.mjs +2 -0
- package/dist/chunk-3VCAAF34.mjs +2 -0
- package/dist/{chunk-WD4U6XXT.mjs → chunk-3ZTRLGSA.mjs} +1 -1
- package/dist/chunk-4FRA5AUE.mjs +2 -0
- package/dist/chunk-4LR2QEC2.mjs +4 -0
- package/dist/chunk-56SVJENP.mjs +966 -0
- package/dist/chunk-5CQISL2G.mjs +4 -0
- package/dist/{chunk-RRKJMHNX.mjs → chunk-5I5ZDDWC.mjs} +5 -5
- package/dist/chunk-5IHDLEB4.mjs +10 -0
- package/dist/chunk-5LRDMV7O.mjs +2 -0
- package/dist/chunk-5MVUYRKG.mjs +5 -0
- package/dist/chunk-5TLLIZT7.mjs +2 -0
- package/dist/chunk-6ILJWFYW.mjs +4 -0
- package/dist/chunk-6N25CJ6Y.mjs +3 -0
- package/dist/{chunk-SQ6Y2ZMA.mjs → chunk-7DTINW6U.mjs} +2 -2
- package/dist/chunk-7KWAGKIK.mjs +2 -0
- package/dist/chunk-7WNUOAFK.mjs +2 -0
- package/dist/chunk-7XCRW5RC.mjs +2 -0
- package/dist/{chunk-BYNPEUDS.mjs → chunk-7XSDTILF.mjs} +1 -1
- package/dist/chunk-ABKYFOVR.mjs +2 -0
- package/dist/chunk-AMUF7PUN.mjs +2 -0
- package/dist/{chunk-FJLM7YE7.mjs → chunk-B2EIMERT.mjs} +2 -2
- package/dist/{chunk-QNECWMGR.mjs → chunk-B45IAAU6.mjs} +1 -1
- package/dist/chunk-B6RAGF4R.mjs +2 -0
- package/dist/chunk-BSJKFE7I.mjs +4 -0
- package/dist/chunk-C72EEVLK.mjs +2 -0
- package/dist/chunk-CRAUKZ23.mjs +4 -0
- package/dist/chunk-F4CT35KL.mjs +2 -0
- package/dist/chunk-F63BNJLG.mjs +2 -0
- package/dist/chunk-FBKUPS44.mjs +2 -0
- package/dist/chunk-FL52ZL5Z.mjs +15 -0
- package/dist/chunk-FOZCU4VW.mjs +2 -0
- package/dist/chunk-G5HV7BC3.mjs +2 -0
- package/dist/{chunk-ERLAIBOA.mjs → chunk-GB5SOYQJ.mjs} +2 -2
- package/dist/chunk-GN3SG3IF.mjs +2 -0
- package/dist/{chunk-6QWQXHAV.mjs → chunk-HFTBVEZK.mjs} +1 -1
- package/dist/chunk-IOW5HLRK.mjs +2 -0
- package/dist/chunk-IQ3GJNTI.mjs +6 -0
- package/dist/chunk-K6SPFBUJ.mjs +2 -0
- package/dist/chunk-K7Y7SPTS.mjs +2 -0
- package/dist/chunk-L2IJCEUH.mjs +2 -0
- package/dist/{chunk-GPIWQ4X4.mjs → chunk-L3VT6LM4.mjs} +1 -1
- package/dist/chunk-L4WLQYFC.mjs +5 -0
- package/dist/{chunk-NLDF35P5.mjs → chunk-LAMCAGK6.mjs} +1 -1
- package/dist/chunk-LHGKPMTI.mjs +2 -0
- package/dist/chunk-LJZIYVNA.mjs +3 -0
- package/dist/chunk-LSVCTLS2.mjs +2 -0
- package/dist/chunk-LZY74XTP.mjs +2 -0
- package/dist/chunk-MC4LRSVH.mjs +2 -0
- package/dist/chunk-MCEXN4G5.mjs +2 -0
- package/dist/chunk-MTLAOCFF.mjs +181 -0
- package/dist/chunk-MZ66LN3B.mjs +2 -0
- package/dist/chunk-N5QWJRRI.mjs +2 -0
- package/dist/chunk-NF4MMOUB.mjs +2 -0
- package/dist/chunk-NHJYAJ73.mjs +3 -0
- package/dist/chunk-NRSD7SVY.mjs +2 -0
- package/dist/chunk-NT23I4XW.mjs +2 -0
- package/dist/chunk-NUALABIS.mjs +3 -0
- package/dist/chunk-NW6LAU22.mjs +2 -0
- package/dist/chunk-NWWHDGKF.mjs +5 -0
- package/dist/chunk-OFRCVMSJ.mjs +2 -0
- package/dist/chunk-OKVQQ3CX.mjs +4 -0
- package/dist/chunk-OM24O4KR.mjs +8 -0
- package/dist/{chunk-4B2TB5K2.mjs → chunk-PBMOC7KA.mjs} +2 -2
- package/dist/chunk-PVTCP43W.mjs +2 -0
- package/dist/chunk-Q62HZAJS.mjs +2 -0
- package/dist/chunk-QBJWIR3Q.mjs +2 -0
- package/dist/chunk-QDSEZX7Z.mjs +6 -0
- package/dist/chunk-QR4V4A2Z.mjs +15 -0
- package/dist/{chunk-QVGOPHOF.mjs → chunk-QRLVVZ3R.mjs} +1 -1
- package/dist/chunk-QW3QG67J.mjs +2 -0
- package/dist/chunk-R2T2EVWY.mjs +8 -0
- package/dist/{chunk-JJNHKGTD.mjs → chunk-R5RERE6G.mjs} +1 -1
- package/dist/chunk-RFHEWT3Y.mjs +5 -0
- package/dist/chunk-RS2Z2JQP.mjs +2 -0
- package/dist/chunk-S6YEC4GE.mjs +16 -0
- package/dist/chunk-SHZLMGGW.mjs +2 -0
- package/dist/chunk-TALJQM2O.mjs +2 -0
- package/dist/chunk-TLNJWEFY.mjs +22 -0
- package/dist/{chunk-ZLS2FRS6.mjs → chunk-TQZU7D7C.mjs} +1 -1
- package/dist/chunk-UTKNWYBA.mjs +2 -0
- package/dist/chunk-V3TM3TEN.mjs +2 -0
- package/dist/chunk-V3XROSP5.mjs +7 -0
- package/dist/{chunk-6QBHU4DF.mjs → chunk-V5JTW2LH.mjs} +1 -1
- package/dist/chunk-VAXLSISW.mjs +4 -0
- package/dist/chunk-VBHI3RG6.mjs +2 -0
- package/dist/{chunk-CRDG6EF2.mjs → chunk-VKZJRGDR.mjs} +1 -1
- package/dist/chunk-VZXZM7NS.mjs +2 -0
- package/dist/chunk-WDS6HEPE.mjs +2 -0
- package/dist/chunk-WHGANNEY.mjs +13 -0
- package/dist/chunk-WMJWC4QY.mjs +56 -0
- package/dist/{chunk-UTMAY3WK.mjs → chunk-WWONJJQS.mjs} +1 -1
- package/dist/chunk-X4WNLFIK.mjs +2 -0
- package/dist/chunk-XNNCQROX.mjs +11 -0
- package/dist/chunk-XRYECF23.mjs +170 -0
- package/dist/{chunk-225ALKYI.mjs → chunk-Z4NEXXFK.mjs} +1 -1
- package/dist/{chunk-PKVK6WFC.mjs → chunk-ZFVF2YPV.mjs} +2 -2
- package/dist/chunk-ZM2IX3R2.mjs +2 -0
- package/dist/chunk-ZNGGDUZI.mjs +2 -0
- package/dist/chunk-ZOEJQOKT.mjs +2 -0
- package/dist/cli.mjs +1 -1
- package/dist/server.mjs +1 -1
- package/dist/update-fetch.mjs +1 -1
- package/dist/worker.mjs +1 -1
- package/package.json +1 -1
- package/release.json +3 -3
- package/skill/SKILL.md +54 -148
- package/skill/references/building-blocks.md +4 -4
- package/skill/references/charts-and-figures.md +73 -0
- package/skill/references/extensions.md +47 -4
- package/skill/references/pages-and-data.md +22 -3
- package/skill/references/preference-files.md +7 -0
- package/skill/references/previews.md +17 -0
- package/skill/references/writing-html.md +21 -82
- package/web/assets/index-B4uUluLM.js +15 -0
- package/web/assets/index-C3vHT8sH.css +1 -0
- package/web/index.html +2 -2
- package/dist/chunk-2HS7ZBPU.mjs +0 -2
- package/dist/chunk-3F57LEN5.mjs +0 -2
- package/dist/chunk-4OZWXZE5.mjs +0 -5
- package/dist/chunk-5GK7HS62.mjs +0 -2
- package/dist/chunk-6GV3CBB2.mjs +0 -2
- package/dist/chunk-6NLXUKAY.mjs +0 -4
- package/dist/chunk-6XD6U3QA.mjs +0 -5
- package/dist/chunk-6Y3HYOBM.mjs +0 -2
- package/dist/chunk-7EFHNQ5B.mjs +0 -3
- package/dist/chunk-7M2BQQGZ.mjs +0 -2
- package/dist/chunk-7UZUQQ7Q.mjs +0 -554
- package/dist/chunk-ALZ6ZENZ.mjs +0 -3
- package/dist/chunk-BX5AKINW.mjs +0 -15
- package/dist/chunk-DR73Y2UJ.mjs +0 -3
- package/dist/chunk-EURLTOTZ.mjs +0 -4
- package/dist/chunk-F4ZA6JRZ.mjs +0 -3
- package/dist/chunk-FNCCVGKX.mjs +0 -2
- package/dist/chunk-FOJ3K5Z3.mjs +0 -6
- package/dist/chunk-FQ72LZHB.mjs +0 -2
- package/dist/chunk-G7N7YFVD.mjs +0 -2
- package/dist/chunk-GIRXJBGC.mjs +0 -2
- package/dist/chunk-GKOBB6DH.mjs +0 -2
- package/dist/chunk-GMJD7ID3.mjs +0 -2
- package/dist/chunk-GQJMVSSN.mjs +0 -2
- package/dist/chunk-HX4DLE3J.mjs +0 -10
- package/dist/chunk-IS7N3N6Q.mjs +0 -168
- package/dist/chunk-JEUK3ZO7.mjs +0 -2
- package/dist/chunk-JPG2LBQM.mjs +0 -11
- package/dist/chunk-JYK5WJPM.mjs +0 -2
- package/dist/chunk-KLQP46Y3.mjs +0 -4
- package/dist/chunk-L55NOXBE.mjs +0 -4
- package/dist/chunk-LCB6MARS.mjs +0 -5
- package/dist/chunk-LJLVCU6W.mjs +0 -16
- package/dist/chunk-LMUZQK5O.mjs +0 -2
- package/dist/chunk-NMF3IPYA.mjs +0 -13
- package/dist/chunk-ONKC4BCC.mjs +0 -2
- package/dist/chunk-OU6ZZVBX.mjs +0 -5
- package/dist/chunk-PUOQ65NC.mjs +0 -2
- package/dist/chunk-QJAVCTY7.mjs +0 -2
- package/dist/chunk-QP7BOHE4.mjs +0 -2
- package/dist/chunk-S3SX3COT.mjs +0 -20
- package/dist/chunk-SQ3FQCCN.mjs +0 -8
- package/dist/chunk-T4Y2ZSVX.mjs +0 -4
- package/dist/chunk-TANZRINB.mjs +0 -2
- package/dist/chunk-UGWMMEJ5.mjs +0 -15
- package/dist/chunk-UTF7GBQU.mjs +0 -4
- package/dist/chunk-V4ZHRALW.mjs +0 -170
- package/dist/chunk-XBNFXWAW.mjs +0 -6
- package/dist/chunk-XZV72SQ4.mjs +0 -2
- package/dist/chunk-YFJB534W.mjs +0 -2
- package/dist/chunk-Z6OKLRHC.mjs +0 -2
- package/dist/chunk-ZMHUDYLV.mjs +0 -2
- package/web/assets/index-D8P8uhDW.js +0 -15
- package/web/assets/index-FLb9jCfl.css +0 -1
- /package/dist/{chunk-PAREWQGB.mjs → chunk-NOGQX56R.mjs} +0 -0
|
@@ -132,6 +132,7 @@ export function createHandle({ info, active, selection, drag, menu, openMenu, dr
|
|
|
132
132
|
let barShown = null; // the section the bar stands beside
|
|
133
133
|
let shown = null; // the block the handle stands beside
|
|
134
134
|
let noted = null; // the block the 批注 button stands beside
|
|
135
|
+
let noteWait = null; // { target, ready, timer }: a new block must stay eligible for 400 ms
|
|
135
136
|
let pointer = null; // { x, y, target }: where the pointer last moved over the page, and what it was on
|
|
136
137
|
let typing = false; // the handle hides while the reader types, until the pointer moves
|
|
137
138
|
let still = null; // the page scrolled with the pointer here: the handle hides until it moves away
|
|
@@ -281,8 +282,19 @@ export function createHandle({ info, active, selection, drag, menu, openMenu, dr
|
|
|
281
282
|
|
|
282
283
|
// The 批注 button beside `target` (shown by its `shape`) at `spot`, or none.
|
|
283
284
|
function placeNote(target, shape, spot) {
|
|
285
|
+
if (noteWait?.target !== target) {
|
|
286
|
+
clearTimeout(noteWait?.timer);
|
|
287
|
+
noteWait = null;
|
|
288
|
+
noted = null;
|
|
289
|
+
note.remove();
|
|
290
|
+
if (target) {
|
|
291
|
+
const waiting = { target, ready: false, timer: null };
|
|
292
|
+
waiting.timer = setTimeout(() => { waiting.ready = true; schedule(); }, 400);
|
|
293
|
+
noteWait = waiting;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (!target || !noteWait?.ready) return;
|
|
284
297
|
noted = target;
|
|
285
|
-
if (!target) return void note.remove();
|
|
286
298
|
own(note, "note button", { owner: target, alive: () => noted === target && target.isConnected });
|
|
287
299
|
drawAt(note, shape.layer, spot.left - shape.origin.left, spot.top - shape.origin.top);
|
|
288
300
|
}
|
|
@@ -613,7 +625,7 @@ export function createHandle({ info, active, selection, drag, menu, openMenu, dr
|
|
|
613
625
|
handle.remove();
|
|
614
626
|
}
|
|
615
627
|
if (!handle.isConnected || !dimmed?.isConnected) undim();
|
|
616
|
-
if (
|
|
628
|
+
if (noteWait && !noteWait.target.isConnected) placeNote(null);
|
|
617
629
|
if (barShown && !barShown.isConnected) {
|
|
618
630
|
barShown = null;
|
|
619
631
|
bar.remove();
|
|
@@ -595,7 +595,7 @@ function keep(href, restore, page) {
|
|
|
595
595
|
const before = ownHTML(host);
|
|
596
596
|
link.replaceWith(mentionOf(link).title);
|
|
597
597
|
use.recordStep(host, before);
|
|
598
|
-
use.say(t("
|
|
598
|
+
use.say(t("链接已移除"));
|
|
599
599
|
return;
|
|
600
600
|
}
|
|
601
601
|
const before = ownHTML(host);
|
|
@@ -616,7 +616,7 @@ function keep(href, restore, page) {
|
|
|
616
616
|
}
|
|
617
617
|
} else return;
|
|
618
618
|
use.recordStep(host, before);
|
|
619
|
-
use.say(href ? t("链接:{0}", wordsFor(href, page)) : entry ? t("入口已移除:页面上没有地方能打开这张详情卡片了 · Ctrl+Z 撤销") : t("
|
|
619
|
+
use.say(href ? t("链接:{0}", wordsFor(href, page)) : entry ? t("入口已移除:页面上没有地方能打开这张详情卡片了 · Ctrl+Z 撤销") : t("链接已移除"));
|
|
620
620
|
}
|
|
621
621
|
|
|
622
622
|
// The gray 链接 given its address: a link of it in its place, its words the address or a page's title
|
|
@@ -27,13 +27,13 @@ const ROUTINE = () => [
|
|
|
27
27
|
t("{0} · Ctrl+Z 撤销"), // whatever was just done
|
|
28
28
|
t("已新建分区 · 标题、分割线都可以按需加"),
|
|
29
29
|
t("已复制 {0} · 在要放的块里按 Ctrl+V 粘贴到它后面"), t("已复制 {0} 格"),
|
|
30
|
-
t("
|
|
31
|
-
t("
|
|
30
|
+
t("已保存"), t("标题已更新"),
|
|
31
|
+
t("已取消调整"), t("已取消移动"), t("位置没有变化"),
|
|
32
32
|
t("已选中 {0} 块 · Delete 删除 · 拖动手柄一起移动 · Esc 取消"), t("已选中{0} · Delete 删除{1} · Esc 取消"),
|
|
33
33
|
t("已选中{0} · Delete 删除 · Esc 取消{1}"), t("已选中这一块的文字 · 再按 {0} 选中{1}"), t("已选中这一块的文字"), // Ctrl+A (select-all/)
|
|
34
34
|
t("正在移动{0} · 拖到要放的位置后松开 · 停在选项卡上切换,停在折叠块标题上展开 · Esc 取消"),
|
|
35
35
|
t("接着打的字:{0} · 再按一次取消"), t("接着打的字照旧"),
|
|
36
|
-
t("链接:{0}"), t("
|
|
36
|
+
t("链接:{0}"), t("链接已移除"),
|
|
37
37
|
t("这一项上面没有同级的项,不能再缩进。"), t("这一项已经在最外层。"), t("选中的项上面没有同级的项,不能再缩进。"), t("选中的项已经在最外层。"),
|
|
38
38
|
t("{0}已经在{1}。"), t("这里只有{0};可以拖动手柄移到别处。"),
|
|
39
39
|
];
|
|
@@ -319,7 +319,7 @@ export function createResize({ units, active, look, say, settle = () => {}, owns
|
|
|
319
319
|
document.addEventListener("pointerup", () => { if (drag) finish(true); }, true);
|
|
320
320
|
// After pointerup the drag is over already; any other way it ends, it is cancelled (gesture.mjs).
|
|
321
321
|
endsGesture({ active: () => Boolean(drag), cancel: () => finish(false) });
|
|
322
|
-
cancelOnEscape({ active: () => Boolean(drag), cancel: () => { finish(false); say(t("
|
|
322
|
+
cancelOnEscape({ active: () => Boolean(drag), cancel: () => { finish(false); say(t("已取消调整")); } });
|
|
323
323
|
|
|
324
324
|
return {
|
|
325
325
|
/** A table's width ("x", from its right edge) or scroll height ("y", from its bottom edge) dragged, the press `event` on it (the table tools' armed edge). */
|
|
@@ -27,7 +27,8 @@ export const SOLO = new URLSearchParams(location.search).has("solo");
|
|
|
27
27
|
* workbench shows it: a toast, `room` px above the frame's bottom, what the page's bottom panels take;
|
|
28
28
|
* text "" takes it away), noticeRoom(room) (that room changed: the workbench's toasts stand above it),
|
|
29
29
|
* reload() (the workbench shows the report
|
|
30
|
-
* again in a new page, swapped in once drawn),
|
|
30
|
+
* again in a new page, swapped in once drawn), focus({ caret, selection }) (where the reader is: the block the caret is in, the
|
|
31
|
+
* words or blocks selected; the workbench reports it to the server, gallery status), annotationsRemoved(items) (批注 deleted in the page:
|
|
31
32
|
* the workbench says so and offers them back), open({ target, block } or { hash }) (the workbench
|
|
32
33
|
* goes to another document), jump({ block, place }) (a link goes to a block of this page: the
|
|
33
34
|
* workbench adds a history entry, `place` where the reader was, for the way back: `show-place`),
|
|
@@ -131,6 +132,8 @@ export function connectWorkbench({ slug, snapshot, commands, modals }) {
|
|
|
131
132
|
// this page's place once drawn; this page does nothing more meanwhile.
|
|
132
133
|
reload: () => send("reload", {}),
|
|
133
134
|
annotationsRemoved: (items) => send("annotations-removed", { items }),
|
|
135
|
+
// Where the reader is, for gallery status (focus.mjs): { caret, selection }, told when it changed.
|
|
136
|
+
focus: (where) => send("focus", where),
|
|
134
137
|
open: (place) => send("open", place),
|
|
135
138
|
jump: (fields) => send("jump", fields),
|
|
136
139
|
workspace: () => send("workspace", {}),
|
|
@@ -44,7 +44,7 @@ export function loadScript(src, ready, { before } = {}) {
|
|
|
44
44
|
|
|
45
45
|
/** An address on the clipboard, and said so: called right in the click that asked, as a browser takes no write after a wait. */
|
|
46
46
|
export function copyAddress(address, say) {
|
|
47
|
-
navigator.clipboard?.writeText(address).then(() => say(t("
|
|
47
|
+
navigator.clipboard?.writeText(address).then(() => say(t("已复制链接")), () => say(t("没能复制:浏览器不允许写剪贴板。"), "error"));
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
export function button(label, action, { primary = false, ...props } = {}) {
|
|
@@ -5,7 +5,7 @@ import { BLOCK_ID, randomId } from "./ids.mjs";
|
|
|
5
5
|
// local Agent, each about a piece of a report: words of its text, or blocks taken whole. One has
|
|
6
6
|
// words: an item with none is none (cleanItem). Kept in reports/<slug>/annotations.json, never in report.html:
|
|
7
7
|
//
|
|
8
|
-
// { v: 1, items: [{ id, created, updated, text, anchor }] }
|
|
8
|
+
// { v: 1, items: [{ id, created, updated, text, anchor, done? }] } (done: true once an Agent has acted on it: gallery notes done)
|
|
9
9
|
// anchor: { kind: "text", blocks: [data-block-id…], quote, prefix, suffix }
|
|
10
10
|
// | { kind: "blocks", blocks: [data-block-id…], quote }
|
|
11
11
|
//
|
|
@@ -396,9 +396,28 @@ export function cleanItem(item, now = new Date().toISOString()) {
|
|
|
396
396
|
if (!anchor) return null;
|
|
397
397
|
const text = item.text.replace(/\r\n?/g, "\n").slice(0, TEXT_MOST);
|
|
398
398
|
if (!text.trim()) return null;
|
|
399
|
-
return { id: item.id, created: typeof item.created === "string" ? item.created : now, updated: now, text, anchor };
|
|
399
|
+
return { id: item.id, created: typeof item.created === "string" ? item.created : now, updated: now, text, anchor, ...(item.done === true ? { done: true } : {}) };
|
|
400
400
|
}
|
|
401
401
|
|
|
402
|
+
/** `items` with those whose id `ids` holds marked done (`done` true) or not done (false), the others as they were. */
|
|
403
|
+
export function markItems(items, ids, done, now = new Date().toISOString()) {
|
|
404
|
+
const named = new Set(ids);
|
|
405
|
+
return items.map((item) => (named.has(item.id) && Boolean(item.done) !== done ? cleanItem({ ...item, done }, now) : item));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* `items` without those a request takes out: `ids` (those ids), `done` (every one marked done), `all`: { items (the rest),
|
|
410
|
+
* removed (those taken out, as they were, for 撤销) }.
|
|
411
|
+
*/
|
|
412
|
+
export function withoutItems(items, { ids = [], done = false, all = false } = {}) {
|
|
413
|
+
const named = new Set(ids);
|
|
414
|
+
const takes = (item) => all || named.has(item.id) || (done && item.done === true);
|
|
415
|
+
return { items: items.filter((item) => !takes(item)), removed: items.filter(takes) };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** The ids of `ids` that no item of `items` has. */
|
|
419
|
+
export const unknownIds = (items, ids) => ids.filter((id) => !items.some((item) => item.id === id));
|
|
420
|
+
|
|
402
421
|
/** The annotations of a file's text (annotations.json), those it holds that are not annotations left out. */
|
|
403
422
|
export function readItems(text) {
|
|
404
423
|
let data = null;
|
|
@@ -555,7 +574,7 @@ export function cardTops(items, front = null) {
|
|
|
555
574
|
* the section bar's line at +12 to +16); the card's width by its words or blocks, in the column, where
|
|
556
575
|
* the margin has no room for cards (those are margin.mjs's: MARGIN.card).
|
|
557
576
|
*/
|
|
558
|
-
export const RAIL = Object.freeze({ marker: 24, markerAt:
|
|
577
|
+
export const RAIL = Object.freeze({ marker: 24, markerAt: 10, bar: 7, card: 280 });
|
|
559
578
|
|
|
560
579
|
/**
|
|
561
580
|
* Where a marker stands across a window `width` wide, the column's right edge being `right`: `markerAt`
|
|
@@ -4,7 +4,7 @@ import { componentSettings, problemIn, writeComponentSettings } from "./componen
|
|
|
4
4
|
import { refusal, said } from "./i18n/index.mjs";
|
|
5
5
|
import { DEFAULT_PACK, OWN_SCOPE, PACK_FILE, PACK_ID, readPackManifest } from "./pack-manifest.mjs";
|
|
6
6
|
import { packsDirectory } from "./paths.mjs";
|
|
7
|
-
import { filesIn, fingerprintOf, fingerprintOfFiles, isSystemFile, recordOf, shippedBlocks, shippedPackIds, versionAfter, writeRecord, writingOrder } from "./shipped-blocks.mjs";
|
|
7
|
+
import { filesIn, fingerprintOf, fingerprintOfFiles, followedAlready, isSystemFile, recordOf, shippedBlocks, shippedPackIds, versionAfter, writeRecord, writingOrder } from "./shipped-blocks.mjs";
|
|
8
8
|
import { DEVICE_NAMES, PACKAGE_MANIFEST, layoutOf, markerOf, packageFolders } from "./workspace.mjs";
|
|
9
9
|
|
|
10
10
|
// Block packs (自定义块包: notes/plans/block-packs-plan-2026-11-09.md; docs/render.md "Block packs"). A pack
|
|
@@ -312,6 +312,7 @@ export function workspacePacks(root, { settings = null, shippedIds = shippedPack
|
|
|
312
312
|
for (const name of fs.readdirSync(components)) if (PACK_ID.test(name) && fs.existsSync(path.join(components, name, PACK_FILE))) scopes.add(name);
|
|
313
313
|
} catch {}
|
|
314
314
|
const switches = (settings ?? componentSettings(root)).packs ?? {};
|
|
315
|
+
let ownBlocks;
|
|
315
316
|
const listed = [...scopes].map((id) => {
|
|
316
317
|
const folder = path.join(components, id);
|
|
317
318
|
let manifest = null;
|
|
@@ -348,8 +349,12 @@ export function workspacePacks(root, { settings = null, shippedIds = shippedPack
|
|
|
348
349
|
return null;
|
|
349
350
|
}
|
|
350
351
|
};
|
|
351
|
-
const
|
|
352
|
-
|
|
352
|
+
const behind = official && shipped && changed.some((block) => {
|
|
353
|
+
if (block.endsWith(`/${PACK_FILE}`)) return versionAfter(shipped.version, manifest?.version ?? null);
|
|
354
|
+
ownBlocks ??= new Map(shippedBlocks(directory).map((one) => [one.id, one]));
|
|
355
|
+
const own = ownBlocks.get(block);
|
|
356
|
+
return own && versionAfter(own.version, versionAt(block));
|
|
357
|
+
});
|
|
353
358
|
const newer = behind ? shipped.version : null;
|
|
354
359
|
const told = id === OWN_SCOPE ? DEFAULT_PACK : manifest ?? (problem ? null : shipped);
|
|
355
360
|
return {
|
|
@@ -392,7 +397,8 @@ function installedLines(root, made, first) {
|
|
|
392
397
|
* cannot be written leaves that pack for the next start), one installed is brought up to Gallery's.
|
|
393
398
|
* `env`: whose NO_PACKS is read; `log`: told what was done, a line each. Never rejects: { did: "off"
|
|
394
399
|
* (NO_PACKS), "unread" (gallery.json does not read as Gallery's) or "done", packs: [installPack's answer
|
|
395
|
-
* with `first`, or { id, failed }] }.
|
|
400
|
+
* with `first`, or { id, failed }] }. The blocks it leaves as Gallery ships them (added, updated or the
|
|
401
|
+
* same) are followShipped's looked at (followedAlready): nothing for it to do of them in this process.
|
|
396
402
|
*/
|
|
397
403
|
export async function installOfficialPacks(root, { directory = packsDirectory, env = process.env, log = () => {} } = {}) {
|
|
398
404
|
if (packsSkipped(env)) return { did: "off", packs: [] };
|
|
@@ -402,6 +408,7 @@ export async function installOfficialPacks(root, { directory = packsDirectory, e
|
|
|
402
408
|
} catch {}
|
|
403
409
|
if (!marker.ours) return { did: "unread", packs: [] };
|
|
404
410
|
const ids = shippedPackIds(directory);
|
|
411
|
+
const shipped = shippedBlocks(directory);
|
|
405
412
|
const packs = [];
|
|
406
413
|
for (const id of ids) {
|
|
407
414
|
try {
|
|
@@ -409,6 +416,8 @@ export async function installOfficialPacks(root, { directory = packsDirectory, e
|
|
|
409
416
|
if (first && OFF_AT_FIRST.includes(id) && componentSettings(root).packs[id] === undefined) await writeComponentSettings(root, { pack: id, enabled: false });
|
|
410
417
|
const made = installPack(root, folderFiles(path.join(directory, id)), { official: true, shippedIds: ids });
|
|
411
418
|
packs.push({ ...made, first });
|
|
419
|
+
const current = new Set([...made.added, ...made.updated, ...made.same]);
|
|
420
|
+
followedAlready(root, shipped.filter((block) => current.has(block.id)));
|
|
412
421
|
for (const line of installedLines(root, made, first)) log(line);
|
|
413
422
|
} catch (error) {
|
|
414
423
|
log(`Gallery's pack ${id} could not be installed in ${path.resolve(root)}: ${error?.message ?? error}`);
|
|
@@ -123,6 +123,20 @@ export function ownLookFor(look, workspace) {
|
|
|
123
123
|
return Object.fromEntries(Object.keys(LOOK).filter((key) => shown[key] !== base[key]).map((key) => [key, shown[key]]));
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/** A legacy full-look request: retain explicit choices for fields whose displayed value did not change. */
|
|
127
|
+
export function updatedOwnLook(own, workspace, look) {
|
|
128
|
+
const before = fullLook(effectiveLook(own, workspace));
|
|
129
|
+
const after = fullLook(look);
|
|
130
|
+
const changed = ownLookFor(look, workspace);
|
|
131
|
+
const kept = ownLookOf(own);
|
|
132
|
+
for (const key of Object.keys(LOOK)) {
|
|
133
|
+
if (before[key] === after[key]) continue;
|
|
134
|
+
if (Object.hasOwn(changed, key)) kept[key] = changed[key];
|
|
135
|
+
else delete kept[key];
|
|
136
|
+
}
|
|
137
|
+
return kept;
|
|
138
|
+
}
|
|
139
|
+
|
|
126
140
|
/**
|
|
127
141
|
* A document's doc.json (its parsed object) with its own look as given (ownLookOf: an explicit default
|
|
128
142
|
* kept): in the field's place when it had one, else last; the field gone when it makes no choice.
|
|
@@ -53,7 +53,7 @@ export const clashingIds = (ids = registeredIds()) => [...ids].filter(([, roots]
|
|
|
53
53
|
/** Whether a draft's doc.json says it is a document (not the 写作偏好 page of an old layout), and what it says. */
|
|
54
54
|
async function metadataAt(folder) {
|
|
55
55
|
const files = documentFiles(folder);
|
|
56
|
-
const [html, metadata] = await Promise.all([exists(files.html), fs.readFile(files.json, "utf8").then((text) => JSON.parse(text.replace(/^/, ""))
|
|
56
|
+
const [html, metadata] = await Promise.all([exists(files.html), fs.readFile(files.json, "utf8").then((text) => JSON.parse(text.replace(/^/, ""))).catch(() => null)]);
|
|
57
57
|
return { files, html, metadata: metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : null };
|
|
58
58
|
}
|
|
59
59
|
|
|
@@ -6,6 +6,12 @@ import { isExtensionId } from "./outline-shape.mjs";
|
|
|
6
6
|
import { sha256, writeAtomic } from "./files.mjs";
|
|
7
7
|
import { withOutlineLock } from "./outline.mjs";
|
|
8
8
|
|
|
9
|
+
// The recursive workspace watcher covers imported modules as well as manifest entries.
|
|
10
|
+
// Requests never rescan the whole extension tree just to serve a single asset.
|
|
11
|
+
const generations = new Map();
|
|
12
|
+
let generation = 0;
|
|
13
|
+
export function invalidateExtension(folder) { generations.set(path.resolve(folder), ++generation); }
|
|
14
|
+
|
|
9
15
|
export const extensionDirectory = id => {
|
|
10
16
|
if (!isExtensionId(id)) throw new Error("扩展 ID 不正确");
|
|
11
17
|
return path.join(layoutOf(defaultWorkspace()).extensions, id);
|
|
@@ -42,42 +48,64 @@ export async function readManifest(folder) {
|
|
|
42
48
|
const value = JSON.parse(text);
|
|
43
49
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("extension.json 必须是对象");
|
|
44
50
|
if (value.id !== undefined && !isExtensionId(value.id)) throw new Error("扩展 ID 不正确");
|
|
51
|
+
for (const key of ["name", "version", "description"]) if (value[key] !== undefined && (typeof value[key] !== "string" || value[key].length > 1000)) throw new Error("扩展名称、版本和描述必须是短文本");
|
|
52
|
+
if (value.icon !== undefined) {
|
|
53
|
+
if (typeof value.icon !== "string" || !value.icon || value.icon.length > 256) throw new Error("icon 不正确");
|
|
54
|
+
if (/\.(?:png|jpe?g|webp|gif|svg)$/i.test(value.icon)) await extensionFile(folder, value.icon);
|
|
55
|
+
else if (value.icon.length > 16 || /[/\\]/.test(value.icon)) throw new Error("icon 必须是表情或扩展里的图片");
|
|
56
|
+
}
|
|
45
57
|
if (value.workbench !== undefined && (!value.workbench || typeof value.workbench !== "object" || Array.isArray(value.workbench))) throw new Error("workbench 必须是对象");
|
|
46
58
|
for (const name of [value.workbench?.script, value.workbench?.style, value.agent].filter(value => value !== undefined)) await extensionFile(folder, name);
|
|
47
59
|
if (value.server !== undefined) {
|
|
48
60
|
const command = value.server?.command;
|
|
49
61
|
if (!Array.isArray(command) || !command.length || command.length > 32 || command.some(word => typeof word !== "string" || !word || word.includes("\0"))) throw new Error("server.command 必须是非空的参数数组");
|
|
62
|
+
if (value.server.timeout !== undefined && (!Number.isInteger(value.server.timeout) || value.server.timeout < 1000 || value.server.timeout > 600000)) throw new Error("server.timeout 必须是 1000 到 600000 毫秒");
|
|
50
63
|
}
|
|
51
|
-
|
|
64
|
+
// Files named by the manifest can change while extension.json stays byte-for-byte identical.
|
|
65
|
+
// Their metadata gives clients and the backend manager a fresh revision after a replacement.
|
|
66
|
+
const named = [value.workbench?.script, value.workbench?.style, ...(value.server?.command?.slice(1) ?? [])].filter(name => typeof name === "string" && !path.isAbsolute(name));
|
|
67
|
+
const stamps = await Promise.all(named.map(async name => {
|
|
68
|
+
try { const file = await extensionFile(folder, name); const stat = await fs.stat(file); return [name, stat.size, stat.mtimeMs]; } catch { return [name, null]; }
|
|
69
|
+
}));
|
|
70
|
+
return { ...value, revision: sha256(JSON.stringify([text, stamps, generations.get(path.resolve(folder)) ?? 0])) };
|
|
52
71
|
}
|
|
53
72
|
|
|
54
73
|
const configFile = () => path.join(defaultWorkspace(), "gallery.json");
|
|
55
74
|
const configuration = async () => JSON.parse(await fs.readFile(configFile(), "utf8"));
|
|
56
75
|
|
|
57
|
-
export async function listCapabilities() {
|
|
76
|
+
export async function listCapabilities({ ids = null } = {}) {
|
|
58
77
|
const config = await configuration();
|
|
59
|
-
|
|
78
|
+
const wantedIds = ids === null ? null : new Set(ids);
|
|
79
|
+
const folders = await extensionFolders(defaultWorkspace());
|
|
80
|
+
return Promise.all(folders.filter(({ id }) => !wantedIds || wantedIds.has(id)).map(async ({ id, folder }) => {
|
|
81
|
+
const wanted = config.extensions?.[id]?.enabled === true;
|
|
82
|
+
const configured = Object.hasOwn(config.extensions ?? {}, id);
|
|
60
83
|
try {
|
|
61
84
|
const manifest = await readManifest(folder);
|
|
62
|
-
|
|
63
|
-
return { id, folder, manifest, enabled: grant?.enabled === true && grant.revision === manifest.revision,
|
|
64
|
-
confirmed: grant?.revision === manifest.revision, agent: manifest.agent ? path.join(folder, manifest.agent) : null };
|
|
85
|
+
return { id, folder, manifest, enabled: wanted, wanted, agent: manifest.agent ? path.join(folder, manifest.agent) : null };
|
|
65
86
|
} catch (error) {
|
|
66
|
-
|
|
87
|
+
let message = error.message;
|
|
88
|
+
if (error.code === "ENOENT") {
|
|
89
|
+
const hasManifest = await fs.lstat(path.join(folder, "extension.json")).then(() => true, () => false);
|
|
90
|
+
const hasSidebar = await fs.lstat(path.join(folder, "sidebar.json")).then(() => true, () => false);
|
|
91
|
+
message = hasManifest ? "扩展引用的文件不存在" : (!configured && hasSidebar ? null : "找不到 extension.json");
|
|
92
|
+
}
|
|
93
|
+
return { id, folder, enabled: false, wanted, manifest: null, error: message };
|
|
67
94
|
}
|
|
68
95
|
}));
|
|
69
96
|
}
|
|
70
97
|
|
|
71
|
-
export async function setExtensionEnabled({ id, enabled
|
|
98
|
+
export async function setExtensionEnabled({ id, enabled }) {
|
|
72
99
|
const folder = extensionDirectory(id);
|
|
73
100
|
if (typeof enabled !== "boolean") throw new Error("扩展开关的值不正确");
|
|
74
101
|
return withOutlineLock(defaultWorkspace(), async () => {
|
|
75
|
-
|
|
76
|
-
if (
|
|
102
|
+
if (enabled) await readManifest(folder);
|
|
103
|
+
else if (!(await fs.lstat(folder).catch(() => null))) throw new Error("找不到扩展");
|
|
77
104
|
const config = await configuration();
|
|
78
|
-
if (
|
|
79
|
-
config.extensions = { ...config.extensions, [id]: { enabled
|
|
105
|
+
if ((config.extensions?.[id]?.enabled === true) === enabled) return { changed: false };
|
|
106
|
+
config.extensions = { ...config.extensions, [id]: { enabled } };
|
|
80
107
|
await writeAtomic(configFile(), JSON.stringify(config, null, 2) + "\n");
|
|
108
|
+
return { changed: true };
|
|
81
109
|
});
|
|
82
110
|
}
|
|
83
111
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// What the workbench shows as 收藏 when it reads the list again (pure, so that the page imports it; the file side is ./favorites.mjs).
|
|
2
|
+
// The browser keeps its own list (localStorage) and the user's data folder keeps one that gallery favorite and every browser share: `local`
|
|
3
|
+
// is the browser's, `seen` what the shared list held when this browser last read it, `server` what it holds now. A three-way merge: an
|
|
4
|
+
// entry the shared list dropped since (in `seen`, not in `server`) leaves the browser's; one it gained since (in `server`, not in `seen`)
|
|
5
|
+
// joins it; one the browser took out since (in `seen` and `server`, not in `local`) stays out and is for the shared list to drop (`drop`:
|
|
6
|
+
// a read that lands while the browser's own removal is on its way never brings the star back); one only the browser has is for the
|
|
7
|
+
// shared list to take (`push`). The browser's order stays, the shared list's new entries last.
|
|
8
|
+
export function mergeFavorites({ local, seen, server }) {
|
|
9
|
+
const gone = new Set(seen.filter((key) => !server.includes(key)));
|
|
10
|
+
const own = local.filter((key) => !gone.has(key));
|
|
11
|
+
const gained = server.filter((key) => !seen.includes(key) && !own.includes(key));
|
|
12
|
+
return {
|
|
13
|
+
favorites: [...own, ...gained],
|
|
14
|
+
push: own.filter((key) => !server.includes(key)),
|
|
15
|
+
drop: server.filter((key) => seen.includes(key) && !local.includes(key)),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { writeAtomic } from "./files.mjs";
|
|
4
|
+
import { entryPattern, isForeign } from "./outline-shape.mjs";
|
|
5
|
+
import { outlineRoot, withOutlineLock } from "./outline.mjs";
|
|
6
|
+
import { userDirectory } from "./user-directory.mjs";
|
|
7
|
+
import { workspaceId, workspaceKey } from "./workspace-registry.mjs";
|
|
8
|
+
|
|
9
|
+
// The sidebar's 收藏 (favorites): entries (a slug of the workspace, a web link's, or "~<ws id>/<slug>" for a draft of
|
|
10
|
+
// another workspace) the reader starred. The workbench keeps them in its browser (localStorage, which no command can
|
|
11
|
+
// reach) and, since gallery favorite, in favorites.json of the user's data folder too (beside the registry of
|
|
12
|
+
// workspaces: ./user-directory.mjs), as the one place a gallery command and the browsers of this machine share; never
|
|
13
|
+
// in a workspace's files: a star is the reader's, not the project's (a workspace synced or shared carries none). The
|
|
14
|
+
// list is written under the default workspace's outline lock; its keys are the ones the default
|
|
15
|
+
// workspace's sidebar names the entries by, so that a workspace's own entries are plain slugs there and another
|
|
16
|
+
// workspace's are "~<ws id>/<slug>" (the projection ./outline.mjs does for the tree): `favoriteKey` and `favoriteSlug`
|
|
17
|
+
// turn one into the other for a command working in a workspace other than the default.
|
|
18
|
+
|
|
19
|
+
const readJson = (file) => fs.readFile(file, "utf8").then(JSON.parse).catch(() => null);
|
|
20
|
+
|
|
21
|
+
/** The file the shared list is kept in: favorites.json of the user's data folder (GALLERY_DATA_DIR in the tests). */
|
|
22
|
+
export const favoritesFile = () => path.join(userDirectory("data"), "favorites.json");
|
|
23
|
+
|
|
24
|
+
/** The key entry `slug` of workspace `root` has in the stored list: plain in the default workspace, "~<id>/<slug>" elsewhere. */
|
|
25
|
+
export function favoriteKey(root, slug) {
|
|
26
|
+
const home = outlineRoot(root);
|
|
27
|
+
if (workspaceKey(root) === workspaceKey(home)) return slug;
|
|
28
|
+
const homes = `~${workspaceId(home)}/`;
|
|
29
|
+
if (slug.startsWith(homes)) return slug.slice(homes.length); // the default workspace's own, as the file names them
|
|
30
|
+
return isForeign(slug) ? slug : `~${workspaceId(root)}/${slug}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A stored key as workspace `root`'s sidebar names the entry (its own entries lose the prefix, the default workspace's gain one: the projection ./outline.mjs does for the tree). */
|
|
34
|
+
export function favoriteSlug(root, key) {
|
|
35
|
+
const home = outlineRoot(root);
|
|
36
|
+
if (workspaceKey(root) === workspaceKey(home)) return key;
|
|
37
|
+
const prefix = `~${workspaceId(root)}/`;
|
|
38
|
+
if (key.startsWith(prefix)) return key.slice(prefix.length);
|
|
39
|
+
return isForeign(key) ? key : `~${workspaceId(home)}/${key}`; // the default workspace's own entries are another workspace's drafts here
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The favorites as a file holds them: entries once each, only valid ones. */
|
|
43
|
+
const kept = (value) => [...new Set((Array.isArray(value) ? value : []).filter((entry) => typeof entry === "string" && entryPattern.test(entry)))];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The file's lists are kept by the default workspace whose sidebar they belong to (`workspaces`: its id -> its list), so that
|
|
47
|
+
* another default workspace (GALLERY_DEFAULT_WORKSPACE, a test's) never sees a list that names other entries.
|
|
48
|
+
*/
|
|
49
|
+
const listIn = (document, root) => kept(document?.workspaces?.[workspaceId(outlineRoot(root))]);
|
|
50
|
+
|
|
51
|
+
/** The stored favorites seen from workspace `root` (its own entries as plain slugs). Read alone. */
|
|
52
|
+
export async function readFavorites(root) {
|
|
53
|
+
return listIn(await readJson(favoritesFile()), root).map((key) => favoriteSlug(root, key));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Favorites changed in the stored list under the outline's lock: `add` and `remove` are entries as workspace `root`'s
|
|
58
|
+
* sidebar names them. Resolves to { favorites (seen from `root`), changed }. The other default workspaces' lists stay as they are.
|
|
59
|
+
*/
|
|
60
|
+
export function changeFavorites(root, { add = [], remove = [] }) {
|
|
61
|
+
return withOutlineLock(root, async () => {
|
|
62
|
+
const file = favoritesFile();
|
|
63
|
+
const value = await readJson(file);
|
|
64
|
+
const document = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
65
|
+
const id = workspaceId(outlineRoot(root));
|
|
66
|
+
const before = listIn(document, root);
|
|
67
|
+
const dropped = new Set(kept(remove).map((slug) => favoriteKey(root, slug)));
|
|
68
|
+
const after = [...before.filter((key) => !dropped.has(key)), ...kept(add).map((slug) => favoriteKey(root, slug)).filter((key) => !before.includes(key))];
|
|
69
|
+
const changed = JSON.stringify(before) !== JSON.stringify(after);
|
|
70
|
+
if (changed) {
|
|
71
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
72
|
+
const workspaces = document.workspaces && typeof document.workspaces === "object" && !Array.isArray(document.workspaces) ? document.workspaces : {};
|
|
73
|
+
await writeAtomic(file, `${JSON.stringify({ version: 1, workspaces: { ...workspaces, [id]: after } }, null, 2)}\n`);
|
|
74
|
+
}
|
|
75
|
+
return { favorites: after.map((key) => favoriteSlug(root, key)), changed };
|
|
76
|
+
});
|
|
77
|
+
}
|