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.
- package/LICENSE +21 -21
- package/README.md +12 -6
- package/cordis.patch.yml +13 -13
- package/lib/client.bundle.js +2173 -1652
- package/lib/client.js +2183 -1662
- package/lib/index.js +2424 -2263
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/editor-popup.ts +121 -121
- package/src/client/index.ts +2 -0
- package/src/client/markdown.ts +137 -0
- package/src/client/note-widget.ts +515 -373
- package/src/client/panel.ts +51 -25
- package/src/client/settings-page.ts +32 -2
- package/src/client/sidebar-entry.ts +148 -148
- package/src/client/state.ts +39 -39
- package/src/client/styles.ts +111 -28
- package/src/client/sync-button.ts +188 -0
- package/src/client/toast.ts +22 -22
- package/src/host/admin.ts +408 -408
- package/src/host/config.ts +4 -2
- package/src/host/git.ts +13 -3
- package/src/host/routes.ts +151 -1
- package/src/host/seed-notes.ts +79 -122
- package/src/host/tools.ts +28 -1
- package/src/host/tw-api.ts +157 -157
- package/src/host/wiki.ts +287 -287
- package/src/index.ts +13 -12
- package/src/sdk.ts +198 -198
package/src/host/tw-api.ts
CHANGED
|
@@ -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
|
+
}
|