dsh-tiddlywiki 0.28.3 → 0.28.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 -0
- package/docs/CHANGELOG.md +1 -0
- package/lib/client.js +37 -27
- package/lib/index.js +41 -3
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/knowledge-fab.ts +38 -4
- package/src/client/settings-page.ts +45 -1
- package/src/client/sidebar-entry.ts +14 -12
- package/src/client/status-cache.ts +2 -0
- package/src/client/styles.ts +10 -0
- package/src/client/wiki-icon.ts +65 -0
- package/src/host/wiki-registry.ts +47 -0
- package/src/index.ts +4 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tiddlywiki",
|
|
3
3
|
"description": "把 TiddlyWiki 5 变成你和 Agent 共用的本地文档中心(纯文本 + git):你在 DSH 界面里阅读、写作、记日记,Agent 用 15 个 tiddlywiki_* 工具读写同一批笔记;每条笔记都是你能读、能改、能审计、能随时带走的文件——不是自动记忆插件,不做后台抓取。可选一键发布到微信公众号草稿箱。 Turn TiddlyWiki 5 into a document center you and your agent share, in plain text under git — a knowledge base you can read, edit, audit and take with you, not an auto-memory plugin. Mounts via the official dsh plugin system; no DSH source changes.",
|
|
4
|
-
"version": "0.28.
|
|
4
|
+
"version": "0.28.4",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"dsh",
|
|
@@ -21,7 +21,7 @@ import type { NoteWidgetHandle } from './note-widget.ts'
|
|
|
21
21
|
import type { SyncController } from './sync-button.ts'
|
|
22
22
|
import { PANEL_RELOAD_EVENT } from './tw-frame.ts'
|
|
23
23
|
import { ROUTE_PREFIX } from './endpoints.ts'
|
|
24
|
-
import { resolveFocusWiki, setFocusWiki } from './wiki-focus.ts'
|
|
24
|
+
import { resolveFocusWiki, setFocusWiki, subscribeFocusWiki } from './wiki-focus.ts'
|
|
25
25
|
|
|
26
26
|
import { fetchStatus } from './status-cache.ts'
|
|
27
27
|
import { fetchUiConfig } from './ui-config.ts'
|
|
@@ -91,6 +91,13 @@ export function mountKnowledgeFab(state: PanelState, note: NoteWidgetHandle, syn
|
|
|
91
91
|
let tip: HTMLDivElement | undefined
|
|
92
92
|
let panelLabel: HTMLSpanElement | undefined
|
|
93
93
|
let menuOpen = false
|
|
94
|
+
/** 焦点库订阅(多库菜单需要,dispose 时回收)。 */
|
|
95
|
+
let focusOff: (() => void) | undefined
|
|
96
|
+
/**
|
|
97
|
+
* 重画"知识库"分组的选中标记。打开菜单前调用一次 —— 订阅负责切库时即时重画,
|
|
98
|
+
* 这里负责兜住"订阅建立之前的窗口"与外部改动(v0.28.3)。
|
|
99
|
+
*/
|
|
100
|
+
let repaintWikiMenu: (() => void) | undefined
|
|
94
101
|
/** Latest TW health snapshot, merged into the hover tip. */
|
|
95
102
|
let twHealth: TwHealth = { state: 'unknown', text: 'TiddlyWiki 服务…', logs: [] }
|
|
96
103
|
|
|
@@ -196,17 +203,33 @@ export function mountKnowledgeFab(state: PanelState, note: NoteWidgetHandle, syn
|
|
|
196
203
|
// 多知识库切换(v0.28.0):只有多于一个库时才长出这一段,单库安装的菜单不变。
|
|
197
204
|
// 点一下 = 把"焦点库"切过去并打开面板——面板会加载那个库自己的 /tw/<id>/。
|
|
198
205
|
if (roster.wikis.length > 1) {
|
|
199
|
-
const focus = resolveFocusWiki(roster.wikis, roster.defaultId)
|
|
200
206
|
const heading = document.createElement('div')
|
|
201
207
|
heading.className = 'dsh-tw-fab-group'
|
|
202
208
|
heading.textContent = '知识库'
|
|
203
209
|
menu.append(heading)
|
|
210
|
+
/** 每个库的菜单项,便于焦点变化时就地刷新选中标记。 */
|
|
211
|
+
const wikiItems = new Map<string, HTMLButtonElement>()
|
|
212
|
+
/**
|
|
213
|
+
* 重画选中标记(v0.28.3)。
|
|
214
|
+
*
|
|
215
|
+
* 原来的实现只在**菜单构建时**算一次 focus,之后无论怎么切都不再更新 ——
|
|
216
|
+
* 于是"切了库、选中项还停在上一个"(作者报障)。这里改成从当前焦点重算,
|
|
217
|
+
* 并在 setFocusWiki 的订阅里重画;菜单关闭时也重画,保证下次打开是对的。
|
|
218
|
+
*/
|
|
219
|
+
const paint = (): void => {
|
|
220
|
+
const current = resolveFocusWiki(roster.wikis, roster.defaultId)
|
|
221
|
+
for (const [id, el] of wikiItems) {
|
|
222
|
+
const isCurrent = id === current
|
|
223
|
+
el.dataset.current = isCurrent ? '1' : '0'
|
|
224
|
+
const wiki = roster.wikis.find((w) => w.id === id)
|
|
225
|
+
el.textContent = `${isCurrent ? '●' : '○'} ${wiki?.label ?? id}`
|
|
226
|
+
}
|
|
227
|
+
}
|
|
204
228
|
for (const wiki of roster.wikis) {
|
|
205
229
|
const item = document.createElement('button')
|
|
206
230
|
item.type = 'button'
|
|
207
231
|
item.className = 'dsh-tw-fab-item'
|
|
208
|
-
item.dataset.current =
|
|
209
|
-
item.textContent = `${wiki.id === focus ? '●' : '○'} ${wiki.label}`
|
|
232
|
+
item.dataset.current = '0'
|
|
210
233
|
item.title = `${wiki.path}${wiki.running ? '' : '(未运行,打开会启动)'}`
|
|
211
234
|
item.addEventListener('click', () => {
|
|
212
235
|
closeMenu()
|
|
@@ -224,8 +247,14 @@ export function mountKnowledgeFab(state: PanelState, note: NoteWidgetHandle, syn
|
|
|
224
247
|
}
|
|
225
248
|
state.openPanel()
|
|
226
249
|
})
|
|
250
|
+
wikiItems.set(wiki.id, item)
|
|
227
251
|
menu.append(item)
|
|
228
252
|
}
|
|
253
|
+
paint()
|
|
254
|
+
// 焦点变化(可能来自别的入口:侧边栏某一行、快速笔记卡片)也要跟上。
|
|
255
|
+
focusOff = subscribeFocusWiki(() => { paint() })
|
|
256
|
+
// 每次打开菜单前重画一次,避免因为订阅时机错过而显示旧值。
|
|
257
|
+
repaintWikiMenu = paint
|
|
229
258
|
}
|
|
230
259
|
|
|
231
260
|
panelLabel = document.createElement('span')
|
|
@@ -280,6 +309,9 @@ export function mountKnowledgeFab(state: PanelState, note: NoteWidgetHandle, syn
|
|
|
280
309
|
if (menuOpen) { closeMenu(); return }
|
|
281
310
|
menuOpen = true
|
|
282
311
|
menu.hidden = false
|
|
312
|
+
// 每次打开都重画一次选中标记(v0.28.3):切库可能发生在菜单关着的时候
|
|
313
|
+
// (侧边栏入口、快速笔记卡片),不重画就会显示上一次的选中项。
|
|
314
|
+
repaintWikiMenu?.()
|
|
283
315
|
renderDot()
|
|
284
316
|
void refreshTwStatus()
|
|
285
317
|
})
|
|
@@ -307,6 +339,8 @@ export function mountKnowledgeFab(state: PanelState, note: NoteWidgetHandle, syn
|
|
|
307
339
|
|
|
308
340
|
return () => {
|
|
309
341
|
disposed = true
|
|
342
|
+
focusOff?.()
|
|
343
|
+
focusOff = undefined
|
|
310
344
|
document.removeEventListener('click', onDocumentClick, true)
|
|
311
345
|
unsubPanel()
|
|
312
346
|
unsubSync()
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import * as React from 'react'
|
|
25
25
|
import { toast } from './toast.ts'
|
|
26
26
|
import { invalidateUiConfig } from './ui-config.ts'
|
|
27
|
+
import { ICON_NAMES } from './wiki-icon.ts'
|
|
27
28
|
import {
|
|
28
29
|
ADMIN_CONFIG_ENDPOINT as CONFIG_ENDPOINT,
|
|
29
30
|
ADMIN_INFO_ENDPOINT as INFO_ENDPOINT,
|
|
@@ -943,8 +944,28 @@ interface WikiListItemView {
|
|
|
943
944
|
autostart: boolean
|
|
944
945
|
running: boolean
|
|
945
946
|
status: string
|
|
947
|
+
/** Per-wiki entry icon (v0.28.4): a curated name or a short emoji. */
|
|
948
|
+
icon?: string
|
|
946
949
|
}
|
|
947
950
|
|
|
951
|
+
/**
|
|
952
|
+
* Icon picker labels (v0.28.4). The NAMES come from `wiki-icon.ts`, so the set
|
|
953
|
+
* the picker offers is exactly the set the sidebar can render.
|
|
954
|
+
*/
|
|
955
|
+
const ICON_LABELS: Record<string, string> = {
|
|
956
|
+
book: '📖 书(book)',
|
|
957
|
+
briefcase: '💼 公文包(briefcase)',
|
|
958
|
+
home: '🏠 房子(home)',
|
|
959
|
+
notebook: '📓 笔记本(notebook)',
|
|
960
|
+
flask: '🧪 实验(flask)',
|
|
961
|
+
globe: '🌐 地球(globe)',
|
|
962
|
+
star: '⭐ 星标(star)',
|
|
963
|
+
archive: '🗄 归档(archive)',
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** A few emoji that read well at sidebar size and are easy to tell apart. */
|
|
967
|
+
const EMOJI_CHOICES = ['📚', '💼', '🏠', '✍️', '🧪', '🌱', '🎯', '🗂️']
|
|
968
|
+
|
|
948
969
|
/** `GET`/`POST /dsh-tiddlywiki/admin/wikis` payload (v0.28.0). */
|
|
949
970
|
interface WikisView {
|
|
950
971
|
ok?: boolean
|
|
@@ -1111,6 +1132,29 @@ function renderWikiListSection(body: HTMLElement, isDisposed: () => boolean, ref
|
|
|
1111
1132
|
const ok = window.confirm(`把「${wiki.label}」移出清单?\n\n**目录与内容不会被删除**(仍在 ${wiki.path}),只是插件不再管理它。`)
|
|
1112
1133
|
if (ok) void post({ action: 'remove', id: wiki.id }, '移出中…')
|
|
1113
1134
|
})
|
|
1135
|
+
// 图标选择(v0.28.4):下拉里是内置图形 + 一组常用 emoji;选「默认」即清除自定义。
|
|
1136
|
+
// 它和 label 一样属于"库的身份",所以在列表里改,而不是塞进常规配置区。
|
|
1137
|
+
const iconPicker = make('select', 'dsh-tw-settings-icon-select')
|
|
1138
|
+
iconPicker.setAttribute('aria-label', `「${wiki.label}」的入口图标`)
|
|
1139
|
+
const currentIcon = typeof wiki.icon === 'string' ? wiki.icon : ''
|
|
1140
|
+
const options: Array<[string, string]> = [['', '默认图标']]
|
|
1141
|
+
for (const name of ICON_NAMES) options.push([name, ICON_LABELS[name] ?? name])
|
|
1142
|
+
for (const emoji of EMOJI_CHOICES) options.push([emoji, emoji])
|
|
1143
|
+
// 当前值不在候选里(用户填了自己的 emoji):保留它,别让下拉把它抹掉。
|
|
1144
|
+
if (currentIcon.length > 0 && !options.some(([v]) => v === currentIcon)) options.push([currentIcon, currentIcon])
|
|
1145
|
+
for (const [value, text] of options) {
|
|
1146
|
+
const opt = document.createElement('option')
|
|
1147
|
+
opt.value = value
|
|
1148
|
+
opt.textContent = text
|
|
1149
|
+
iconPicker.append(opt)
|
|
1150
|
+
}
|
|
1151
|
+
iconPicker.value = currentIcon
|
|
1152
|
+
iconPicker.addEventListener('change', () => {
|
|
1153
|
+
const next = iconPicker.value
|
|
1154
|
+
void post(next.length > 0
|
|
1155
|
+
? { action: 'update', wiki: { ...wiki, icon: next } }
|
|
1156
|
+
: { action: 'update', wiki: { ...wiki, icon: '' } }, '保存中…')
|
|
1157
|
+
})
|
|
1114
1158
|
// 「改目录」只在多库模式出现(v0.28.1):单库模式下这件事由「知识库位置」负责
|
|
1115
1159
|
// (它写 wiki 之外的指针文件),两处并存会让用户不知道以哪个为准。
|
|
1116
1160
|
if (view.mode === 'multi') {
|
|
@@ -1132,7 +1176,7 @@ function renderWikiListSection(body: HTMLElement, isDisposed: () => boolean, ref
|
|
|
1132
1176
|
})
|
|
1133
1177
|
actions.append(move)
|
|
1134
1178
|
}
|
|
1135
|
-
actions.append(configure, power, makeDefault, visibility, autostart, remove)
|
|
1179
|
+
actions.append(configure, power, makeDefault, visibility, autostart, iconPicker, remove)
|
|
1136
1180
|
row.append(actions)
|
|
1137
1181
|
list.append(row)
|
|
1138
1182
|
}
|
|
@@ -13,12 +13,11 @@ import type { PanelState } from './state.ts'
|
|
|
13
13
|
import { fetchUiConfig, subscribeUiConfig } from './ui-config.ts'
|
|
14
14
|
import { fetchStatus } from './status-cache.ts'
|
|
15
15
|
import { resolveFocusWiki, setFocusWiki, subscribeFocusWiki } from './wiki-focus.ts'
|
|
16
|
+
import { applyWikiIcon } from './wiki-icon.ts'
|
|
16
17
|
|
|
17
18
|
/** Stable data attribute identifying this entry row. */
|
|
18
19
|
export const ENTRY_SELECTOR = '[data-dsh-tw-entry]'
|
|
19
20
|
|
|
20
|
-
/** Inline icon: a wiki page with a TiddlyWiki-style "T" (nav-icon look). */
|
|
21
|
-
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>'
|
|
22
21
|
|
|
23
22
|
/** Family entries from sibling plugins, kept in a stable relative order. */
|
|
24
23
|
const FAMILY_SELECTOR = '[data-dsh-tw-entry], [data-dsh-atb-entry], [data-dsh-taskboard-entry], [data-dsh-ssh-entry]'
|
|
@@ -59,26 +58,27 @@ function createEntry(
|
|
|
59
58
|
state: PanelState,
|
|
60
59
|
label: string,
|
|
61
60
|
wikiId?: string,
|
|
62
|
-
|
|
61
|
+
icon?: string,
|
|
62
|
+
): { entry: HTMLButtonElement; labelEl: HTMLSpanElement; iconEl: HTMLSpanElement } {
|
|
63
63
|
const entry = document.createElement('button')
|
|
64
64
|
entry.type = 'button'
|
|
65
65
|
entry.dataset.dshTwEntry = ''
|
|
66
66
|
entry.className = 'dsh-tw-entry'
|
|
67
67
|
if (wikiId !== undefined) entry.dataset.wiki = wikiId
|
|
68
68
|
entry.setAttribute('aria-label', `TiddlyWiki 知识库:${label}`)
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
icon
|
|
69
|
+
const iconEl = document.createElement('span')
|
|
70
|
+
iconEl.className = 'dsh-tw-entry-icon'
|
|
71
|
+
applyWikiIcon(iconEl, icon)
|
|
72
72
|
const labelEl = document.createElement('span')
|
|
73
73
|
labelEl.className = 'dsh-tw-entry-label'
|
|
74
74
|
labelEl.textContent = label
|
|
75
|
-
entry.append(
|
|
75
|
+
entry.append(iconEl, labelEl)
|
|
76
76
|
entry.addEventListener('click', () => {
|
|
77
77
|
// 多库:先把这个库设为「焦点库」,面板随后加载它自己的 /tw/<id>/。
|
|
78
78
|
if (wikiId !== undefined) setFocusWiki(wikiId)
|
|
79
79
|
state.toggle()
|
|
80
80
|
})
|
|
81
|
-
return { entry, labelEl }
|
|
81
|
+
return { entry, labelEl, iconEl }
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/** Re-insert the entry before the whole family block (stable ordering). */
|
|
@@ -116,9 +116,9 @@ function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
|
|
|
116
116
|
*/
|
|
117
117
|
export function mountSidebarEntry(state: PanelState): () => void {
|
|
118
118
|
/** 多库:每个**在运行**的库一个入口行,用自己的显示名(作者 2026-09-28 要求)。 */
|
|
119
|
-
const rows = new Map<string, { entry: HTMLButtonElement; labelEl: HTMLSpanElement }>()
|
|
120
|
-
const { entry, labelEl } = createEntry(state, 'TiddlyWiki')
|
|
121
|
-
rows.set('', { entry, labelEl })
|
|
119
|
+
const rows = new Map<string, { entry: HTMLButtonElement; labelEl: HTMLSpanElement; iconEl: HTMLSpanElement }>()
|
|
120
|
+
const { entry, labelEl, iconEl } = createEntry(state, 'TiddlyWiki')
|
|
121
|
+
rows.set('', { entry, labelEl, iconEl })
|
|
122
122
|
let disposed = false
|
|
123
123
|
/**
|
|
124
124
|
* 自定义显示名:/status 返回 ui.sidebarLabel(设置页「侧边栏入口显示名称」)。
|
|
@@ -172,10 +172,12 @@ export function mountSidebarEntry(state: PanelState): () => void {
|
|
|
172
172
|
for (const w of running) {
|
|
173
173
|
let row = rows.get(w.id)
|
|
174
174
|
if (row === undefined) {
|
|
175
|
-
row = createEntry(state, w.label, w.id)
|
|
175
|
+
row = createEntry(state, w.label, w.id, w.icon)
|
|
176
176
|
rows.set(w.id, row)
|
|
177
177
|
}
|
|
178
178
|
row.labelEl.textContent = w.label
|
|
179
|
+
// 图标每次同步(v0.28.4):在设置页改完图标,10s 内的轮询就会把它换过来。
|
|
180
|
+
applyWikiIcon(row.iconEl, w.icon)
|
|
179
181
|
row.entry.setAttribute('aria-label', `TiddlyWiki 知识库:${w.label}`)
|
|
180
182
|
// 当前焦点库高亮
|
|
181
183
|
const focused = resolveFocusWiki(running, payload?.defaultId) === w.id
|
package/src/client/styles.ts
CHANGED
|
@@ -23,6 +23,16 @@ const CSS_TEXT = `
|
|
|
23
23
|
cursor: pointer; text-align: left;
|
|
24
24
|
}
|
|
25
25
|
.dsh-tw-entry:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); color: var(--dsw-alias-label-primary, inherit); }
|
|
26
|
+
/* 每库自定义图标(v0.28.4):emoji 走 textContent,尺寸与 SVG 图标对齐。 */
|
|
27
|
+
.dsh-tw-entry-icon.dsh-tw-entry-emoji {
|
|
28
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
29
|
+
width: 14px; height: 14px; font-size: 13px; line-height: 1;
|
|
30
|
+
}
|
|
31
|
+
.dsh-tw-settings-icon-select {
|
|
32
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
|
|
33
|
+
background: var(--dsw-alias-bg-layer-2, #fff); color: inherit; font: inherit; font-size: 12px;
|
|
34
|
+
padding: 3px 8px; border-radius: 999px; max-width: 180px;
|
|
35
|
+
}
|
|
26
36
|
.dsh-tw-entry[data-active="true"] { background: var(--dsw-alias-interactive-bg-active, rgba(128,128,128,.18)); color: var(--dsw-alias-label-primary, inherit); font-weight: 500; }
|
|
27
37
|
/* 多库(v0.28.2):每库一个入口行,当前焦点库加一条左侧标线,和「面板正打开哪个库」区分开
|
|
28
38
|
—— 面板是同一个,但焦点库决定它加载谁。 */
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-wiki entry icons (v0.28.4).
|
|
3
|
+
*
|
|
4
|
+
* A wiki's icon is part of its identity, stored next to `label` in the control
|
|
5
|
+
* file (`wikis.json`). A value is either one of {@link ICON_SVG}'s names — a
|
|
6
|
+
* curated set of hand-written 16×16 stroke SVGs that inherit `currentColor`, so
|
|
7
|
+
* they look right in both DSH themes with no assets to ship — or a short emoji
|
|
8
|
+
* literal (📚 / 💼 / 🏠), which costs nothing and tells four wikis apart at a
|
|
9
|
+
* glance.
|
|
10
|
+
*
|
|
11
|
+
* The host validates the same shape (`normalizeWikiIcon`); this module is only
|
|
12
|
+
* the rendering side, and it must never throw on a value it does not recognise —
|
|
13
|
+
* an unknown string falls back to the default icon rather than breaking the row.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-tiddlywiki/client/wiki-icon
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The default icon: a wiki page with a TiddlyWiki-style "T". */
|
|
19
|
+
export const DEFAULT_ICON_SVG =
|
|
20
|
+
'<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>'
|
|
21
|
+
|
|
22
|
+
/** The curated name → SVG map. Names match `WIKI_ICON_NAMES` on the host. */
|
|
23
|
+
export const ICON_SVG: Record<string, string> = {
|
|
24
|
+
book: '<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="M3.5 3h9v10h-9z"/><path d="M6.5 3v10"/><path d="M8.5 6h2.5M8.5 8.5h2"/></svg>',
|
|
25
|
+
briefcase: '<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="M2.5 5.5h11v7h-11z"/><path d="M6 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5"/><path d="M2.5 8.5h11"/></svg>',
|
|
26
|
+
home: '<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="M2.5 7.5 8 3l5.5 4.5"/><path d="M4 7v6h8V7"/></svg>',
|
|
27
|
+
notebook: '<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.5h7.5v11H4z"/><path d="M6 2.5v11"/><path d="M8 6h2M8 8.5h2"/></svg>',
|
|
28
|
+
flask: '<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="M6.5 2.5v4L3.5 12a1 1 0 0 0 .9 1.5h7.2A1 1 0 0 0 12.5 12L9.5 6.5v-4"/><path d="M5.5 2.5h5"/><path d="M4.8 10h6.4"/></svg>',
|
|
29
|
+
globe: '<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"><circle cx="8" cy="8" r="5.5"/><path d="M2.5 8h11"/><path d="M8 2.5c1.6 1.6 2.4 3.5 2.4 5.5S9.6 12 8 13.5C6.4 12 5.6 10 5.6 8s.8-3.9 2.4-5.5z"/></svg>',
|
|
30
|
+
star: '<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="m8 2.5 1.7 3.6 3.8.5-2.8 2.7.7 3.9L8 11.4l-3.4 1.8.7-3.9-2.8-2.7 3.8-.5z"/></svg>',
|
|
31
|
+
archive: '<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="M2.5 3.5h11v3h-11z"/><path d="M3.5 6.5v6h9v-6"/><path d="M6.5 9h3"/></svg>',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The curated names, in picker order (mirrors the host's `WIKI_ICON_NAMES`). */
|
|
35
|
+
export const ICON_NAMES: readonly string[] = Object.keys(ICON_SVG)
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Is this value a name we ship an SVG for? Anything else is treated as an emoji
|
|
39
|
+
* literal by {@link applyWikiIcon}.
|
|
40
|
+
*/
|
|
41
|
+
export function isIconName(value: string): boolean {
|
|
42
|
+
return Object.prototype.hasOwnProperty.call(ICON_SVG, value)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Put `icon` into `el` (an `.dsh-tw-entry-icon` span), falling back to the
|
|
47
|
+
* default. A name sets innerHTML to our own SVG; anything else is set as TEXT
|
|
48
|
+
* content, never HTML — the value comes from a file a user can edit, so it must
|
|
49
|
+
* not be able to inject markup into the sidebar.
|
|
50
|
+
*/
|
|
51
|
+
export function applyWikiIcon(el: HTMLElement, icon: string | undefined): void {
|
|
52
|
+
if (icon !== undefined && isIconName(icon)) {
|
|
53
|
+
el.innerHTML = ICON_SVG[icon] ?? DEFAULT_ICON_SVG
|
|
54
|
+
el.classList.remove('dsh-tw-entry-emoji')
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
if (icon !== undefined && icon.trim().length > 0) {
|
|
58
|
+
// textContent, deliberately: a control-file string must never be markup.
|
|
59
|
+
el.textContent = icon.trim()
|
|
60
|
+
el.classList.add('dsh-tw-entry-emoji')
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
el.innerHTML = DEFAULT_ICON_SVG
|
|
64
|
+
el.classList.remove('dsh-tw-entry-emoji')
|
|
65
|
+
}
|
|
@@ -108,6 +108,50 @@ export interface WikiEntry {
|
|
|
108
108
|
agentVisible: boolean
|
|
109
109
|
/** Start its TW child process at boot (default false; the default wiki is true). */
|
|
110
110
|
autostart: boolean
|
|
111
|
+
/**
|
|
112
|
+
* Sidebar/entry icon (v0.28.4). Either a NAME from {@link WIKI_ICON_NAMES}
|
|
113
|
+
* (a curated set of built-in SVGs) or a short EMOJI literal.
|
|
114
|
+
*
|
|
115
|
+
* Why free-form-but-tiny instead of a fixed enum: a curated set covers the
|
|
116
|
+
* common cases without shipping a picker that needs its own assets, while an
|
|
117
|
+
* emoji lets someone tell four wikis apart at a glance (📚 / 💼 / 🏠) with
|
|
118
|
+
* nothing to download or maintain.
|
|
119
|
+
*
|
|
120
|
+
* It lives in the control file next to `label` because it is part of a wiki's
|
|
121
|
+
* IDENTITY — the same reason `label` is here and not in a tiddler. Absent =
|
|
122
|
+
* the built-in default icon (so upgrades see no change).
|
|
123
|
+
*/
|
|
124
|
+
icon?: string
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Built-in icon names (the picker's curated set). Deliberately small: each is a
|
|
129
|
+
* hand-written 16×16 stroke SVG inheriting `currentColor`, so it renders in both
|
|
130
|
+
* DSH themes with no asset pipeline.
|
|
131
|
+
*/
|
|
132
|
+
export const WIKI_ICON_NAMES = ['book', 'briefcase', 'home', 'notebook', 'flask', 'globe', 'star', 'archive'] as const
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Normalise an icon value: a known name, or a SHORT emoji-ish string, else
|
|
136
|
+
* undefined (= use the default).
|
|
137
|
+
*
|
|
138
|
+
* The length cap matters: the value can end up as text content in the sidebar,
|
|
139
|
+
* and an unbounded string would let a control file wreck the layout. Emoji are
|
|
140
|
+
* 1–2 code points; 8 UTF-16 units fits a ZWJ cluster without being a place to
|
|
141
|
+
* hide a paragraph.
|
|
142
|
+
*/
|
|
143
|
+
export function normalizeWikiIcon(value: unknown): string | undefined {
|
|
144
|
+
if (typeof value !== 'string') return undefined
|
|
145
|
+
const trimmed = value.trim()
|
|
146
|
+
if (trimmed.length === 0) return undefined
|
|
147
|
+
if ((WIKI_ICON_NAMES as readonly string[]).includes(trimmed)) return trimmed
|
|
148
|
+
if (trimmed.length > 8) return undefined
|
|
149
|
+
// A control character would be invisible or break the row.
|
|
150
|
+
for (const ch of trimmed) {
|
|
151
|
+
const code = ch.codePointAt(0) ?? 0
|
|
152
|
+
if (code < 0x20 || code === 0x7f) return undefined
|
|
153
|
+
}
|
|
154
|
+
return trimmed
|
|
111
155
|
}
|
|
112
156
|
|
|
113
157
|
/** The whole registry: the mode, a non-empty list, and the default-wiki pointer. */
|
|
@@ -268,6 +312,9 @@ export function normalizeEntry(input: unknown): { entry?: WikiEntry; error?: str
|
|
|
268
312
|
name: normalized.location.name,
|
|
269
313
|
agentVisible: input.agentVisible !== false,
|
|
270
314
|
autostart: input.autostart === true,
|
|
315
|
+
// 图标可选:只在给了合法值时才带上,这样"没设过"的库在 JSON 里保持干净
|
|
316
|
+
// (也保证既有清单重新序列化后逐字不变)。
|
|
317
|
+
...(normalizeWikiIcon(input.icon) !== undefined ? { icon: normalizeWikiIcon(input.icon) } : {}),
|
|
271
318
|
},
|
|
272
319
|
}
|
|
273
320
|
}
|
package/src/index.ts
CHANGED
|
@@ -187,12 +187,14 @@ export {
|
|
|
187
187
|
findEntry,
|
|
188
188
|
findPathConflicts,
|
|
189
189
|
normalizeEntry,
|
|
190
|
+
normalizeWikiIcon,
|
|
190
191
|
normalizeWikiId,
|
|
191
192
|
readRegistry,
|
|
192
193
|
removeWiki,
|
|
193
194
|
singleEntryRegistry,
|
|
194
195
|
upsertWiki,
|
|
195
196
|
validateRegistry,
|
|
197
|
+
WIKI_ICON_NAMES,
|
|
196
198
|
writeRegistry,
|
|
197
199
|
type RegistryReadResult,
|
|
198
200
|
type RegistryValidation,
|
|
@@ -1133,6 +1135,8 @@ export function apply(ctx: HostCtx, rawConfig: TiddlywikiConfig = {}): void {
|
|
|
1133
1135
|
autostart: entry.autostart,
|
|
1134
1136
|
running: runtime !== undefined,
|
|
1135
1137
|
path: entryPath(entry),
|
|
1138
|
+
// 每库图标(v0.28.4):侧边栏入口与设置页选择器共用这个来源。
|
|
1139
|
+
icon: entry.icon,
|
|
1136
1140
|
}
|
|
1137
1141
|
}),
|
|
1138
1142
|
}
|