dsh-tiddlywiki 0.28.7 → 0.28.8
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 -0
- package/docs/CHANGELOG.md +1 -0
- package/docs/features.md +4 -0
- package/docs/usage.md +13 -3
- package/lib/client.js +63 -27
- package/lib/index.js +10655 -10188
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/client/dom.ts +26 -0
- package/src/client/index.ts +14 -0
- package/src/client/note-widget-draft.ts +154 -0
- package/src/client/note-widget-tags.ts +184 -0
- package/src/client/note-widget-upload.ts +50 -0
- package/src/client/note-widget.ts +19 -348
- package/src/client/panel.ts +12 -3
- package/src/client/render-fetch.ts +11 -4
- package/src/client/scope-seat.ts +117 -0
- package/src/client/session-summary.ts +29 -7
- package/src/client/settings-icon-picker.ts +238 -0
- package/src/client/settings-page-catalog.ts +578 -0
- package/src/client/settings-page-config.ts +445 -0
- package/src/client/settings-page-runtime.ts +149 -0
- package/src/client/settings-page-wikis.ts +432 -0
- package/src/client/settings-page.ts +127 -1425
- package/src/client/styles.ts +36 -0
- package/src/client/tool-views.ts +170 -36
- package/src/client/wiki-icon.generated.ts +97 -0
- package/src/client/wiki-icon.ts +50 -15
- package/src/client/wiki-scope-dock.ts +56 -2
- package/src/client/wiki-scope.ts +98 -0
- package/src/host/admin-catalog.ts +488 -0
- package/src/host/admin-routes.ts +670 -0
- package/src/host/admin-secrets.ts +119 -0
- package/src/host/admin.ts +3 -1244
- package/src/host/clip-bridge.ts +24 -1
- package/src/host/config.ts +10 -2
- package/src/host/routes-session.ts +617 -0
- package/src/host/routes-tw-proxy.ts +390 -0
- package/src/host/routes.ts +77 -829
- package/src/host/tools-attach.ts +151 -0
- package/src/host/tools-git.ts +213 -0
- package/src/host/tools-notes.ts +672 -0
- package/src/host/tools-read.ts +421 -0
- package/src/host/tools-support.ts +513 -0
- package/src/host/tools.ts +33 -1814
- package/src/host/wiki-registry.ts +25 -4
- package/src/host/wiki.ts +55 -5
- package/src/index-clip.ts +142 -0
- package/src/index-git.ts +196 -0
- package/src/index-prompt.ts +139 -0
- package/src/index-wikis.ts +353 -0
- package/src/index.ts +89 -429
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件 / 主题 / 语言 / 初始化 sections of the settings page (v0.28.8 split).
|
|
3
|
+
*
|
|
4
|
+
* Everything that configures the CONTENT of ONE wiki: which bundled plugins and
|
|
5
|
+
* themes it loads, which language packs are enabled, and the one-time seeds.
|
|
6
|
+
* All four are per-wiki and reach the host through per-wiki admin routes, and
|
|
7
|
+
* all four keep "not touched yet" in a mount-level `CatalogPending` (see below)
|
|
8
|
+
* so an unrelated refresh cannot silently discard an unapplied checkbox.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-tiddlywiki/client/settings-page-catalog
|
|
11
|
+
*/
|
|
12
|
+
import { toast } from './toast.ts'
|
|
13
|
+
import { make } from './dom.ts'
|
|
14
|
+
import {
|
|
15
|
+
ADMIN_INFO_ENDPOINT as INFO_ENDPOINT,
|
|
16
|
+
ADMIN_SEEDS_ENDPOINT as SEEDS_ENDPOINT,
|
|
17
|
+
ADMIN_SEEDS_REMOVE_ENDPOINT as SEEDS_REMOVE_ENDPOINT,
|
|
18
|
+
ADMIN_SEEDS_RUN_ENDPOINT as SEEDS_RUN_ENDPOINT,
|
|
19
|
+
} from './endpoints.ts'
|
|
20
|
+
import { fetchJson, withWiki } from './settings-page-runtime.ts'
|
|
21
|
+
|
|
22
|
+
/** One bundled plugin/theme/language, as the host catalog reports it. */
|
|
23
|
+
export interface CatalogEntry {
|
|
24
|
+
name: string
|
|
25
|
+
title: string
|
|
26
|
+
label: string
|
|
27
|
+
description: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `state.info` as `GET /admin/state` reports it (the loaded/bundled sets).
|
|
32
|
+
*
|
|
33
|
+
* Named rather than inlined as an object type: this parameter list used to spell
|
|
34
|
+
* the shape out inline, and a guard that locates a function body by scanning for
|
|
35
|
+
* the first `{` would then stop at the TYPE instead of the body (v0.28.8 split —
|
|
36
|
+
* see `bodyOf()` in scripts/verify-frame-guards.mjs).
|
|
37
|
+
*/
|
|
38
|
+
export interface AdminInfoView {
|
|
39
|
+
plugins?: string[]
|
|
40
|
+
themes?: string[]
|
|
41
|
+
languages?: string[]
|
|
42
|
+
themeActive?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `state.catalog` — the bundled, offline-usable plugin/theme/language list. */
|
|
46
|
+
export interface CatalogView {
|
|
47
|
+
plugins?: CatalogEntry[]
|
|
48
|
+
themes?: CatalogEntry[]
|
|
49
|
+
languages?: CatalogEntry[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Runtime plugin truth of the wiki folder (v0.26.0). `null`/absent = the host
|
|
54
|
+
* could not scan the tiddlers dir; renderers must treat that as "unknown".
|
|
55
|
+
*/
|
|
56
|
+
export interface RuntimePluginsView {
|
|
57
|
+
wikiPlugins?: Array<{ title: string; name?: string; description?: string; version?: string }>
|
|
58
|
+
disabled?: string[]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One one-time seed, as `GET /admin/seeds` reports it. */
|
|
62
|
+
export interface SeedItem {
|
|
63
|
+
id: string
|
|
64
|
+
title: string
|
|
65
|
+
description: string
|
|
66
|
+
present: boolean
|
|
67
|
+
/** true = 可选 seed(非功能必需),可「反初始化」移除。 */
|
|
68
|
+
removable?: boolean
|
|
69
|
+
detail?: string
|
|
70
|
+
/** v0.22.0:内置内容比本 wiki 预置时更新(可重新初始化拿来)。 */
|
|
71
|
+
updateAvailable?: boolean
|
|
72
|
+
/** true=本地改过;false=没动过;undefined=旧格式标记,无法判断。 */
|
|
73
|
+
userModified?: boolean
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 未应用的勾选(插件管理 / 主题管理 / 语言管理)。
|
|
78
|
+
*
|
|
79
|
+
* `renderCatalogSection` 每次 refresh() 都从 `state.info` 重建勾选态,而 refresh()
|
|
80
|
+
* 由「同步 / 重启 TW / 保存」触发 —— 用户勾完插件顺手点状态行的「同步」,勾选就被
|
|
81
|
+
* 静默丢掉(勾选当时只活在这一次渲染的 DOM 里)。所以把「用户期望的集合」提升到
|
|
82
|
+
* mount 级:
|
|
83
|
+
* - `undefined` = 用户还没碰过,完全跟随服务器;
|
|
84
|
+
* - 有值 = 用户的完整期望集合(不是增量 —— 「取消勾选」也必须活过 refresh)。
|
|
85
|
+
* 点「应用」成功后清回 undefined,重新跟随服务器。
|
|
86
|
+
*/
|
|
87
|
+
export interface CatalogPending {
|
|
88
|
+
plugins?: Set<string>
|
|
89
|
+
themes?: Set<string>
|
|
90
|
+
languages?: Set<string>
|
|
91
|
+
themeActive?: string
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Plugin/theme manager: checkboxes/radios + apply (writes info, restarts). */
|
|
95
|
+
export function renderCatalogSection(
|
|
96
|
+
body: HTMLElement,
|
|
97
|
+
info: AdminInfoView | undefined,
|
|
98
|
+
catalog: CatalogView | undefined,
|
|
99
|
+
runtimePlugins: RuntimePluginsView | null | undefined,
|
|
100
|
+
refresh: () => Promise<void>,
|
|
101
|
+
pending: CatalogPending,
|
|
102
|
+
): void {
|
|
103
|
+
const plugins = catalog?.plugins ?? []
|
|
104
|
+
const themes = catalog?.themes ?? []
|
|
105
|
+
const languages = catalog?.languages ?? []
|
|
106
|
+
const serverPlugins = info?.plugins ?? []
|
|
107
|
+
const serverLoadedThemes = new Set(info?.themes ?? [])
|
|
108
|
+
const serverLanguages = info?.languages ?? []
|
|
109
|
+
// 期望集合(见 CatalogPending):没有被用户碰过时纯跟随服务器。
|
|
110
|
+
const desiredPlugins = (): Set<string> => pending.plugins ?? new Set(serverPlugins)
|
|
111
|
+
const desiredThemes = (): Set<string> => pending.themes ?? new Set(serverLoadedThemes)
|
|
112
|
+
const desiredLanguages = (): Set<string> => pending.languages ?? new Set(serverLanguages)
|
|
113
|
+
// 运行时真相(v0.26.0):被 TW 禁用的插件标题集合 + wiki 内插件 tiddler 的标题集合。
|
|
114
|
+
// `runtimePlugins == null` = 宿主扫不出(tiddlers 目录读失败)→ 一律当「未知」,
|
|
115
|
+
// 不显示徽标也不显示只读小节,绝不显示「空的假象」(读失败 ≠ 不存在)。
|
|
116
|
+
const disabledTitles = new Set(runtimePlugins?.disabled ?? [])
|
|
117
|
+
const wikiTitles = new Set((runtimePlugins?.wikiPlugins ?? []).map((p) => p.title))
|
|
118
|
+
|
|
119
|
+
// ── plugins ──────────────────────────────────────────────────────────────
|
|
120
|
+
const pluginSection = make('section', 'dsh-tw-settings-section')
|
|
121
|
+
pluginSection.append(make('h3', 'dsh-tw-settings-h', '插件管理(自带官方插件)'))
|
|
122
|
+
const pluginHint = make('div', 'dsh-tw-settings-muted', '勾选 = 写入 tiddlywiki.info 的启动安装清单(离线、与引擎版本配套),应用后重启 TW。这里只管引擎自带插件;在 TW 控制面板里安装/禁用/卸载的插件见下方「wiki 内插件」。')
|
|
123
|
+
pluginSection.append(pluginHint)
|
|
124
|
+
const search = make('input', 'dsh-tw-settings-input dsh-tw-settings-search')
|
|
125
|
+
search.placeholder = '搜索插件…'
|
|
126
|
+
const listWrap = make('div', 'dsh-tw-settings-list')
|
|
127
|
+
const applyPlugins = make('button', 'dsh-tw-settings-btn dsh-tw-settings-primary', '应用插件(重启 TW)')
|
|
128
|
+
applyPlugins.type = 'button'
|
|
129
|
+
/**
|
|
130
|
+
* 没碰过勾选时禁用「应用」(v0.26.1):`pending.plugins === undefined` = 跟随服务器,
|
|
131
|
+
* 提交没有意义;把它当成「空期望集合」误提交就是清空整个启动清单。勾选变化时重算。
|
|
132
|
+
*/
|
|
133
|
+
const syncApplyPlugins = (): void => {
|
|
134
|
+
applyPlugins.disabled = pending.plugins === undefined
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const renderPluginList = (needle: string): void => {
|
|
138
|
+
listWrap.replaceChildren()
|
|
139
|
+
const q = needle.toLowerCase()
|
|
140
|
+
for (const plugin of plugins) {
|
|
141
|
+
if (q.length > 0 && !`${plugin.label} ${plugin.name} ${plugin.description}`.toLowerCase().includes(q)) continue
|
|
142
|
+
const input = make('input', 'dsh-tw-settings-check')
|
|
143
|
+
input.type = 'checkbox'
|
|
144
|
+
input.checked = desiredPlugins().has(plugin.name)
|
|
145
|
+
input.addEventListener('change', () => {
|
|
146
|
+
const next = desiredPlugins()
|
|
147
|
+
if (input.checked) next.add(plugin.name)
|
|
148
|
+
else next.delete(plugin.name)
|
|
149
|
+
pending.plugins = next
|
|
150
|
+
syncApplyPlugins()
|
|
151
|
+
})
|
|
152
|
+
const label = make('label', 'dsh-tw-settings-row dsh-tw-settings-plugin')
|
|
153
|
+
const name = make('span', 'dsh-tw-settings-name', plugin.label)
|
|
154
|
+
name.title = plugin.name
|
|
155
|
+
const desc = make('span', 'dsh-tw-settings-muted', plugin.description || plugin.name)
|
|
156
|
+
label.append(input, name)
|
|
157
|
+
// 状态徽标(v0.26.0):勾选只反映 tiddlywiki.info,运行时真相反映在这里。
|
|
158
|
+
// ① 已在启动清单(勾选)但被 TW 禁用;② 不在启动清单但 wiki 内有同名 tiddler
|
|
159
|
+
// 版本(TW 原生装的)。两种都常见,且不显示就会让用户误判「没装成功」。
|
|
160
|
+
// 判据取**服务器集合**而不是 desiredPlugins():徽标描述的是当前事实,
|
|
161
|
+
// 不该随「还没应用的勾选」抖动(v0.26.1)。
|
|
162
|
+
if (runtimePlugins != null && disabledTitles.has(plugin.title)) {
|
|
163
|
+
const badge = make('span', 'dsh-tw-settings-chip', 'TW 内已禁用')
|
|
164
|
+
badge.dataset.state = 'disabled'
|
|
165
|
+
badge.title = '该插件在 tiddlywiki.info 里,但已在 TW 控制面板被禁用($:/config/Plugins/Disabled)——去 TW 面板重新启用'
|
|
166
|
+
label.append(badge)
|
|
167
|
+
} else if (runtimePlugins != null && !serverPlugins.includes(plugin.name) && wikiTitles.has(plugin.title)) {
|
|
168
|
+
const badge = make('span', 'dsh-tw-settings-chip', 'wiki 内已装')
|
|
169
|
+
badge.dataset.state = 'update'
|
|
170
|
+
badge.title = '不在启动清单,但 wiki 里存在同名插件 tiddler(经 TW 原生安装),TW 运行时已在用'
|
|
171
|
+
label.append(badge)
|
|
172
|
+
}
|
|
173
|
+
label.append(desc)
|
|
174
|
+
listWrap.append(label)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
search.addEventListener('input', () => renderPluginList(search.value))
|
|
178
|
+
applyPlugins.addEventListener('click', () => {
|
|
179
|
+
if (pending.plugins === undefined) return
|
|
180
|
+
applyPlugins.disabled = true
|
|
181
|
+
void (async () => {
|
|
182
|
+
try {
|
|
183
|
+
await fetchJson(withWiki(INFO_ENDPOINT), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ plugins: [...desiredPlugins()] }) })
|
|
184
|
+
// 应用成功才清 pending:之后重新跟随服务器(TW 重启后 info 会给出真实集合)。
|
|
185
|
+
pending.plugins = undefined
|
|
186
|
+
toast('插件已应用,TW 已重启')
|
|
187
|
+
void refresh()
|
|
188
|
+
} catch (err) {
|
|
189
|
+
toast(`应用失败:${err instanceof Error ? err.message : String(err)}`)
|
|
190
|
+
syncApplyPlugins()
|
|
191
|
+
}
|
|
192
|
+
})()
|
|
193
|
+
})
|
|
194
|
+
renderPluginList('')
|
|
195
|
+
syncApplyPlugins()
|
|
196
|
+
pluginSection.append(search, listWrap, applyPlugins)
|
|
197
|
+
body.append(pluginSection)
|
|
198
|
+
|
|
199
|
+
// ── wiki-installed plugins (read-only mirror of TW's own management) ─────
|
|
200
|
+
// v0.26.0:这些插件以 tiddler 形式存在 wiki 里(TW 原生插件库/导入装的),
|
|
201
|
+
// tiddlywiki.info 与上面的勾选都不覆盖它们——不列出来,用户就会像作者本人
|
|
202
|
+
// 一样疑惑「TW 里明明启用着,这里怎么没有」。只读:启停/卸载去 TW 控制面板。
|
|
203
|
+
if (runtimePlugins != null) {
|
|
204
|
+
const catalogTitles = new Set(plugins.map((p) => p.title))
|
|
205
|
+
const externals = (runtimePlugins.wikiPlugins ?? []).filter((p) => !catalogTitles.has(p.title) || disabledTitles.has(p.title))
|
|
206
|
+
if (externals.length > 0) {
|
|
207
|
+
const wikiSection = make('section', 'dsh-tw-settings-section')
|
|
208
|
+
wikiSection.append(make('h3', 'dsh-tw-settings-h', 'wiki 内插件(经 TW 原生安装,只读)'))
|
|
209
|
+
wikiSection.append(
|
|
210
|
+
make(
|
|
211
|
+
'div',
|
|
212
|
+
'dsh-tw-settings-muted',
|
|
213
|
+
'下列插件以 tiddler 形式随 wiki 文件保存(TW 控制面板 → 插件 安装/导入),不受上面勾选影响。启用 / 禁用 / 卸载请到 TW 控制面板操作。',
|
|
214
|
+
),
|
|
215
|
+
)
|
|
216
|
+
const wikiWrap = make('div', 'dsh-tw-settings-list')
|
|
217
|
+
for (const plugin of externals) {
|
|
218
|
+
const row = make('div', 'dsh-tw-settings-row dsh-tw-settings-plugin')
|
|
219
|
+
const name = make('span', 'dsh-tw-settings-name', plugin.name || plugin.title)
|
|
220
|
+
name.title = plugin.title
|
|
221
|
+
row.append(name)
|
|
222
|
+
if (disabledTitles.has(plugin.title)) {
|
|
223
|
+
const badge = make('span', 'dsh-tw-settings-chip', '已禁用')
|
|
224
|
+
badge.dataset.state = 'disabled'
|
|
225
|
+
badge.title = '该插件已在 TW 控制面板被禁用'
|
|
226
|
+
row.append(badge)
|
|
227
|
+
} else if (plugin.version) {
|
|
228
|
+
const ver = make('span', 'dsh-tw-settings-chip', `v${plugin.version}`)
|
|
229
|
+
ver.dataset.state = 'ok'
|
|
230
|
+
row.append(ver)
|
|
231
|
+
}
|
|
232
|
+
const desc = make('span', 'dsh-tw-settings-muted', plugin.description || plugin.title)
|
|
233
|
+
desc.title = plugin.title
|
|
234
|
+
row.append(desc)
|
|
235
|
+
wikiWrap.append(row)
|
|
236
|
+
}
|
|
237
|
+
wikiSection.append(wikiWrap)
|
|
238
|
+
body.append(wikiSection)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── themes ───────────────────────────────────────────────────────────────
|
|
243
|
+
// `info.themes` = WHICH theme plugins are LOADED (multi-select; TW's own
|
|
244
|
+
// default is [vanilla, snowwhite]); `$:/theme` = the ACTIVE one (single).
|
|
245
|
+
// Two controls per theme: ☑ 加载 (multi) + ◉ 活动 (single, auto-added to the
|
|
246
|
+
// loaded set on apply). The host computes the dependency closure (heavier →
|
|
247
|
+
// vanilla+snowwhite+heavier) and writes $:/theme.
|
|
248
|
+
const themeSection = make('section', 'dsh-tw-settings-section')
|
|
249
|
+
themeSection.append(make('h3', 'dsh-tw-settings-h', '主题管理(自带主题)'))
|
|
250
|
+
const themeHint = make('div', 'dsh-tw-settings-muted', '「加载」= TW 里可用的主题(可多选,依赖链自动带上,如 heavier 会带 snowwhite+vanilla);「活动」= 当前视觉主题(单选,自动加入加载集)。应用后重启 TW。')
|
|
251
|
+
const themeHead = make('div', 'dsh-tw-settings-row dsh-tw-settings-head')
|
|
252
|
+
themeHead.append(
|
|
253
|
+
make('span', 'dsh-tw-settings-col', '加载'),
|
|
254
|
+
make('span', 'dsh-tw-settings-col', '活动'),
|
|
255
|
+
make('span', 'dsh-tw-settings-name', '主题'),
|
|
256
|
+
)
|
|
257
|
+
const themeList = info?.themes ?? []
|
|
258
|
+
// The ACTIVE theme comes from the host (`$:/theme`, v0.22.3). Deriving it from
|
|
259
|
+
// the last loaded entry was a guess: with an explicitly activated non-last
|
|
260
|
+
// theme the wrong radio showed up checked, and one click on 应用主题 (without
|
|
261
|
+
// touching the radios) rewrote `$:/theme` to that wrong pick. Guessing stays
|
|
262
|
+
// as the fallback only when the host cannot tell (wiki down / no `$:/theme`).
|
|
263
|
+
const activeFromServer = info?.themeActive
|
|
264
|
+
const lastLoadedTheme = themeList[themeList.length - 1]
|
|
265
|
+
const serverActiveThemeName =
|
|
266
|
+
typeof activeFromServer === 'string' && themes.some((theme) => theme.name === activeFromServer)
|
|
267
|
+
? activeFromServer
|
|
268
|
+
: lastLoadedTheme ?? 'tiddlywiki/vanilla'
|
|
269
|
+
/** 活动主题同样走 pending:单选按钮的未应用选择也要活过 refresh(见 CatalogPending)。 */
|
|
270
|
+
const activeThemeName = (): string => pending.themeActive ?? serverActiveThemeName
|
|
271
|
+
const applyThemes = make('button', 'dsh-tw-settings-btn dsh-tw-settings-primary', '应用主题(重启 TW)')
|
|
272
|
+
applyThemes.type = 'button'
|
|
273
|
+
/**
|
|
274
|
+
* 没碰过主题的任何控件时禁用「应用」(v0.26.1,同插件管理):`pending.* === undefined`
|
|
275
|
+
* 才是「跟随服务器」;未碰过就提交会把「空期望集合」写成 `themes: []`,把主题清空。
|
|
276
|
+
*/
|
|
277
|
+
const syncApplyThemes = (): void => {
|
|
278
|
+
applyThemes.disabled = pending.themes === undefined && pending.themeActive === undefined
|
|
279
|
+
}
|
|
280
|
+
const themeWrap = make('div', 'dsh-tw-settings-list')
|
|
281
|
+
for (const theme of themes) {
|
|
282
|
+
const load = make('input', 'dsh-tw-settings-check')
|
|
283
|
+
load.type = 'checkbox'
|
|
284
|
+
load.checked = desiredThemes().has(theme.name)
|
|
285
|
+
load.title = '加载该主题'
|
|
286
|
+
load.addEventListener('change', () => {
|
|
287
|
+
const next = desiredThemes()
|
|
288
|
+
if (load.checked) next.add(theme.name)
|
|
289
|
+
else next.delete(theme.name)
|
|
290
|
+
pending.themes = next
|
|
291
|
+
syncApplyThemes()
|
|
292
|
+
})
|
|
293
|
+
const act = make('input', 'dsh-tw-settings-check')
|
|
294
|
+
act.type = 'radio'
|
|
295
|
+
act.name = 'dsh-tw-active-theme'
|
|
296
|
+
act.checked = theme.name === activeThemeName()
|
|
297
|
+
act.title = '设为活动主题'
|
|
298
|
+
act.addEventListener('change', () => {
|
|
299
|
+
if (act.checked) pending.themeActive = theme.name
|
|
300
|
+
syncApplyThemes()
|
|
301
|
+
})
|
|
302
|
+
const name = make('span', 'dsh-tw-settings-name', theme.label)
|
|
303
|
+
name.title = theme.name
|
|
304
|
+
const desc = make('span', 'dsh-tw-settings-muted', theme.description || theme.name)
|
|
305
|
+
const row = make('div', 'dsh-tw-settings-row dsh-tw-settings-plugin')
|
|
306
|
+
row.append(load, act, name, desc)
|
|
307
|
+
themeWrap.append(row)
|
|
308
|
+
}
|
|
309
|
+
applyThemes.addEventListener('click', () => {
|
|
310
|
+
if (pending.themes === undefined && pending.themeActive === undefined) return
|
|
311
|
+
applyThemes.disabled = true
|
|
312
|
+
void (async () => {
|
|
313
|
+
try {
|
|
314
|
+
await fetchJson(withWiki(INFO_ENDPOINT), {
|
|
315
|
+
method: 'POST',
|
|
316
|
+
headers: { 'content-type': 'application/json' },
|
|
317
|
+
body: JSON.stringify({ themes: [...desiredThemes()], themeActive: activeThemeName() }),
|
|
318
|
+
})
|
|
319
|
+
// 应用成功才清 pending(同插件管理)。
|
|
320
|
+
pending.themes = undefined
|
|
321
|
+
pending.themeActive = undefined
|
|
322
|
+
toast('主题已应用,TW 已重启')
|
|
323
|
+
void refresh()
|
|
324
|
+
} catch (err) {
|
|
325
|
+
toast(`应用失败:${err instanceof Error ? err.message : String(err)}`)
|
|
326
|
+
syncApplyThemes()
|
|
327
|
+
}
|
|
328
|
+
})()
|
|
329
|
+
})
|
|
330
|
+
syncApplyThemes()
|
|
331
|
+
themeSection.append(themeHint, themeHead, themeWrap, applyThemes)
|
|
332
|
+
body.append(themeSection)
|
|
333
|
+
|
|
334
|
+
// ── languages (bundled, offline — enable → restart TW) ──────────────────
|
|
335
|
+
const langSection = make('section', 'dsh-tw-settings-section')
|
|
336
|
+
langSection.append(make('h3', 'dsh-tw-settings-h', '语言管理(自带官方语言包)'))
|
|
337
|
+
const langHint = make('div', 'dsh-tw-settings-muted', '勾选启用语言插件并重启 TW;如中文请选 zh-Hans(简体)或 zh-CN。')
|
|
338
|
+
const langWrap = make('div', 'dsh-tw-settings-list')
|
|
339
|
+
const applyLangs = make('button', 'dsh-tw-settings-btn dsh-tw-settings-primary', '应用语言(重启 TW)')
|
|
340
|
+
applyLangs.type = 'button'
|
|
341
|
+
/** 没碰过语言勾选时禁用「应用」(v0.26.1,同插件管理):未碰过 ≠ 期望集合为空。 */
|
|
342
|
+
const syncApplyLangs = (): void => {
|
|
343
|
+
applyLangs.disabled = pending.languages === undefined
|
|
344
|
+
}
|
|
345
|
+
for (const lang of languages) {
|
|
346
|
+
const input = make('input', 'dsh-tw-settings-check')
|
|
347
|
+
input.type = 'checkbox'
|
|
348
|
+
input.checked = desiredLanguages().has(lang.name)
|
|
349
|
+
input.addEventListener('change', () => {
|
|
350
|
+
const next = desiredLanguages()
|
|
351
|
+
if (input.checked) next.add(lang.name)
|
|
352
|
+
else next.delete(lang.name)
|
|
353
|
+
pending.languages = next
|
|
354
|
+
syncApplyLangs()
|
|
355
|
+
})
|
|
356
|
+
const label = make('label', 'dsh-tw-settings-row dsh-tw-settings-plugin')
|
|
357
|
+
const name = make('span', 'dsh-tw-settings-name', lang.label)
|
|
358
|
+
name.title = lang.name
|
|
359
|
+
const desc = make('span', 'dsh-tw-settings-muted', lang.description || lang.name)
|
|
360
|
+
label.append(input, name, desc)
|
|
361
|
+
langWrap.append(label)
|
|
362
|
+
}
|
|
363
|
+
applyLangs.addEventListener('click', () => {
|
|
364
|
+
if (pending.languages === undefined) return
|
|
365
|
+
applyLangs.disabled = true
|
|
366
|
+
void (async () => {
|
|
367
|
+
try {
|
|
368
|
+
await fetchJson(withWiki(INFO_ENDPOINT), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ languages: [...desiredLanguages()] }) })
|
|
369
|
+
pending.languages = undefined
|
|
370
|
+
toast('语言已应用,TW 已重启')
|
|
371
|
+
void refresh()
|
|
372
|
+
} catch (err) {
|
|
373
|
+
toast(`应用失败:${err instanceof Error ? err.message : String(err)}`)
|
|
374
|
+
syncApplyLangs()
|
|
375
|
+
}
|
|
376
|
+
})()
|
|
377
|
+
})
|
|
378
|
+
syncApplyLangs()
|
|
379
|
+
langSection.append(langHint, langWrap, applyLangs)
|
|
380
|
+
body.append(langSection)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* 初始化 section: lists every one-time seed with its live status and offers
|
|
385
|
+
* per item (and for all):
|
|
386
|
+
* - 「重新初始化」(force) — write/restore the built-in content;
|
|
387
|
+
* - 「反初始化」(remove, optional seeds only) — delete the seeded
|
|
388
|
+
* tiddlers + markers, returning the wiki to the "never seeded" state.
|
|
389
|
+
* Core seeds (发送给 Agent 按钮 / TW 前端 API 基址) are 功能必需: auto-seeded
|
|
390
|
+
* on startup and never removable. Optional seeds are opt-in — never forced.
|
|
391
|
+
*/
|
|
392
|
+
export function renderSeedsSection(body: HTMLElement, isDisposed: () => boolean): void {
|
|
393
|
+
const section = make('section', 'dsh-tw-settings-section')
|
|
394
|
+
section.append(make('h3', 'dsh-tw-settings-h', '初始化(一次性预置)'))
|
|
395
|
+
const hint = make('div', 'dsh-tw-settings-muted', '这些是插件与 dsh 联动、需要在 wiki 里预置的 tiddler/配置。「发送给 Agent 按钮」和「TW 前端 API 基址」是功能必需项,启动时自动写入(只写缺失,不覆盖你的改动);其余为可选项,默认不自动写入、也不会强绑定——需要时点「重新初始化」写入,不想要了可随时「反初始化」移除。')
|
|
396
|
+
const wrap = make('div', 'dsh-tw-settings-list')
|
|
397
|
+
const statusLine = make('div', 'dsh-tw-settings-muted')
|
|
398
|
+
|
|
399
|
+
const load = async (): Promise<void> => {
|
|
400
|
+
// 与 refresh() 相同的卸载守卫:本函数由组件挂载时触发、也可能在卸载后由
|
|
401
|
+
// 按钮回调的 await 之后调用,晚到的响应不得再 wrap.replaceChildren()。
|
|
402
|
+
if (isDisposed()) return
|
|
403
|
+
try {
|
|
404
|
+
const data = await fetchJson<{ ok?: boolean; items?: SeedItem[]; error?: string }>(withWiki(SEEDS_ENDPOINT))
|
|
405
|
+
if (isDisposed()) return
|
|
406
|
+
if (data.ok !== true || !Array.isArray(data.items)) throw new Error(data.error ?? '获取失败')
|
|
407
|
+
wrap.replaceChildren()
|
|
408
|
+
let presentCount = 0
|
|
409
|
+
let removableCount = 0
|
|
410
|
+
for (const item of data.items) {
|
|
411
|
+
if (item.present) presentCount += 1
|
|
412
|
+
if (item.removable) removableCount += 1
|
|
413
|
+
const dot = make('span', 'dsh-tw-settings-chip', item.present ? '✓' : '✗')
|
|
414
|
+
dot.dataset.state = item.present ? 'ok' : 'missing'
|
|
415
|
+
dot.title = item.present ? '已存在' : '缺失'
|
|
416
|
+
const title = make('span', 'dsh-tw-settings-name', item.title)
|
|
417
|
+
title.title = item.id
|
|
418
|
+
const desc = make('span', 'dsh-tw-settings-muted', item.detail ?? item.description)
|
|
419
|
+
// v0.22.0: the marker records content hashes, so we can say whether the
|
|
420
|
+
// BUILT-IN moved on and whether the user edited their copy — and warn
|
|
421
|
+
// before a 「重新初始化」 overwrites local edits.
|
|
422
|
+
const updateChip = item.updateAvailable === true
|
|
423
|
+
? make('span', 'dsh-tw-settings-chip', '⬆ 有更新')
|
|
424
|
+
: undefined
|
|
425
|
+
if (updateChip !== undefined) {
|
|
426
|
+
updateChip.dataset.state = 'update'
|
|
427
|
+
updateChip.title = '内置内容在本 wiki 预置之后更新过,可点「重新初始化」取用'
|
|
428
|
+
}
|
|
429
|
+
const modifiedChip = item.userModified === true
|
|
430
|
+
? make('span', 'dsh-tw-settings-chip', '✏️ 本地已修改')
|
|
431
|
+
: undefined
|
|
432
|
+
if (modifiedChip !== undefined) {
|
|
433
|
+
modifiedChip.dataset.state = 'missing'
|
|
434
|
+
modifiedChip.title = '这篇是你的内容:重新初始化会覆盖它'
|
|
435
|
+
}
|
|
436
|
+
const btn = make('button', 'dsh-tw-settings-btn', item.updateAvailable === true ? '更新到内置版本' : '重新初始化')
|
|
437
|
+
btn.type = 'button'
|
|
438
|
+
btn.title = `强制重写「${item.title}」的内置内容(会覆盖当前 tiddler)`
|
|
439
|
+
btn.addEventListener('click', () => {
|
|
440
|
+
if (item.updateAvailable === true) {
|
|
441
|
+
const warning = item.userModified === true
|
|
442
|
+
? `「${item.title}」有你的本地修改,更新会覆盖它。`
|
|
443
|
+
: item.userModified === undefined
|
|
444
|
+
? `无法确认「${item.title}」是否被你编辑过(旧格式标记),更新可能覆盖你的改动。`
|
|
445
|
+
: ''
|
|
446
|
+
if (!window.confirm(`${warning}${warning.length > 0 ? '\n\n' : ''}用内置版本覆盖?`)) return
|
|
447
|
+
}
|
|
448
|
+
btn.disabled = true
|
|
449
|
+
btn.textContent = '执行中…'
|
|
450
|
+
void (async () => {
|
|
451
|
+
try {
|
|
452
|
+
const res = await fetch(withWiki(SEEDS_RUN_ENDPOINT), {
|
|
453
|
+
method: 'POST',
|
|
454
|
+
headers: { 'content-type': 'application/json' },
|
|
455
|
+
body: JSON.stringify({ id: item.id, force: true }),
|
|
456
|
+
signal: AbortSignal.timeout(30_000),
|
|
457
|
+
})
|
|
458
|
+
const payload = (await res.json().catch(() => null)) as { ok?: boolean; results?: Array<{ id?: string; detail?: string; error?: string }>; error?: string } | null
|
|
459
|
+
if (!res.ok || payload?.ok !== true) {
|
|
460
|
+
toast(`重新初始化失败:${payload?.error ?? payload?.results?.[0]?.error ?? `HTTP ${res.status}`}`)
|
|
461
|
+
} else {
|
|
462
|
+
toast(`已重新初始化「${item.title}」`)
|
|
463
|
+
}
|
|
464
|
+
void load()
|
|
465
|
+
} catch (err) {
|
|
466
|
+
toast(`重新初始化失败:${err instanceof Error ? err.message : String(err)}`)
|
|
467
|
+
} finally {
|
|
468
|
+
btn.disabled = false
|
|
469
|
+
btn.textContent = item.updateAvailable === true ? '更新到内置版本' : '重新初始化'
|
|
470
|
+
}
|
|
471
|
+
})()
|
|
472
|
+
})
|
|
473
|
+
const row = make('div', 'dsh-tw-settings-row dsh-tw-settings-plugin')
|
|
474
|
+
row.append(dot, title)
|
|
475
|
+
if (updateChip !== undefined) row.append(updateChip)
|
|
476
|
+
if (modifiedChip !== undefined) row.append(modifiedChip)
|
|
477
|
+
row.append(desc, btn)
|
|
478
|
+
if (item.removable) {
|
|
479
|
+
const rm = make('button', 'dsh-tw-settings-btn dsh-tw-settings-danger', '反初始化')
|
|
480
|
+
rm.type = 'button'
|
|
481
|
+
rm.title = `移除「${item.title}」写入的 tiddler 与 marker(恢复未初始化状态)`
|
|
482
|
+
rm.addEventListener('click', () => {
|
|
483
|
+
if (!window.confirm(`确定反初始化「${item.title}」?将删除它写入的 tiddler(含其一次性 marker),需要时可用「重新初始化」恢复。`)) return
|
|
484
|
+
rm.disabled = true
|
|
485
|
+
rm.textContent = '移除中…'
|
|
486
|
+
void (async () => {
|
|
487
|
+
try {
|
|
488
|
+
const res = await fetch(withWiki(SEEDS_REMOVE_ENDPOINT), {
|
|
489
|
+
method: 'POST',
|
|
490
|
+
headers: { 'content-type': 'application/json' },
|
|
491
|
+
body: JSON.stringify({ id: item.id }),
|
|
492
|
+
signal: AbortSignal.timeout(30_000),
|
|
493
|
+
})
|
|
494
|
+
const payload = (await res.json().catch(() => null)) as { ok?: boolean; results?: Array<{ id?: string; detail?: string; error?: string }>; error?: string } | null
|
|
495
|
+
if (!res.ok || payload?.ok !== true) {
|
|
496
|
+
toast(`反初始化失败:${payload?.error ?? payload?.results?.[0]?.error ?? `HTTP ${res.status}`}`)
|
|
497
|
+
} else {
|
|
498
|
+
toast(`已反初始化「${item.title}」`)
|
|
499
|
+
}
|
|
500
|
+
void load()
|
|
501
|
+
} catch (err) {
|
|
502
|
+
toast(`反初始化失败:${err instanceof Error ? err.message : String(err)}`)
|
|
503
|
+
} finally {
|
|
504
|
+
rm.disabled = false
|
|
505
|
+
rm.textContent = '反初始化'
|
|
506
|
+
}
|
|
507
|
+
})()
|
|
508
|
+
})
|
|
509
|
+
row.append(rm)
|
|
510
|
+
}
|
|
511
|
+
wrap.append(row)
|
|
512
|
+
}
|
|
513
|
+
statusLine.textContent = `共 ${data.items.length} 项,${presentCount} 项已就绪;可移除 ${removableCount} 项`
|
|
514
|
+
} catch (err) {
|
|
515
|
+
if (isDisposed()) return
|
|
516
|
+
statusLine.textContent = `加载初始化状态失败:${err instanceof Error ? err.message : String(err)}`
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const runAll = make('button', 'dsh-tw-settings-btn dsh-tw-settings-primary', '全部重新初始化')
|
|
521
|
+
runAll.type = 'button'
|
|
522
|
+
runAll.title = '强制重写所有一次性预置内容(含可选 seed,会覆盖当前 tiddler/配置)'
|
|
523
|
+
runAll.addEventListener('click', () => {
|
|
524
|
+
runAll.disabled = true
|
|
525
|
+
runAll.textContent = '执行中…'
|
|
526
|
+
void (async () => {
|
|
527
|
+
try {
|
|
528
|
+
const res = await fetch(withWiki(SEEDS_RUN_ENDPOINT), {
|
|
529
|
+
method: 'POST',
|
|
530
|
+
headers: { 'content-type': 'application/json' },
|
|
531
|
+
body: JSON.stringify({ force: true }),
|
|
532
|
+
signal: AbortSignal.timeout(60_000),
|
|
533
|
+
})
|
|
534
|
+
const payload = (await res.json().catch(() => null)) as { ok?: boolean; error?: string } | null
|
|
535
|
+
if (!res.ok || payload?.ok !== true) toast(`重新初始化失败:${payload?.error ?? `HTTP ${res.status}`}`)
|
|
536
|
+
else toast('全部已重新初始化')
|
|
537
|
+
void load()
|
|
538
|
+
} catch (err) {
|
|
539
|
+
toast(`重新初始化失败:${err instanceof Error ? err.message : String(err)}`)
|
|
540
|
+
} finally {
|
|
541
|
+
runAll.disabled = false
|
|
542
|
+
runAll.textContent = '全部重新初始化'
|
|
543
|
+
}
|
|
544
|
+
})()
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
const removeAll = make('button', 'dsh-tw-settings-btn dsh-tw-settings-danger', '全部反初始化')
|
|
548
|
+
removeAll.type = 'button'
|
|
549
|
+
removeAll.title = '移除所有可选 seed 写入的 tiddler 与 marker(功能必需项保留)'
|
|
550
|
+
removeAll.addEventListener('click', () => {
|
|
551
|
+
if (!window.confirm('确定全部反初始化?将删除所有可选 seed 写入的 tiddler(含一次性 marker)。功能必需项(发送给 Agent 按钮 / TW 前端 API 基址)会保留。')) return
|
|
552
|
+
removeAll.disabled = true
|
|
553
|
+
removeAll.textContent = '移除中…'
|
|
554
|
+
void (async () => {
|
|
555
|
+
try {
|
|
556
|
+
const res = await fetch(withWiki(SEEDS_REMOVE_ENDPOINT), {
|
|
557
|
+
method: 'POST',
|
|
558
|
+
headers: { 'content-type': 'application/json' },
|
|
559
|
+
body: JSON.stringify({}),
|
|
560
|
+
signal: AbortSignal.timeout(60_000),
|
|
561
|
+
})
|
|
562
|
+
const payload = (await res.json().catch(() => null)) as { ok?: boolean; error?: string } | null
|
|
563
|
+
if (!res.ok || payload?.ok !== true) toast(`反初始化失败:${payload?.error ?? `HTTP ${res.status}`}`)
|
|
564
|
+
else toast('全部可选 seed 已反初始化')
|
|
565
|
+
void load()
|
|
566
|
+
} catch (err) {
|
|
567
|
+
toast(`反初始化失败:${err instanceof Error ? err.message : String(err)}`)
|
|
568
|
+
} finally {
|
|
569
|
+
removeAll.disabled = false
|
|
570
|
+
removeAll.textContent = '全部反初始化'
|
|
571
|
+
}
|
|
572
|
+
})()
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
section.append(hint, statusLine, wrap, runAll, removeAll)
|
|
576
|
+
body.append(section)
|
|
577
|
+
void load()
|
|
578
|
+
}
|