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.
Files changed (52) hide show
  1. package/README.md +1 -0
  2. package/docs/CHANGELOG.md +1 -0
  3. package/docs/features.md +4 -0
  4. package/docs/usage.md +13 -3
  5. package/lib/client.js +63 -27
  6. package/lib/index.js +10655 -10188
  7. package/lib/index.js.map +1 -1
  8. package/package.json +2 -2
  9. package/src/client/dom.ts +26 -0
  10. package/src/client/index.ts +14 -0
  11. package/src/client/note-widget-draft.ts +154 -0
  12. package/src/client/note-widget-tags.ts +184 -0
  13. package/src/client/note-widget-upload.ts +50 -0
  14. package/src/client/note-widget.ts +19 -348
  15. package/src/client/panel.ts +12 -3
  16. package/src/client/render-fetch.ts +11 -4
  17. package/src/client/scope-seat.ts +117 -0
  18. package/src/client/session-summary.ts +29 -7
  19. package/src/client/settings-icon-picker.ts +238 -0
  20. package/src/client/settings-page-catalog.ts +578 -0
  21. package/src/client/settings-page-config.ts +445 -0
  22. package/src/client/settings-page-runtime.ts +149 -0
  23. package/src/client/settings-page-wikis.ts +432 -0
  24. package/src/client/settings-page.ts +127 -1425
  25. package/src/client/styles.ts +36 -0
  26. package/src/client/tool-views.ts +170 -36
  27. package/src/client/wiki-icon.generated.ts +97 -0
  28. package/src/client/wiki-icon.ts +50 -15
  29. package/src/client/wiki-scope-dock.ts +56 -2
  30. package/src/client/wiki-scope.ts +98 -0
  31. package/src/host/admin-catalog.ts +488 -0
  32. package/src/host/admin-routes.ts +670 -0
  33. package/src/host/admin-secrets.ts +119 -0
  34. package/src/host/admin.ts +3 -1244
  35. package/src/host/clip-bridge.ts +24 -1
  36. package/src/host/config.ts +10 -2
  37. package/src/host/routes-session.ts +617 -0
  38. package/src/host/routes-tw-proxy.ts +390 -0
  39. package/src/host/routes.ts +77 -829
  40. package/src/host/tools-attach.ts +151 -0
  41. package/src/host/tools-git.ts +213 -0
  42. package/src/host/tools-notes.ts +672 -0
  43. package/src/host/tools-read.ts +421 -0
  44. package/src/host/tools-support.ts +513 -0
  45. package/src/host/tools.ts +33 -1814
  46. package/src/host/wiki-registry.ts +25 -4
  47. package/src/host/wiki.ts +55 -5
  48. package/src/index-clip.ts +142 -0
  49. package/src/index-git.ts +196 -0
  50. package/src/index-prompt.ts +139 -0
  51. package/src/index-wikis.ts +353 -0
  52. package/src/index.ts +89 -429
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.7",
4
+ "version": "0.28.8",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "dsh",
@@ -92,7 +92,7 @@
92
92
  "smoke:client": "node scripts/smoke-client.mjs",
93
93
  "selftest": "node scripts/selftest.mjs",
94
94
  "verify:static": "node scripts/verify-send-to-agent-bundle.mjs && node scripts/verify-render-bundle.mjs && node scripts/verify-wechat-publish-bundle.mjs && node scripts/verify-package-contents.mjs && node scripts/verify-version-consistency.mjs && node scripts/verify-constants.mjs && node scripts/verify-doc-links.mjs",
