dsh-tiddlywiki 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,6 +25,8 @@ export interface PluginConfigShape {
25
25
  showQuickNote?: boolean
26
26
  /** 是否显示 TW 面板右下角的「状态/重载」悬浮按钮(默认 true)。 */
27
27
  showPanelStatus?: boolean
28
+ /** 是否在界面右下角显示「同步」悬浮按钮(默认 true)。 */
29
+ showSyncButton?: boolean
28
30
  }
29
31
  uiLanguage?: string
30
32
  [key: string]: unknown
package/src/host/git.ts CHANGED
@@ -118,10 +118,20 @@ export class GitFace {
118
118
  return { exists: true, branch, dirty, dirtyFiles, remote, ...(lastCommit !== undefined ? { lastCommit } : {}), ...(ahead !== undefined ? { ahead } : {}), ...(behind !== undefined ? { behind } : {}) }
119
119
  }
120
120
 
121
- /** `git pull --rebase --autostash`; on conflict: abort + report files. */
122
- async pull(dir: string): Promise<GitActionResult> {
121
+ /** `git pull --rebase --autostash`; on conflict: abort + report files.
122
+ * On success, `changed: true` means HEAD actually moved (files came in /
123
+ * commits were replayed) — callers use it to decide whether a running TW
124
+ * child needs a restart to drop its stale in-memory snapshot. */
125
+ async pull(dir: string): Promise<GitActionResult & { changed?: boolean }> {
126
+ const before = await this.exec(['rev-parse', 'HEAD'], { cwd: dir, timeout: QUICK_TIMEOUT_MS })
127
+ const beforeHead = before.ok ? before.stdout.trim() : ''
123
128
  const r = await this.exec(['pull', '--rebase', '--autostash'], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
124
- if (r.ok) return { ok: true, message: r.stdout.trim() || 'pull ok' }
129
+ if (r.ok) {
130
+ const after = await this.exec(['rev-parse', 'HEAD'], { cwd: dir, timeout: QUICK_TIMEOUT_MS })
131
+ const afterHead = after.ok ? after.stdout.trim() : ''
132
+ const changed = beforeHead.length > 0 && beforeHead !== afterHead
133
+ return { ok: true, message: r.stdout.trim() || 'pull ok', ...(changed ? { changed: true } : {}) }
134
+ }
125
135
  const conflictFiles = await this.unmergedFiles(dir)
126
136
  await this.exec(['rebase', '--abort'], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
127
137
  const reason = (r.stderr.trim() || r.stdout.trim()).slice(0, 500)
@@ -15,6 +15,8 @@
15
15
  * @module dsh-tiddlywiki/host/routes
16
16
  */
17
17
  import type { IncomingMessage, ServerResponse } from 'node:http'
18
+ import { mkdir, writeFile, access } from 'node:fs/promises'
19
+ import { basename, extname, join } from 'node:path'
18
20
  import type { TiddlyWebClient } from './tw-api.ts'
19
21
  import type { WikiServer } from './wiki.ts'
20
22
  import type { GitFace } from './git.ts'
@@ -28,18 +30,28 @@ const MAX_BODY_BYTES = 2 * 1024 * 1024
28
30
  /** Max passthrough body (tiddler content can be large). */
29
31
  const MAX_PROXY_BODY_BYTES = 16 * 1024 * 1024
30
32
 
33
+ /** Max uploaded file body. */
34
+ const MAX_UPLOAD_BYTES = 64 * 1024 * 1024
35
+
31
36
  /** Structural webserver face (a subset of dsh-host-webserver). */
32
37
  export interface WebServerFace {
33
38
  register(route: { kind: 'exact' | 'prefix'; path: string; handler: (req: IncomingMessage, res: ServerResponse) => void }): () => void
34
39
  }
35
40
 
41
+ /** Effective UI visibility flags returned by /status (mirror index.ts). */
42
+ export interface UiDefaultsPublic {
43
+ showQuickNote: boolean
44
+ showPanelStatus: boolean
45
+ showSyncButton: boolean
46
+ }
47
+
36
48
  export interface RouteDeps {
37
49
  server: WikiServer
38
50
  getClient: () => TiddlyWebClient | undefined
39
51
  git: GitFace
40
52
  autoCommit: () => void
41
53
  noteDefaults: () => { tag: string }
42
- uiDefaults: () => { showQuickNote: boolean; showPanelStatus: boolean }
54
+ uiDefaults: () => UiDefaultsPublic
43
55
  getWikiPath: () => string
44
56
  }
45
57
 
@@ -61,6 +73,41 @@ function readBody(req: IncomingMessage, limit = MAX_BODY_BYTES): Promise<string>
61
73
  })
62
74
  }
63
75
 
76
+ /** Read a raw (binary-safe) request body up to `limit` bytes. */
77
+ function readBodyBuffer(req: IncomingMessage, limit = MAX_UPLOAD_BYTES): Promise<Buffer> {
78
+ return new Promise((resolveP, rejectP) => {
79
+ let size = 0
80
+ const chunks: Buffer[] = []
81
+ req.on('data', (chunk: Buffer) => {
82
+ size += chunk.length
83
+ if (size > limit) {
84
+ rejectP(new Error('file too large'))
85
+ req.destroy()
86
+ return
87
+ }
88
+ chunks.push(chunk)
89
+ })
90
+ req.on('end', () => resolveP(Buffer.concat(chunks)))
91
+ req.on('error', rejectP)
92
+ })
93
+ }
94
+
95
+ /**
96
+ * Sanitize an uploaded filename into a safe bare name (no path separators,
97
+ * no `..`, no control characters). Returns '' when nothing usable remains.
98
+ */
99
+ function sanitizeUploadName(input: unknown): string {
100
+ if (typeof input !== 'string') return ''
101
+ const name = basename(input.trim().replace(/[\\/]+/g, '/'))
102
+ .replace(/[\u0000-\u001f\u007f]/g, '')
103
+ .replace(/[<>:"|?*]/g, '_')
104
+ .replace(/^\.+/, '')
105
+ .trim()
106
+ if (name.length === 0 || name === '.' || name === '..') return ''
107
+ if (name.length > 160) return name.slice(0, 160)
108
+ return name
109
+ }
110
+
64
111
  function json(res: ServerResponse, payload: unknown, status = 200): void {
65
112
  const body = JSON.stringify(payload)
66
113
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
@@ -216,6 +263,107 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
216
263
  }
217
264
  }
218
265
 
266
+ /** One-click git sync for the floating button / settings page: pull →
267
+ * commit → push, then return the fresh status. Mirrors the agent tool's
268
+ * `action=sync` (design doc §7 conflict policy — rebase conflict aborts).
269
+ * When the pull actually changed the working tree, the running TW child
270
+ * still holds the old in-memory snapshot — restart it (same port) so the
271
+ * UI reflects the pulled files instead of looking stale. */
272
+ const handleSync = async (_req: IncomingMessage, res: ServerResponse): Promise<void> => {
273
+ const dir = deps.getWikiPath()
274
+ const status = async (): Promise<GitStatusViewPublic | null> => {
275
+ try { return await deps.git.status(dir) } catch { return null }
276
+ }
277
+ try {
278
+ const pulled = await deps.git.pull(dir)
279
+ if (!pulled.ok) {
280
+ json(res, {
281
+ ok: false,
282
+ action: 'sync',
283
+ message: pulled.message,
284
+ ...(pulled.conflictFiles !== undefined ? { conflictFiles: pulled.conflictFiles } : {}),
285
+ status: await status(),
286
+ }, 409)
287
+ return
288
+ }
289
+ let restarted = false
290
+ let restartError: string | undefined
291
+ if (pulled.changed === true) {
292
+ try {
293
+ await deps.server.restart()
294
+ restarted = true
295
+ } catch (err) {
296
+ restartError = err instanceof Error ? err.message : String(err)
297
+ }
298
+ }
299
+ const committed = await deps.git.commit(dir, `sync ${new Date().toISOString()}`)
300
+ const pushed = await deps.git.push(dir)
301
+ const fresh = await status()
302
+ json(res, {
303
+ ok: pushed.ok,
304
+ action: 'sync',
305
+ message: pushed.ok ? '同步完成' : pushed.message,
306
+ pull: 'ok',
307
+ ...(pulled.changed === true ? { changed: true } : {}),
308
+ restarted,
309
+ ...(restartError !== undefined ? { restartError } : {}),
310
+ commit: committed.message,
311
+ push: pushed.message,
312
+ status: fresh,
313
+ lastSync: new Date().toISOString(),
314
+ }, pushed.ok ? 200 : 502)
315
+ } catch (err) {
316
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Save an uploaded file into the wiki's `files/` folder (git-tracked; TW's
322
+ * core server serves it at `/files/<name>`, get-file.js — no restart
323
+ * needed). Body is the raw file; the name arrives in `X-Filename`. A
324
+ * collision appends `-1`, `-2`, … so nothing is ever overwritten.
325
+ */
326
+ const handleUpload = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
327
+ try {
328
+ const buf = await readBodyBuffer(req)
329
+ // Name comes from ?name= (URL-encoded) or the X-Filename header.
330
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
331
+ const nameParam = url.searchParams.get('name')
332
+ let name = sanitizeUploadName(nameParam ?? '')
333
+ if (name.length === 0 && typeof req.headers['x-filename'] === 'string') {
334
+ let decoded = ''
335
+ try { decoded = decodeURIComponent(req.headers['x-filename'] as string) } catch { decoded = req.headers['x-filename'] as string }
336
+ name = sanitizeUploadName(decoded)
337
+ }
338
+ if (name.length === 0) {
339
+ json(res, { ok: false, error: 'missing or invalid filename' }, 400)
340
+ return
341
+ }
342
+ const filesDir = join(deps.getWikiPath(), 'files')
343
+ await mkdir(filesDir, { recursive: true })
344
+ const ext = extname(name)
345
+ const stem = ext.length > 0 ? name.slice(0, -ext.length) : name
346
+ // Collision avoidance: files/some.txt, files/some-1.txt, …
347
+ let candidate = name
348
+ for (let i = 1; ; i++) {
349
+ try { await access(join(filesDir, candidate)) } catch { break }
350
+ candidate = `${stem}-${i}${ext}`
351
+ }
352
+ await writeFile(join(filesDir, candidate), buf)
353
+ deps.autoCommit()
354
+ json(res, {
355
+ ok: true,
356
+ name: candidate,
357
+ path: `files/${candidate}`,
358
+ url: `/files/${encodeURIComponent(candidate)}`,
359
+ size: buf.length,
360
+ type: req.headers['content-type'] ?? 'application/octet-stream',
361
+ })
362
+ } catch (err) {
363
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, err instanceof Error && /too large/.test(err.message) ? 413 : 500)
364
+ }
365
+ }
366
+
219
367
  /** Passthrough /dsh-tiddlywiki/api/<rest> → TW root /<rest>. */
220
368
  const handleApiProxy = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
221
369
  const client = deps.getClient()
@@ -251,6 +399,8 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
251
399
  ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/note`, handler: (req, res) => { void handleNote(req, res) } }),
252
400
  ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/edit`, handler: (req, res) => { void handleEdit(req, res) } }),
