@tiddlywiki/mws 0.0.21 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/README.md +42 -9
  2. package/dist/metafile-esm.json +1 -0
  3. package/dist/mws.d.ts +1 -0
  4. package/dist/mws.js +34710 -0
  5. package/dist/mws.js.map +1 -0
  6. package/editions/mws-docs/tiddlers/$__StoryList.tid +1 -2
  7. package/editions/mws-docs/tiddlers/2025-02-18 - User Management.md.meta +2 -2
  8. package/editions/mws-docs/tiddlers/2025-04-14 - client plugins /342/234/224/357/270/217.md" +31 -0
  9. package/editions/mws-docs/tiddlers/2025-04-14 - client plugins /342/234/224/357/270/217.md.meta" +5 -0
  10. package/editions/mws-docs/tiddlers/{2025-05-04.md → 2025-05-04 - event hooks /342/234/224/357/270/217.md" } +7 -1
  11. package/editions/mws-docs/tiddlers/2025-05-04 - event hooks /342/234/224/357/270/217.md.meta" +5 -0
  12. package/editions/mws-docs/tiddlers/{2025-05-06.md → 2025-05-06 - syncer design.md } +0 -4
  13. package/editions/mws-docs/tiddlers/2025-05-06 - syncer design.md.meta +5 -0
  14. package/editions/mws-docs/tiddlers/Allowed Methods.tid +8 -0
  15. package/editions/mws-docs/tiddlers/Application Access & Security.md +14 -84
  16. package/editions/mws-docs/tiddlers/Application Access & Security.md.meta +1 -1
  17. package/editions/mws-docs/tiddlers/Body Format.md +19 -0
  18. package/editions/mws-docs/tiddlers/Body Format.md.meta +4 -0
  19. package/editions/mws-docs/tiddlers/Checklists and Features.md +0 -3
  20. package/editions/mws-docs/tiddlers/Checklists and Features.md.meta +1 -1
  21. package/editions/mws-docs/tiddlers/Client Plugins.md +17 -0
  22. package/editions/mws-docs/tiddlers/Client Plugins.md.meta +5 -0
  23. package/editions/mws-docs/tiddlers/HTTP API.md +4 -3
  24. package/editions/mws-docs/tiddlers/HTTP API.md.meta +1 -1
  25. package/editions/mws-docs/tiddlers/HelloThere.tid +2 -2
  26. package/editions/mws-docs/tiddlers/Installation.tid +2 -3
  27. package/editions/mws-docs/tiddlers/MWS Architecture.tid +6 -1
  28. package/editions/mws-docs/tiddlers/Multi-user Trust.md +12 -0
  29. package/editions/mws-docs/tiddlers/Multi-user Trust.md.meta +5 -0
  30. package/editions/mws-docs/tiddlers/Notes.tid +5 -3
  31. package/editions/mws-docs/tiddlers/Oracle Attacks.md +29 -0
  32. package/editions/mws-docs/tiddlers/Oracle Attacks.md.meta +5 -0
  33. package/editions/mws-docs/tiddlers/Reference.md +1 -0
  34. package/editions/mws-docs/tiddlers/Reference.md.meta +1 -1
  35. package/editions/mws-docs/tiddlers/Request Handling.md +53 -0
  36. package/editions/mws-docs/tiddlers/Request Handling.md.meta +5 -0
  37. package/editions/mws-docs/tiddlers/Roadmap.tid +7 -0
  38. package/editions/mws-docs/tiddlers/Route Definers.md +206 -0
  39. package/editions/mws-docs/tiddlers/Route Definers.md.meta +5 -0
  40. package/editions/mws-docs/tiddlers/Security.md +75 -0
  41. package/editions/mws-docs/tiddlers/Security.md.meta +5 -0
  42. package/editions/mws-docs/tiddlers/Server Plugins.md +7 -0
  43. package/editions/mws-docs/tiddlers/Server Plugins.md.meta +5 -0
  44. package/editions/mws-docs/tiddlers/Startup Cache.md +28 -0
  45. package/editions/mws-docs/tiddlers/Startup Cache.md.meta +5 -0
  46. package/editions/mws-docs/tiddlers/TiddlerFields.md +5 -0
  47. package/editions/mws-docs/tiddlers/TiddlerFields.md.meta +4 -0
  48. package/editions/mws-docs/tiddlers/Usage.tid +2 -2
  49. package/editions/mws-docs/tiddlers/system/$__mws_cssvar-macro.tid +10 -0
  50. package/editions/mws-docs/tiddlers/system/$__palettes_MUI +143 -0
  51. package/editions/mws-docs/tiddlers/system/$__palettes_MUI.meta +7 -0
  52. package/editions/mws-docs/tiddlers/system/$__palettes_MUI_styles.css +567 -0
  53. package/editions/mws-docs/tiddlers/system/$__palettes_MUI_styles.css.meta +6 -0
  54. package/editions/mws-docs/tiddlers/system/$__theme.tid +6 -0
  55. package/editions/mws-docs/tiddlers/system/SplashScreen.tid +21 -0
  56. package/editions/mws-docs/tiddlers/system/StaticBanner.tid +1 -1
  57. package/editions/mws-docs/tiddlers/system/Styles.tid +13 -0
  58. package/editions/mws-docs/tiddlers/system/palette.tid +4 -1
  59. package/editions/mws-docs/tiddlers/system/sidebarlayout.tid +4 -1
  60. package/editions/mws-docs/tiddlers/x-mws-tiddler.md +35 -0
  61. package/editions/mws-docs/tiddlers/x-mws-tiddler.md.meta +5 -0
  62. package/mws.run.mjs +30 -0
  63. package/package.json +47 -36
  64. package/plugins/client/package-lock.json +46 -83
  65. package/plugins/client/package.json +1 -6
  66. package/plugins/client/plugin.info +2 -2
  67. package/plugins/client/src/fflate.d.ts +1501 -0
  68. package/plugins/client/src/multiwikiclientadaptor.ts +768 -317
  69. package/plugins/client/src/mws.d.ts +4 -0
  70. package/plugins/client/src/types.d.ts +3928 -0
  71. package/plugins/client/tiddlers/en-US.multids +3 -0
  72. package/plugins/client/tiddlers/status/SaveWikiButtonTemplate.tid +3 -0
  73. package/plugins/client/tiddlers/status/icon-cloud.tid +4 -0
  74. package/plugins/client/tiddlers/status/info-segment.tid +8 -0
  75. package/plugins/client/tiddlers/status/readonly-styles.tid +27 -0
  76. package/plugins/client/tiddlers/status/save-offline.tid +7 -0
  77. package/plugins/client/tiddlers/status/save-wiki-button.tid +26 -0
  78. package/plugins/client/tiddlers/status/styles.tid +44 -0
  79. package/plugins/client/tiddlers/status/syncer-actions-copy-logs.tid +6 -0
  80. package/plugins/client/tiddlers/status/syncer-actions-login-status.tid +9 -0
  81. package/plugins/client/tiddlers/status/syncer-actions-login.tid +8 -0
  82. package/plugins/client/tiddlers/status/syncer-actions-logout.tid +8 -0
  83. package/plugins/client/tiddlers/status/syncer-actions-refresh.tid +9 -0
  84. package/plugins/client/tiddlers/status/syncer-actions-save-snapshot.tid +9 -0
  85. package/plugins/client/tiddlers/status/tags-syncerdropdown.tid +2 -0
  86. package/plugins/client/tiddlers/syncer/multiwikiclientadaptor.js +640 -0
  87. package/plugins/client/tiddlers/syncer/syncer.js +831 -0
  88. package/plugins/client/tsconfig.json +19 -16
  89. package/plugins/filter-ast-to-sql/example-run-series.ts +146 -0
  90. package/plugins/server/SplashScreen.tid +150 -0
  91. package/plugins/server/fflate.js +9 -0
  92. package/plugins/server/plugin.info +8 -0
  93. package/plugins/server/readme +1 -0
  94. package/plugins/server/url-polyfill.js +294 -0
  95. package/prisma/client/client.d.ts +1 -0
  96. package/prisma/client/client.js +3 -0
  97. package/prisma/client/default.d.ts +1 -0
  98. package/prisma/client/default.js +3 -0
  99. package/prisma/client/edge.d.ts +1 -0
  100. package/prisma/client/edge.js +297 -0
  101. package/prisma/client/index-browser.js +275 -0
  102. package/prisma/client/index.d.ts +17770 -0
  103. package/prisma/client/index.js +324 -0
  104. package/prisma/client/package.json +173 -0
  105. package/prisma/client/query_engine_bg.js +811 -0
  106. package/prisma/client/query_engine_bg.wasm +0 -0
  107. package/prisma/client/runtime/edge-esm.js +10000 -0
  108. package/prisma/client/runtime/edge.js +10036 -0
  109. package/prisma/client/runtime/index-browser.d.ts +370 -0
  110. package/prisma/client/runtime/index-browser.js +2300 -0
  111. package/prisma/client/runtime/library.d.ts +3681 -0
  112. package/prisma/client/runtime/library.js +10604 -0
  113. package/prisma/client/runtime/react-native.js +10629 -0
  114. package/prisma/client/runtime/wasm-compiler-edge.js +11238 -0
  115. package/prisma/client/runtime/wasm-engine-edge.js +7801 -0
  116. package/prisma/client/schema.prisma +156 -0
  117. package/prisma/client/wasm-edge-light-loader.mjs +4 -0
  118. package/prisma/client/wasm-worker-loader.mjs +4 -0
  119. package/prisma/client/wasm.d.ts +1 -0
  120. package/prisma/client/wasm.js +304 -0
  121. package/prisma/global.ts +53 -0
  122. package/prisma/migrations/{20250406213424_init → 20250606001949_init}/migration.sql +46 -48
  123. package/prisma/migrations/20250613000526_remove_group/migration.sql +22 -0
  124. package/prisma/migrations/20250620014502_settings/migration.sql +5 -0
  125. package/prisma/migrations/20250707205229_recipes/migration.sql +20 -0
  126. package/prisma/migrations/migration_lock.toml +1 -1
  127. package/prisma/notes.md +1 -2
  128. package/prisma/schema.prisma +52 -49
  129. package/prisma/test.sqlite +0 -0
  130. package/prisma/test.sqlite-journal +0 -0
  131. package/public/react-admin/android-chrome-192x192.png +0 -0
  132. package/public/react-admin/android-chrome-512x512.png +0 -0
  133. package/public/react-admin/apple-touch-icon.png +0 -0
  134. package/{react-user-mgmt/public/esm-QKDFEP3B.js → public/react-admin/esm-L4WLVWJN.js} +1 -1
  135. package/public/react-admin/esm-L4WLVWJN.js.map +7 -0
  136. package/public/react-admin/esm-QMNGI3LF.js +3 -0
  137. package/public/react-admin/favicon-16x16.png +0 -0
  138. package/public/react-admin/favicon-32x32.png +0 -0
  139. package/public/react-admin/favicon.ico +0 -0
  140. package/public/react-admin/index.html +36 -0
  141. package/public/react-admin/main.js +268 -0
  142. package/public/react-admin/main.js.map +7 -0
  143. package/public/react-admin/site.webmanifest +20 -0
  144. package/public/react-admin/stats.json +1 -0
  145. package/scripts.mjs +134 -25
  146. package/dist/server.d.ts +0 -945
  147. package/dist/server.js +0 -20775
  148. package/dist/server.js.map +0 -1
  149. package/editions/mws-docs/tiddlers/2025-04-03 - Phases of Development.md +0 -64
  150. package/editions/mws-docs/tiddlers/2025-04-03 - Phases of Development.md.meta +0 -5
  151. package/editions/mws-docs/tiddlers/2025-04-13 - Plugins.md +0 -18
  152. package/editions/mws-docs/tiddlers/2025-04-13 - Plugins.md.meta +0 -5
  153. package/editions/mws-docs/tiddlers/2025-05-04.md.meta +0 -5
  154. package/editions/mws-docs/tiddlers/2025-05-06.md.meta +0 -5
  155. package/editions/mws-docs/tiddlers/Moduler Design.md +0 -29
  156. package/editions/mws-docs/tiddlers/Moduler Design.md.meta +0 -5
  157. package/editions/mws-docs/tiddlers/Request Server.md +0 -20
  158. package/editions/mws-docs/tiddlers/Request Server.md.meta +0 -5
  159. package/editions/mws-docs/tiddlers/system/$__palettes_MUI-Dark +0 -173
  160. package/editions/mws-docs/tiddlers/system/$__palettes_MUI-Dark.meta +0 -6
  161. package/editions/mws-docs/tiddlers/system/$__palettes_MUI-Light +0 -172
  162. package/editions/mws-docs/tiddlers/system/$__palettes_MUI-Light.meta +0 -6
  163. package/plugins/client/tiddlers/config-tiddlers-filter.tid +0 -2
  164. package/plugins/client/tiddlers/config-use-server-sent-events.tid +0 -2
  165. package/plugins/client/tiddlers/info-segment.tid +0 -8
  166. package/plugins/client/tiddlers/multiwikiclientadaptor.js +0 -444
  167. package/prisma/migrations/20250407002306_disconnect_user_tables/migration.sql +0 -52
  168. package/prisma/old-db.sql +0 -144
  169. package/react-user-mgmt/README.md +0 -50
  170. package/react-user-mgmt/eslint.config.js +0 -28
  171. package/react-user-mgmt/package-lock.json +0 -2738
  172. package/react-user-mgmt/package.json +0 -31
  173. package/react-user-mgmt/public/index.html +0 -1
  174. package/react-user-mgmt/public/main.js +0 -295
  175. package/react-user-mgmt/public/main.js.map +0 -7
  176. package/react-user-mgmt/public/stats.json +0 -1
  177. package/react-user-mgmt/tsconfig.json +0 -40
  178. package/react-user-mgmt/types.d.ts +0 -2
  179. package/tiddlywiki-types/boot/boot.d.ts +0 -378
  180. package/tiddlywiki-types/boot/bootprefix.d.ts +0 -110
  181. package/tiddlywiki-types/core/modules/filters.d.ts +0 -107
  182. package/tiddlywiki-types/core/modules/tiddler.d.ts +0 -47
  183. package/tiddlywiki-types/core/modules/wiki-bulkops.d.ts +0 -36
  184. package/tiddlywiki-types/core/modules/wiki.d.ts +0 -208
  185. package/tiddlywiki-types/index.d.ts +0 -7
  186. package/tiddlywiki-types/package.json +0 -3
  187. /package/plugins/client/tiddlers/{sidebarsegment.tid → status/sidebarsegment.tid} +0 -0
  188. /package/plugins/client/tiddlers/{GettingStarted.tid → syncer/GettingStarted.tid} +0 -0
  189. /package/plugins/client/tiddlers/{config-incoming-updates-filter.tid → syncer/config-incoming-updates-filter.tid} +0 -0
  190. /package/plugins/client/tiddlers/{readme.tid → syncer/readme.tid} +0 -0
  191. /package/{react-user-mgmt/public → public/react-admin}/chunk-YALWN5OT.js +0 -0
  192. /package/{react-user-mgmt/public → public/react-admin}/chunk-YALWN5OT.js.map +0 -0
  193. /package/{react-user-mgmt/public/esm-QKDFEP3B.js.map → public/react-admin/esm-QMNGI3LF.js.map} +0 -0
  194. /package/{react-user-mgmt/public → public/react-admin}/favicon.png +0 -0
  195. /package/{react-user-mgmt/public → public/react-admin}/missing-favicon.png +0 -0
  196. /package/{react-user-mgmt/public → public/react-admin}/motovun-jack.jpg +0 -0
  197. /package/{react-user-mgmt/public → public/react-admin}/mws-icon.png +0 -0
  198. /package/{react-user-mgmt/public → public/react-admin}/stats.css +0 -0
  199. /package/{react-user-mgmt/public → public/react-admin}/stats.html +0 -0
  200. /package/{react-user-mgmt/public → public/react-admin}/stats.js +0 -0
  201. /package/{react-user-mgmt/public → public/react-admin}/stats.svg +0 -0
  202. /package/{react-user-mgmt/public → public/react-admin}/vite.svg +0 -0
@@ -1,5 +1,5 @@
1
1
  /*\
2
- title: $:/plugins/tiddlywiki/tiddlyweb/tiddlywebadaptor.js
2
+ title: $:/plugins/mws/client/tiddlywebadaptor.js
3
3
  type: application/javascript
4
4
  module-type: syncadaptor
5
5
 
@@ -18,23 +18,192 @@ previous operation to complete before sending a new one.
18
18
 
19
19
  // the blank line is important, and so is the following use strict
20
20
  "use strict";
21
- import type { Logger } from "$:/core/modules/utils/logger.js";
22
- import type { Syncer, Tiddler, ITiddlyWiki } from "tiddlywiki";
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 } from 'tiddlywiki';
26
+ declare global { const fflate: typeof import("./fflate"); }
27
+ declare const self: never;
28
+
29
+ // registerZodRoutes(parent, new WikiStatusRoutes(), Object.keys({
30
+ // handleGetAllBagStates: true,
31
+ // handleGetBagState: true,
32
+ // handleGetRecipeStatus: true,
33
+ // handleGetRecipeEvents: true,
34
+ // handleGetBags: true,
35
+ // } satisfies RouterKeyMap<WikiStatusRoutes, true>));
36
+
37
+ // registerZodRoutes(parent, new WikiRecipeRoutes(), Object.keys({
38
+ // handleDeleteRecipeTiddler: true,
39
+ // handleLoadRecipeTiddler: true,
40
+ // handleSaveRecipeTiddler: true,
41
+ // rpcDeleteRecipeTiddlerList: true,
42
+ // rpcLoadRecipeTiddlerList: true,
43
+ // rpcSaveRecipeTiddlerList: true,
44
+ // } satisfies RouterKeyMap<WikiRecipeRoutes, true>));
45
+
46
+ declare class Logger {
47
+ constructor(componentName: any, options: any);
48
+ componentName: any;
49
+ colour: any;
50
+ enable: any;
51
+ save: any;
52
+ saveLimit: any;
53
+ saveBufferLogger: this;
54
+ buffer: string;
55
+ alertCount: number;
56
+ setSaveBuffer(logger: any): void;
57
+ log(...args: any[]): any;
58
+ getBuffer(): string;
59
+ table(value: any): void;
60
+ alert(...args: any[]): void;
61
+ clearAlerts(): void;
62
+ }
63
+
64
+ type WikiStatusTypes = {
65
+ [K in keyof WikiStatusRoutes]: WikiStatusRoutes[K] extends ZodRoute<infer M, infer B, infer P, infer Q, infer T, infer R>
66
+ ? { M: M, B: B, P: P, Q: Q, T: T, R: R }
67
+ : never
68
+ }
69
+ type WikiRecipeTypes = {
70
+ [K in keyof WikiRecipeRoutes]: WikiRecipeRoutes[K] extends ZodRoute<infer M, infer B, infer P, infer Q, infer T, infer R>
71
+ ? { M: M, B: B, P: P, Q: Q, T: T, R: R }
72
+ : never
73
+ }
74
+ interface RouteTypes extends WikiStatusTypes, WikiRecipeTypes {
75
+ }
23
76
 
24
77
  declare module 'tiddlywiki' {
25
- export interface Syncer {
78
+ export interface Syncer<AD> {
26
79
  wiki: Wiki;
27
80
  logger: Logger;
28
- tiddlerInfo: Record<string, { bag: string; revision: string }>;
81
+ tiddlerInfo: Record<string, {
82
+ changeCount: number,
83
+ adaptorInfo: AD,
84
+ revision: string,
85
+ timestampLastSaved: Date
86
+ }>;
29
87
  enqueueLoadTiddler(title: string): void;
30
88
  storeTiddler(tiddler: Tiddler): void;
31
89
  processTaskQueue(): void;
90
+ syncFromServer(): void;
32
91
  }
33
92
  interface ITiddlyWiki {
34
93
  browserStorage: any;
35
94
  }
36
95
  }
37
96
 
97
+ type ServerStatusCallback = (
98
+ err: any,
99
+ /**
100
+ * $:/status/IsLoggedIn mostly appears alongside the username
101
+ * or other login-conditional behavior.
102
+ */
103
+ isLoggedIn?: boolean,
104
+ /**
105
+ * $:/status/UserName is still used for things like drafts even if the
106
+ * user isn't logged in, although the username is less likely to be shown
107
+ * to the user.
108
+ */
109
+ username?: string,
110
+ /**
111
+ * $:/status/IsReadOnly puts the UI in readonly mode,
112
+ * but does not prevent automatic changes from attempting to save.
113
+ */
114
+ isReadOnly?: boolean,
115
+ /**
116
+ * $:/status/IsAnonymous does not appear anywhere in the TW5 repo!
117
+ * So it has no apparent purpose.
118
+ */
119
+ isAnonymous?: boolean
120
+ ) => void
121
+
122
+ interface SyncAdaptor<AD> {
123
+ name?: string;
124
+
125
+ isReady?(): boolean;
126
+
127
+ getStatus?(
128
+ cb: ServerStatusCallback
129
+ ): void;
130
+
131
+ getSkinnyTiddlers?(
132
+ cb: (err: any, tiddlerFields: Record<string, string>[]) => void
133
+ ): void;
134
+ getUpdatedTiddlers?(
135
+ syncer: Syncer<AD>,
136
+ cb: (
137
+ err: any,
138
+ /** Arrays of titles that have been modified or deleted */
139
+ updates?: { modifications: string[], deletions: string[] }
140
+ ) => void
141
+ ): void;
142
+
143
+ /**
144
+ * used to override the default Syncer getTiddlerRevision behavior
145
+ * of returning the revision field
146
+ *
147
+ */
148
+ getTiddlerRevision?(title: string): string;
149
+ /**
150
+ * used to get the adapter info from a tiddler in situations
151
+ * other than the saveTiddler callback
152
+ */
153
+ getTiddlerInfo(tiddler: Tiddler): AD | undefined;
154
+
155
+ saveTiddler(
156
+ tiddler: any,
157
+ cb: (
158
+ err: any,
159
+ adaptorInfo?: AD,
160
+ revision?: string
161
+ ) => void,
162
+ extra: { tiddlerInfo: SyncerTiddlerInfo<AD> }
163
+ ): void;
164
+
165
+ saveTiddlers?(options: {
166
+ syncer: Syncer<AD>,
167
+ tiddlers: Tiddler[],
168
+ onNext: (title: string, adaptorInfo: any, revision: string) => void,
169
+ onDone: () => void,
170
+ onError: (err: Error) => void
171
+ }): void;
172
+
173
+ loadTiddlers?(options: {
174
+ syncer: Syncer<AD>,
175
+ titles: string[],
176
+ onNext: (tiddlerFields: TiddlerFields) => void,
177
+ onDone: () => void,
178
+ onError: (err: Error) => void
179
+ }): void;
180
+
181
+ deleteTiddlers?(options: {
182
+ syncer: Syncer<AD>,
183
+ titles: string[],
184
+ onNext: (title: string) => void,
185
+ onDone: () => void,
186
+ onError: (err: Error) => void
187
+ }): void;
188
+
189
+ setLoggerSaveBuffer?: (loggerForSaving: Logger) => void;
190
+ displayLoginPrompt?(syncer: Syncer<AD>): void;
191
+ login?(username: string, password: string, cb: (err: any) => void): void;
192
+ logout?(cb: (err: any) => void): any;
193
+ }
194
+ interface SyncerTiddlerInfo<AD> {
195
+ /** this comes from the wiki changeCount record */
196
+ changeCount: number;
197
+ /** Adapter info returned by the sync adapter */
198
+ adaptorInfo: AD;
199
+ /** Revision return by the sync adapter */
200
+ revision: string;
201
+ /** Timestamp set in the callback of the previous save */
202
+ timestampLastSaved: Date;
203
+ }
204
+
205
+ declare const $tw: any;
206
+
38
207
  declare const exports: {
39
208
  adaptorClass: typeof MultiWikiClientAdaptor;
40
209
  };