95
- "verify:unit": "node scripts/verify-clip-bridge.mjs && node scripts/verify-seed-error-policy.mjs && node scripts/verify-render-sanitizer.mjs && node scripts/verify-secret-masking.mjs && node scripts/verify-write-policy.mjs && node scripts/verify-workspace.mjs && node scripts/verify-wiki-registry.mjs && node scripts/verify-wiki-farm.mjs && node scripts/verify-session-scope.mjs && node scripts/verify-skill-install.mjs && node scripts/verify-repo-keying.mjs && node scripts/verify-restart-drain.mjs && node scripts/verify-language-pin.mjs && node scripts/verify-prompt.mjs && node scripts/verify-conflict-config-guards.mjs && node scripts/verify-config-patch.mjs && node scripts/verify-tool-views.mjs && npx tsx scripts/verify-plugin-runtime.mjs && node scripts/verify-wechat-adapters.mjs && node scripts/verify-wechat-docs-seed.mjs && node scripts/verify-wechat-publish.mjs && npx tsx scripts/verify-tw-origin.mjs && node scripts/verify-frame-guards.mjs && npx tsx scripts/verify-ready-policy.mjs && npx tsx scripts/verify-frame-surface.mjs && npx tsx scripts/verify-editor-popup.mjs && npx tsx scripts/verify-status-cache.mjs && npx tsx scripts/verify-wiki-focus.mjs",
95
+ "verify:unit": "node scripts/verify-clip-bridge.mjs && node scripts/verify-seed-error-policy.mjs && node scripts/verify-render-sanitizer.mjs && node scripts/verify-secret-masking.mjs && node scripts/verify-write-policy.mjs && node scripts/verify-workspace.mjs && node scripts/verify-wiki-registry.mjs && node scripts/verify-wiki-farm.mjs && node scripts/verify-session-scope.mjs && node scripts/verify-skill-install.mjs && node scripts/verify-repo-keying.mjs && node scripts/verify-restart-drain.mjs && node scripts/verify-desktop-node.mjs && node scripts/verify-language-pin.mjs && node scripts/verify-prompt.mjs && node scripts/verify-conflict-config-guards.mjs && node scripts/verify-config-patch.mjs && node scripts/verify-tool-views.mjs && npx tsx scripts/verify-plugin-runtime.mjs && node scripts/verify-wechat-adapters.mjs && node scripts/verify-wechat-docs-seed.mjs && node scripts/verify-wechat-publish.mjs && npx tsx scripts/verify-tw-origin.mjs && node scripts/verify-frame-guards.mjs && npx tsx scripts/verify-ready-policy.mjs && npx tsx scripts/verify-frame-surface.mjs && npx tsx scripts/verify-editor-popup.mjs && npx tsx scripts/verify-status-cache.mjs && npx tsx scripts/verify-wiki-focus.mjs",
96
96
  "verify:e2e": "node scripts/verify-auth.mjs && node scripts/verify-anon-username.mjs && node scripts/verify-tools.mjs && node scripts/verify-audit-fixes.mjs && node scripts/verify-git-resolve.mjs && node scripts/verify-resilience.mjs && node scripts/verify-seed-send-to-agent.mjs && node scripts/verify-seeds-admin.mjs && node scripts/verify-wiki-switch.mjs && node scripts/verify-wiki-farm-boot.mjs",
97
97
  "verify:large": "node scripts/verify-large-wiki.mjs",
98
98
  "verify": "npm run verify:static && npm run verify:unit && npm run verify:e2e && npm run verify:large",
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The one DOM element helper shared by the client widgets (v0.28.8).
3
+ *
4
+ * WHY A MODULE FOR ONE FUNCTION: `make()` existed as a private copy inside
5
+ * `settings-page.ts` — fine while the settings page was one file, but extracting
6
+ * the icon picker (and any future widget) needs the same three lines, and a
7
+ * second private copy is exactly the kind of drift this repo has paid for before
8
+ * (see the endpoints.ts note about eight re-declared routes). One definition,
9
+ * imported where needed.
10
+ *
11
+ * @module dsh-tiddlywiki/client/dom
12
+ */
13
+
14
+ /**
15
+ * Create an element with an optional class and text.
16
+ *
17
+ * `text` goes through `textContent` — deliberately, not `innerHTML`: callers
18
+ * pass values that can originate in user-editable files (a wiki's label or icon),
19
+ * and this helper must never be a markup injection point.
20
+ */
21
+ export function make<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string): HTMLElementTagNameMap[K] {
22
+ const node = document.createElement(tag)
23
+ if (className !== undefined) node.className = className
24
+ if (text !== undefined) node.textContent = text
25
+ return node
26
+ }
@@ -20,6 +20,7 @@ import { createSyncController } from './sync-button.ts'
20
20
  import { mountKnowledgeFab } from './knowledge-fab.ts'
21
21
  import { createQuickNoteDock } from './quick-note-dock.ts'