253
401
  ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/tags`, handler: (req, res) => { void handleTags(req, res) } }),
402
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/sync`, handler: (req, res) => { void handleSync(req, res) } }),
403
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/upload`, handler: (req, res) => { void handleUpload(req, res) } }),
254
404
  ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/restart`, handler: (req, res) => { void handleRestart(req, res) } }),
255
405
  ctx.webServer.register({ kind: 'prefix', path: `${ROUTE_PREFIX}/api`, handler: (req, res) => { void handleApiProxy(req, res) } }),
256
406
  ]
package/src/host/tools.ts CHANGED
@@ -27,6 +27,10 @@ export interface ToolsDeps {
27
27
  noteTag: () => string
28
28
  /** Debounced auto-commit touch (fires after our writes). */
29
29
  autoCommit: () => void
30
+ /** Restart the TW child (same port). Called after a pull that changed the
31
+ * working tree, so the server drops its stale in-memory snapshot and the
32
+ * agent sees the pulled content. Optional — absent in headless contexts. */
33
+ restartWiki?: () => Promise<void>
30
34
  }
31
35
 
32
36
  function snippetOf(text: string, max = 160): string {
@@ -180,10 +184,21 @@ export function registerTiddlywikiTools(ctx: ToolsCtx, deps: ToolsDeps): Array<(
180
184
  },
181
185
  execute: async (args: { action: 'pull' | 'push' | 'sync'; message?: string }): Promise<SyncResult> => {
182
186
  const dir = deps.wikiPath()
187
+ /** Restart TW after a pull that changed the tree (stale snapshot drop). */
188
+ const restartIfChanged = async (pulled: { changed?: boolean }): Promise<{ restarted?: boolean; restartError?: string }> => {
189
+ if (pulled.changed !== true || deps.restartWiki === undefined) return {}
190
+ try {
191
+ await deps.restartWiki()
192
+ return { restarted: true }
193
+ } catch (err) {
194
+ return { restartError: err instanceof Error ? err.message : String(err) }
195
+ }
196
+ }
183
197
  switch (args.action) {
184
198
  case 'pull': {
185
199
  const r = await deps.git.pull(dir)
186
- return { action: args.action, ok: r.ok, message: r.message, ...(r.conflictFiles !== undefined ? { conflictFiles: r.conflictFiles } : {}) }
200
+ const restart = await restartIfChanged(r)
201
+ return { action: args.action, ok: r.ok, message: r.message, ...(r.conflictFiles !== undefined ? { conflictFiles: r.conflictFiles } : {}), ...(r.changed === true ? { changed: true } : {}), ...restart }
187
202
  }
188
203
  case 'push': {
189
204
  const r = await deps.git.push(dir)
@@ -192,6 +207,7 @@ export function registerTiddlywikiTools(ctx: ToolsCtx, deps: ToolsDeps): Array<(
192
207
  case 'sync': {
193
208
  const pulled = await deps.git.pull(dir)
194
209
  if (!pulled.ok) return { action: args.action, ok: false, message: pulled.message, ...(pulled.conflictFiles !== undefined ? { conflictFiles: pulled.conflictFiles } : {}) }
210
+ const restart = await restartIfChanged(pulled)
195
211
  const committed = await deps.git.commit(dir, args.message ?? `sync ${new Date().toISOString()}`)
196
212
  const pushed = await deps.git.push(dir)
197
213
  const status = await deps.git.status(dir)
@@ -200,6 +216,8 @@ export function registerTiddlywikiTools(ctx: ToolsCtx, deps: ToolsDeps): Array<(
200
216
  ok: pushed.ok,
201
217
  message: pushed.ok ? '同步完成' : pushed.message,
202
218
  pull: 'ok',
219
+ ...(pulled.changed === true ? { changed: true } : {}),
220
+ ...restart,
203
221
  commit: committed.message,
204
222
  push: pushed.message,
205
223
  status,
@@ -227,6 +245,9 @@ interface SyncResult {
227
245
  pull?: string
228
246
  commit?: string
229
247
  push?: string
248
+ changed?: boolean
249
+ restarted?: boolean
250
+ restartError?: string
230
251
  status?: { branch: string; dirty: boolean; dirtyFiles: string[]; remote: string; lastCommit?: string; ahead?: number; behind?: number }
231
252
  }
232
253
 
@@ -240,6 +261,12 @@ function renderSync(value: SyncResult): Array<{ type: 'text'; text: string }> {
240
261
  }
241
262
  if (value.commit !== undefined) lines.push(`本地 commit: ${value.commit}`)
242
263
  if (value.push !== undefined) lines.push(`远端 push: ${value.push}`)
264
+ if (value.changed === true) {
265
+ lines.push(value.restarted === true
266
+ ? '本次 pull 拉取了新内容,TW 服务已自动重启(同端口),读取/搜索均为最新快照。'
267
+ : '本次 pull 拉取了新内容,但 TW 服务未能自动重启(如需最新快照,请手动重启 TW)。')
268
+ }
269
+ if (value.restartError !== undefined) lines.push(`TW 重启失败: ${value.restartError}`)
243
270
  if (value.status !== undefined) {
244
271
  const s = value.status
245
272
  const bits = [`分支 ${s.branch}`]
@@ -1,157 +1,157 @@
1
- /**
2
- * TiddlyWeb REST client (design doc §5) — the ONLY way every writer reaches
3
- * the wiki (quick notes, agent tools, editor saves all go through the TW
4
- * service, D1), so there is never a second write path.
5
- *
6
- * ROUTES ARE EMPIRICALLY VERIFIED against tiddlywiki 5.4.1's core-server
7
- * (`core-server/server/routes/`):
8
- * GET /recipes/default/tiddlers.json[?exclude=...] list (skinny)
9
- * GET /recipes/default/tiddlers/<title> read one (404 absent)
10
- * PUT /recipes/default/tiddlers/<title> write one (204)
11
- * DELETE /bags/default/tiddlers/<title> delete one (204)
12
- * Writes require the `X-Requested-With: TiddlyWiki` header (TW CSRF), which
13
- * this client always sends. Tags arrive as a whitespace-joined STRING and are
14
- * normalized to arrays here.
15
- *
16
- * SEARCH (R2): the server blocks arbitrary `filter=` queries with 403 unless
17
- * the exact filter is whitelisted in $:/config/Server/ExternalFilters. So
18
- * `search()` fetches the default listing WITH text (`?exclude=` a sentinel)
19
- * and matches locally — one request, no 403, no per-tiddler round-trips.
20
- *
21
- * @module dsh-tiddlywiki/host/tw-api
22
- */
23
-
24
- /** A tiddler's readable fields (loose on purpose). */
25
- export interface Tiddler {
26
- title: string
27
- text?: string
28
- tags?: string[]
29
- type?: string
30
- created?: string
31
- modified?: string
32
- /** Extra custom fields returned by the server are folded under `fields`. */
33
- fields?: Record<string, unknown>
34
- [key: string]: unknown
35
- }
36
-
37
- const REQUEST_TIMEOUT_MS = 10_000
38
-
39
- /** TW's CSRF gate: writes must carry this header (TW's own UI always does). */
40
- const CSRF_HEADER = { 'x-requested-with': 'TiddlyWiki' }
41
-
42
- /** Sentinel `exclude` value: excludes nothing, so `text` stays in the list. */
43
- const LIST_WITH_TEXT_EXCLUDE = '__dsh_tw_none__'
44
-
45
- /** Split TW's whitespace-joined tags string into an array. */
46
- function normalizeTags(tags: unknown): string[] | undefined {
47
- if (tags === undefined) return undefined
48
- if (Array.isArray(tags)) return tags.map(String)
49
- if (typeof tags === 'string') {
50
- const parts = tags.trim().split(/\s+/).filter(Boolean)
51
- return parts.length > 0 ? parts : []
52
- }
53
- return []
54
- }
55
-
56
- /** Normalize a raw server tiddler (tags string → array, unknown fields nested). */
57
- function normalizeTiddler(raw: Record<string, unknown>): Tiddler {
58
- const out = { ...raw } as Tiddler
59
- const tags = normalizeTags(raw.tags)
60
- if (tags !== undefined) out.tags = tags
61
- return out
62
- }
63
-
64
- export class TiddlyWebClient {
65
- constructor(private readonly baseUrl: string) {}
66
-
67
- private async request(path: string, init?: RequestInit): Promise<Response> {
68
- return fetch(`${this.baseUrl}${path}`, {
69
- ...init,
70
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
71
- })
72
- }
73
-
74
- /** GET /status → { username, anonymous, space, tiddlywiki_version, ... }. */
75
- async status(): Promise<Record<string, unknown>> {
76
- const res = await this.request('/status')
77
- if (!res.ok) throw new Error(`TiddlyWeb /status HTTP ${res.status}`)
78
- return res.json() as Promise<Record<string, unknown>>
79
- }
80
-
81
- /** Read one tiddler; undefined when it does not exist (404). */
82
- async get(title: string): Promise<Tiddler | undefined> {
83
- const res = await this.request(`/recipes/default/tiddlers/${encodeURIComponent(title)}`)
84
- if (res.status === 404) return undefined
85
- if (!res.ok) throw new Error(`TiddlyWeb GET /recipes/default/tiddlers/${title} HTTP ${res.status}`)
86
- return normalizeTiddler((await res.json()) as Record<string, unknown>)
87
- }
88
-
89
- /** Write (create or overwrite) one tiddler via PUT (204 on success). */
90
- async put(tiddler: Tiddler): Promise<Tiddler> {
91
- const title = tiddler.title
92
- const res = await this.request(`/recipes/default/tiddlers/${encodeURIComponent(title)}`, {
93
- method: 'PUT',
94
- headers: { 'content-type': 'application/json', ...CSRF_HEADER },
95
- body: JSON.stringify(tiddler),
96
- })
97
- if (!res.ok) {
98
- const detail = await res.text().catch(() => '')
99
- throw new Error(`TiddlyWeb PUT /recipes/default/tiddlers/${title} HTTP ${res.status}: ${detail.slice(0, 300)}`)
100
- }
101
- return tiddler
102
- }
103
-
104
- /** Delete one tiddler via the bags route (204); a missing one is a no-op. */
105
- async delete(title: string): Promise<void> {
106
- const res = await this.request(`/bags/default/tiddlers/${encodeURIComponent(title)}`, {
107
- method: 'DELETE',
108
- headers: CSRF_HEADER,
109
- })
110
- if (res.status === 404) return
111
- if (!res.ok) throw new Error(`TiddlyWeb DELETE /bags/default/tiddlers/${title} HTTP ${res.status}`)
112
- }
113
-
114
- /**
115
- * List tiddlers via the default server filter. Arbitrary `filter=` queries
116
- * are blocked by the server (403) unless whitelisted, so callers needing a
117
- * subset should use search(); a supplied filter that is 403-blocked falls
118
- * back to the default listing.
119
- */
120
- async list(filter?: string, includeText = false): Promise<Tiddler[]> {
121
- const params = new URLSearchParams()
122
- if (includeText) params.set('exclude', LIST_WITH_TEXT_EXCLUDE)
123
- if (filter !== undefined && filter.length > 0) params.set('filter', filter)
124
- const query = params.toString()
125
- let res = await this.request(`/recipes/default/tiddlers.json${query.length > 0 ? `?${query}` : ''}`)
126
- if (!res.ok && res.status === 403 && filter !== undefined && filter.length > 0) {
127
- // Filter not whitelisted → refetch with the default filter.
128
- const retry = new URLSearchParams()
129
- if (includeText) retry.set('exclude', LIST_WITH_TEXT_EXCLUDE)
130
- const retryQuery = retry.toString()
131
- res = await this.request(`/recipes/default/tiddlers.json${retryQuery.length > 0 ? `?${retryQuery}` : ''}`)
132
- }
133
- if (!res.ok) throw new Error(`TiddlyWeb recipe list HTTP ${res.status}`)
134
- const data = (await res.json()) as Array<Record<string, unknown>> | { tiddlers?: Array<Record<string, unknown>> }
135
- const items = Array.isArray(data) ? data : (data.tiddlers ?? [])
136
- return items.map(normalizeTiddler)
137
- }
138
-
139
- /**
140
- * Search non-system tiddlers: one request (default listing with text) plus
141
- * local case-insensitive substring matching on title + text, optional exact
142
- * tag, capped at `limit`. Robust against the server's external-filter 403.
143
- */
144
- async search(query: string, tag?: string, limit = 30): Promise<Tiddler[]> {
145
- const items = await this.list(undefined, true)
146
- const needle = query.toLowerCase()
147
- const matched = items.filter((t) => {
148
- if (!t.title.toLowerCase().includes(needle) && !(t.text ?? '').toLowerCase().includes(needle)) return false
149
- if (tag !== undefined && tag.length > 0) {
150
- const tags = t.tags ?? []
151
- if (!tags.some((t2) => t2.toLowerCase() === tag.toLowerCase())) return false
152
- }
153
- return true
154
- })
155
- return matched.slice(0, limit)
156
- }
157
- }
1
+ /**
2
+ * TiddlyWeb REST client (design doc §5) — the ONLY way every writer reaches
3
+ * the wiki (quick notes, agent tools, editor saves all go through the TW
4
+ * service, D1), so there is never a second write path.
5
+ *
6
+ * ROUTES ARE EMPIRICALLY VERIFIED against tiddlywiki 5.4.1's core-server
7
+ * (`core-server/server/routes/`):
8
+ * GET /recipes/default/tiddlers.json[?exclude=...] list (skinny)
9
+ * GET /recipes/default/tiddlers/<title> read one (404 absent)
10
+ * PUT /recipes/default/tiddlers/<title> write one (204)
11
+ * DELETE /bags/default/tiddlers/<title> delete one (204)
12
+ * Writes require the `X-Requested-With: TiddlyWiki` header (TW CSRF), which
13
+ * this client always sends. Tags arrive as a whitespace-joined STRING and are
14
+ * normalized to arrays here.
15
+ *
16
+ * SEARCH (R2): the server blocks arbitrary `filter=` queries with 403 unless
17
+ * the exact filter is whitelisted in $:/config/Server/ExternalFilters. So
18
+ * `search()` fetches the default listing WITH text (`?exclude=` a sentinel)
19
+ * and matches locally — one request, no 403, no per-tiddler round-trips.
20
+ *
21
+ * @module dsh-tiddlywiki/host/tw-api
22
+ */
23
+
24
+ /** A tiddler's readable fields (loose on purpose). */
25
+ export interface Tiddler {
26
+ title: string
27
+ text?: string
28
+ tags?: string[]
29
+ type?: string
30
+ created?: string
31
+ modified?: string
32
+ /** Extra custom fields returned by the server are folded under `fields`. */
33
+ fields?: Record<string, unknown>
34
+ [key: string]: unknown
35
+ }
36
+
37
+ const REQUEST_TIMEOUT_MS = 10_000
38
+
39
+ /** TW's CSRF gate: writes must carry this header (TW's own UI always does). */
40
+ const CSRF_HEADER = { 'x-requested-with': 'TiddlyWiki' }
41
+
42
+ /** Sentinel `exclude` value: excludes nothing, so `text` stays in the list. */
43
+ const LIST_WITH_TEXT_EXCLUDE = '__dsh_tw_none__'
44
+
45
+ /** Split TW's whitespace-joined tags string into an array. */
46
+ function normalizeTags(tags: unknown): string[] | undefined {
47
+ if (tags === undefined) return undefined
48
+ if (Array.isArray(tags)) return tags.map(String)
49
+ if (typeof tags === 'string') {
50
+ const parts = tags.trim().split(/\s+/).filter(Boolean)
51
+ return parts.length > 0 ? parts : []
52
+ }
53
+ return []
54
+ }
55
+
56
+ /** Normalize a raw server tiddler (tags string → array, unknown fields nested). */
57
+ function normalizeTiddler(raw: Record<string, unknown>): Tiddler {
58
+ const out = { ...raw } as Tiddler
59
+ const tags = normalizeTags(raw.tags)
60
+ if (tags !== undefined) out.tags = tags
61
+ return out
62
+ }
63
+
64
+ export class TiddlyWebClient {
65
+ constructor(private readonly baseUrl: string) {}
66
+
67
+ private async request(path: string, init?: RequestInit): Promise<Response> {
68
+ return fetch(`${this.baseUrl}${path}`, {
69
+ ...init,
70
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
71
+ })
72
+ }
73
+
74
+ /** GET /status → { username, anonymous, space, tiddlywiki_version, ... }. */
75
+ async status(): Promise<Record<string, unknown>> {
76
+ const res = await this.request('/status')
77
+ if (!res.ok) throw new Error(`TiddlyWeb /status HTTP ${res.status}`)
78
+ return res.json() as Promise<Record<string, unknown>>
79
+ }
80
+
81
+ /** Read one tiddler; undefined when it does not exist (404). */
82
+ async get(title: string): Promise<Tiddler | undefined> {
83
+ const res = await this.request(`/recipes/default/tiddlers/${encodeURIComponent(title)}`)
84
+ if (res.status === 404) return undefined
85
+ if (!res.ok) throw new Error(`TiddlyWeb GET /recipes/default/tiddlers/${title} HTTP ${res.status}`)
86
+ return normalizeTiddler((await res.json()) as Record<string, unknown>)
87
+ }
88
+
89
+ /** Write (create or overwrite) one tiddler via PUT (204 on success). */
90
+ async put(tiddler: Tiddler): Promise<Tiddler> {
91
+ const title = tiddler.title
92
+ const res = await this.request(`/recipes/default/tiddlers/${encodeURIComponent(title)}`, {
93
+ method: 'PUT',
94
+ headers: { 'content-type': 'application/json', ...CSRF_HEADER },
95
+ body: JSON.stringify(tiddler),
96
+ })
97
+ if (!res.ok) {
98
+ const detail = await res.text().catch(() => '')
99
+ throw new Error(`TiddlyWeb PUT /recipes/default/tiddlers/${title} HTTP ${res.status}: ${detail.slice(0, 300)}`)
100
+ }
101
+ return tiddler
102
+ }
103
+
104
+ /** Delete one tiddler via the bags route (204); a missing one is a no-op. */
105
+ async delete(title: string): Promise<void> {
106
+ const res = await this.request(`/bags/default/tiddlers/${encodeURIComponent(title)}`, {
107
+ method: 'DELETE',
108
+ headers: CSRF_HEADER,
109
+ })
110
+ if (res.status === 404) return
111
+ if (!res.ok) throw new Error(`TiddlyWeb DELETE /bags/default/tiddlers/${title} HTTP ${res.status}`)
112
+ }
113
+
114
+ /**
115
+ * List tiddlers via the default server filter. Arbitrary `filter=` queries
116
+ * are blocked by the server (403) unless whitelisted, so callers needing a
117
+ * subset should use search(); a supplied filter that is 403-blocked falls
118
+ * back to the default listing.
119
+ */
120
+ async list(filter?: string, includeText = false): Promise<Tiddler[]> {
121
+ const params = new URLSearchParams()
122
+ if (includeText) params.set('exclude', LIST_WITH_TEXT_EXCLUDE)
123
+ if (filter !== undefined && filter.length > 0) params.set('filter', filter)
124
+ const query = params.toString()
125
+ let res = await this.request(`/recipes/default/tiddlers.json${query.length > 0 ? `?${query}` : ''}`)
126
+ if (!res.ok && res.status === 403 && filter !== undefined && filter.length > 0) {
127
+ // Filter not whitelisted → refetch with the default filter.
128
+ const retry = new URLSearchParams()
129
+ if (includeText) retry.set('exclude', LIST_WITH_TEXT_EXCLUDE)
130
+ const retryQuery = retry.toString()
131
+ res = await this.request(`/recipes/default/tiddlers.json${retryQuery.length > 0 ? `?${retryQuery}` : ''}`)
132
+ }
133
+ if (!res.ok) throw new Error(`TiddlyWeb recipe list HTTP ${res.status}`)
134
+ const data = (await res.json()) as Array<Record<string, unknown>> | { tiddlers?: Array<Record<string, unknown>> }
135
+ const items = Array.isArray(data) ? data : (data.tiddlers ?? [])
136
+ return items.map(normalizeTiddler)
137
+ }
138
+
139
+ /**
140
+ * Search non-system tiddlers: one request (default listing with text) plus
141
+ * local case-insensitive substring matching on title + text, optional exact
142
+ * tag, capped at `limit`. Robust against the server's external-filter 403.
143
+ */
144
+ async search(query: string, tag?: string, limit = 30): Promise<Tiddler[]> {
145
+ const items = await this.list(undefined, true)
146
+ const needle = query.toLowerCase()
147
+ const matched = items.filter((t) => {
148
+ if (!t.title.toLowerCase().includes(needle) && !(t.text ?? '').toLowerCase().includes(needle)) return false
149
+ if (tag !== undefined && tag.length > 0) {
150
+ const tags = t.tags ?? []
151
+ if (!tags.some((t2) => t2.toLowerCase() === tag.toLowerCase())) return false
152
+ }
153
+ return true
154
+ })
155
+ return matched.slice(0, limit)
156
+ }
157
+ }