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/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.3",
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 = wiki.id === focus ? '1' : '0'
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
- ): { entry: HTMLButtonElement; labelEl: HTMLSpanElement } {
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 icon = document.createElement('span')
70
- icon.className = 'dsh-tw-entry-icon'
71
- icon.innerHTML = 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(icon, labelEl)
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
@@ -59,6 +59,8 @@ export interface StatusPayload {
59
59
  autostart: boolean
60
60
  running: boolean
61
61
  path: string
62
+ /** Per-wiki entry icon (v0.28.4): a curated name or a short emoji. */
63
+ icon?: string
62
64
  }>
63
65
  ui?: {
64
66
  showQuickNote?: boolean
@@ -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
  }