dsh-tiddlywiki 0.1.0
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/LICENSE +21 -0
- package/README.md +172 -0
- package/cordis.patch.yml +13 -0
- package/lib/client.bundle.js +1417 -0
- package/lib/client.js +1427 -0
- package/lib/index.js +2153 -0
- package/lib/index.js.map +1 -0
- package/package.json +63 -0
- package/src/client/editor-popup.ts +121 -0
- package/src/client/index.ts +76 -0
- package/src/client/note-widget.ts +210 -0
- package/src/client/panel.ts +304 -0
- package/src/client/settings-page.ts +397 -0
- package/src/client/sidebar-entry.ts +148 -0
- package/src/client/state.ts +39 -0
- package/src/client/styles.ts +235 -0
- package/src/client/toast.ts +22 -0
- package/src/host/admin.ts +408 -0
- package/src/host/config.ts +86 -0
- package/src/host/git.ts +218 -0
- package/src/host/routes.ts +233 -0
- package/src/host/seed-notes.ts +62 -0
- package/src/host/tools.ts +254 -0
- package/src/host/tw-api.ts +157 -0
- package/src/host/wiki.ts +287 -0
- package/src/index.ts +331 -0
- package/src/sdk.ts +198 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings-page half (design doc §13, config panel): a pure-DOM page mounted
|
|
3
|
+
* inside a `settings.section` React wrapper. Everything talks to the host
|
|
4
|
+
* admin routes — same-origin JSON, no client services beyond `slots`:
|
|
5
|
+
*
|
|
6
|
+
* GET /dsh-tiddlywiki/admin/state current info + catalog + config
|
|
7
|
+
* POST /dsh-tiddlywiki/admin/info { plugins?, themes? } → restart TW
|
|
8
|
+
* POST /dsh-tiddlywiki/admin/config { ...patch } → persist
|
|
9
|
+
* POST /dsh-tiddlywiki/admin/restart restart the TW child
|
|
10
|
+
*
|
|
11
|
+
* Sections:
|
|
12
|
+
* 1. 状态/重启 TW 运行状态 + git 概览 + 重启按钮
|
|
13
|
+
* 2. 常规配置 note.tag / git.* / uiLanguage(改了什么保存什么)
|
|
14
|
+
* 3. 插件管理 自带官方插件勾选(可搜索)→ 应用并重启 TW
|
|
15
|
+
* 4. 主题管理 自带主题单选 → 应用并重启 TW
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-tiddlywiki/client/settings-page
|
|
18
|
+
*/
|
|
19
|
+
import * as React from 'react'
|
|
20
|
+
import { toast } from './toast.ts'
|
|
21
|
+
|
|
22
|
+
const STATE_ENDPOINT = '/dsh-tiddlywiki/admin/state'
|
|
23
|
+
const INFO_ENDPOINT = '/dsh-tiddlywiki/admin/info'
|
|
24
|
+
const CONFIG_ENDPOINT = '/dsh-tiddlywiki/admin/config'
|
|
25
|
+
const RESTART_ENDPOINT = '/dsh-tiddlywiki/admin/restart'
|
|
26
|
+
|
|
27
|
+
interface CatalogEntry {
|
|
28
|
+
name: string
|
|
29
|
+
title: string
|
|
30
|
+
label: string
|
|
31
|
+
description: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface AdminState {
|
|
35
|
+
ok?: boolean
|
|
36
|
+
server?: { status?: string; url?: string; wikiPath?: string; error?: string }
|
|
37
|
+
info?: { plugins?: string[]; themes?: string[]; languages?: string[] }
|
|
38
|
+
catalog?: { plugins?: CatalogEntry[]; themes?: CatalogEntry[]; languages?: CatalogEntry[] }
|
|
39
|
+
config?: Record<string, unknown>
|
|
40
|
+
git?: { exists?: boolean; branch?: string; dirty?: boolean; lastCommit?: string; remote?: string } | null
|
|
41
|
+
error?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function make<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string): HTMLElementTagNameMap[K] {
|
|
45
|
+
const node = document.createElement(tag)
|
|
46
|
+
if (className !== undefined) node.className = className
|
|
47
|
+
if (text !== undefined) node.textContent = text
|
|
48
|
+
return node
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
|
52
|
+
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(15_000) })
|
|
53
|
+
const data = (await res.json().catch(() => ({}))) as T
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
const err = (data as { error?: string }).error ?? `HTTP ${res.status}`
|
|
56
|
+
throw new Error(err)
|
|
57
|
+
}
|
|
58
|
+
return data
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Form controls registry for the config section (changed-only patch). */
|
|
62
|
+
interface ConfigField {
|
|
63
|
+
key: string
|
|
64
|
+
input: HTMLInputElement
|
|
65
|
+
initial: string | boolean | number
|
|
66
|
+
read: () => string | boolean | number
|
|
67
|
+
changed: () => boolean
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function mountSettingsPage(container: HTMLElement): () => void {
|
|
71
|
+
let disposed = false
|
|
72
|
+
container.classList.add('dsh-tw-settings')
|
|
73
|
+
|
|
74
|
+
const statusRow = make('div', 'dsh-tw-settings-row dsh-tw-settings-status')
|
|
75
|
+
const body = make('div', 'dsh-tw-settings-body')
|
|
76
|
+
container.append(statusRow, body)
|
|
77
|
+
|
|
78
|
+
const disposers: Array<() => void> = []
|
|
79
|
+
|
|
80
|
+
const refresh = async (): Promise<void> => {
|
|
81
|
+
try {
|
|
82
|
+
const state = await fetchJson<AdminState>(STATE_ENDPOINT)
|
|
83
|
+
if (disposed) return
|
|
84
|
+
renderStatus(statusRow, state, refresh)
|
|
85
|
+
renderMain(body, state, refresh)
|
|
86
|
+
} catch (err) {
|
|
87
|
+
if (disposed) return
|
|
88
|
+
body.replaceChildren()
|
|
89
|
+
statusRow.replaceChildren()
|
|
90
|
+
const msg = make('div', 'dsh-tw-settings-error', `加载配置失败:${err instanceof Error ? err.message : String(err)}`)
|
|
91
|
+
const retry = make('button', 'dsh-tw-settings-btn', '重试')
|
|
92
|
+
retry.type = 'button'
|
|
93
|
+
retry.addEventListener('click', () => { void refresh() })
|
|
94
|
+
body.append(msg, retry)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
void refresh()
|
|
99
|
+
disposers.push(() => {
|
|
100
|
+
disposed = true
|
|
101
|
+
container.replaceChildren()
|
|
102
|
+
container.classList.remove('dsh-tw-settings')
|
|
103
|
+
})
|
|
104
|
+
return () => {
|
|
105
|
+
for (const dispose of disposers.splice(0)) dispose()
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function renderStatus(row: HTMLElement, state: AdminState, refresh: () => Promise<void>): void {
|
|
110
|
+
row.replaceChildren()
|
|
111
|
+
const server = state.server ?? {}
|
|
112
|
+
const status = server.status ?? 'unknown'
|
|
113
|
+
const chip = make('span', `dsh-tw-settings-chip`, status)
|
|
114
|
+
chip.dataset.state = status
|
|
115
|
+
const info = [
|
|
116
|
+
server.url !== undefined ? `TW ${server.url}` : '',
|
|
117
|
+
state.git?.branch !== undefined ? `git ${state.git.branch}` : '',
|
|
118
|
+
state.git?.lastCommit !== undefined ? state.git.lastCommit : '',
|
|
119
|
+
state.git?.dirty === true ? '有未提交改动' : '',
|
|
120
|
+
].filter(Boolean).join(' · ')
|
|
121
|
+
const label = make('span', 'dsh-tw-settings-muted', info)
|
|
122
|
+
const restart = make('button', 'dsh-tw-settings-btn', '重启 TW')
|
|
123
|
+
restart.type = 'button'
|
|
124
|
+
restart.addEventListener('click', () => {
|
|
125
|
+
restart.disabled = true
|
|
126
|
+
restart.textContent = '重启中…'
|
|
127
|
+
void (async () => {
|
|
128
|
+
try {
|
|
129
|
+
await fetchJson(RESTART_ENDPOINT, { method: 'POST' })
|
|
130
|
+
toast('TW 已重启')
|
|
131
|
+
} catch (err) {
|
|
132
|
+
toast(`重启失败:${err instanceof Error ? err.message : String(err)}`)
|
|
133
|
+
} finally {
|
|
134
|
+
restart.disabled = false
|
|
135
|
+
restart.textContent = '重启 TW'
|
|
136
|
+
void refresh()
|
|
137
|
+
}
|
|
138
|
+
})()
|
|
139
|
+
})
|
|
140
|
+
row.append(chip, label, restart)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Config section: fields bound to effective config, changed-only save. */
|
|
144
|
+
function renderConfigSection(body: HTMLElement, config: Record<string, unknown>, refresh: () => Promise<void>): void {
|
|
145
|
+
const section = make('section', 'dsh-tw-settings-section')
|
|
146
|
+
section.append(make('h3', 'dsh-tw-settings-h', '常规配置'))
|
|
147
|
+
const note = (config.note ?? {}) as Record<string, unknown>
|
|
148
|
+
const git = (config.git ?? {}) as Record<string, unknown>
|
|
149
|
+
const fields: ConfigField[] = []
|
|
150
|
+
|
|
151
|
+
const textField = (key: string, label: string, initial: string): void => {
|
|
152
|
+
const input = make('input', 'dsh-tw-settings-input')
|
|
153
|
+
input.value = initial
|
|
154
|
+
const wrap = make('label', 'dsh-tw-settings-field')
|
|
155
|
+
wrap.append(make('span', 'dsh-tw-settings-label', label), input)
|
|
156
|
+
fields.push({ key, input, initial, read: () => input.value.trim(), changed: () => input.value.trim() !== initial })
|
|
157
|
+
section.append(wrap)
|
|
158
|
+
}
|
|
159
|
+
const checkField = (key: string, label: string, initial: boolean): void => {
|
|
160
|
+
const input = make('input', 'dsh-tw-settings-check')
|
|
161
|
+
input.type = 'checkbox'
|
|
162
|
+
input.checked = initial
|
|
163
|
+
const wrap = make('label', 'dsh-tw-settings-field dsh-tw-settings-field-check')
|
|
164
|
+
wrap.append(input, make('span', 'dsh-tw-settings-label', label))
|
|
165
|
+
fields.push({ key, input, initial, read: () => input.checked, changed: () => input.checked !== initial })
|
|
166
|
+
section.append(wrap)
|
|
167
|
+
}
|
|
168
|
+
const numField = (key: string, label: string, initial: number): void => {
|
|
169
|
+
const input = make('input', 'dsh-tw-settings-input')
|
|
170
|
+
input.type = 'number'
|
|
171
|
+
input.value = String(initial)
|
|
172
|
+
const wrap = make('label', 'dsh-tw-settings-field')
|
|
173
|
+
wrap.append(make('span', 'dsh-tw-settings-label', label), input)
|
|
174
|
+
fields.push({ key, input, initial, read: () => Number(input.value) || initial, changed: () => (Number(input.value) || initial) !== initial })
|
|
175
|
+
section.append(wrap)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
textField('note.tag', '快速笔记默认 tag', typeof note.tag === 'string' ? note.tag : 'inbox')
|
|
179
|
+
checkField('git.autoCommit', '自动 commit(防抖)', git.autoCommit !== false)
|
|
180
|
+
numField('git.debounceMs', '自动 commit 防抖(ms)', typeof git.debounceMs === 'number' ? git.debounceMs : 60_000)
|
|
181
|
+
textField('git.remote', 'git 远端(空=仅本地)', typeof git.remote === 'string' ? git.remote : '')
|
|
182
|
+
textField('git.branch', 'git 分支', typeof git.branch === 'string' ? git.branch : 'main')
|
|
183
|
+
// 界面语言在下方「语言管理」区块设置(config 的 uiLanguage 仅供启动时自动应用)。
|
|
184
|
+
|
|
185
|
+
const save = make('button', 'dsh-tw-settings-btn dsh-tw-settings-primary', '保存配置')
|
|
186
|
+
save.type = 'button'
|
|
187
|
+
save.addEventListener('click', () => {
|
|
188
|
+
save.disabled = true
|
|
189
|
+
void (async () => {
|
|
190
|
+
const patch: Record<string, unknown> = {}
|
|
191
|
+
for (const field of fields) {
|
|
192
|
+
if (!field.changed()) continue
|
|
193
|
+
const parts = field.key.split('.')
|
|
194
|
+
if (parts.length === 1) {
|
|
195
|
+
const key = parts[0]
|
|
196
|
+
if (key !== undefined) patch[key] = field.read()
|
|
197
|
+
} else {
|
|
198
|
+
const [top, rest] = parts as [string, string]
|
|
199
|
+
const obj = (patch[top] ?? {}) as Record<string, unknown>
|
|
200
|
+
obj[rest] = field.read()
|
|
201
|
+
patch[top] = obj
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
await fetchJson(CONFIG_ENDPOINT, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch) })
|
|
206
|
+
toast('配置已保存')
|
|
207
|
+
void refresh()
|
|
208
|
+
} catch (err) {
|
|
209
|
+
toast(`保存失败:${err instanceof Error ? err.message : String(err)}`)
|
|
210
|
+
} finally {
|
|
211
|
+
save.disabled = false
|
|
212
|
+
}
|
|
213
|
+
})()
|
|
214
|
+
})
|
|
215
|
+
section.append(save)
|
|
216
|
+
body.append(section)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Plugin/theme manager: checkboxes/radios + apply (writes info, restarts). */
|
|
220
|
+
function renderCatalogSection(
|
|
221
|
+
body: HTMLElement,
|
|
222
|
+
info: AdminState['info'],
|
|
223
|
+
catalog: AdminState['catalog'],
|
|
224
|
+
refresh: () => Promise<void>,
|
|
225
|
+
): void {
|
|
226
|
+
const plugins = catalog?.plugins ?? []
|
|
227
|
+
const themes = catalog?.themes ?? []
|
|
228
|
+
const languages = catalog?.languages ?? []
|
|
229
|
+
const activePlugins = new Set(info?.plugins ?? [])
|
|
230
|
+
const loadedThemes = new Set(info?.themes ?? [])
|
|
231
|
+
const activeLanguages = new Set(info?.languages ?? [])
|
|
232
|
+
|
|
233
|
+
// ── plugins ──────────────────────────────────────────────────────────────
|
|
234
|
+
const pluginSection = make('section', 'dsh-tw-settings-section')
|
|
235
|
+
pluginSection.append(make('h3', 'dsh-tw-settings-h', '插件管理(自带官方插件)'))
|
|
236
|
+
const search = make('input', 'dsh-tw-settings-input dsh-tw-settings-search')
|
|
237
|
+
search.placeholder = '搜索插件…'
|
|
238
|
+
const listWrap = make('div', 'dsh-tw-settings-list')
|
|
239
|
+
const applyPlugins = make('button', 'dsh-tw-settings-btn dsh-tw-settings-primary', '应用插件(重启 TW)')
|
|
240
|
+
applyPlugins.type = 'button'
|
|
241
|
+
|
|
242
|
+
const renderPluginList = (needle: string): void => {
|
|
243
|
+
listWrap.replaceChildren()
|
|
244
|
+
const q = needle.toLowerCase()
|
|
245
|
+
for (const plugin of plugins) {
|
|
246
|
+
if (q.length > 0 && !`${plugin.label} ${plugin.name} ${plugin.description}`.toLowerCase().includes(q)) continue
|
|
247
|
+
const input = make('input', 'dsh-tw-settings-check')
|
|
248
|
+
input.type = 'checkbox'
|
|
249
|
+
input.checked = activePlugins.has(plugin.name)
|
|
250
|
+
input.addEventListener('change', () => {
|
|
251
|
+
if (input.checked) activePlugins.add(plugin.name)
|
|
252
|
+
else activePlugins.delete(plugin.name)
|
|
253
|
+
})
|
|
254
|
+
const label = make('label', 'dsh-tw-settings-row dsh-tw-settings-plugin')
|
|
255
|
+
const name = make('span', 'dsh-tw-settings-name', plugin.label)
|
|
256
|
+
name.title = plugin.name
|
|
257
|
+
const desc = make('span', 'dsh-tw-settings-muted', plugin.description || plugin.name)
|
|
258
|
+
label.append(input, name, desc)
|
|
259
|
+
listWrap.append(label)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
search.addEventListener('input', () => renderPluginList(search.value))
|
|
263
|
+
applyPlugins.addEventListener('click', () => {
|
|
264
|
+
applyPlugins.disabled = true
|
|
265
|
+
void (async () => {
|
|
266
|
+
try {
|
|
267
|
+
await fetchJson(INFO_ENDPOINT, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ plugins: [...activePlugins] }) })
|
|
268
|
+
toast('插件已应用,TW 已重启')
|
|
269
|
+
void refresh()
|
|
270
|
+
} catch (err) {
|
|
271
|
+
toast(`应用失败:${err instanceof Error ? err.message : String(err)}`)
|
|
272
|
+
applyPlugins.disabled = false
|
|
273
|
+
}
|
|
274
|
+
})()
|
|
275
|
+
})
|
|
276
|
+
renderPluginList('')
|
|
277
|
+
pluginSection.append(search, listWrap, applyPlugins)
|
|
278
|
+
body.append(pluginSection)
|
|
279
|
+
|
|
280
|
+
// ── themes ───────────────────────────────────────────────────────────────
|
|
281
|
+
// `info.themes` = WHICH theme plugins are LOADED (multi-select; TW's own
|
|
282
|
+
// default is [vanilla, snowwhite]); `$:/theme` = the ACTIVE one (single).
|
|
283
|
+
// Two controls per theme: ☑ 加载 (multi) + ◉ 活动 (single, auto-added to the
|
|
284
|
+
// loaded set on apply). The host computes the dependency closure (heavier →
|
|
285
|
+
// vanilla+snowwhite+heavier) and writes $:/theme.
|
|
286
|
+
const themeSection = make('section', 'dsh-tw-settings-section')
|
|
287
|
+
themeSection.append(make('h3', 'dsh-tw-settings-h', '主题管理(自带主题)'))
|
|
288
|
+
const themeHint = make('div', 'dsh-tw-settings-muted', '「加载」= TW 里可用的主题(可多选,依赖链自动带上,如 heavier 会带 snowwhite+vanilla);「活动」= 当前视觉主题(单选,自动加入加载集)。应用后重启 TW。')
|
|
289
|
+
const themeHead = make('div', 'dsh-tw-settings-row dsh-tw-settings-head')
|
|
290
|
+
themeHead.append(
|
|
291
|
+
make('span', 'dsh-tw-settings-col', '加载'),
|
|
292
|
+
make('span', 'dsh-tw-settings-col', '活动'),
|
|
293
|
+
make('span', 'dsh-tw-settings-name', '主题'),
|
|
294
|
+
)
|
|
295
|
+
const themeList = info?.themes ?? []
|
|
296
|
+
let activeThemeName = themeList.length > 0 ? themeList[themeList.length - 1] : 'tiddlywiki/vanilla'
|
|
297
|
+
const themeWrap = make('div', 'dsh-tw-settings-list')
|
|
298
|
+
for (const theme of themes) {
|
|
299
|
+
const load = make('input', 'dsh-tw-settings-check')
|
|
300
|
+
load.type = 'checkbox'
|
|
301
|
+
load.checked = loadedThemes.has(theme.name)
|
|
302
|
+
load.title = '加载该主题'
|
|
303
|
+
load.addEventListener('change', () => {
|
|
304
|
+
if (load.checked) loadedThemes.add(theme.name)
|
|
305
|
+
else loadedThemes.delete(theme.name)
|
|
306
|
+
})
|
|
307
|
+
const act = make('input', 'dsh-tw-settings-check')
|
|
308
|
+
act.type = 'radio'
|
|
309
|
+
act.name = 'dsh-tw-active-theme'
|
|
310
|
+
act.checked = theme.name === activeThemeName
|
|
311
|
+
act.title = '设为活动主题'
|
|
312
|
+
act.addEventListener('change', () => {
|
|
313
|
+
if (act.checked) activeThemeName = theme.name
|
|
314
|
+
})
|
|
315
|
+
const name = make('span', 'dsh-tw-settings-name', theme.label)
|
|
316
|
+
name.title = theme.name
|
|
317
|
+
const desc = make('span', 'dsh-tw-settings-muted', theme.description || theme.name)
|
|
318
|
+
const row = make('div', 'dsh-tw-settings-row dsh-tw-settings-plugin')
|
|
319
|
+
row.append(load, act, name, desc)
|
|
320
|
+
themeWrap.append(row)
|
|
321
|
+
}
|
|
322
|
+
const applyThemes = make('button', 'dsh-tw-settings-btn dsh-tw-settings-primary', '应用主题(重启 TW)')
|
|
323
|
+
applyThemes.type = 'button'
|
|
324
|
+
applyThemes.addEventListener('click', () => {
|
|
325
|
+
applyThemes.disabled = true
|
|
326
|
+
void (async () => {
|
|
327
|
+
try {
|
|
328
|
+
await fetchJson(INFO_ENDPOINT, {
|
|
329
|
+
method: 'POST',
|
|
330
|
+
headers: { 'content-type': 'application/json' },
|
|
331
|
+
body: JSON.stringify({ themes: [...loadedThemes], themeActive: activeThemeName }),
|
|
332
|
+
})
|
|
333
|
+
toast('主题已应用,TW 已重启')
|
|
334
|
+
void refresh()
|
|
335
|
+
} catch (err) {
|
|
336
|
+
toast(`应用失败:${err instanceof Error ? err.message : String(err)}`)
|
|
337
|
+
applyThemes.disabled = false
|
|
338
|
+
}
|
|
339
|
+
})()
|
|
340
|
+
})
|
|
341
|
+
themeSection.append(themeHint, themeHead, themeWrap, applyThemes)
|
|
342
|
+
body.append(themeSection)
|
|
343
|
+
|
|
344
|
+
// ── languages (bundled, offline — enable → restart TW) ──────────────────
|
|
345
|
+
const langSection = make('section', 'dsh-tw-settings-section')
|
|
346
|
+
langSection.append(make('h3', 'dsh-tw-settings-h', '语言管理(自带官方语言包)'))
|
|
347
|
+
const langHint = make('div', 'dsh-tw-settings-muted', '勾选启用语言插件并重启 TW;如中文请选 zh-Hans(简体)或 zh-CN。')
|
|
348
|
+
const langWrap = make('div', 'dsh-tw-settings-list')
|
|
349
|
+
for (const lang of languages) {
|
|
350
|
+
const input = make('input', 'dsh-tw-settings-check')
|
|
351
|
+
input.type = 'checkbox'
|
|
352
|
+
input.checked = activeLanguages.has(lang.name)
|
|
353
|
+
input.addEventListener('change', () => {
|
|
354
|
+
if (input.checked) activeLanguages.add(lang.name)
|
|
355
|
+
else activeLanguages.delete(lang.name)
|
|
356
|
+
})
|
|
357
|
+
const label = make('label', 'dsh-tw-settings-row dsh-tw-settings-plugin')
|
|
358
|
+
const name = make('span', 'dsh-tw-settings-name', lang.label)
|
|
359
|
+
name.title = lang.name
|
|
360
|
+
const desc = make('span', 'dsh-tw-settings-muted', lang.description || lang.name)
|
|
361
|
+
label.append(input, name, desc)
|
|
362
|
+
langWrap.append(label)
|
|
363
|
+
}
|
|
364
|
+
const applyLangs = make('button', 'dsh-tw-settings-btn dsh-tw-settings-primary', '应用语言(重启 TW)')
|
|
365
|
+
applyLangs.type = 'button'
|
|
366
|
+
applyLangs.addEventListener('click', () => {
|
|
367
|
+
applyLangs.disabled = true
|
|
368
|
+
void (async () => {
|
|
369
|
+
try {
|
|
370
|
+
await fetchJson(INFO_ENDPOINT, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ languages: [...activeLanguages] }) })
|
|
371
|
+
toast('语言已应用,TW 已重启')
|
|
372
|
+
void refresh()
|
|
373
|
+
} catch (err) {
|
|
374
|
+
toast(`应用失败:${err instanceof Error ? err.message : String(err)}`)
|
|
375
|
+
applyLangs.disabled = false
|
|
376
|
+
}
|
|
377
|
+
})()
|
|
378
|
+
})
|
|
379
|
+
langSection.append(langHint, langWrap, applyLangs)
|
|
380
|
+
body.append(langSection)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function renderMain(body: HTMLElement, state: AdminState, refresh: () => Promise<void>): void {
|
|
384
|
+
body.replaceChildren()
|
|
385
|
+
renderConfigSection(body, state.config ?? {}, refresh)
|
|
386
|
+
renderCatalogSection(body, state.info, state.catalog, refresh)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** React wrapper consumed by the shell's settings.section slot. */
|
|
390
|
+
export function SettingsSection(): React.ReactElement {
|
|
391
|
+
const ref = React.useRef<HTMLDivElement | null>(null)
|
|
392
|
+
React.useEffect(() => {
|
|
393
|
+
const el = ref.current
|
|
394
|
+
return el === null ? undefined : mountSettingsPage(el)
|
|
395
|
+
}, [])
|
|
396
|
+
return React.createElement('div', { ref })
|
|
397
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar entry injection — structure ported from dsh-taskboard's
|
|
3
|
+
* sidebar-entry.ts (verified live in this shell): scope to the sidebar root,
|
|
4
|
+
* find the New Session button, and insert the entry as a direct child of that
|
|
5
|
+
* root next to the family block. A body-level MutationObserver self-heals
|
|
6
|
+
* React re-renders; a slow timer covers shells that mount late without
|
|
7
|
+
* further mutations. The row is plain DOM so it never disturbs the shell's
|
|
8
|
+
* reconciliation.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-tiddlywiki/client/sidebar-entry
|
|
11
|
+
*/
|
|
12
|
+
import type { PanelState } from './state.ts'
|
|
13
|
+
|
|
14
|
+
/** Stable data attribute identifying this entry row. */
|
|
15
|
+
export const ENTRY_SELECTOR = '[data-dsh-tw-entry]'
|
|
16
|
+
|
|
17
|
+
/** Inline icon: a wiki page with a TiddlyWiki-style "T" (nav-icon look). */
|
|
18
|
+
const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 2.5h8a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/><path d="M6 6h4M6 8.5h2.5"/></svg>'
|
|
19
|
+
|
|
20
|
+
/** Family entries from sibling plugins, kept in a stable relative order. */
|
|
21
|
+
const FAMILY_SELECTOR = '[data-dsh-tw-entry], [data-dsh-atb-entry], [data-dsh-taskboard-entry], [data-dsh-ssh-entry]'
|
|
22
|
+
|
|
23
|
+
/** Find the sidebar shell root element, or undefined while not yet mounted. */
|
|
24
|
+
function sidebarRoot(): HTMLElement | undefined {
|
|
25
|
+
const column = document.querySelector<HTMLElement>(
|
|
26
|
+
'[data-pane="sidebar"], [class*="sidebarCol"], .dshDesktopUpstreamSidebar, .dshDesktopSidebarSurface',
|
|
27
|
+
)
|
|
28
|
+
if (column === null) return undefined
|
|
29
|
+
const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
|
|
30
|
+
return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The New Session button inside the sidebar root. */
|
|
34
|
+
function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
|
|
35
|
+
const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
|
|
36
|
+
if (nested !== null) return nested
|
|
37
|
+
for (const child of root.children) {
|
|
38
|
+
if (child instanceof HTMLButtonElement && !child.matches(ENTRY_SELECTOR)) return child
|
|
39
|
+
}
|
|
40
|
+
const byAria = root.querySelector<HTMLButtonElement>(
|
|
41
|
+
'button[aria-label="新建会话"], button[aria-label="New Session"], button[aria-label*="新会话"], button[aria-label*="new session" i]',
|
|
42
|
+
)
|
|
43
|
+
if (byAria !== null) return byAria
|
|
44
|
+
const buttons = Array.from(root.querySelectorAll<HTMLButtonElement>('button'))
|
|
45
|
+
return buttons.find(button => !button.matches(ENTRY_SELECTOR) && /新会话|新建会话|new session/i.test(button.textContent ?? ''))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Build the entry row (a detached button; insert once the shell is up). */
|
|
49
|
+
function createEntry(state: PanelState): HTMLButtonElement {
|
|
50
|
+
const entry = document.createElement('button')
|
|
51
|
+
entry.type = 'button'
|
|
52
|
+
entry.dataset.dshTwEntry = ''
|
|
53
|
+
entry.className = 'dsh-tw-entry'
|
|
54
|
+
entry.setAttribute('aria-label', 'TiddlyWiki 知识库')
|
|
55
|
+
entry.innerHTML = `<span class="dsh-tw-entry-icon">${ICON}</span><span class="dsh-tw-entry-label">TiddlyWiki</span>`
|
|
56
|
+
entry.addEventListener('click', () => { state.toggle() })
|
|
57
|
+
return entry
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Re-insert the entry before the whole family block (stable ordering). */
|
|
61
|
+
function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
|
|
62
|
+
const button = newSessionButton(root)
|
|
63
|
+
if (button === undefined) return false
|
|
64
|
+
if (entry.parentElement !== root) {
|
|
65
|
+
const row = button.closest('[class*="logoRow"]')
|
|
66
|
+
const base = (row !== null && row.parentElement === root) ? row : button
|
|
67
|
+
const family = Array.from(root.children).filter(
|
|
68
|
+
(el): el is HTMLElement => el instanceof HTMLElement && el.matches(FAMILY_SELECTOR),
|
|
69
|
+
)
|
|
70
|
+
const anchor = family.length > 0 ? (family[0] ?? null) : (base.nextElementSibling ?? null)
|
|
71
|
+
root.insertBefore(entry, anchor)
|
|
72
|
+
}
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Debug counters (window.__twDebug) — evidence if the entry fails to appear. */
|
|
77
|
+
interface TwDebug { attempts: number; found: boolean; placed: boolean }
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Mount the sidebar entry, waiting for the shell and self-healing on later
|
|
81
|
+
* re-renders.
|
|
82
|
+
* @param state - the shared panel state the entry toggles.
|
|
83
|
+
* @returns disposer removing the entry and its observers.
|
|
84
|
+
*/
|
|
85
|
+
export function mountSidebarEntry(state: PanelState): () => void {
|
|
86
|
+
const entry = createEntry(state)
|
|
87
|
+
const debug: TwDebug = { attempts: 0, found: false, placed: false }
|
|
88
|
+
const host = globalThis.location?.hostname
|
|
89
|
+
if (host === 'localhost' || host === '127.0.0.1') {
|
|
90
|
+
;(window as unknown as { __twDebug?: TwDebug }).__twDebug = debug
|
|
91
|
+
}
|
|
92
|
+
let root: HTMLElement | undefined
|
|
93
|
+
let placed = false
|
|
94
|
+
|
|
95
|
+
const tryPlace = (): void => {
|
|
96
|
+
debug.attempts++
|
|
97
|
+
if (root !== undefined && !root.isConnected) {
|
|
98
|
+
rootObserver.disconnect()
|
|
99
|
+
root = undefined
|
|
100
|
+
placed = false
|
|
101
|
+
}
|
|
102
|
+
if (placed) {
|
|
103
|
+
if (document.body.contains(entry)) return
|
|
104
|
+
rootObserver.disconnect()
|
|
105
|
+
root = undefined
|
|
106
|
+
placed = false
|
|
107
|
+
}
|
|
108
|
+
root ??= sidebarRoot()
|
|
109
|
+
if (root === undefined) return
|
|
110
|
+
debug.found = newSessionButton(root) !== undefined
|
|
111
|
+
placed = placeEntry(root, entry)
|
|
112
|
+
debug.placed = placed
|
|
113
|
+
if (placed) rootObserver.observe(root, { childList: true, subtree: true })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Body-level watcher as the whole-rebuild fallback.
|
|
117
|
+
const waitObserver = new MutationObserver(() => { tryPlace() })
|
|
118
|
+
waitObserver.observe(document.body, { childList: true, subtree: true })
|
|
119
|
+
|
|
120
|
+
// Self-heal: re-insert in the same frame when a re-render displaces the row.
|
|
121
|
+
const rootObserver = new MutationObserver(() => {
|
|
122
|
+
if (root === undefined || !root.isConnected) {
|
|
123
|
+
placed = false
|
|
124
|
+
tryPlace()
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
if (!root.contains(entry)) placed = placeEntry(root, entry)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// Belt-and-braces: a late shell mount with no further mutations still heals.
|
|
131
|
+
const retry = setInterval(() => { tryPlace() }, 2_000)
|
|
132
|
+
|
|
133
|
+
const syncActive = (): void => {
|
|
134
|
+
if (state.isOpen()) entry.dataset.active = 'true'
|
|
135
|
+
else delete entry.dataset.active
|
|
136
|
+
}
|
|
137
|
+
const unsubscribe = state.subscribe(syncActive)
|
|
138
|
+
syncActive()
|
|
139
|
+
tryPlace()
|
|
140
|
+
|
|
141
|
+
return () => {
|
|
142
|
+
clearInterval(retry)
|
|
143
|
+
waitObserver.disconnect()
|
|
144
|
+
rootObserver.disconnect()
|
|
145
|
+
unsubscribe()
|
|
146
|
+
entry.remove()
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny pub/sub panel state shared by the sidebar entry and the center panel.
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-tiddlywiki/client/state
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type PanelListener = (open: boolean) => void
|
|
8
|
+
|
|
9
|
+
export class PanelState {
|
|
10
|
+
private open = false
|
|
11
|
+
private readonly listeners = new Set<PanelListener>()
|
|
12
|
+
|
|
13
|
+
isOpen(): boolean {
|
|
14
|
+
return this.open
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
toggle(): void {
|
|
18
|
+
this.set(!this.open)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
openPanel(): void {
|
|
22
|
+
this.set(true)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
closePanel(): void {
|
|
26
|
+
this.set(false)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
set(value: boolean): void {
|
|
30
|
+
if (this.open === value) return
|
|
31
|
+
this.open = value
|
|
32
|
+
for (const listener of [...this.listeners]) listener(value)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
subscribe(listener: PanelListener): () => void {
|
|
36
|
+
this.listeners.add(listener)
|
|
37
|
+
return () => { this.listeners.delete(listener) }
|
|
38
|
+
}
|
|
39
|
+
}
|