@@ -46,48 +215,65 @@ var CONFIG_HOST_TIDDLER = "$:/config/multiwikiclient/host",
46
215
  REVISION_STATE_TIDDLER = "$:/state/multiwikiclient/tiddlers/revision",
47
216
  CONNECTION_STATE_TIDDLER = "$:/state/multiwikiclient/connection",
48
217
  INCOMING_UPDATES_FILTER_TIDDLER = "$:/config/multiwikiclient/incoming-updates-filter",
49
- ENABLE_SSE_TIDDLER = "$:/config/multiwikiclient/use-server-sent-events";
218
+ ENABLE_SSE_TIDDLER = "$:/config/multiwikiclient/use-server-sent-events",
219
+ IS_DEV_MODE_TIDDLER = `$:/state/multiwikiclient/dev-mode`,
220
+ ENABLE_GZIP_STREAM_TIDDLER = `$:/state/multiwikiclient/gzip-stream`;
221
+
50
222
 
51
223
  var SERVER_NOT_CONNECTED = "NOT CONNECTED",
52
224
  SERVER_CONNECTING_SSE = "CONNECTING SSE",
53
225
  SERVER_CONNECTED_SSE = "CONNECTED SSE",
54
226
  SERVER_POLLING = "SERVER POLLING";
55
227
 