22
22
  import { createWikiScopeDock } from './wiki-scope-dock.ts'
23
+ import { mountScopeSeat, type ScopeSeatSlots } from './scope-seat.ts'
23
24
  import { fetchUiConfig } from './ui-config.ts'
24
25
  import { mountSessionSummaryView } from './session-summary.ts'
25
26
  import { disposeEditorPopup } from './editor-popup.ts'
@@ -127,6 +128,19 @@ export function apply(ctx: ClientContextFace): void {
127
128
  if (removeDock !== undefined) disposers.push(removeDock)
128
129
  })
129
130
  }
131
+ // 空白会话(新建)里的知识库选择器(v0.28.8,需求 9):挂到
132
+ // `conversation.input.selector.context` —— 模式/预设选择器旁边那一格,
133
+ // 正是作者要的位置(参考 dsh-client-ui-git-graph 的做法)。
134
+ //
135
+ // 与会话内那个选择器的分工(**两者不会同时出现**):
136
+ // · selector.context 是 session-maybe + 只在**空白会话**渲染 →
137
+ // 新建会话时出现在模式选择器旁;
138
+ // · 会话内那个在 quick-note 行里(v0.28.7),只在**已有消息**的会话出现。
139
+ // 组件自身在两种情况下都会因为拿不到可用数据而不渲染,但这层「哪个座位」
140
+ // 的选择由 scope-seat.ts 负责;本机 shell 未声明 selector.context 时它会
141
+ // 回落到 dock(dock 里已经有 quick-note 行的选择器,所以回落态下这一条
142
+ // 用 blank-only 组件,避免同一选择器在 dock 里出现两次)。
143
+ safeMount('wiki scope seat', () => disposers.push(mountScopeSeat(ctx.slots as ScopeSeatSlots, createWikiScopeDock({ blankOnly: true }))))
130
144
  // 会话顶部「知识库」Tab(conversation.view 槽位):显示本会话产生/读取/
131
145
  // 检索过的 wiki 笔记汇总(TW 原生渲染)。由 ui.showSessionTab 控制(默认
