dsh-tiddlywiki 0.1.2 → 0.2.1

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 as the DSH persistent knowledge base: tiddlywiki_* agent tools (search/get/put/delete + git sync), a full TiddlyWiki editor embedded in the GUI center column, a floating quick-note widget in the chat area, and git-based sync with auto-commit. Mounts via the official dsh plugin system — no DSH source changes.",
4
- "version": "0.1.2",
4
+ "version": "0.2.1",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "dsh",
@@ -1,210 +1,373 @@
1
- /**
2
- * Floating quick-note widget (design doc §12, D6/D7) — bottom-right, fixed,
3
- * collapsible, independent of any shell DOM. Lets the human jot drafts /
4
- * scratch notes while waiting for the AI or drafting the next prompt.
5
- *
6
- * Save posts to /dsh-tiddlywiki/note → an independent tiddler (title & tag
7
- * editable; defaults: timestamp title + config tag, usually "inbox").
8
- *
9
- * @module dsh-tiddlywiki/client/note-widget
10
- */
11
- import { toast } from './toast.ts'
12
- import { openEditorPopup } from './editor-popup.ts'
13
-
14
- const NOTE_ENDPOINT = '/dsh-tiddlywiki/note'
15
- const EDIT_ENDPOINT = '/dsh-tiddlywiki/edit'
16
- const STATUS_ENDPOINT = '/dsh-tiddlywiki/status'
17
-
18
- function pad(n: number): string {
19
- return n < 10 ? `0${n}` : String(n)
20
- }
21
-
22
- /** Default note title: `YYYY-MM-DD HH:mm`. */
23
- function timestampTitle(date = new Date()): string {
24
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
25
- }
26
-
27
- async function fetchDefaultTag(): Promise<string> {
28
- try {
29
- const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(5_000) })
30
- if (!res.ok) return 'inbox'
31
- const payload = (await res.json()) as { note?: { tag?: string } }
32
- return payload.note?.tag ?? 'inbox'
33
- } catch {
34
- return 'inbox'
35
- }
36
- }
37
-
38
- export function mountNoteWidget(): () => void {
39
- const root = document.createElement('div')
40
- root.className = 'dsh-tw-note'
41
-
42
- const card = document.createElement('div')
43
- card.className = 'dsh-tw-note-card'
44
- card.hidden = true
45
-
46
- const head = document.createElement('div')
47
- head.className = 'dsh-tw-note-head'
48
- const label = document.createElement('span')
49
- label.className = 'dsh-tw-note-label'
50
- label.textContent = '📝 快速笔记'
51
- const closeBtn = document.createElement('button')
52
- closeBtn.type = 'button'
53
- closeBtn.className = 'dsh-tw-note-close'
54
- closeBtn.title = '收起'
55
- closeBtn.textContent = '✕'
56
- head.append(label, closeBtn)
57
-
58
- const fields = document.createElement('div')
59
- fields.className = 'dsh-tw-note-fields'
60
- const titleInput = document.createElement('input')
61
- titleInput.className = 'dsh-tw-note-title'
62
- titleInput.placeholder = '标题(默认时间戳)'
63
- const tagInput = document.createElement('input')
64
- tagInput.className = 'dsh-tw-note-tag'
65
- tagInput.placeholder = 'tag(默认 inbox)'
66
- fields.append(titleInput, tagInput)
67
-
68
- const textarea = document.createElement('textarea')
69
- textarea.className = 'dsh-tw-note-text'
70
- textarea.placeholder = '写点东西…(Ctrl+Enter 保存)'
71
-
72
- const foot = document.createElement('div')
73
- foot.className = 'dsh-tw-note-foot'
74
- const hint = document.createElement('span')
75
- hint.className = 'dsh-tw-note-hint'
76
- hint.textContent = 'Ctrl+Enter'
77
- const edit = document.createElement('button')
78
- edit.type = 'button'
79
- edit.className = 'dsh-tw-note-edit'
80
- edit.title = '保存并在 TiddlyWiki 原生编辑器中打开'
81
- edit.textContent = '✏️ 在 TW 中编辑'
82
- const save = document.createElement('button')
83
- save.type = 'button'
84
- save.className = 'dsh-tw-note-save'
85
- save.textContent = '保存'
86
- foot.append(hint, edit, save)
87
-
88
- card.append(head, fields, textarea, foot)
89
-
90
- const toggle = document.createElement('button')
91
- toggle.type = 'button'
92
- toggle.className = 'dsh-tw-note-toggle'
93
- toggle.textContent = '📝 快速笔记'
94
-
95
- root.append(card, toggle)
96
- document.body.append(root)
97
-
98
- let defaultTag = 'inbox'
99
- let opened = false
100
-
101
- const resetTitle = (): void => {
102
- titleInput.value = timestampTitle()
103
- }
104
-
105
- const open = async (): Promise<void> => {
106
- card.hidden = false
107
- opened = true
108
- resetTitle()
109
- if (tagInput.value.length === 0) tagInput.value = defaultTag
110
- textarea.focus()
111
- // Refresh the configured default tag on each open (cheap, best effort).
112
- defaultTag = await fetchDefaultTag()
113
- if (tagInput.value.length === 0) tagInput.value = defaultTag
114
- }
115
-
116
- const close = (): void => {
117
- card.hidden = true
118
- opened = false
119
- }
120
-
121
- toggle.addEventListener('click', () => { void (opened ? close() : open()) })
122
- closeBtn.addEventListener('click', close)
123
-
124
- const doSave = async (): Promise<void> => {
125
- const text = textarea.value.trim()
126
- if (text.length === 0) {
127
- toast('内容为空,未保存')
128
- return
129
- }
130
- save.disabled = true
131
- save.textContent = '保存中…'
132
- try {
133
- const res = await fetch(NOTE_ENDPOINT, {
134
- method: 'POST',
135
- headers: { 'content-type': 'application/json' },
136
- body: JSON.stringify({
137
- title: titleInput.value.trim(),
138
- tag: tagInput.value.trim(),
139
- text,
140
- }),
141
- signal: AbortSignal.timeout(10_000),
142
- })
143
- const payload = (await res.json().catch(() => null)) as { ok?: boolean; title?: string; error?: string } | null
144
- if (!res.ok || payload?.ok !== true) {
145
- toast(`保存失败:${payload?.error ?? `HTTP ${res.status}`}`)
146
- return
147
- }
148
- textarea.value = ''
149
- resetTitle()
150
- toast(`已保存「${payload.title ?? titleInput.value}」`)
151
- close()
152
- } catch (err) {
153
- toast(`保存失败:${err instanceof Error ? err.message : String(err)}`)
154
- } finally {
155
- save.disabled = false
156
- save.textContent = '保存'
157
- }
158
- }
159
-
160
- save.addEventListener('click', () => { void doSave() })
161
- textarea.addEventListener('keydown', (event) => {
162
- if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
163
- event.preventDefault()
164
- void doSave()
165
- }
166
- })
167
-
168
- /** Save (if non-empty) and open the tiddler in TW's native editor. */
169
- const doEdit = async (): Promise<void> => {
170
- const title = titleInput.value.trim().length > 0 ? titleInput.value.trim() : timestampTitle()
171
- const text = textarea.value
172
- const tag = tagInput.value.trim()
173
- edit.disabled = true
174
- edit.textContent = '打开中…'
175
- try {
176
- const res = await fetch(EDIT_ENDPOINT, {
177
- method: 'POST',
178
- headers: { 'content-type': 'application/json' },
179
- body: JSON.stringify({ title, tag, text }),
180
- signal: AbortSignal.timeout(10_000),
181
- })
182
- const payload = (await res.json().catch(() => null)) as
183
- | { ok?: boolean; title?: string; draftTitle?: string; twUrl?: string; error?: string }
184
- | null
185
- if (!res.ok || payload?.ok !== true) {
186
- toast(`打开失败:${payload?.error ?? `HTTP ${res.status}`}`)
187
- return
188
- }
189
- if (typeof payload.twUrl !== 'string' || typeof payload.draftTitle !== 'string') {
190
- toast('打开失败:服务未返回编辑器地址')
191
- return
192
- }
193
- openEditorPopup(`${payload.twUrl}#${encodeURIComponent(payload.draftTitle)}`, payload.title ?? title)
194
- toast(`已在弹出窗口打开「${payload.title ?? title}」编辑器`)
195
- } catch (err) {
196
- toast(`打开失败:${err instanceof Error ? err.message : String(err)}`)
197
- } finally {
198
- edit.disabled = false
199
- edit.textContent = '✏️ 在 TW 中编辑'
200
- }
201
- }
202
-
203
- edit.addEventListener('click', () => { void doEdit() })
204
-
205
- return () => {
206
- root.remove()
207
- const toastEl = document.querySelector<HTMLElement>('.dsh-tw-toast')
208
- toastEl?.remove()
209
- }
210
- }
1
+ /**
2
+ * Floating quick-note widget (design doc §12, D6/D7) — bottom-right, fixed,
3
+ * collapsible, independent of any shell DOM. Lets the human jot drafts /
4
+ * scratch notes while waiting for the AI or drafting the next prompt.
5
+ *
6
+ * Save posts to /dsh-tiddlywiki/note → an independent tiddler (title & tags
7
+ * editable; defaults: timestamp title + config tag, usually "inbox").
8
+ *
9
+ * ui.showQuickNote config: the widget is mounted async and stays hidden (and
10
+ * never appended) when the option is off, so the toggle button can be disabled
11
+ * from the settings page without touching the shell.
12
+ *
13
+ * Tag editor: multi-select chips + autocomplete from the wiki's existing tags
14
+ * (GET /dsh-tiddlywiki/tags). Enter/`,` commits the typed value; Backspace on
15
+ * an empty field removes the last chip; the dropdown filters on typing.
16
+ *
17
+ * @module dsh-tiddlywiki/client/note-widget
18
+ */
19
+ import { toast } from './toast.ts'
20
+ import { openEditorPopup } from './editor-popup.ts'
21
+
22
+ const NOTE_ENDPOINT = '/dsh-tiddlywiki/note'
23
+ const EDIT_ENDPOINT = '/dsh-tiddlywiki/edit'
24
+ const STATUS_ENDPOINT = '/dsh-tiddlywiki/status'
25
+ const TAGS_ENDPOINT = '/dsh-tiddlywiki/tags'
26
+
27
+ function pad(n: number): string {
28
+ return n < 10 ? `0${n}` : String(n)
29
+ }
30
+
31
+ /** Default note title: `YYYY-MM-DD HH:mm`. */
32
+ function timestampTitle(date = new Date()): string {
33
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
34
+ }
35
+
36
+ interface UiOptions {
37
+ showQuickNote: boolean
38
+ defaultTag: string
39
+ }
40
+
41
+ async function fetchUiOptions(): Promise<UiOptions> {
42
+ const fallback: UiOptions = { showQuickNote: true, defaultTag: 'inbox' }
43
+ try {
44
+ const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(5_000) })
45
+ if (!res.ok) return fallback
46
+ const payload = (await res.json()) as { ui?: { showQuickNote?: boolean }; note?: { tag?: string } }
47
+ return {
48
+ showQuickNote: payload.ui?.showQuickNote !== false,
49
+ defaultTag: payload.note?.tag ?? 'inbox',
50
+ }
51
+ } catch {
52
+ return fallback
53
+ }
54
+ }
55
+
56
+ /** Multi-tag chip editor with autocomplete from the wiki's existing tags. */
57
+ function buildTagEditor(): {
58
+ el: HTMLDivElement
59
+ getTags: () => string[]
60
+ setDefault: (tag: string) => void
61
+ } {
62
+ const wrap = document.createElement('div')
63
+ wrap.className = 'dsh-tw-note-tags'
64
+
65
+ const chipWrap = document.createElement('div')
66
+ chipWrap.className = 'dsh-tw-note-chips'
67
+ const input = document.createElement('input')
68
+ input.className = 'dsh-tw-note-taginput'
69
+ input.placeholder = 'tag(可多选,自动补全)'
70
+ const suggest = document.createElement('div')
71
+ suggest.className = 'dsh-tw-note-tagsuggest'
72
+ suggest.hidden = true
73
+ wrap.append(chipWrap, input, suggest)
74
+
75
+ const chips: string[] = []
76
+ const hideSuggest = (): void => { suggest.hidden = true }
77
+
78
+ const renderChips = (): void => {
79
+ chipWrap.replaceChildren()
80
+ for (const tag of chips) {
81
+ const chip = document.createElement('span')
82
+ chip.className = 'dsh-tw-note-tagchip'
83
+ chip.textContent = tag
84
+ const x = document.createElement('span')
85
+ x.className = 'dsh-tw-note-tagchip-x'
86
+ x.textContent = '×'
87
+ x.title = `移除 tag「${tag}」`
88
+ x.addEventListener('click', (event) => {
89
+ event.stopPropagation()
90
+ const i = chips.indexOf(tag)
91
+ if (i >= 0) {
92
+ chips.splice(i, 1)
93
+ renderChips()
94
+ }
95
+ })
96
+ chip.append(x)
97
+ chipWrap.append(chip)
98
+ }
99
+ }
100
+
101
+ const addTag = (tag: string): void => {
102
+ const t = tag.trim()
103
+ if (t.length === 0 || chips.includes(t)) return
104
+ chips.push(t)
105
+ input.value = ''
106
+ renderChips()
107
+ hideSuggest()
108
+ input.focus()
109
+ }
110
+
111
+ const commitInput = (): void => {
112
+ for (const raw of input.value.split(/\s+/)) addTag(raw)
113
+ }
114
+
115
+ // Existing tags, fetched lazily once per widget lifetime.
116
+ let knownTags: string[] = []
117
+ let tagsPromise: Promise<string[]> | undefined
118
+ const ensureTags = (): Promise<string[]> => {
119
+ tagsPromise ??= fetch(TAGS_ENDPOINT, { signal: AbortSignal.timeout(5_000) })
120
+ .then((r) => (r.ok ? (r.json() as Promise<{ tags?: string[] }>) : Promise.resolve<{ tags?: string[] }>({})))
121
+ .then((p) => [...(p.tags ?? [])].sort((a, b) => a.localeCompare(b, 'zh')))
122
+ .catch(() => [])
123
+ void tagsPromise.then((list) => { knownTags = list })
124
+ return tagsPromise
125
+ }
126
+
127
+ const showSuggest = (): void => {
128
+ const q = input.value.trim().toLowerCase()
129
+ const matches = knownTags
130
+ .filter((t) => !chips.includes(t) && (q.length === 0 || t.toLowerCase().includes(q)))
131
+ .slice(0, 8)
132
+ suggest.replaceChildren()
133
+ for (const tag of matches) {
134
+ const item = document.createElement('div')
135
+ item.className = 'dsh-tw-note-tagsuggest-item'
136
+ item.textContent = tag
137
+ item.addEventListener('mousedown', (event) => {
138
+ event.preventDefault()
139
+ addTag(tag)
140
+ })
141
+ suggest.append(item)
142
+ }
143
+ suggest.hidden = matches.length === 0
144
+ }
145
+
146
+ input.addEventListener('focus', () => { void ensureTags().then(showSuggest) })
147
+ input.addEventListener('input', () => {
148
+ if (knownTags.length === 0) void ensureTags().then(showSuggest)
149
+ else showSuggest()
150
+ })
151
+ input.addEventListener('keydown', (event) => {
152
+ if (event.key === 'Enter' || event.key === ',') {
153
+ event.preventDefault()
154
+ commitInput()
155
+ } else if (event.key === 'Backspace' && input.value.length === 0 && chips.length > 0) {
156
+ chips.pop()
157
+ renderChips()
158
+ } else if (event.key === 'Escape') {
159
+ hideSuggest()
160
+ }
161
+ })
162
+ document.addEventListener('click', (event) => {
163
+ if (!wrap.contains(event.target as Node)) hideSuggest()
164
+ }, true)
165
+
166
+ return {
167
+ el: wrap,
168
+ getTags: () => {
169
+ commitInput()
170
+ return [...chips]
171
+ },
172
+ setDefault: (tag: string) => {
173
+ // Only pre-fill when nothing is chosen yet; never steal focus.
174
+ if (chips.length === 0) {
175
+ const t = tag.trim()
176
+ if (t.length > 0) {
177
+ chips.push(t)
178
+ renderChips()
179
+ }
180
+ }
181
+ },
182
+ }
183
+ }
184
+
185
+ /** Build the whole widget DOM and wire it up (returns the appended root). */
186
+ function buildWidget(): HTMLDivElement {
187
+ const root = document.createElement('div')
188
+ root.className = 'dsh-tw-note'
189
+
190
+ const card = document.createElement('div')
191
+ card.className = 'dsh-tw-note-card'
192
+ card.hidden = true
193
+
194
+ const head = document.createElement('div')
195
+ head.className = 'dsh-tw-note-head'
196
+ const label = document.createElement('span')
197
+ label.className = 'dsh-tw-note-label'
198
+ label.textContent = '📝 快速笔记'
199
+ const closeBtn = document.createElement('button')
200
+ closeBtn.type = 'button'
201
+ closeBtn.className = 'dsh-tw-note-close'
202
+ closeBtn.title = '收起'
203
+ closeBtn.textContent = '✕'
204
+ head.append(label, closeBtn)
205
+
206
+ const fields = document.createElement('div')
207
+ fields.className = 'dsh-tw-note-fields'
208
+ const titleInput = document.createElement('input')
209
+ titleInput.className = 'dsh-tw-note-title'
210
+ titleInput.placeholder = '标题(默认时间戳)'
211
+ const tagEditor = buildTagEditor()
212
+ fields.append(titleInput, tagEditor.el)
213
+
214
+ const textarea = document.createElement('textarea')
215
+ textarea.className = 'dsh-tw-note-text'
216
+ textarea.placeholder = '写点东西…(Ctrl+Enter 保存)'
217
+
218
+ const foot = document.createElement('div')
219
+ foot.className = 'dsh-tw-note-foot'
220
+ const hint = document.createElement('span')
221
+ hint.className = 'dsh-tw-note-hint'
222
+ hint.textContent = 'Ctrl+Enter'
223
+ const edit = document.createElement('button')
224
+ edit.type = 'button'
225
+ edit.className = 'dsh-tw-note-edit'
226
+ edit.title = '保存并在 TiddlyWiki 原生编辑器中打开'
227
+ edit.textContent = '✏️ 在 TW 中编辑'
228
+ const save = document.createElement('button')
229
+ save.type = 'button'
230
+ save.className = 'dsh-tw-note-save'
231
+ save.textContent = '保存'
232
+ foot.append(hint, edit, save)
233
+
234
+ card.append(head, fields, textarea, foot)
235
+
236
+ const toggle = document.createElement('button')
237
+ toggle.type = 'button'
238
+ toggle.className = 'dsh-tw-note-toggle'
239
+ toggle.textContent = '📝 快速笔记'
240
+
241
+ root.append(card, toggle)
242
+ document.body.append(root)
243
+
244
+ let defaultTag = 'inbox'
245
+ let opened = false
246
+
247
+ const resetTitle = (): void => {
248
+ titleInput.value = timestampTitle()
249
+ }
250
+
251
+ const open = async (): Promise<void> => {
252
+ card.hidden = false
253
+ opened = true
254
+ resetTitle()
255
+ textarea.focus()
256
+ // Fetch the effective default tag once, then pre-fill if nothing is chosen.
257
+ defaultTag = (await fetchUiOptions()).defaultTag
258
+ tagEditor.setDefault(defaultTag)
259
+ }
260
+
261
+ const close = (): void => {
262
+ card.hidden = true
263
+ opened = false
264
+ }
265
+
266
+ toggle.addEventListener('click', () => { void (opened ? close() : open()) })
267
+ closeBtn.addEventListener('click', close)
268
+
269
+ const doSave = async (): Promise<void> => {
270
+ const text = textarea.value.trim()
271
+ if (text.length === 0) {
272
+ toast('内容为空,未保存')
273
+ return
274
+ }
275
+ save.disabled = true
276
+ save.textContent = '保存中…'
277
+ try {
278
+ const res = await fetch(NOTE_ENDPOINT, {
279
+ method: 'POST',
280
+ headers: { 'content-type': 'application/json' },
281
+ body: JSON.stringify({
282
+ title: titleInput.value.trim(),
283
+ tags: tagEditor.getTags(),
284
+ text,
285
+ }),
286
+ signal: AbortSignal.timeout(10_000),
287
+ })
288
+ const payload = (await res.json().catch(() => null)) as { ok?: boolean; title?: string; error?: string } | null
289
+ if (!res.ok || payload?.ok !== true) {
290
+ toast(`保存失败:${payload?.error ?? `HTTP ${res.status}`}`)
291
+ return
292
+ }
293
+ textarea.value = ''
294
+ resetTitle()
295
+ toast(`已保存「${payload.title ?? titleInput.value}」`)
296
+ close()
297
+ } catch (err) {
298
+ toast(`保存失败:${err instanceof Error ? err.message : String(err)}`)
299
+ } finally {
300
+ save.disabled = false
301
+ save.textContent = '保存'
302
+ }
303
+ }
304
+
305
+ save.addEventListener('click', () => { void doSave() })
306
+ textarea.addEventListener('keydown', (event) => {
307
+ if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
308
+ event.preventDefault()
309
+ void doSave()
310
+ }
311
+ })
312
+
313
+ /** Save (if non-empty) and open the tiddler in TW's native editor. */
314
+ const doEdit = async (): Promise<void> => {
315
+ const title = titleInput.value.trim().length > 0 ? titleInput.value.trim() : timestampTitle()
316
+ const text = textarea.value
317
+ const tags = tagEditor.getTags()
318
+ edit.disabled = true
319
+ edit.textContent = '打开中…'
320
+ try {
321
+ const res = await fetch(EDIT_ENDPOINT, {
322
+ method: 'POST',
323
+ headers: { 'content-type': 'application/json' },
324
+ body: JSON.stringify({ title, tags, text }),
325
+ signal: AbortSignal.timeout(10_000),
326
+ })
327
+ const payload = (await res.json().catch(() => null)) as
328
+ | { ok?: boolean; title?: string; draftTitle?: string; twUrl?: string; error?: string }
329
+ | null
330
+ if (!res.ok || payload?.ok !== true) {
331
+ toast(`打开失败:${payload?.error ?? `HTTP ${res.status}`}`)
332
+ return
333
+ }
334
+ if (typeof payload.twUrl !== 'string' || typeof payload.draftTitle !== 'string') {
335
+ toast('打开失败:服务未返回编辑器地址')
336
+ return
337
+ }
338
+ openEditorPopup(`${payload.twUrl}#${encodeURIComponent(payload.draftTitle)}`, payload.title ?? title)
339
+ toast(`已在弹出窗口打开「${payload.title ?? title}」编辑器`)
340
+ } catch (err) {
341
+ toast(`打开失败:${err instanceof Error ? err.message : String(err)}`)
342
+ } finally {
343
+ edit.disabled = false
344
+ edit.textContent = '✏️ 在 TW 中编辑'
345
+ }
346
+ }
347
+
348
+ edit.addEventListener('click', () => { void doEdit() })
349
+
350
+ return root
351
+ }
352
+
353
+ /**
354
+ * Mount the floating quick-note widget. Fetches /status first: when
355
+ * `ui.showQuickNote` is off the widget is never created (no DOM side effects).
356
+ * Returns a disposer that removes it.
357
+ */
358
+ export function mountNoteWidget(): () => void {
359
+ let disposed = false
360
+ let root: HTMLDivElement | undefined
361
+ const disposer = (): void => {
362
+ disposed = true
363
+ root?.remove()
364
+ const toastEl = document.querySelector<HTMLElement>('.dsh-tw-toast')
365
+ toastEl?.remove()
366
+ }
367
+ void (async () => {
368
+ const ui = await fetchUiOptions()
369
+ if (disposed || !ui.showQuickNote) return
370
+ root = buildWidget()
371
+ })()
372
+ return disposer
373
+ }