56
- class MultiWikiClientAdaptor {
57
- wiki;
58
- host;
59
- recipe;
60
- useServerSentEvents;
61
- last_known_tiddler_id;
62
- outstandingRequests;
63
- lastRecordedUpdate;
64
- logger;
65
- isLoggedIn;
66
- isReadOnly;
67
- logoutIsAvailable;
68
- incomingUpdatesFilterFn;
69
- serverUpdateConnectionStatus!: string;
228
+ interface MWSAdaptorInfo {
229
+ bag: string
230
+ revision: string
231
+ title: string
232
+ }
233
+
234
+
235
+ class MultiWikiClientAdaptor implements SyncAdaptor<MWSAdaptorInfo> {
236
+ private wiki;
237
+ private host;
238
+ private recipe;
239
+ private useServerSentEvents;
240
+ private last_known_revision_id;
241
+ private outstandingRequests;
242
+ private lastRecordedUpdate;
243
+ private logger;
244
+ private isLoggedIn;
245
+ private isReadOnly;
246
+ private offline;
247
+ private username;
248
+ private incomingUpdatesFilterFn;
249
+ private serverUpdateConnectionStatus!: string;
250
+ private isDevMode: boolean;
251
+ private useGzipStream: boolean;
252
+ private triggerPoll: (() => void) | null = null;
70
253
 
71
254
  name = "multiwikiclient";
72
- supportsLazyLoading = true;
255
+ private supportsLazyLoading = true;
73
256
  constructor(options: { wiki: any }) {
74
257
  this.wiki = options.wiki;
75
258
  this.host = this.getHost();
76
259
  this.recipe = this.wiki.getTiddlerText("$:/config/multiwikiclient/recipe");
77
260
  this.useServerSentEvents = this.wiki.getTiddlerText(ENABLE_SSE_TIDDLER) === "yes";
78
- this.last_known_tiddler_id = $tw.utils.parseNumber(this.wiki.getTiddlerText("$:/state/multiwikiclient/recipe/last_tiddler_id", "0"));
261
+ this.isDevMode = this.wiki.getTiddlerText(IS_DEV_MODE_TIDDLER) === "yes";
262
+ this.useGzipStream = this.wiki.getTiddlerText(ENABLE_GZIP_STREAM_TIDDLER) === "yes";
263
+ this.last_known_revision_id = this.wiki.getTiddlerText("$:/state/multiwikiclient/recipe/last_revision_id", "0")
79
264
  this.outstandingRequests = Object.create(null); // Hashmap by title of outstanding request object: {type: "PUT"|"GET"|"DELETE"}
80
- this.lastRecordedUpdate = Object.create(null); // Hashmap by title of last recorded update via SSE: {type: "update"|"detetion", tiddler_id:}
265
+ this.lastRecordedUpdate = Object.create(null); // Hashmap by title of last recorded update via SSE: {type: "update"|"detetion", revision_id:}
81
266
  this.logger = new $tw.utils.Logger("MultiWikiClientAdaptor");
82
267
  this.isLoggedIn = false;
83
268
  this.isReadOnly = false;
84
- this.logoutIsAvailable = true;
269
+ this.offline = false;
270
+ this.username = "";
85
271
  // Compile the dirty tiddler filter
86
272
  this.incomingUpdatesFilterFn = this.wiki.compileFilter(this.wiki.getTiddlerText(INCOMING_UPDATES_FILTER_TIDDLER));
87
- this.setUpdateConnectionStatus(SERVER_NOT_CONNECTED);
273
+ this.setConnectionStatus(SERVER_NOT_CONNECTED);
88
274
  }
89
275
 
90
- setUpdateConnectionStatus(status: string) {
276
+ private setConnectionStatus(status: string) {
91
277
  this.serverUpdateConnectionStatus = status;
92
278
  this.wiki.addTiddler({
93
279
  title: CONNECTION_STATE_TIDDLER,
@@ -100,20 +286,29 @@ class MultiWikiClientAdaptor {
100
286
  isReady() {
101
287
  return true;
102
288
  }
103
- getHost() {
289
+ subscribe(callback: () => void) {
290
+ // TODO: not working at the moment for some reason
291
+ console.log("Subscribing to server updates");
292
+ this.triggerPoll = callback;
293
+ this.connectRecipeEvents();
294
+ }
295
+ private getHost() {
104
296
  var text = this.wiki.getTiddlerText(CONFIG_HOST_TIDDLER, DEFAULT_HOST_TIDDLER), substitutions = [
105
297
  { name: "protocol", value: document.location.protocol },
106
298
  { name: "host", value: document.location.host },
107
299
  { name: "pathname", value: document.location.pathname }
108
300
  ];
109
301
  for (var t = 0; t < substitutions.length; t++) {
110
- var s = substitutions[t];
302
+ var s = substitutions[t]!;
111
303
  text = $tw.utils.replaceString(text, new RegExp("\\$" + s.name + "\\$", "mg"), s.value);
112
304
  }
113
305
  return text;
114
306
  }
115
- getTiddlerInfo(tiddler: Tiddler) {
116
- var title = tiddler.fields.title, revision = this.wiki.extractTiddlerDataItem(REVISION_STATE_TIDDLER, title), bag = this.wiki.extractTiddlerDataItem(BAG_STATE_TIDDLER, title);
307
+
308
+ getTiddlerInfo(tiddler: Tiddler): MWSAdaptorInfo | undefined {
309
+ var title = tiddler.fields.title,
310
+ revision = this.wiki.extractTiddlerDataItem(REVISION_STATE_TIDDLER, title),
311
+ bag = this.wiki.extractTiddlerDataItem(BAG_STATE_TIDDLER, title);
117
312
  if (revision && bag) {
118
313
  return {
119
314
  title: title,
@@ -124,354 +319,374 @@ class MultiWikiClientAdaptor {
124
319
  return undefined;
125
320
  }
126
321
  }
127
- getTiddlerBag(title: string) {
322
+ private getTiddlerBag(title: string) {
128
323
  return this.wiki.extractTiddlerDataItem(BAG_STATE_TIDDLER, title);
129
324
  }
130
325
  getTiddlerRevision(title: string) {
131
326
  return this.wiki.extractTiddlerDataItem(REVISION_STATE_TIDDLER, title);
132
327
  }
133
- setTiddlerInfo(title: string, revision: string, bag: string) {
134
- this.wiki.setText(BAG_STATE_TIDDLER, null, title, bag, { suppressTimestamp: true });
135
- this.wiki.setText(REVISION_STATE_TIDDLER, null, title, revision, { suppressTimestamp: true });
328
+ private setTiddlerInfo({ title, revision_id, bag_name }: { title: string; revision_id: string; bag_name: string; }) {
329
+ this.wiki.setText(BAG_STATE_TIDDLER, null, title, bag_name, { suppressTimestamp: true });
330
+ this.wiki.setText(REVISION_STATE_TIDDLER, null, title, revision_id, { suppressTimestamp: true });
136
331
  }
137
- removeTiddlerInfo(title: string) {
332
+ private removeTiddlerInfo({ title, revision_id, bag_id }: { title: string; revision_id: string; bag_id: string; }) {
138
333
  this.wiki.setText(BAG_STATE_TIDDLER, null, title, undefined, { suppressTimestamp: true });
139
334
  this.wiki.setText(REVISION_STATE_TIDDLER, null, title, undefined, { suppressTimestamp: true });
140
335
  }
141
336
 
142
- httpRequest<RT extends "text" | "arraybuffer" | "json">(options: {
143
- /** url to retrieve (must not contain `?` if GET or HEAD) */
144
- url: string;
145
- /** hashmap of headers to send */
146
- headers?: Record<string, string>;
147
- /** request method: GET, PUT, POST etc */
148
- type?: string;
149
- /** optional function invoked with (lengthComputable,loaded,total) */
150
- progress?: (lengthComputable: boolean, loaded: number, total: number) => void;
151
- /** name of the property to return as first argument of callback */
152
- returnProp?: string;
153
- responseType?: RT;
154
- useDefaultHeaders?: boolean;
155
- /** urlencoded string or hashmap of data to send. If type is GET or HEAD, this is appended to the URL as a query string */
156
- data?: object | string;
157
- }) {
158
- type ResponseErr = [false, any, undefined];
159
- type ResponseOk = [true, undefined, {
160
- data:
161
- "json" extends RT ? any :
162
- "text" extends RT ? string :
163
- "arraybuffer" extends RT ? ArrayBuffer :
164
- unknown;
165
- headers: Record<string, string>
166
- }];
167
- return (new Promise<ResponseErr | ResponseOk>((resolve) => {
168
- $tw.utils.httpRequest({
169
- ...options,
170
- responseType: options.responseType === "json" ? "text" : options.responseType,
171
- callback: (err: any, data: any, request: XMLHttpRequest) => {
172
- if (err) return resolve([false, err || new Error("Unknown error"), undefined]);
173
-
174
- // Create a map of header names to values
175
-
176
- const headers = {} as any;
177
- request.getAllResponseHeaders()?.trim().split(/[\r\n]+/).forEach((line) => {
178
- const parts = line.split(": ");
179
- const header = parts.shift()?.toLowerCase();
180
- const value = parts.join(": ");
181
- if (header) headers[header] = value;
182
- });
183
- // Resolve the promise with the response data and headers
184
- resolve([true, undefined, {
185
- headers,
186
- data: options.responseType === "json" ? $tw.utils.parseJSONSafe(data, () => undefined) : data,
187
- }]);
188
- },
189
- });
190
- }));
191
- }
337
+ error: string | null = null;
192
338
  /*
193
339
  Get the current status of the server connection
194
340
  */
195
- async getStatus(callback: (
196
- err: any,
197
- isLoggedIn?: boolean,
198
- username?: string,
199
- isReadOnly?: boolean,
200
- isAnonymous?: boolean,
201
- ) => void) {
202
- interface UserAuthStatus {
203
- isAdmin: boolean;
204
- user_id: number;
205
- username: string;
206
- isLoggedIn: boolean;
207
- isReadOnly: boolean;
208
- allowAnonReads: boolean;
209
- allowAnonWrites: boolean;
210
- }
341
+ async getStatus(callback: ServerStatusCallback) {
211
342
 
212
- const [ok, error, data] = await this.httpRequest({
213
- url: this.host + "recipes/" + this.recipe + "/status",
214
- type: "GET",
215
- responseType: "json",
216
- headers: {
217
- 'Content-Type': 'application/json',
218
- "X-Requested-With": "TiddlyWiki"
219
- },
343
+ const [ok, error, data] = await this.recipeRequest({
344
+ key: "handleGetRecipeStatus",
345
+ method: "GET",
346
+ url: "/status",
220
347
  });
221
- if (!ok) {
222
- this.logger.log("Error getting status", error);
223
- if (callback) callback(error);
224
- return;
348
+
349
+ if (!ok && data?.status === 0) {
350
+ this.error = "The webpage is forbidden from contacting the server."
351
+ this.isLoggedIn = false;
352
+ this.isReadOnly = true;
353
+ this.username = "(offline)";
354
+ this.offline = true;
355
+ } else if (ok) {
356
+ this.error = null;
357
+ const status = data.responseJSON;
358
+ this.isReadOnly = status?.isReadOnly ?? true;
359
+ this.isLoggedIn = status?.isLoggedIn ?? false;
360
+ this.username = status?.username ?? "(anon)";
361
+ this.offline = false;
362
+ } else {
363
+ this.error = error.message ?? `${error}`;
225
364
  }
226
- /** @type {Partial<UserAuthStatus>} */
227
- const status = data.data;
228
365
  if (callback) {
229
366
  callback(
230
367
  // Error
231
- null,
368
+ this.error,
232
369
  // Is logged in
233
- status.isLoggedIn ?? false,
370
+ this.isLoggedIn,
234
371
  // Username
235
- status.username ?? "(anon)",
372
+ this.username,
236
373
  // Is read only
237
- status.isReadOnly ?? true,
374
+ this.isReadOnly,
238
375
  // Is anonymous
239
- !status.isLoggedIn,
376
+ // no idea what this means, always return false
377
+ false,
240
378
  );
241
379
  }
242
380
  }
243
381
  /*
244
382
  Get details of changed tiddlers from the server
245
383
  */
246
- getUpdatedTiddlers(syncer: Syncer, callback: (err: any, changes?: { modifications: string[]; deletions: string[] }) => void) {
247
- if (!this.useServerSentEvents) {
248
- this.pollServer({
249
- callback: function (err, changes) {
250
- callback(null, changes);
251
- }
252
- });
253
- return;
254
- }
384
+ async getUpdatedTiddlers(syncer: Syncer<MWSAdaptorInfo>, callback: (err: any, changes?: { modifications: string[]; deletions: string[] }) => void) {
385
+ if (this.offline) return callback(null);
386
+ if (!this.bags || this.serverUpdateConnectionStatus === SERVER_NOT_CONNECTED) await this.pollServer();
387
+ const { modifications, deletions, last_revision } = this.updateBags();
388
+ this.last_known_revision_id = last_revision;
389
+ callback(null, { modifications, deletions });
390
+ }
255
391
 
256
- var self = this;
257
- // Do nothing if there's already a connection in progress.
258
- if (this.serverUpdateConnectionStatus !== SERVER_NOT_CONNECTED) {
259
- return callback(null, {
260
- modifications: [],
261
- deletions: []
262
- });
263
- }
264
- // Try to connect a server stream
265
- this.setUpdateConnectionStatus(SERVER_CONNECTING_SSE);
266
- this.connectServerStream({
267
- syncer: syncer,
268
- onerror: function (err) {
269
- self.logger.log("Error connecting SSE stream", err);
270
- // If the stream didn't work, try polling
271
- self.setUpdateConnectionStatus(SERVER_POLLING);
272
- self.pollServer({
273
- callback: function (err, changes) {
274
- self.setUpdateConnectionStatus(SERVER_NOT_CONNECTED);
275
- callback(null, changes);
392
+
393
+ connectRecipeEvents() {
394
+ const event = new EventSource(this.host + "recipe/" + encodeURIComponent(this.recipe) + "/events?first-event-id=" + encodeURIComponent(this.last_known_revision_id));
395
+ this.setConnectionStatus(SERVER_CONNECTING_SSE);
396
+ let syncTimeout: NodeJS.Timeout | null = null;
397
+ const debounceSyncFromServer = () => {
398
+ if (syncTimeout) {
399
+ clearTimeout(syncTimeout);
400
+ }
401
+ syncTimeout = setTimeout(() => {
402
+ syncTimeout = null;
403
+ this.triggerPoll?.();
404
+ }, 100);
405
+ };
406
+
407
+ const onUpdate = (e: MessageEvent) => {
408
+ if (!this.bags) return console.log("No bags loaded, cannot process updates");
409
+ const updates: {
410
+ bag: {
411
+ bag_name: PrismaField<"Bags", "bag_name">;
412
+ tiddlers: {
413
+ revision_id: PrismaField<"Tiddlers", "revision_id">;
414
+ title: PrismaField<"Tiddlers", "title">;
415
+ is_deleted: PrismaField<"Tiddlers", "is_deleted">;
416
+ }[];
417
+ };
418
+ }[] = JSON.parse(e.data);
419
+ const existBags = new Map(this.bags.map(b => [b.bag_name, b.tiddlers] as const));
420
+ updates.forEach(e => {
421
+ const existBag = existBags.get(e.bag.bag_name);
422
+ if (!existBag) return;
423
+ const existTids = new Map(existBag.map(t => [t.title, t] as const));
424
+ e.bag.tiddlers.forEach(tid => {
425
+ const existTid = existTids.get(tid.title);
426
+ if (existTid) {
427
+ Object.assign(existTid, tid)
428
+ } else {
429
+ existBag.push(tid);
276
430
  }
277
431
  });
278
- },
279
- onopen: function () {
280
- self.setUpdateConnectionStatus(SERVER_CONNECTED_SSE);
281
- // The syncer is expecting a callback but we don't have any data to send
282
- callback(null, {
283
- modifications: [],
284
- deletions: []
285
- });
432
+ });
433
+ debounceSyncFromServer();
434
+ };
435
+ // event.addEventListener("tiddler.save", onEvent);
436
+ // event.addEventListener("tiddler.delete", onEvent);
437
+ event.addEventListener("tiddler.since-last-event", onUpdate);
438
+ event.addEventListener("test", console.log);
439
+ event.addEventListener("open", () => {
440
+ this.setConnectionStatus(SERVER_CONNECTED_SSE);
441
+ console.log("Connected to server events");
442
+ });
443
+ event.addEventListener("error", (e) => {
444
+ if (event.readyState === EventSource.CLOSED) {
445
+ this.setConnectionStatus(SERVER_NOT_CONNECTED);
446
+ console.error("Server events connection closed");
447
+ } else if (event.readyState === EventSource.CONNECTING) {
448
+ this.setConnectionStatus(SERVER_CONNECTING_SSE);
449
+ console.warn("Connecting to server events");
450
+ } else {
451
+ console.error("Error in server events connection", e);
286
452
  }
287
453
  });
288
-
289
454
  }
290
- /*
291
- Attempt to establish an SSE stream with the server and transfer tiddler changes. Options include:
292
-
293
- syncer: reference to syncer object used for storing data
294
- onopen: invoked when the stream is successfully opened
295
- onerror: invoked if there is an error
296
- */
297
- connectServerStream(options: {
298
- syncer: Syncer;
299
- onopen: (event: Event) => void;
300
- onerror: (event: Event) => void;
301
- }) {
302
- var self = this;
303
- const eventSource = new EventSource("/recipes/" + this.recipe + "/events?last_known_tiddler_id=" + this.last_known_tiddler_id);
304
- eventSource.onerror = function (event) {
305
- if (options.onerror) {
306
- options.onerror(event);
307
- }
308
- };
309
- eventSource.onopen = function (event) {
310
- if (options.onopen) {
311
- options.onopen(event);
312
- }
455
+
456
+ bags?: {
457
+ position: number & {
458
+ __prisma_table: "Recipe_bags";
459
+ __prisma_field: "position";
313
460
  };
314
- eventSource.addEventListener("change", function (event) {
315
-
316
- const data: {
317
- title: string;
318
- tiddler_id: number;
319
- is_deleted: boolean;
320
- bag_name: string;
321
- tiddler: any;
322
- } = $tw.utils.parseJSONSafe(event.data);
323
- if (!data) return;
324
-
325
- console.log("SSE data", data);
326
- // Update last seen tiddler_id
327
- if (data.tiddler_id > self.last_known_tiddler_id) {
328
- self.last_known_tiddler_id = data.tiddler_id;
329
- }
330
- // Record the last update to this tiddler
331
- self.lastRecordedUpdate[data.title] = {
332
- type: data.is_deleted ? "deletion" : "update",
333
- tiddler_id: data.tiddler_id
461
+ bag_id: string;
462
+ bag_name: string;
463
+ tiddlers: {
464
+ title: string;
465
+ revision_id: string;
466
+ is_deleted: boolean;
467
+ }[];
468
+ }[]
469
+
470
+ private async pollServer() {
471
+ type t1 = RouteTypes["handleGetBags"];
472
+ const [ok, err, result] = await this.recipeRequest({
473
+ key: "handleGetBags",
474
+ url: "/bags",
475
+ method: "GET",
476
+ });
477
+ if (!ok) throw err;
478
+ if (!result.responseJSON) throw new Error("no result returned");
479
+ type t2 = RouteTypes["handleGetBagState"];
480
+ this.bags = await Promise.all(result.responseJSON.map(e => this.recipeRequest({
481
+ key: "handleGetBagState",
482
+ url: "/bags/" + encodeURIComponent(e.bag_name) + "/state",
483
+ method: "GET",
484
+ queryParams: { include_deleted: "yes" },
485
+ }).then(([ok, err, f]) => {
486
+ if (!ok) throw err;
487
+ if (!f.responseJSON) throw new Error("no result returned");
488
+ return {
489
+ ...f.responseJSON,
490
+ position: e.position,
334
491
  };
335
- console.log(`Oustanding requests is ${JSON.stringify(self.outstandingRequests[data.title])}`);
336
- // Process the update if the tiddler is not the subject of an outstanding request
337
- if (self.outstandingRequests[data.title]) return;
338
- if (data.is_deleted) {
339
- self.removeTiddlerInfo(data.title);
340
- delete options.syncer.tiddlerInfo[data.title];
341
- options.syncer.logger.log("Deleting tiddler missing from server:", data.title);
342
- options.syncer.wiki.deleteTiddler(data.title);
343
- options.syncer.processTaskQueue();
344
- } else {
345
- var result = self.incomingUpdatesFilterFn.call(self.wiki, self.wiki.makeTiddlerIterator([data.title]));
346
- if (result.length > 0) {
347
- self.setTiddlerInfo(data.title, data.tiddler_id.toString(), data.bag_name);
348
- options.syncer.storeTiddler(data.tiddler);
349
- }
350
- }
492
+ })));
493
+
494
+ this.bags.sort((a, b) => b.position - a.position);
351
495
 
496
+ }
497
+
498
+ updateBags() {
499
+
500
+ if (!this.bags) throw new Error("No bags loaded, call pollServer first");
501
+
502
+ const modified = new Set<string>(),
503
+ deleted = new Set<string>();
504
+
505
+ const incomingTitles = new Set<string>(this.bags.map(
506
+ // get the titles in each layer that aren't deleted
507
+ e => e.tiddlers.filter(f => !f.is_deleted).map(f => f.title)
508
+ // and flatten them for Set
509
+ ).flat());
510
+
511
+ let last_revision = this.last_known_revision_id;
352
512
 
513
+ this.bags.forEach(bag => {
514
+ bag.tiddlers.forEach(tid => {
515
+ // if the revision is old, ignore, since deletions create a new revision
516
+ if (tid.revision_id <= this.last_known_revision_id) return;
517
+ if (tid.revision_id > last_revision) last_revision = tid.revision_id;
518
+ // check if this title still exists in any layer
519
+ if (incomingTitles.has(tid.title))
520
+ modified.add(tid.title);
521
+ else
522
+ deleted.add(tid.title);
523
+ })
353
524
  });
525
+
526
+ return {
527
+ modifications: [...modified.keys()],
528
+ deletions: [...deleted.keys()],
529
+ last_revision,
530
+ incomingTitles,
531
+ }
354
532
  }
355
533
  /*
356
- Poll the server for changes. Options include:
357
-
358
- callback: invoked on completion as (err,changes)
534
+ Queue a load for a tiddler if there has been an update for it since the specified revision
359
535
  */
360
- async pollServer(options: {
361
- callback: (err: any, changes?: { modifications: string[]; deletions: string[] }) => void;
536
+ private checkLastRecordedUpdate(title: string, revision: string) {
537
+ // var lru = this.lastRecordedUpdate[title];
538
+ // if (lru && lru.revision_id > revision) {
539
+ // console.log(`Checking for updates to ${title} since ${JSON.stringify(lru)} comparing to ${revision}`);
540
+ // this.syncer && this.syncer.enqueueLoadTiddler(title);
541
+ // }
542
+ }
543
+ private get syncer() {
544
+ if ($tw.syncadaptor === this) return $tw.syncer as Syncer<MWSAdaptorInfo>;
545
+ }
546
+ async loadTiddlers(options: {
547
+ syncer: Syncer<MWSAdaptorInfo>;
548
+ titles: string[];
549
+ onNext: (tiddlerFields: TiddlerFields) => void;
550
+ onDone: () => void;
551
+ onError: (err: Error) => void;
362
552
  }) {
363
- var self = this;
364
- const [ok, err, result] = await this.httpRequest({
365
- url: this.host + "recipes/" + this.recipe + "/tiddlers.json",
366
- data: {
367
- last_known_tiddler_id: this.last_known_tiddler_id,
368
- include_deleted: "true"
369
- },
370
- responseType: "json",
553
+ const { syncer, titles, onNext, onDone, onError } = options;
554
+
555
+ const tiddlers = await this.rpcRequest({
556
+ key: "rpcLoadRecipeTiddlerList",
557
+ body: { titles: titles as PrismaField<"Tiddlers", "title">[] }
558
+ }).catch(err => {
559
+ onError(err);
560
+ throw new Error("Error loading tiddlers: " + err.message);
561
+ });
562
+ tiddlers.forEach(({ bag_name, tiddler: fields }) => {
563
+ this.setTiddlerInfo({ title: fields.title, revision_id: fields.revision_id, bag_name: bag_name });
564
+ onNext(fields);
371
565
  });
566
+ onDone();
567
+ }
568
+ async saveTiddlers(options: {
569
+ syncer: Syncer<MWSAdaptorInfo>;
570
+ tiddlers: Tiddler[];
571
+ onNext: (title: string, adaptorInfo: MWSAdaptorInfo, revision: string) => void;
572
+ onDone: () => void;
573
+ onError: (err: Error) => void;
574
+ }) {
575
+ const { syncer, tiddlers, onNext, onDone, onError } = options;
372
576
 
373
- if (!ok) { return options.callback(err); }
374
- const { data: tiddlerInfoArray = [] } = result;
577
+ const fields = tiddlers.map(e => e.getFieldStrings());
375
578
 
376
- var modifications: string[] = [], deletions: string[] = [];
579
+ const results = await this.rpcRequest({
580
+ key: "rpcSaveRecipeTiddlerList",
581
+ body: { tiddlers: fields }
582
+ }).catch(err => {
583
+ onError(err);
584
+ throw new Error("Error saving tiddlers: " + err.message);
585
+ });
377
586
 
378
- $tw.utils.each(tiddlerInfoArray,
379
- /**
380
- * @param {{ title: string; tiddler_id: number; is_deleted: boolean; bag_name: string; }} tiddlerInfo
381
- */
382
- function (tiddlerInfo) {
383
- if (tiddlerInfo.tiddler_id > self.last_known_tiddler_id) {
384
- self.last_known_tiddler_id = tiddlerInfo.tiddler_id;
385
- }
386
- if (tiddlerInfo.is_deleted) {
387
- deletions.push(tiddlerInfo.title);
388
- } else {
389
- modifications.push(tiddlerInfo.title);
390
- }
391
- }
392
- );
587
+ results.forEach((tiddler) => {
588
+ this.setTiddlerInfo({ title: tiddler.title, revision_id: tiddler.revision_id, bag_name: tiddler.bag.bag_name });
589
+ onNext(tiddler.title, {
590
+ bag: tiddler.bag.bag_name,
591
+ title: tiddler.title,
592
+ revision: tiddler.revision_id
593
+ }, tiddler.revision_id);
594
+ });
595
+
596
+ onDone();
597
+ }
393
598
 
394
- // Invoke the callback with the results
395
- options.callback(null, {
396
- modifications: modifications,
397
- deletions: deletions
599
+ async deleteTiddlers(options: { syncer: Syncer<MWSAdaptorInfo>; titles: string[]; onNext: (title: string) => void; onDone: () => void; onError: (err: Error) => void; }) {
600
+ const { syncer, titles, onNext, onDone, onError } = options;
601
+
602
+ const results = await this.rpcRequest({
603
+ key: "rpcDeleteRecipeTiddlerList",
604
+ body: { titles: titles as PrismaField<"Tiddlers", "title">[] }
605
+ }).catch(err => {
606
+ onError(err);
607
+ throw new Error("Error deleting tiddlers: " + err.message);
398
608
  });
399
609
 
400
- setTimeout(() => {
401
- // If Browswer Storage tiddlers were cached on reloading the wiki, add them after sync from server completes in the above callback.
402
- if ($tw.browserStorage && $tw.browserStorage.isEnabled()) {
403
- $tw.browserStorage.addCachedTiddlers();
404
- }
610
+ results.forEach(({ title, revision_id, bag_id }) => {
611
+ this.removeTiddlerInfo({ title, revision_id, bag_id });
612
+ onNext(title);
405
613
  });
614
+
615
+ onDone();
406
616
  }
407
- /*
408
- Queue a load for a tiddler if there has been an update for it since the specified revision
409
- */
410
- checkLastRecordedUpdate(title: string, revision: string) {
411
- var lru = this.lastRecordedUpdate[title];
412
- if (lru) {
413
- var numRevision = $tw.utils.getInt(revision, 0);
414
- if (!numRevision) {
415
- this.logger.log("Error: revision is not a number", revision);
416
- return;
417
- }
418
- console.log(`Checking for updates to ${title} since ${JSON.stringify(revision)} comparing to ${numRevision}`);
419
- if (lru.tiddler_id > numRevision) {
420
- this.syncer && this.syncer.enqueueLoadTiddler(title);
421
- }
422
- }
423
- }
424
- get syncer() {
425
- //@ts-expect-error
426
- if ($tw.syncadaptor === this) return $tw.syncer;
427
- }
617
+
618
+
619
+
428
620
  /*
429
621
  Save a tiddler and invoke the callback with (err,adaptorInfo,revision)
430
622
  */
431
623
  async saveTiddler(
432
624
  tiddler: Tiddler,
433
- callback: (err: any, adaptorInfo?: { bag: string }, revision?: string) => void,
625
+ callback: (
626
+ err: any,
627
+ adaptorInfo?: MWSAdaptorInfo,
628
+ revision?: string
629
+ ) => void,
434
630
  options?: {}
435
631
  ) {
436
- var self = this, title = tiddler.fields.title;
632
+ var self = this, title = tiddler.fields.title as string;
633
+ if (title === "$:/StoryList") {
634
+ return callback(null);
635
+ }
437
636
  if (this.isReadOnly || title.substr(0, MWC_STATE_TIDDLER_PREFIX.length) === MWC_STATE_TIDDLER_PREFIX) {
438
637
  return callback(null);
439
638
  }
440
639
  self.outstandingRequests[title] = { type: "PUT" };
441
- // TODO: not using getFieldStringBlock because what happens if a field name has a colon in it?
442
- let body = JSON.stringify(tiddler.getFieldStrings({ exclude: ["text"] }));
640
+
641
+ // application/x-mws-tiddler
642
+ // The .tid file format does not support field names with colons.
643
+ // Rather than trying to catch all the unsupported variations that may appear,
644
+ // we'll just use JSON to send it across the wire, since that is the official fallback format anyway.
645
+ // However, parsing a huge string value inside a JSON object is very slow,
646
+ // so we split off the text field and send it after the other fields.
647
+
648
+ const fields = tiddler.getFieldStrings({});
649
+ const text = fields.text;
650
+ delete fields.text;
651
+ let body = JSON.stringify(fields);
652
+
443
653
  if (tiddler.hasField("text")) {
444
- if (typeof tiddler.fields.text !== "string" && tiddler.fields.text)
445
- return callback(new Error("Error saving tiddler " + tiddler.fields.title + ": the text field is truthy but not a string"));
446
- body += `\n\n${tiddler.fields.text}`
654
+ if (typeof text !== "string" && text)
655
+ return callback(new Error("Error saving tiddler " + fields.title + ": the text field is truthy but not a string"));
656
+ body += `\n\n${text}`
447
657
  }
448
658
 
449
- const [ok, err, result] = await this.httpRequest({
450
- url: this.host + "recipes/" + encodeURIComponent(this.recipe) + "/tiddlers/" + encodeURIComponent(title),
451
- type: "PUT",
659
+ type t = RouteTypes["handleSaveRecipeTiddler"]
660
+ const [ok, err, result] = await this.recipeRequest({
661
+ key: "handleSaveRecipeTiddler",
662
+ url: "/tiddlers/" + encodeURIComponent(title),
663
+ method: "PUT",
664
+ requestBodyString: body,
452
665
  headers: {
453
- "Content-type": "application/x-mws-tiddler"
454
- },
455
- data: body,
456
- responseType: "json",
666
+ "content-type": "application/x-mws-tiddler"
667
+ }
457
668
  });
669
+
458
670
  delete self.outstandingRequests[title];
459
671
  if (!ok) return callback(err);
460
- const { headers, data } = result;
672
+
673
+ const data = result.responseJSON;
674
+ if (!data) return callback(null); // a 2xx response without a body is unlikely
461
675
 
462
676
  //If Browser-Storage plugin is present, remove tiddler from local storage after successful sync to the server
463
677
  if ($tw.browserStorage && $tw.browserStorage.isEnabled()) {
464
678
  $tw.browserStorage.removeTiddlerFromLocalStorage(title);
465
679
  }
466
680
 
681
+
467
682
  // Save the details of the new revision of the tiddler
468
- const revision = data.tiddler_id, bag_name = data.bag_name;
683
+ const revision = data.revision_id, bag_name = data.bag_name;
469
684
  console.log(`Saved ${title} with revision ${revision} and bag ${bag_name}`);
470
685
  // If there has been a more recent update from the server then enqueue a load of this tiddler
471
686
  self.checkLastRecordedUpdate(title, revision);
472
687
  // Invoke the callback
473
- self.setTiddlerInfo(title, revision, bag_name);
474
- callback(null, { bag: bag_name }, revision);
688
+ self.setTiddlerInfo({ title, revision_id: revision, bag_name: bag_name });
689
+ callback(null, { bag: bag_name, title: title, revision: revision }, revision);
475
690
 
476
691
  }
477
692
  /*
@@ -480,24 +695,29 @@ class MultiWikiClientAdaptor {
480
695
  The syncer does not pass itself into options.
481
696
  */
482
697
  async loadTiddler(title: string, callback: (err: any, fields?: any) => void, options: any) {
483
- var self = this;
698
+ const self = this;
699
+ // if (!self.syncer) return callback(new Error("No syncer available"));
484
700
  self.outstandingRequests[title] = { type: "GET" };
485
- const [ok, err, result] = await this.httpRequest({
486
- url: this.host + "recipes/" + encodeURIComponent(this.recipe) + "/tiddlers/" + encodeURIComponent(title),
487
- });
701
+ const [ok, err, result] = await this.recipeRequest({
702
+ key: "handleLoadRecipeTiddler",
703
+ url: "/tiddlers/" + encodeURIComponent(title),
704
+ method: "GET",
705
+ })
488
706
  delete self.outstandingRequests[title];
489
- if (err === 404) {
490
- return callback(null, null);
491
- } else if (!ok) {
492
- return callback(err);
493
- }
494
- const { data, headers } = result;
495
- const revision = headers["x-revision-number"], bag_name = headers["x-bag-name"];
707
+
708
+ if (!ok) return callback(err);
709
+
710
+ const { responseJSON: data, headers } = result;
711
+ const revision = headers.get("x-revision-number") ?? "",
712
+ bag_name = headers.get("x-bag-name") ?? "";
713
+
714
+ if (!revision || !bag_name || !data) return callback(null, null);
715
+
496
716
  // If there has been a more recent update from the server then enqueue a load of this tiddler
497
717
  self.checkLastRecordedUpdate(title, revision);
498
718
  // Invoke the callback
499
- self.setTiddlerInfo(title, revision, bag_name);
500
- callback(null, $tw.utils.parseJSONSafe(data));
719
+ self.setTiddlerInfo({ title, revision_id: revision, bag_name: bag_name });
720
+ callback(null, data);
501
721
  }
502
722
  /*
503
723
  Delete a tiddler and invoke the callback with (err)
@@ -512,23 +732,254 @@ class MultiWikiClientAdaptor {
512
732
  // if(!bag) { return callback(null, options.tiddlerInfo.adaptorInfo); }
513
733
  self.outstandingRequests[title] = { type: "DELETE" };
514
734
  // Issue HTTP request to delete the tiddler
515
- const [ok, err, result] = await this.httpRequest({
516
- url: this.host + "recipes/" + encodeURIComponent(this.recipe) + "/tiddlers/" + encodeURIComponent(title),
517
- type: "DELETE",
735
+ type t = RouteTypes["handleDeleteRecipeTiddler"]
736
+ const [ok, err, result] = await this.recipeRequest({
737
+ key: "handleDeleteRecipeTiddler",
738
+ url: "/tiddlers/" + encodeURIComponent(title),
739
+ method: "DELETE",
518
740
  });
519
741
  delete self.outstandingRequests[title];
520
- if (!ok) { return callback(err); }
521
- const { data } = result;
522
- const revision = data.tiddler_id, bag_name = data.bag_name;
742
+ if (!ok) return callback(err);
743
+ const { responseJSON: data } = result;
744
+ if (!data) return callback(null);
745
+
746
+ const { revision_id, bag_name, bag_id } = data;
523
747
  // If there has been a more recent update from the server then enqueue a load of this tiddler
524
- self.checkLastRecordedUpdate(title, revision);
525
- self.removeTiddlerInfo(title);
748
+ self.checkLastRecordedUpdate(title, revision_id);
749
+ self.removeTiddlerInfo({ title, revision_id, bag_id });
526
750
  // Invoke the callback & return null adaptorInfo
527
751
  callback(null, null);
528
752
  }
753
+
754
+ async rpcRequest<T extends string & keyof RouteTypes>(
755
+ options: {
756
+ key: T;
757
+ body: zod.infer<RouteTypes[T]["T"]>;
758
+ }
759
+ ): Promise<RouteTypes[T]["R"]> {
760
+
761
+ type t = RouteTypes["handleSaveRecipeTiddler"]
762
+ const [ok, err, result] = await this.recipeRequest({
763
+ key: options.key,
764
+ url: "/rpc/" + options.key,
765
+ method: "PUT",
766
+ requestBodyString: JSON.stringify(options.body),
767
+ headers: {
768
+ "content-type": "application/json"
769
+ }
770
+ });
771
+
772
+ if (!ok) throw err;
773
+
774
+ if (!result.responseJSON) throw new Error("No response JSON returned");
775
+
776
+ return result.responseJSON as RouteTypes[T]["R"];
777
+ }
778
+
779
+ /**
780
+ * This throws an error if the response status is not 2xx, but will still return the response alongside the error.
781
+ *
782
+ * So if the first parameter is false, the third parameter may still contain the response.
783
+ */
784
+ private async recipeRequest<KEY extends (string & keyof RouteTypes)>(
785
+ options: Omit<HttpRequestOptions<"arraybuffer">, "responseType"> & { key: KEY }
786
+ ) {
787
+ if (!options.url.startsWith("/"))
788
+ throw new Error("The url does not start with a slash");
789
+
790
+ return await httpRequest({
791
+ ...options,
792
+ responseType: "blob",
793
+ url: this.host + "recipe/" + encodeURIComponent(this.recipe) + options.url,
794
+ }).then(async e => {
795
+ if (!e.ok) return [
796
+ false, new Error(
797
+ `The server return a status code ${e.status} with the following reason: `
798
+ + `${e.headers.get("x-reason") ?? "(no reason given)"}`
799
+ ), {
800
+ ...e,
801
+ responseString: "",
802
+ responseJSON: undefined,
803
+ }
804
+ ] as const;
805
+ let responseString: string = "";
806
+ if (e.headers.get("x-gzip-stream") === "yes") {
807
+ // Browsers only decode the first stream,
808
+ // so we cant use content-encoding or DecompressionStream
809
+
810
+ await new Promise<void>(async resolve => {
811
+
812
+ const gunzip = new fflate.AsyncGunzip((err, chunk, final) => {
813
+ if (err) return console.log(err);
814
+ responseString += fflate.strFromU8(chunk);
815
+ if (final) resolve();
816
+ });
817
+
818
+ if (this.isDevMode) gunzip.onmember = e => console.log("gunzip member", e);
819
+
820
+ gunzip.push(new Uint8Array(await readBlobAsArrayBuffer(e.response)));
821
+ // this has to be on a separate line
822
+ gunzip.push(new Uint8Array(), true);
823
+
824
+ });
825
+
826
+ if (this.isDevMode) console.log("gunzip result", responseString.length);
827
+
828
+ } else {
829
+ responseString = fflate.strFromU8(new Uint8Array(await readBlobAsArrayBuffer(e.response)));
830
+ }
831
+
832
+ return [true, void 0, {
833
+ ...e,
834
+ responseString,
835
+ /** this is undefined if status is not 200 */
836
+ responseJSON: e.status === 200
837
+ ? tryParseJSON(responseString) as RouteTypes[KEY]["R"]
838
+ : undefined,
839
+ }] as const;
840
+ }, e => [false, e, void 0] as const);
841
+
842
+ function tryParseJSON(data: string) {
843
+ try {
844
+ return JSON.parse(data);
845
+ } catch (e) {
846
+ console.error("Error parsing JSON, returning undefined", e);
847
+ // console.log(data);
848
+ return undefined;
849
+ }
850
+ }
851
+
852
+ }
853
+
529
854
  }
530
855
 
531
856
 
532
857
  if ($tw.browser && document.location.protocol.startsWith("http")) {
533
858
  exports.adaptorClass = MultiWikiClientAdaptor;
534
859
  }
860
+
861
+ type ParamsInput = URLSearchParams | [string, string][] | object | string | undefined;
862
+
863
+ interface HttpRequestOptions<TYPE extends "arraybuffer" | "blob" | "text"> {
864
+ /** The request METHOD. Maybe be anything except CONNECT, TRACE, or TRACK. */
865
+ method: string;
866
+ /** The url may also contain query params. */
867
+ url: string;
868
+ /** The response types */
869
+ responseType: TYPE;
870
+ headers?: HeadersInit;
871
+ /** This is parsed separately from the url and appended to it. */
872
+ queryParams?: ParamsInput;
873
+ /**
874
+ * The string to send as the request body. Not valid for GET and HEAD.
875
+ *
876
+ * For `application/x-www-form-urlencoded`, use `new URLSearchParams().toString()`.
877
+ *
878
+ * For `application/json`, use `JSON.stringify()`
879
+ */
880
+ requestBodyString?: string;
881
+ progress?: (event: ProgressEvent<EventTarget>) => void;
882
+ }
883
+
884
+
885
+ function httpRequest<TYPE extends "arraybuffer" | "blob" | "text">(options: HttpRequestOptions<TYPE>) {
886
+
887
+ return new Promise<{
888
+ /** Shorthand to check if the response is in the 2xx range. */
889
+ ok: boolean;
890
+ status: number;
891
+ statusText: string;
892
+ headers: Headers;
893
+ response:
894
+ TYPE extends "arraybuffer" ? ArrayBuffer :
895
+ TYPE extends "blob" ? Blob :
896
+ TYPE extends "text" ? string :
897
+ never;
898
+ }>((resolve, reject) => {
899
+ // if this throws sync'ly, the promise will reject.
900
+
901
+ options.method = options.method.toUpperCase();
902
+
903
+ if ((options.method === "GET" || options.method === "HEAD") && options.requestBodyString)
904
+ throw new Error("requestBodyString must be falsy if method is GET or HEAD");
905
+
906
+
907
+ const url = new URL(options.url, location.href);
908
+ const query = paramsInput(options.queryParams);
909
+ query.forEach((v, k) => { url.searchParams.append(k, v); });
910
+
911
+ const headers = new Headers(options.headers || {});
912
+
913
+ const request = new XMLHttpRequest();
914
+ request.responseType = options.responseType || "text";
915
+
916
+ try {
917
+ request.open(options.method, url, true);
918
+ } catch (e) { console.log(e, { e }); throw e; }
919
+
920
+ if (!headers.has("content-type"))
921
+ headers.set("content-type", "application/x-www-form-urlencoded; charset=UTF-8");
922
+
923
+ if (!headers.has("x-requested-with"))
924
+ headers.set("x-requested-with", "TiddlyWiki");
925
+
926
+ headers.set("accept", "application/json");
927
+
928
+
929
+ headers.forEach((v, k) => {
930
+ request.setRequestHeader(k, v);
931
+ });
932
+
933
+ request.onreadystatechange = function () {
934
+ if (this.readyState !== 4) return;
935
+
936
+ const headers = new Headers();
937
+ request.getAllResponseHeaders()?.trim().split(/[\r\n]+/).forEach((line) => {
938
+ const parts = line.split(": ");
939
+ const header = parts.shift()?.toLowerCase();
940
+ const value = parts.join(": ");
941
+ if (header) headers.append(header, value);
942
+ });
943
+ resolve({
944
+ ok: this.status >= 200 && this.status < 300,
945
+ status: this.status,
946
+ statusText: this.statusText,
947
+ response: this.response,
948
+ headers,
949
+ });
950
+
951
+ };
952
+
953
+ if (options.progress)
954
+ request.onprogress = options.progress;
955
+
956
+ try { request.send(options.requestBodyString); } catch (e) { console.log(e, { e }); throw e; }
957
+
958
+
959
+ });
960
+
961
+
962
+ function paramsInput(input: ParamsInput) {
963
+ if (!input) return new URLSearchParams();
964
+ if (input instanceof URLSearchParams) return input;
965
+ if (Array.isArray(input) || typeof input === "string") return new URLSearchParams(input);
966
+ return new URLSearchParams(Object.entries(input));
967
+ }
968
+
969
+ }
970
+
971
+ function readBlobAsArrayBuffer(blob: Blob) {
972
+ const error = new Error("Error reading blob");
973
+ return new Promise<ArrayBuffer>((resolve, reject) => {
974
+ const reader = new FileReader();
975
+ reader.onload = () => {
976
+ resolve(reader.result as ArrayBuffer)
977
+ };
978
+ reader.onerror = () => {
979
+ reject(error);
980
+ };
981
+ reader.readAsArrayBuffer(blob);
982
+ });
983
+
984
+ }
985
+