@tiddlywiki/mws 0.1.13 → 0.2.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.
Files changed (118) hide show
  1. package/README.md +41 -20
  2. package/dist/metafile-esm.json +1 -1
  3. package/dist/mws.js +40249 -20249
  4. package/dist/mws.js.map +1 -1
  5. package/mws.run.mjs +28 -8
  6. package/package.json +20 -33
  7. package/plugins/client/plugin.info +1 -1
  8. package/plugins/client/src/multiwikiclientadaptor.ts +11 -39
  9. package/plugins/client/src/mws.d.ts +2 -1
  10. package/plugins/client/src/new-multiwikiclientadaptor.ts +634 -0
  11. package/plugins/client/src/types.d.ts +2531 -1036
  12. package/plugins/client/tiddlers/managetiddleraction.js +0 -5
  13. package/plugins/client/tiddlers/syncer/multiwikiclientadaptor.js +1 -1
  14. package/plugins/client/tiddlers/syncer/new-multiwikiclientadaptor.js +432 -0
  15. package/plugins/client/tiddlers/syncer/syncer.js +123 -241
  16. package/plugins/client/tsconfig.json +5 -1
  17. package/prisma/browser.ts +2 -0
  18. package/prisma/client/browser.ts +84 -0
  19. package/prisma/client/client.ts +108 -0
  20. package/prisma/client/commonInputTypes.ts +598 -0
  21. package/prisma/client/enums.ts +42 -0
  22. package/prisma/client/internal/class.ts +326 -0
  23. package/prisma/client/internal/prismaNamespace.ts +1870 -0
  24. package/prisma/client/internal/prismaNamespaceBrowser.ts +259 -0
  25. package/prisma/client/models/Bag.ts +1701 -0
  26. package/prisma/client/models/BagPermission.ts +1284 -0
  27. package/prisma/client/models/Recipe.ts +1722 -0
  28. package/prisma/client/models/RecipeBag.ts +1518 -0
  29. package/prisma/client/models/RecipePermission.ts +1284 -0
  30. package/prisma/client/models/Roles.ts +1316 -0
  31. package/prisma/client/models/Sessions.ts +1344 -0
  32. package/prisma/client/models/Settings.ts +1093 -0
  33. package/prisma/client/models/Template.ts +1477 -0
  34. package/prisma/client/models/TemplatePermission.ts +1284 -0
  35. package/prisma/client/models/Tiddler.ts +1434 -0
  36. package/prisma/client/models/TiddlerEvent.ts +1360 -0
  37. package/prisma/client/models/Users.ts +1579 -0
  38. package/prisma/client/models.ts +24 -0
  39. package/prisma/client/pjtg.ts +183 -0
  40. package/prisma/index.ts +74 -0
  41. package/prisma/migrations/20260708160259_init/migration.sql +172 -0
  42. package/prisma/migrations-v1/migration_lock.toml +3 -0
  43. package/prisma/package.json +12 -0
  44. package/prisma/schema.prisma +147 -89
  45. package/prisma/types.ts +9 -0
  46. package/public/.default.html +1 -0
  47. package/public/admin-vanilla/fish.svg +399 -0
  48. package/public/admin-vanilla/fish2.svg +389 -0
  49. package/public/admin-vanilla/main-64WYHWYI.js +1791 -0
  50. package/public/admin-vanilla/main-64WYHWYI.js.map +7 -0
  51. package/public/admin-vanilla/main-LYDE4NDJ.css +1 -0
  52. package/public/admin-vanilla/normalize.css +349 -0
  53. package/public/admin-vanilla/polyfill-ADOI2A4V.js +2 -0
  54. package/public/admin-vanilla/polyfill-ADOI2A4V.js.map +7 -0
  55. package/public/{react-admin → admin-vanilla}/site.webmanifest +1 -1
  56. package/public/admin-vanilla.html +62 -0
  57. package/public/admin-vanilla.json +1 -0
  58. package/public/{react-admin → stats}/stats.html +5 -2
  59. package/public/{react-admin → stats}/stats.js +2 -1
  60. package/scripts/build-docs.sh +30 -0
  61. package/scripts/helpers.mjs +107 -0
  62. package/scripts/json.js +8 -0
  63. package/scripts/scripts.mjs +114 -0
  64. package/prisma/client/client.d.ts +0 -1
  65. package/prisma/client/client.js +0 -3
  66. package/prisma/client/default.d.ts +0 -1
  67. package/prisma/client/default.js +0 -3
  68. package/prisma/client/edge.d.ts +0 -1
  69. package/prisma/client/edge.js +0 -297
  70. package/prisma/client/index-browser.js +0 -275
  71. package/prisma/client/index.d.ts +0 -17770
  72. package/prisma/client/index.js +0 -324
  73. package/prisma/client/package.json +0 -173
  74. package/prisma/client/query_engine_bg.js +0 -811
  75. package/prisma/client/query_engine_bg.wasm +0 -0
  76. package/prisma/client/runtime/edge-esm.js +0 -10000
  77. package/prisma/client/runtime/edge.js +0 -10036
  78. package/prisma/client/runtime/index-browser.d.ts +0 -370
  79. package/prisma/client/runtime/index-browser.js +0 -2300
  80. package/prisma/client/runtime/library.d.ts +0 -3681
  81. package/prisma/client/runtime/library.js +0 -10604
  82. package/prisma/client/runtime/react-native.js +0 -10629
  83. package/prisma/client/runtime/wasm-compiler-edge.js +0 -11238
  84. package/prisma/client/runtime/wasm-engine-edge.js +0 -7801
  85. package/prisma/client/schema.prisma +0 -156
  86. package/prisma/client/wasm-edge-light-loader.mjs +0 -4
  87. package/prisma/client/wasm-worker-loader.mjs +0 -4
  88. package/prisma/client/wasm.d.ts +0 -1
  89. package/prisma/client/wasm.js +0 -304
  90. package/prisma/global.ts +0 -53
  91. package/public/react-admin/chunk-YALWN5OT.js +0 -2
  92. package/public/react-admin/esm-L4WLVWJN.js +0 -3
  93. package/public/react-admin/esm-L4WLVWJN.js.map +0 -7
  94. package/public/react-admin/esm-QMNGI3LF.js +0 -3
  95. package/public/react-admin/esm-QMNGI3LF.js.map +0 -7
  96. package/public/react-admin/index.html +0 -36
  97. package/public/react-admin/main.js +0 -268
  98. package/public/react-admin/main.js.map +0 -7
  99. package/public/react-admin/stats.json +0 -1
  100. package/scripts.mjs +0 -142
  101. /package/prisma/{migrations → migrations-v1}/20250606001949_init/migration.sql +0 -0
  102. /package/prisma/{migrations → migrations-v1}/20250613000526_remove_group/migration.sql +0 -0
  103. /package/prisma/{migrations → migrations-v1}/20250620014502_settings/migration.sql +0 -0
  104. /package/prisma/{migrations → migrations-v1}/20250707205229_recipes/migration.sql +0 -0
  105. /package/public/{react-admin → admin-vanilla}/android-chrome-192x192.png +0 -0
  106. /package/public/{react-admin → admin-vanilla}/android-chrome-512x512.png +0 -0
  107. /package/public/{react-admin → admin-vanilla}/apple-touch-icon.png +0 -0
  108. /package/public/{react-admin → admin-vanilla}/favicon-16x16.png +0 -0
  109. /package/public/{react-admin → admin-vanilla}/favicon-32x32.png +0 -0
  110. /package/public/{react-admin → admin-vanilla}/favicon.ico +0 -0
  111. /package/public/{react-admin → admin-vanilla}/favicon.png +0 -0
  112. /package/public/{react-admin/chunk-YALWN5OT.js.map → admin-vanilla/main-LYDE4NDJ.css.map} +0 -0
  113. /package/public/{react-admin → admin-vanilla}/missing-favicon.png +0 -0
  114. /package/public/{react-admin → admin-vanilla}/motovun-jack.jpg +0 -0
  115. /package/public/{react-admin → admin-vanilla}/mws-icon.png +0 -0
  116. /package/public/{react-admin → admin-vanilla}/vite.svg +0 -0
  117. /package/public/{react-admin → stats}/stats.css +0 -0
  118. /package/public/{react-admin → stats}/stats.svg +0 -0
