dsh-tiddlywiki 0.2.0 → 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.
@@ -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
+ }