132
146
  // 开),tab 名跟随 ui.tabLabel(默认「知识库」)。
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Draft persistence + small pure helpers of the quick-note card (v0.28.8 split).
3
+ *
4
+ * WHY IT IS ITS OWN MODULE: the card's draft layer is a self-contained concern —
5
+ * one window-scoped localStorage namespace, one JSON shape, one signature rule —
6
+ * and it is the part with the most accumulated history (per-window keys, the
7
+ * one-time legacy migration, the "already persisted" signature). Keeping it in
8
+ * `note-widget.ts` buried it under the DOM building; it now reads on its own.
9
+ *
10
+ * Nothing here touches the DOM or the card's state machine: the widget owns the
11
+ * debounce timer and the "is this content already in the wiki" decision, this
12
+ * module owns only how a draft is stored, read, migrated and compared.
13
+ *
14
+ * @module dsh-tiddlywiki/client/note-widget-draft
15
+ */
16
+ import { fetchStatus } from './status-cache.ts'
17
+
18
+ /**
19
+ * 旧版全局草稿 key(v0.18.0 及以前)。多标签页会互相覆盖,现在只作为一次性
20
+ * 迁移来源读取:读到且本窗口还没有草稿 → 迁移到本窗口的 key 并删除它。
21
+ */
22
+ const LEGACY_DRAFT_KEY = 'dsh-tw-note-draft-v1'
23
+ /** 本窗口草稿 key 前缀(`<前缀><窗口 id>`),窗口之间互不覆盖。 */
24
+ const DRAFT_KEY_PREFIX = 'dsh-tw-note-draft-v1:'
25
+ /** sessionStorage 里保存本标签页窗口 id 的 key(刷新后仍是同一个窗口)。 */
26
+ const DRAFT_WINDOW_ID_KEY = 'dsh-tw-note-window-id'
27
+ /** Draft auto-save debounce. */
28
+ export const DRAFT_DEBOUNCE_MS = 500
29
+
30
+ /**
31
+ * 本窗口的草稿命名空间 id:优先取 sessionStorage 里的随机 id(同一标签页刷新后
32
+ * 仍是同一个窗口 → 自己的草稿静默恢复);sessionStorage 不可用时退化为「本次
33
+ * 加载一个随机 id」(刷新后会被当成「其它窗口的草稿」,恢复时给出提示而不是
34
+ * 静默覆盖)。
35
+ */
36
+ const DRAFT_WINDOW_ID: string = (() => {
37
+ try {
38
+ const existing = sessionStorage.getItem(DRAFT_WINDOW_ID_KEY)
39
+ if (existing !== null && existing.length > 0) return existing
40
+ const created = Math.random().toString(36).slice(2, 10)
41
+ sessionStorage.setItem(DRAFT_WINDOW_ID_KEY, created)
42
+ return created
43
+ } catch {
44
+ return Math.random().toString(36).slice(2, 10)
45
+ }
46
+ })()
47
+
48
+ export interface Draft { text: string; title: string; tags: string[]; savedAt: number; windowId?: string }
49
+
50
+ /** 读取结果:draft = 草稿本体;foreign = 不是本窗口写的(迁移/跨窗口)。 */
51
+ export interface DraftHit { draft: Draft; foreign: boolean }
52
+
53
+ function draftKey(): string {
54
+ return `${DRAFT_KEY_PREFIX}${DRAFT_WINDOW_ID}`
55
+ }
56
+
57
+ function parseDraft(raw: string | null): Draft | null {
58
+ if (raw === null) return null
59
+ try {
60
+ const parsed = JSON.parse(raw) as Partial<Draft>
61
+ if (typeof parsed.text !== 'string' || typeof parsed.title !== 'string') return null
62
+ return {
63
+ text: parsed.text,
64
+ title: parsed.title,
65
+ tags: Array.isArray(parsed.tags) ? parsed.tags.filter((t): t is string => typeof t === 'string') : [],
66
+ savedAt: typeof parsed.savedAt === 'number' ? parsed.savedAt : 0,
67
+ windowId: typeof parsed.windowId === 'string' ? parsed.windowId : undefined,
68
+ }
69
+ } catch {
70
+ return null
71
+ }
72
+ }
73
+
74
+ function readDraftFrom(key: string): Draft | null {
75
+ try {
76
+ return parseDraft(localStorage.getItem(key))
77
+ } catch {
78
+ return null
79
+ }
80
+ }
81
+
82
+ /**
83
+ * 读取本窗口草稿。本窗口 key 为空时尝试迁移旧全局 key(一次性:迁移后删除旧
84
+ * key)。返回 null = 没有任何草稿;foreign=true = 草稿来源不是本窗口(旧 key
85
+ * 迁移过来,或 sessionStorage 不可用时窗口 id 变了),调用方需要给出可见提示。
86
+ */
87
+ export function loadDraft(): DraftHit | null {
88
+ const own = readDraftFrom(draftKey())
89
+ if (own !== null) {
90
+ return { draft: own, foreign: own.windowId !== undefined && own.windowId !== DRAFT_WINDOW_ID }
91
+ }
92
+ const legacy = readDraftFrom(LEGACY_DRAFT_KEY)
93
+ if (legacy === null) return null
94
+ // 旧 key 一次性迁移:补上本窗口标记写进本窗口 key,然后删除旧 key。
95
+ const migrated: Draft = { ...legacy, windowId: DRAFT_WINDOW_ID }
96
+ persistDraft(migrated)
97
+ try { localStorage.removeItem(LEGACY_DRAFT_KEY) } catch { /* ignore */ }
98
+ return { draft: migrated, foreign: true }
99
+ }
100
+
101
+ /** 采纳草稿(标记为本窗口所有),避免每次打开都重复提示「来自其它窗口」。 */
102
+ export function adoptDraft(draft: Draft): void {
103
+ persistDraft({ ...draft, windowId: DRAFT_WINDOW_ID })
104
+ }
105
+
106
+ export function persistDraft(draft: Draft): void {
107
+ try { localStorage.setItem(draftKey(), JSON.stringify({ ...draft, windowId: DRAFT_WINDOW_ID })) } catch { /* storage unavailable */ }
108
+ }
109
+
110
+ export function clearDraft(): void {
111
+ try { localStorage.removeItem(draftKey()) } catch { /* ignore */ }
112
+ }
113
+
114
+ /**
115
+ * 内容签名(标题 + 正文 + 标签):用来判断「这份内容是不是已经写进 wiki 了」。
116
+ * 只做相等比较,不是哈希——不做安全用途(v0.19.1)。
117
+ */
118
+ export function draftSignature(title: string, text: string, tags: string[]): string {
119
+ return `${title}\u0000${text}\u0000${tags.join('\u0001')}`
120
+ }
121
+
122
+ function pad(n: number): string {
123
+ return n < 10 ? `0${n}` : String(n)
124
+ }
125
+
126
+ /** Default note title: `YYYY-MM-DD HH:mm`. */
127
+ export function timestampTitle(date = new Date()): string {
128
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
129
+ }
130
+
131
+ /** Compact Chinese relative time for the recent picker (e.g. "3小时前"). */
132
+ export function relativeTime(iso: string | null): string {
133
+ if (iso === null) return ''
134
+ const ms = new Date(iso).getTime()
135
+ if (Number.isNaN(ms)) return ''
136
+ const diff = Date.now() - ms
137
+ const min = Math.floor(diff / 60_000)
138
+ if (min < 1) return '刚刚'
139
+ if (min < 60) return `${min}分钟前`
140
+ const hr = Math.floor(min / 60)
141
+ if (hr < 24) return `${hr}小时前`
142
+ const day = Math.floor(hr / 24)
143
+ if (day < 30) return `${day}天前`
144
+ return new Date(ms).toLocaleDateString('zh-CN')
145
+ }
146
+
147
+ /** The default tag for a new note (settings `note.tag`).
148
+ *
149
+ * Reads the shared, TTL-cached `/status` projection rather than a private copy
150
+ * of the `ui.*` shape (v0.22.8 — this was one of three duplicates). */
151
+ export async function fetchDefaultTag(): Promise<string> {
152
+ const status = await fetchStatus()
153
+ return typeof status?.note?.tag === 'string' && status.note.tag.length > 0 ? status.note.tag : 'inbox'
154
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Multi-tag chip editor for the quick-note card (v0.28.8 split).
3
+ *
4
+ * WHY IT IS ITS OWN MODULE: a self-contained widget with its own DOM, its own
5
+ * tag-suggestion cache and its own document-level listener — the card only
6
+ * mounts it. It also carries a rule worth reading on its own: tag suggestions
7
+ * are fetched PER TARGET WIKI (v0.28.0), because a note written to wiki B must
8
+ * not offer wiki A's tags.
9
+ *
10
+ * @module dsh-tiddlywiki/client/note-widget-tags
11
+ */
12
+ import { TAGS_ENDPOINT } from './endpoints.ts'
13
+
14
+ /** Multi-tag chip editor with autocomplete from the wiki's existing tags. */
15
+ export function buildTagEditor(opts: { onChange?: () => void; wikiQuery?: (url: string) => string } = {}): {
16
+ el: HTMLDivElement
17
+ /** Commits any pending input first, then returns the chips (user actions). */
18
+ getTags: () => string[]
19
+ /** Pure read of the committed chips — never commits pending input. */
20
+ peekTags: () => string[]
21
+ setDefault: (tag: string) => void
22
+ setTags: (tags: string[]) => void
23
+ /** Remove the document-level outside-click listener (unmount must not leak). */
24
+ dispose: () => void
25
+ } {
26
+ const wrap = document.createElement('div')
27
+ wrap.className = 'dsh-tw-note-tags'
28
+
29
+ const chipWrap = document.createElement('div')
30
+ chipWrap.className = 'dsh-tw-note-chips'
31
+ const input = document.createElement('input')
32
+ input.className = 'dsh-tw-note-taginput'
33
+ input.placeholder = 'tag(可多选,自动补全)'
34
+ const suggest = document.createElement('div')
35
+ suggest.className = 'dsh-tw-note-tagsuggest'
36
+ suggest.hidden = true
37
+ wrap.append(chipWrap, input, suggest)
38
+
39
+ const chips: string[] = []
40
+ const hideSuggest = (): void => { suggest.hidden = true }
41
+
42
+ const emit = (): void => { opts.onChange?.() }
43
+
44
+ const renderChips = (): void => {
45
+ chipWrap.replaceChildren()
46
+ for (const tag of chips) {
47
+ const chip = document.createElement('span')
48
+ chip.className = 'dsh-tw-note-tagchip'
49
+ chip.textContent = tag
50
+ const x = document.createElement('span')
51
+ x.className = 'dsh-tw-note-tagchip-x'
52
+ x.textContent = '×'
53
+ x.title = `移除 tag「${tag}」`
54
+ x.addEventListener('click', (event) => {
55
+ event.stopPropagation()
56
+ const i = chips.indexOf(tag)
57
+ if (i >= 0) {
58
+ chips.splice(i, 1)
59
+ renderChips()
60
+ emit()
61
+ }
62
+ })
63
+ chip.append(x)
64
+ chipWrap.append(chip)
65
+ }
66
+ }
67
+
68
+ const addTag = (tag: string): void => {
69
+ const t = tag.trim()
70
+ if (t.length === 0 || chips.includes(t)) return
71
+ chips.push(t)
72
+ input.value = ''
73
+ renderChips()
74
+ hideSuggest()
75
+ input.focus()
76
+ emit()
77
+ }
78
+
79
+ const commitInput = (): void => {
80
+ for (const raw of input.value.split(/\s+/)) addTag(raw)
81
+ }
82
+
83
+ // Existing tags, fetched lazily once per widget lifetime — PER TARGET WIKI
84
+ // (v0.28.0): the card can be pointed at another knowledge base, and its tag
85
+ // suggestions must come from the same one the note is written to. The memo is
86
+ // keyed by the resolved URL, so switching the target re-reads automatically
87
+ // (no reset call, nothing to forget).
88
+ let knownTags: string[] = []
89
+ let tagsPromise: Promise<string[]> | undefined
90
+ let tagsPromiseKey: string | undefined
91
+ const ensureTags = (): Promise<string[]> => {
92
+ const url = opts.wikiQuery?.(TAGS_ENDPOINT) ?? TAGS_ENDPOINT
93
+ if (tagsPromise === undefined || tagsPromiseKey !== url) {
94
+ tagsPromiseKey = url
95
+ tagsPromise = fetch(url, { signal: AbortSignal.timeout(5_000) })
96
+ .then((r) => (r.ok ? (r.json() as Promise<{ tags?: string[] }>) : Promise.resolve<{ tags?: string[] }>({})))
97
+ .then((p) => [...(p.tags ?? [])].sort((a, b) => a.localeCompare(b, 'zh')))
98
+ .catch(() => [])
99
+ void tagsPromise.then((list) => { knownTags = list })
100
+ }
101
+ return tagsPromise
102
+ }
103
+
104
+ const showSuggest = (): void => {
105
+ const q = input.value.trim().toLowerCase()
106
+ const matches = knownTags
107
+ .filter((t) => !chips.includes(t) && (q.length === 0 || t.toLowerCase().includes(q)))
108
+ .slice(0, 8)
109
+ suggest.replaceChildren()
110
+ for (const tag of matches) {
111
+ const item = document.createElement('div')
112
+ item.className = 'dsh-tw-note-tagsuggest-item'
113
+ item.textContent = tag
114
+ // 键盘可达:div + mousedown 对键盘用户不可用,补 role/tabIndex/Enter·Space。
115
+ item.setAttribute('role', 'button')
116
+ item.tabIndex = 0
117
+ item.setAttribute('aria-label', `添加标签「${tag}」`)
118
+ item.addEventListener('mousedown', (event) => {
119
+ event.preventDefault()
120
+ addTag(tag)
121
+ })
122
+ item.addEventListener('keydown', (event) => {
123
+ if (event.key === 'Enter' || event.key === ' ') {
124
+ event.preventDefault()
125
+ addTag(tag)
126
+ }
127
+ })
128
+ suggest.append(item)
129
+ }
130
+ suggest.hidden = matches.length === 0
131
+ }
132
+
133
+ input.addEventListener('focus', () => { void ensureTags().then(showSuggest) })
134
+ input.addEventListener('input', () => {
135
+ if (knownTags.length === 0) void ensureTags().then(showSuggest)
136
+ else showSuggest()
137
+ })
138
+ input.addEventListener('keydown', (event) => {
139
+ if (event.key === 'Enter' || event.key === ',') {
140
+ event.preventDefault()
141
+ commitInput()
142
+ } else if (event.key === 'Backspace' && input.value.length === 0 && chips.length > 0) {
143
+ chips.pop()
144
+ renderChips()
145
+ emit()
146
+ } else if (event.key === 'Escape') {
147
+ hideSuggest()
148
+ }
149
+ })
150
+ const onDocClick = (event: Event): void => {
151
+ if (!wrap.contains(event.target as Node)) hideSuggest()
152
+ }
153
+ document.addEventListener('click', onDocClick, true)
154
+
155
+ return {
156
+ el: wrap,
157
+ getTags: () => {
158
+ commitInput()
159
+ return [...chips]
160
+ },
161
+ peekTags: () => [...chips],
162
+ setDefault: (tag: string) => {
163
+ // Only pre-fill when nothing is chosen yet; never steal focus.
164
+ if (chips.length === 0) {
165
+ const t = tag.trim()
166
+ if (t.length > 0) {
167
+ chips.push(t)
168
+ renderChips()
169
+ }
170
+ }
171
+ },
172
+ setTags: (tags: string[]) => {
173
+ chips.length = 0
174
+ for (const tag of Array.isArray(tags) ? tags : []) {
175
+ const t = typeof tag === 'string' ? tag.trim() : ''
176
+ if (t.length > 0 && !chips.includes(t)) chips.push(t)
177
+ }
178
+ input.value = ''
179
+ renderChips()
180
+ hideSuggest()
181
+ },
182
+ dispose: () => document.removeEventListener('click', onDocClick, true),
183
+ }
184
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Attachment upload for the quick-note card (v0.28.8 split).
3
+ *
4
+ * One function, one concern: take a `File` the user dropped / picked, POST it to
5
+ * the upload route, and insert the resulting Markdown image/link line at the
6
+ * caret. Kept out of `note-widget.ts` so the widget file stays about the card's
7
+ * state machine and DOM.
8
+ *
9
+ * @module dsh-tiddlywiki/client/note-widget-upload
10
+ */
11
+ import { toast } from './toast.ts'
12
+ import { type MarkdownEditor } from './markdown-editor.ts'
13
+ import { UPLOAD_ENDPOINT } from './endpoints.ts'
14
+
15
+ export const MAX_UPLOAD_BYTES = 64 * 1024 * 1024
16
+
17
+ /**
18
+ * Upload one file to the wiki (raw body, name in ?name=), then insert a
19
+ * Markdown image/link line at the caret of the given editor.
20
+ *
21
+ * `wikiQuery` scopes the upload to the CARD's target knowledge base (v0.28.0):
22
+ * an attachment must land in the same wiki as the note that references it.
23
+ */
24
+ export async function uploadInto(file: File, editor: MarkdownEditor, wikiQuery: (url: string) => string = (url) => url): Promise<void> {
25
+ if (file.size > MAX_UPLOAD_BYTES) {
26
+ toast(`文件过大(≤ ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)}MB)`)
27
+ return
28
+ }
29
+ try {
30
+ const res = await fetch(wikiQuery(`${UPLOAD_ENDPOINT}?name=${encodeURIComponent(file.name)}`), {
31
+ method: 'POST',
32
+ headers: { 'content-type': file.type || 'application/octet-stream' },
33
+ body: file,
34
+ signal: AbortSignal.timeout(120_000),
35
+ })
36
+ const payload = (await res.json().catch(() => null)) as { ok?: boolean; name?: string; url?: string; error?: string } | null
37
+ if (!res.ok || payload?.ok !== true) {
38
+ toast(`上传失败:${payload?.error ?? `HTTP ${res.status}`}`)
39
+ return
40
+ }
41
+ const name = payload.name ?? file.name
42
+ const markdown = file.type.startsWith('image/')
43
+ ? `![${name}](${payload.url})`
44
+ : `[${name}](${payload.url})`
45
+ editor.insertAtCaret(markdown)
46
+ toast(`已上传「${name}」并插入链接`)
47
+ } catch (err) {
48
+ toast(`上传失败:${err instanceof Error ? err.message : String(err)}`)
49
+ }
50
+ }