@@ -0,0 +1,634 @@
1
+ /*\
2
+ title: $:/plugins/mws/client/new-multiwikiclientadaptor.js
3
+ type: application/javascript
4
+ module-type: syncadaptor
5
+
6
+ A sync adaptor module for synchronising with MultiWikiServer-compatible servers.
7
+
8
+ It has three key areas of concern:
9
+
10
+ * Basic operations like put, get, and delete a tiddler on the server
11
+ * Real time updates from the server (handled by SSE)
12
+ * Bags and recipes, which are unknown to the syncer
13
+
14
+ A key aspect of the design is that the syncer never overlaps basic server operations; it waits for the
15
+ previous operation to complete before sending a new one.
16
+
17
+ \*/
18
+
19
+ // the blank line is important, and so is the following use strict
20
+ "use strict";
21
+
22
+ // import type { ServerEventsMap } from '@tiddlywiki/events';
23
+ // import type { ZodRoute, WikiStatusRoutes, WikiRecipeRoutes } from '@tiddlywiki/mws';
24
+ // import type { zod } from '@tiddlywiki/server';
25
+ import type { Syncer, Tiddler, TiddlerFields, Wiki } from 'tiddlywiki';
26
+
27
+ // import {} from "@tiddlywiki/mws-prisma";
28
+ declare global { const fflate: typeof import("./fflate"); }
29
+ declare const self: never;
30
+
31
+ declare class Logger {
32
+ constructor(componentName: any, options: any);
33
+ componentName: any;
34
+ colour: any;
35
+ enable: any;
36
+ save: any;
37
+ saveLimit: any;
38
+ saveBufferLogger: this;
39
+ buffer: string;
40
+ alertCount: number;
41
+ setSaveBuffer(logger: any): void;
42
+ log(...args: any[]): any;
43
+ getBuffer(): string;
44
+ table(value: any): void;
45
+ alert(...args: any[]): void;
46
+ clearAlerts(): void;
47
+ }
48
+
49
+ declare module 'tiddlywiki' {
50
+ export interface Syncer<AD> {
51
+ wiki: Wiki;
52
+ logger: Logger;
53
+ tiddlerInfo: Record<string, {
54
+ changeCount: number,
55
+ adaptorInfo: AD,
56
+ revision: string,
57
+ timestampLastSaved: Date
58
+ }>;
59
+ enqueueLoadTiddler(title: string): void;
60
+ storeTiddler(tiddler: Tiddler): void;
61
+ processTaskQueue(): void;
62
+ syncFromServer(): void;
63
+ }
64
+ interface ITiddlyWiki {
65
+ browserStorage: any;
66
+ }
67
+ }
68
+
69
+ type ServerStatusCallback = (
70
+ err: any,
71
+ /**
72
+ * $:/status/IsLoggedIn mostly appears alongside the username
73
+ * or other login-conditional behavior.
74
+ */
75
+ isLoggedIn?: boolean,
76
+ /**
77
+ * $:/status/UserName is still used for things like drafts even if the
78
+ * user isn't logged in, although the username is less likely to be shown
79
+ * to the user.
80
+ */
81
+ username?: string,
82
+ /**
83
+ * $:/status/IsReadOnly puts the UI in readonly mode,
84
+ * but does not prevent automatic changes from attempting to save.
85
+ */
86
+ isReadOnly?: boolean,
87
+ /**
88
+ * $:/status/IsAnonymous does not appear anywhere in the TW5 repo!
89
+ * So it has no apparent purpose.
90
+ */
91
+ isAnonymous?: boolean
92
+ ) => void
93
+
94
+ interface SyncAdaptor<AD> {
95
+ name?: string;
96
+
97
+ isReady?(): boolean;
98
+
99
+ registerSyncer?(syncer: Syncer<AD>): void;
100
+
101
+ getStatus?(
102
+ cb: ServerStatusCallback
103
+ ): void;
104
+
105
+ getSkinnyTiddlers?(
106
+ cb: (err: any, tiddlerFields: Record<string, string>[]) => void
107
+ ): void;
108
+ getUpdatedTiddlers?(
109
+ syncer: Syncer<AD>,
110
+ cb: (
111
+ err: any,
112
+ /** Arrays of titles that have been modified or deleted */
113
+ updates?: { modifications: string[], deletions: string[] }
114
+ ) => void
115
+ ): void;
116
+
117
+ /**
118
+ * used to override the default Syncer getTiddlerRevision behavior
119
+ * of returning the revision field
120
+ *
121
+ */
122
+ getTiddlerRevision?(title: string): string;
123
+ /**
124
+ * used to get the adapter info from a tiddler in situations
125
+ * other than the saveTiddler callback
126
+ */
127
+ getTiddlerInfo(tiddler: Tiddler): AD | undefined;
128
+
129
+ saveTiddler(
130
+ tiddler: any,
131
+ cb: (
132
+ err: any,
133
+ adaptorInfo?: AD,
134
+ revision?: string
135
+ ) => void,
136
+ extra: { tiddlerInfo: SyncerTiddlerInfo<AD> }
137
+ ): void;
138
+
139
+ saveTiddlers?(options: {
140
+ syncer: Syncer<AD>,
141
+ tiddlers: Tiddler[],
142
+ onNext: (title: string, adaptorInfo: any, revision: string) => void,
143
+ onDone: () => void,
144
+ onError: (err: Error) => void
145
+ }): void;
146
+
147
+ loadTiddlers?(options: {
148
+ syncer: Syncer<AD>,
149
+ titles: string[],
150
+ onNext: (tiddlerFields: TiddlerFields) => void,
151
+ onDone: () => void,
152
+ onError: (err: Error) => void
153
+ }): void;
154
+
155
+ deleteTiddlers?(options: {
156
+ syncer: Syncer<AD>,
157
+ titles: string[],
158
+ onNext: (title: string) => void,
159
+ onDone: () => void,
160
+ onError: (err: Error) => void
161
+ }): void;
162
+
163
+ setLoggerSaveBuffer?: (loggerForSaving: Logger) => void;
164
+ displayLoginPrompt?(syncer: Syncer<AD>): void;
165
+ login?(username: string, password: string, cb: (err: any) => void): void;
166
+ logout?(cb: (err: any) => void): any;
167
+
168
+ }
169
+ interface SyncerTiddlerInfo<AD> {
170
+ /** this comes from the wiki changeCount record */
171
+ changeCount: number;
172
+ /** Adapter info returned by the sync adapter */
173
+ adaptorInfo: AD;
174
+ /** Revision return by the sync adapter */
175
+ revision: string;
176
+ /** Timestamp set in the callback of the previous save */
177
+ timestampLastSaved: Date;
178
+ }
179
+
180
+ declare const $tw: any;
181
+
182
+ declare const exports: {
183
+ adaptorClass: typeof MultiWikiClientAdaptor;
184
+ };
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Constants
188
+ // ---------------------------------------------------------------------------
189
+
190
+ const CONFIG_HOST_TIDDLER = "$:/config/multiwikiclient/host";
191
+ const DEFAULT_HOST_TIDDLER = "$protocol$//$host$/";
192
+ const CONFIG_RECIPE_TIDDLER = "$:/config/multiwikiclient/recipe";
193
+ const IS_DEV_MODE_TIDDLER = "$:/state/multiwikiclient/dev-mode";
194
+ const LAST_REVISION_ID_TIDDLER = "$:/state/multiwikiclient/recipe/last_revision_id";
195
+ const MWC_STATE_TIDDLER_PREFIX = "$:/state/multiwikiclient/";
196
+ const BAG_STATE_TIDDLER = "$:/state/multiwikiclient/tiddlers/bag";
197
+ const REVISION_STATE_TIDDLER = "$:/state/multiwikiclient/tiddlers/revision";
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Types
201
+ // ---------------------------------------------------------------------------
202
+
203
+ interface MWSAdaptorInfo {
204
+ bag: string;
205
+ revision: string;
206
+ title: string;
207
+ }
208
+
209
+ // Status response from GET /recipe/:id/status
210
+ interface RecipeStatus {
211
+ isAdmin: boolean;
212
+ user_id: string;
213
+ username: string;
214
+ isLoggedIn: boolean;
215
+ template: { type: string; definition: unknown; parameters: unknown };
216
+ bags: { bag_id: string; bag_name: string; is_writable: boolean; priority: number; canUserWrite: boolean; info: unknown }[];
217
+ }
218
+
219
+ // TiddlerInfo from resolver
220
+ interface TiddlerInfo {
221
+ title: string;
222
+ writeTo: string | null;
223
+ readFrom: string | null;
224
+ existsIn: string[];
225
+ canWrite: boolean;
226
+ }
227
+
228
+ type BatchMutationResult = { title: string; info: TiddlerInfo; revision?: string };
229
+ type BatchReadResult = { fields: Record<string, any> & { title: string }; info: TiddlerInfo } | null;
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // Adaptor
233
+ // ---------------------------------------------------------------------------
234
+
235
+ class MultiWikiClientAdaptor implements SyncAdaptor<MWSAdaptorInfo> {
236
+ name = "multiwikiclient";
237
+
238
+ private wiki: Wiki;
239
+ private host: string;
240
+ private recipe: string;
241
+ private isDevMode: boolean;
242
+ private logger: Logger;
243
+ private syncer: Syncer<MWSAdaptorInfo> | null = null;
244
+
245
+ private isLoggedIn = false;
246
+ private isReadOnly = true;
247
+ private offline = false;
248
+ private username = "";
249
+ error: string | null = null;
250
+
251
+ private lastSeq = "0";
252
+ private initialLoadDone = false;
253
+ /** title → bag name, populated on load/save */
254
+ private tiddlerBag = new Map<string, string>();
255
+ /** title → revision, populated on save */
256
+ private tiddlerRevision = new Map<string, string>();
257
+
258
+ constructor(options: { wiki: Wiki }) {
259
+ this.wiki = options.wiki;
260
+ this.host = this.getHost();
261
+ this.recipe = this.wiki.getTiddlerText(CONFIG_RECIPE_TIDDLER, "")!;
262
+ this.isDevMode = this.wiki.getTiddlerText(IS_DEV_MODE_TIDDLER) === "yes";
263
+ this.lastSeq = this.wiki.getTiddlerText(LAST_REVISION_ID_TIDDLER, "0")!;
264
+ this.initialLoadDone = this.lastSeq !== "0";
265
+ this.logger = new $tw.utils.Logger("MultiWikiClientAdaptor");
266
+ }
267
+
268
+ isReady() { return true; }
269
+
270
+ setLoggerSaveBuffer(logger: Logger) { this.logger.setSaveBuffer(logger); }
271
+
272
+ registerSyncer(syncer: Syncer<MWSAdaptorInfo>) { this.syncer = syncer; }
273
+
274
+ private isStateTiddler(title: string) {
275
+ return title.startsWith(MWC_STATE_TIDDLER_PREFIX);
276
+ }
277
+
278
+ private setTiddlerInfo(title: string, bag: string | null, revision?: string) {
279
+ if (bag) {
280
+ this.tiddlerBag.set(title, bag);
281
+ this.wiki.setText(BAG_STATE_TIDDLER, null, title, bag, { suppressTimestamp: true });
282
+ } else {
283
+ this.tiddlerBag.delete(title);
284
+ this.wiki.setText(BAG_STATE_TIDDLER, null, title, undefined, { suppressTimestamp: true });
285
+ }
286
+ if (revision) {
287
+ this.tiddlerRevision.set(title, revision);
288
+ this.wiki.setText(REVISION_STATE_TIDDLER, null, title, revision, { suppressTimestamp: true });
289
+ }
290
+ }
291
+
292
+ private clearTiddlerInfo(title: string) {
293
+ this.tiddlerBag.delete(title);
294
+ this.tiddlerRevision.delete(title);
295
+ this.wiki.setText(BAG_STATE_TIDDLER, null, title, undefined, { suppressTimestamp: true });
296
+ this.wiki.setText(REVISION_STATE_TIDDLER, null, title, undefined, { suppressTimestamp: true });
297
+ }
298
+
299
+ private setLastSeq(seq: string) {
300
+ this.lastSeq = seq;
301
+ this.wiki.setText(LAST_REVISION_ID_TIDDLER, null, "text", seq, { suppressTimestamp: true });
302
+ }
303
+
304
+ getTiddlerRevision(title: string) {
305
+ return this.wiki.extractTiddlerDataItem(REVISION_STATE_TIDDLER, title) ?? "";
306
+ }
307
+
308
+ getTiddlerInfo(tiddler: Tiddler): MWSAdaptorInfo | undefined {
309
+ const title = tiddler.fields.title as string;
310
+ const bag = this.wiki.extractTiddlerDataItem(BAG_STATE_TIDDLER, title) ?? this.tiddlerBag.get(title);
311
+ const revision = this.wiki.extractTiddlerDataItem(REVISION_STATE_TIDDLER, title) ?? this.tiddlerRevision.get(title);
312
+ return bag && revision ? { bag, revision, title } : undefined;
313
+ }
314
+
315
+ private getHost() {
316
+ let text = this.wiki.getTiddlerText(CONFIG_HOST_TIDDLER, DEFAULT_HOST_TIDDLER)!;
317
+ [
318
+ { name: "protocol", value: document.location.protocol },
319
+ { name: "host", value: document.location.host },
320
+ { name: "pathname", value: document.location.pathname },
321
+ ].forEach(({ name, value }) => {
322
+ text = $tw.utils.replaceString(text, new RegExp("\\$" + name + "\\$", "mg"), value);
323
+ });
324
+ return text;
325
+ }
326
+
327
+ // -------------------------------------------------------------------------
328
+ // Status
329
+ // -------------------------------------------------------------------------
330
+
331
+ async getStatus(callback: ServerStatusCallback) {
332
+ const [ok, , result] = await this.recipeRequest({ method: "GET", url: "/status" });
333
+ if (!ok && result?.status === 0) {
334
+ this.offline = true;
335
+ this.isLoggedIn = false;
336
+ this.isReadOnly = true;
337
+ this.username = "(offline)";
338
+ this.error = "The webpage is forbidden from contacting the server.";
339
+ } else if (ok) {
340
+ const status = result!.responseJSON as RecipeStatus;
341
+ this.offline = false;
342
+ this.error = null;
343
+ this.isLoggedIn = status?.isLoggedIn ?? false;
344
+ this.username = status?.username ?? "(anon)";
345
+ this.isReadOnly = !(status?.bags?.some(b => b.canUserWrite) ?? false);
346
+ } else {
347
+ this.error = `Server error ${result?.status}`;
348
+ }
349
+ callback(this.error, this.isLoggedIn, this.username, this.isReadOnly, false);
350
+ }
351
+
352
+ // -------------------------------------------------------------------------
353
+ // Update polling
354
+ // -------------------------------------------------------------------------
355
+
356
+ async getUpdatedTiddlers(
357
+ _syncer: Syncer<MWSAdaptorInfo>,
358
+ callback: (err: any, updates?: { modifications: string[]; deletions: string[] }) => void
359
+ ) {
360
+ if (this.offline) return callback(null);
361
+ try {
362
+ if (!this.initialLoadDone) {
363
+ // Fetch full list + current lastSeq in parallel on first load
364
+ const [[listOk, , listResult], [updOk, , updResult]] = await Promise.all([
365
+ this.recipeRequest({ method: "GET", url: "/list.json" }),
366
+ this.recipeRequest({ method: "GET", url: "/updates", queryParams: { since: "0" } }),
367
+ ]);
368
+ if (!listOk) throw new Error("Failed to fetch tiddler list");
369
+ if (!updOk) throw new Error("Failed to fetch updates");
370
+ const list = listResult!.responseJSON as TiddlerInfo[];
371
+ const upd = updResult!.responseJSON as { modifications: string[]; deletions: string[]; lastSeq: string };
372
+ this.setLastSeq(upd.lastSeq);
373
+ this.initialLoadDone = true;
374
+ callback(null, { modifications: list.map(t => t.title), deletions: [] });
375
+ } else {
376
+ const [ok, , result] = await this.recipeRequest({
377
+ method: "GET",
378
+ url: "/updates",
379
+ queryParams: { since: this.lastSeq },
380
+ });
381
+ if (!ok) throw new Error("Failed to fetch updates");
382
+ const upd = result!.responseJSON as { modifications: string[]; deletions: string[]; lastSeq: string };
383
+ this.setLastSeq(upd.lastSeq);
384
+ callback(null, { modifications: upd.modifications, deletions: upd.deletions });
385
+ }
386
+ } catch (e: any) {
387
+ callback(e);
388
+ }
389
+ }
390
+
391
+ // -------------------------------------------------------------------------
392
+ // Batch operations (new API)
393
+ // -------------------------------------------------------------------------
394
+
395
+ async loadTiddlers(options: {
396
+ syncer: Syncer<MWSAdaptorInfo>;
397
+ titles: string[];
398
+ onNext: (fields: TiddlerFields) => void;
399
+ onDone: () => void;
400
+ onError: (err: Error) => void;
401
+ }) {
402
+ const { titles, onNext, onDone, onError } = options;
403
+ try {
404
+ const results = await this.batchOp<BatchReadResult[]>("read", { titles });
405
+ for (const item of results) {
406
+ if (!item) continue;
407
+ this.setTiddlerInfo(item.fields.title, item.info.readFrom, typeof item.fields.revision === "string" ? item.fields.revision : undefined);
408
+ onNext(item.fields as TiddlerFields);
409
+ }
410
+ onDone();
411
+ } catch (e: any) { onError(e); }
412
+ }
413
+
414
+ async saveTiddlers(options: {
415
+ syncer: Syncer<MWSAdaptorInfo>;
416
+ tiddlers: Tiddler[];
417
+ onNext: (title: string, adaptorInfo: MWSAdaptorInfo, revision: string) => void;
418
+ onDone: () => void;
419
+ onError: (err: Error) => void;
420
+ }) {
421
+ const { tiddlers, onNext, onDone, onError } = options;
422
+ try {
423
+ const results = await this.batchOp<BatchMutationResult[]>("save", {
424
+ tiddlers: tiddlers.map(t => t.getFieldStrings()),
425
+ });
426
+ for (const item of results) {
427
+ const bag = item.info.writeTo ?? item.info.readFrom ?? "";
428
+ this.setTiddlerInfo(item.title, bag || null, item.revision ?? "");
429
+ if ($tw.browserStorage?.isEnabled()) $tw.browserStorage.removeTiddlerFromLocalStorage(item.title);
430
+ onNext(item.title, { bag, revision: item.revision ?? "", title: item.title }, item.revision ?? "");
431
+ }
432
+ onDone();
433
+ } catch (e: any) { onError(e); }
434
+ }
435
+
436
+ async deleteTiddlers(options: {
437
+ syncer: Syncer<MWSAdaptorInfo>;
438
+ titles: string[];
439
+ onNext: (title: string) => void;
440
+ onDone: () => void;
441
+ onError: (err: Error) => void;
442
+ }) {
443
+ const { titles, onNext, onDone, onError } = options;
444
+ try {
445
+ const results = await this.batchOp<BatchMutationResult[]>("delete", { titles });
446
+ for (const item of results) {
447
+ this.clearTiddlerInfo(item.title);
448
+ onNext(item.title);
449
+ }
450
+ onDone();
451
+ } catch (e: any) { onError(e); }
452
+ }
453
+
454
+ // -------------------------------------------------------------------------
455
+ // Single-tiddler operations (fallback for older server versions)
456
+ // -------------------------------------------------------------------------
457
+
458
+ async saveTiddler(
459
+ tiddler: Tiddler,
460
+ callback: (err: any, adaptorInfo?: MWSAdaptorInfo, revision?: string) => void
461
+ ) {
462
+ const title = tiddler.fields.title as string;
463
+ if (title === "$:/StoryList" || this.isReadOnly || this.isStateTiddler(title)) return callback(null);
464
+ try {
465
+ const results = await this.batchOp<BatchMutationResult[]>("save", { tiddlers: [tiddler.getFieldStrings()] });
466
+ const item = results[0];
467
+ if (!item) return callback(new Error("No result returned"));
468
+ const bag = item.info.writeTo ?? item.info.readFrom ?? "";
469
+ this.setTiddlerInfo(title, bag || null, item.revision ?? "");
470
+ if ($tw.browserStorage?.isEnabled()) $tw.browserStorage.removeTiddlerFromLocalStorage(title);
471
+ callback(null, { bag, revision: item.revision ?? "", title }, item.revision ?? "");
472
+ } catch (e: any) { callback(e); }
473
+ }
474
+
475
+ async loadTiddler(title: string, callback: (err: any, fields?: any) => void, _options: any) {
476
+ try {
477
+ const results = await this.batchOp<BatchReadResult[]>("read", { titles: [title] });
478
+ const item = results[0];
479
+ if (!item) return callback(null, null);
480
+ this.setTiddlerInfo(title, item.info.readFrom, typeof item.fields.revision === "string" ? item.fields.revision : undefined);
481
+ callback(null, item.fields);
482
+ } catch (e: any) { callback(e); }
483
+ }
484
+
485
+ async deleteTiddler(title: string, callback: (err: any, adaptorInfo?: any) => void, _options: any) {
486
+ if (this.isReadOnly) return callback(null);
487
+ try {
488
+ const results = await this.batchOp<BatchMutationResult[]>("delete", { titles: [title] });
489
+ const item = results[0];
490
+ if (!item) return callback(new Error("No result returned"));
491
+ this.clearTiddlerInfo(title);
492
+ callback(null, null);
493
+ } catch (e: any) { callback(e); }
494
+ }
495
+
496
+ // -------------------------------------------------------------------------
497
+ // HTTP helpers
498
+ // -------------------------------------------------------------------------
499
+
500
+ private async batchOp<T>(op: string, body: Record<string, any>): Promise<T> {
501
+ const [ok, err, result] = await this.recipeRequest({
502
+ method: "PUT",
503
+ url: "/batch/" + op,
504
+ requestBodyString: JSON.stringify(body),
505
+ headers: { "content-type": "application/json" },
506
+ });
507
+ if (!ok) throw err;
508
+ if (!result!.responseJSON) throw new Error("No response JSON from batch/" + op);
509
+ return result!.responseJSON as T;
510
+ }
511
+
512
+ private async recipeRequest(options: {
513
+ method: string;
514
+ url: string;
515
+ headers?: HeadersInit;
516
+ queryParams?: Record<string, string>;
517
+ requestBodyString?: string;
518
+ }) {
519
+ if (!options.url.startsWith("/")) throw new Error("URL must start with /");
520
+ const isDevMode = this.isDevMode;
521
+ return httpRequest({
522
+ ...options,
523
+ responseType: "blob",
524
+ url: this.host + "recipe/" + encodeURIComponent(this.recipe) + options.url,
525
+ }).then(async e => {
526
+ if (!e.ok) return [false, new Error(
527
+ `Server returned ${e.status}: ${e.headers.get("x-reason") ?? "(no reason)"}`
528
+ ), { ...e, responseJSON: undefined }] as const;
529
+
530
+ let responseString: string;
531
+ if (e.headers.get("x-gzip-stream") === "yes") {
532
+ responseString = await new Promise<string>((resolve) => {
533
+ let s = "";
534
+ const gz = new fflate.AsyncGunzip((err, chunk, final) => {
535
+ if (err) return;
536
+ s += fflate.strFromU8(chunk);
537
+ if (final) resolve(s);
538
+ });
539
+ if (isDevMode) gz.onmember = m => console.log("gunzip member", m);
540
+ readBlobAsArrayBuffer(e.response as Blob).then(buf => {
541
+ gz.push(new Uint8Array(buf));
542
+ gz.push(new Uint8Array(0), true);
543
+ });
544
+ });
545
+ } else {
546
+ responseString = fflate.strFromU8(new Uint8Array(await readBlobAsArrayBuffer(e.response as Blob)));
547
+ }
548
+
549
+ return [true, undefined, {
550
+ ...e,
551
+ responseJSON: e.status === 200 ? tryParseJSON(responseString) : undefined,
552
+ }] as const;
553
+ }, e => [false, e, undefined] as const);
554
+ }
555
+ }
556
+
557
+ // ---------------------------------------------------------------------------
558
+ // Utilities
559
+ // ---------------------------------------------------------------------------
560
+
561
+ function tryParseJSON(s: string) {
562
+ try { return JSON.parse(s); } catch (e) { console.error("JSON parse error", e); return undefined; }
563
+ }
564
+
565
+ type ParamsInput = URLSearchParams | [string, string][] | object | string | undefined;
566
+
567
+ interface HttpRequestOptions<TYPE extends "arraybuffer" | "blob" | "text"> {
568
+ method: string;
569
+ url: string;
570
+ responseType: TYPE;
571
+ headers?: HeadersInit;
572
+ queryParams?: ParamsInput;
573
+ requestBodyString?: string;
574
+ }
575
+
576
+ function httpRequest<TYPE extends "arraybuffer" | "blob" | "text">(options: HttpRequestOptions<TYPE>) {
577
+ return new Promise<{
578
+ ok: boolean; status: number; statusText: string; headers: Headers;
579
+ response: TYPE extends "arraybuffer" ? ArrayBuffer : TYPE extends "blob" ? Blob : string;
580
+ }>((resolve, reject) => {
581
+ options.method = options.method.toUpperCase();
582
+ const url = new URL(options.url, location.href);
583
+ paramsInput(options.queryParams).forEach((v, k) => url.searchParams.append(k, v));
584
+ const headers = new Headers(options.headers || {});
585
+ const request = new XMLHttpRequest();
586
+ request.responseType = options.responseType;
587
+ request.open(options.method, url, true);
588
+ if (!headers.has("content-type")) headers.set("content-type", "application/x-www-form-urlencoded; charset=UTF-8");
589
+ if (!headers.has("x-requested-with")) headers.set("x-requested-with", "TiddlyWiki");
590
+ headers.set("accept", "application/json");
591
+ headers.forEach((v, k) => request.setRequestHeader(k, v));
592
+ request.onreadystatechange = function () {
593
+ if (this.readyState !== 4) return;
594
+ const h = new Headers();
595
+ request.getAllResponseHeaders()?.trim().split(/[\r\n]+/).forEach(line => {
596
+ const parts = line.split(": ");
597
+ const key = parts.shift()?.toLowerCase();
598
+ if (key) h.append(key, parts.join(": "));
599
+ });
600
+ resolve({ ok: this.status >= 200 && this.status < 300, status: this.status, statusText: this.statusText, response: this.response, headers: h });
601
+ };
602
+ request.send(options.requestBodyString);
603
+ });
604
+
605
+ function paramsInput(input: ParamsInput) {
606
+ if (!input) return new URLSearchParams();
607
+ if (input instanceof URLSearchParams) return input;
608
+ if (Array.isArray(input) || typeof input === "string") return new URLSearchParams(input);
609
+ const params = new URLSearchParams();
610
+ for (const key in input) {
611
+ if (Object.prototype.hasOwnProperty.call(input, key)) {
612
+ params.append(key, (input as Record<string, string>)[key]);
613
+ }
614
+ }
615
+ return params;
616
+ }
617
+ }
618
+
619
+ function readBlobAsArrayBuffer(blob: Blob) {
620
+ return new Promise<ArrayBuffer>((resolve, reject) => {
621
+ const reader = new FileReader();
622
+ reader.onload = () => resolve(reader.result as ArrayBuffer);
623
+ reader.onerror = () => reject(new Error("Error reading blob"));
624
+ reader.readAsArrayBuffer(blob);
625
+ });
626
+ }
627
+
628
+ // ---------------------------------------------------------------------------
629
+ // Export
630
+ // ---------------------------------------------------------------------------
631
+
632
+ if ($tw.browser && document.location.protocol.startsWith("http")) {
633
+ exports.adaptorClass = MultiWikiClientAdaptor;
634
+ }