@tiddlywiki/mws 0.0.12 → 0.0.13

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 (65) hide show
  1. package/README.md +3 -1
  2. package/dist/server.d.ts +923 -0
  3. package/dist/server.js +1763 -1415
  4. package/dist/server.js.map +1 -1
  5. package/editions/mws-docs/tiddlers/$__StoryList.tid +3 -3
  6. package/editions/mws-docs/tiddlers/MWS and SQLite.md +11 -0
  7. package/editions/mws-docs/tiddlers/Request Server.md +20 -0
  8. package/editions/mws-docs/tiddlers/Request Server.md.meta +5 -0
  9. package/package.json +16 -10
  10. package/prisma/schema.prisma +1 -0
  11. package/react-user-mgmt/package-lock.json +76 -321
  12. package/react-user-mgmt/package.json +2 -5
  13. package/react-user-mgmt/public/chunk-YALWN5OT.js +2 -0
  14. package/react-user-mgmt/public/chunk-YALWN5OT.js.map +7 -0
  15. package/react-user-mgmt/public/esm-QKDFEP3B.js +3 -0
  16. package/react-user-mgmt/public/esm-QKDFEP3B.js.map +7 -0
  17. package/react-user-mgmt/public/index.html +1 -26
  18. package/react-user-mgmt/public/main.js +68 -109
  19. package/react-user-mgmt/public/main.js.map +4 -4
  20. package/react-user-mgmt/public/stats.css +597 -0
  21. package/react-user-mgmt/public/stats.html +59 -0
  22. package/react-user-mgmt/public/stats.js +2013 -0
  23. package/react-user-mgmt/public/stats.json +1 -0
  24. package/react-user-mgmt/public/stats.svg +4 -0
  25. package/react-user-mgmt/tsconfig.json +3 -0
  26. package/dist/server.cjs +0 -16677
  27. package/dist/server.cjs.map +0 -1
  28. package/src/StateObject.ts +0 -316
  29. package/src/commander.ts +0 -417
  30. package/src/commands/divider.ts +0 -20
  31. package/src/commands/index.ts +0 -24
  32. package/src/commands/mws-init-store.ts +0 -77
  33. package/src/commands/mws-listen.ts +0 -136
  34. package/src/commands/mws-load-archive.ts +0 -281
  35. package/src/commands/mws-load-plugin-bags.ts +0 -151
  36. package/src/commands/mws-load-tiddlers.ts +0 -37
  37. package/src/commands/mws-load-wiki-folder.ts +0 -124
  38. package/src/commands/mws-render-tiddlywiki5.ts +0 -55
  39. package/src/commands/mws-save-archive.ts +0 -111
  40. package/src/commands/mws-save-tiddler-text.ts +0 -33
  41. package/src/commands/tests-complete.ts +0 -21
  42. package/src/global.ts +0 -247
  43. package/src/routes/TiddlerStore.ts +0 -722
  44. package/src/routes/bag-file-server.ts +0 -340
  45. package/src/routes/cache.ts +0 -25
  46. package/src/routes/index.ts +0 -138
  47. package/src/routes/managers/index.ts +0 -17
  48. package/src/routes/managers/manager-recipes.ts +0 -438
  49. package/src/routes/managers/manager-users.ts +0 -201
  50. package/src/routes/managers/router-tiddlers.ts +0 -313
  51. package/src/routes/router.ts +0 -429
  52. package/src/routes/services/PasswordService.ts +0 -178
  53. package/src/routes/services/attachments.ts +0 -259
  54. package/src/routes/services/sessions.ts +0 -166
  55. package/src/routes/tw-test.ts +0 -65
  56. package/src/server.ts +0 -151
  57. package/src/setupDevServer.ts +0 -71
  58. package/src/streamer.ts +0 -430
  59. package/src/tiddlywiki.ts +0 -94
  60. package/src/utils/data-checks.ts +0 -172
  61. package/src/utils/index.ts +0 -4
  62. package/src/utils/rootRoute.ts +0 -237
  63. package/src/utils/utils.ts +0 -403
  64. package/src/utils/zodAssert.ts +0 -269
  65. /package/prisma/{migrations/notes.md → notes.md} +0 -0
@@ -1,340 +0,0 @@
1
- import { StateObject } from "../StateObject";
2
- import { TiddlerStore } from "./TiddlerStore";
3
- import { resolve } from "path";
4
- import { createWriteStream, readFileSync } from "fs";
5
- import sjcl from "sjcl";
6
- import { createHash } from "crypto";
7
- import { TiddlerFields } from "./services/attachments";
8
- import { UserError } from "../utils";
9
-
10
-
11
- export class TiddlerServer extends TiddlerStore {
12
-
13
- constructor(
14
- protected state: StateObject,
15
- prisma: PrismaTxnClient
16
- ) {
17
- super(state.commander, prisma);
18
- }
19
-
20
- private toTiddlyWebJson(info: { bag_name: string; tiddler_id: number; tiddler: TiddlerFields }) {
21
- const output: any = { fields: {} };
22
- const knownFields = [
23
- "bag", "created", "creator", "modified", "modifier", "permissions", "recipe", "revision", "tags", "text", "title", "type", "uri"
24
- ];
25
-
26
- each(info.tiddler, (field, name) => {
27
- if (knownFields.indexOf(name) !== -1) {
28
- output[name] = field;
29
- } else {
30
- output.fields[name] = field;
31
- }
32
- });
33
- output.type = info.tiddler.type || "text/vnd.tiddlywiki";
34
-
35
- output.revision = `${info.tiddler_id}`;
36
- output.bag = info.bag_name;
37
- return output;
38
- }
39
-
40
- /** If neither bag_name nor bag_id are specified, the fallback will be sent. */
41
- async sendBagTiddler({ state, bag_name, bag_id, title }: {
42
- state: StateObject;
43
- bag_name?: PrismaField<"Bags", "bag_name">;
44
- bag_id?: PrismaField<"Bags", "bag_id">;
45
- title: PrismaField<"Tiddlers", "title">;
46
- }) {
47
-
48
- const tiddlerInfo = (bag_id || bag_name) && await this.getBagTiddler({ bag_name, bag_id, title });
49
-
50
- if (!tiddlerInfo || !tiddlerInfo.tiddler) {
51
- // Redirect to fallback URL if tiddler not found
52
- const fallback = state.queryParams.fallback?.[0];
53
- if (fallback) {
54
- // await state.pushStream(fallback);
55
- return state.redirect(fallback);
56
- } else {
57
- return state.sendEmpty(404, { "x-reason": "tiddler not found (no fallback)" });
58
- }
59
- }
60
-
61
-
62
- // If application/json is requested then this is an API request, and gets the response in JSON
63
- if (state.headers.accept && state.headers.accept.indexOf("application/json") !== -1) {
64
-
65
- return state.sendResponse(200, {
66
- "Etag": state.makeTiddlerEtag(tiddlerInfo),
67
- "Content-Type": "application/json",
68
- "X-Revision-Number": tiddlerInfo.tiddler_id,
69
- "X-Bag-Name": tiddlerInfo.bag_name,
70
- }, JSON.stringify(tiddlerInfo.tiddler), "utf8");
71
-
72
- } else {
73
-
74
- // This is not a JSON API request, we should return the raw tiddler content
75
- const result = await this.getBagTiddlerStream(title, tiddlerInfo.bag_name);
76
- if (!result || !result.stream) return state.sendEmpty(404);
77
-
78
- return state.sendStream(200, {
79
- "Etag": state.makeTiddlerEtag(result),
80
- "Content-Type": result.type
81
- }, result.stream);
82
-
83
- }
84
-
85
- }
86
-
87
-
88
- /**
89
- * Process an incoming new multipart/form-data stream. Options include:
90
- *
91
- * @param {Object} options
92
- * @param {SqlTiddlerStore} options.store
93
- * @param {ServerState} options.state
94
- * @param {ServerResponse} options.response
95
- * @param {string} options.bag_name
96
- * @param {function} options.callback
97
- */
98
- async processIncomingStream(
99
- bag_name: PrismaField<"Bags", "bag_name">
100
- ): Promise<string[]> {
101
-
102
- // Process the incoming data
103
- const inboxName = new Date().toISOString().replace(/:/g, "-");
104
- const inboxPath = resolve(this.storePath, "inbox", inboxName);
105
- createDirectory(inboxPath);
106
- let fileStream: { write: (arg0: any) => void; end: () => void; } | null = null; // Current file being written
107
- let hash: { update: (arg0: any) => void; finalize: () => any; } | null = null; // Accumulating hash of current part
108
- let length = 0; // Accumulating length of current part
109
- const parts: any[] = []; // Array of {name:, headers:, value:, hash:} and/or {name:, filename:, headers:, inboxFilename:, hash:}
110
-
111
- await this.state.readMultipartData({
112
- cbPartStart: function (headers, name, filename) {
113
- const part: any = {
114
- name: name,
115
- filename: filename,
116
- headers: headers
117
- };
118
- if (filename) {
119
- const inboxFilename = (parts.length).toString();
120
- part.inboxFilename = resolve(inboxPath, inboxFilename);
121
- fileStream = createWriteStream(part.inboxFilename);
122
- } else {
123
- part.value = "";
124
- }
125
- hash = new sjcl.hash.sha256();
126
- length = 0;
127
- parts.push(part);
128
- },
129
- cbPartChunk: function (chunk) {
130
- if (fileStream) {
131
- fileStream.write(chunk);
132
- } else {
133
- parts[parts.length - 1].value += chunk;
134
- }
135
- length = length + chunk.length;
136
- hash!.update(chunk);
137
- },
138
- cbPartEnd: function () {
139
- if (fileStream) {
140
- fileStream.end();
141
- }
142
- fileStream = null;
143
- parts[parts.length - 1].hash = sjcl.codec.hex.fromBits(hash!.finalize()).slice(0, 64).toString();
144
- hash = null;
145
- },
146
- // if an error is given here, it will also be thrown in the promise
147
- cbFinished: (err) => { }
148
- });
149
-
150
- const partFile = parts.find(part => part.name === "file-to-upload" && !!part.filename);
151
- if (!partFile) {
152
- throw await this.state.sendSimple(400, "Missing file to upload");
153
- }
154
-
155
- const type = partFile.headers["content-type"];
156
- const tiddlerFields = { title: partFile.filename, type };
157
-
158
- for (const part of parts) {
159
- const tiddlerFieldPrefix = "tiddler-field-";
160
- if (part.name.startsWith(tiddlerFieldPrefix)) {
161
- (tiddlerFields as any)[part.name.slice(tiddlerFieldPrefix.length)] = part.value.trim();
162
- }
163
- }
164
-
165
- await this.saveBagTiddlerWithAttachment(tiddlerFields, bag_name, {
166
- filepath: partFile.inboxFilename,
167
- type: type,
168
- hash: partFile.hash
169
- } as any).then(() => {
170
- deleteDirectory(inboxPath);
171
- return [tiddlerFields.title];
172
- }, err => {
173
- throw err;
174
- });
175
-
176
- return parts;
177
- }
178
-
179
- async getRecipeTiddlers(recipe_name: PrismaField<"Recipes", "recipe_name">) {
180
- // Get the recipe name from the parameters
181
- const bagTiddlers = await this.getRecipeTiddlersByBag(recipe_name);
182
-
183
- bagTiddlers.sort((a, b) => a.position - b.position);
184
-
185
- return Array.from(new Map(bagTiddlers.flatMap(bag =>
186
- bag.tiddlers.map(tiddler => [tiddler.title, {
187
- title: tiddler.title,
188
- tiddler_id: tiddler.tiddler_id,
189
- is_deleted: tiddler.is_deleted,
190
- is_plugin: false,
191
- bag_name: bag.bag_name,
192
- bag_id: bag.bag_id
193
- }])
194
- )).values());
195
-
196
- // SELECT title, tiddler_id, is_deleted, bag_name
197
- // FROM (
198
- // SELECT t.title, t.tiddler_id, t.is_deleted, b.bag_name, MAX(rb.position) AS position
199
- // FROM bags AS b
200
- // INNER JOIN recipe_bags AS rb ON b.bag_id = rb.bag_id
201
- // INNER JOIN tiddlers AS t ON b.bag_id = t.bag_id
202
- // WHERE rb.recipe_id = $recipe_id
203
- // ${options.include_deleted ? "" : "AND t.is_deleted = FALSE"}
204
- // ${options.last_known_tiddler_id ? "AND tiddler_id > $last_known_tiddler_id" : ""}
205
- // GROUP BY t.title
206
- // ORDER BY t.title, tiddler_id DESC
207
- // ${options.limit ? "LIMIT $limit" : ""}
208
- // )
209
-
210
- }
211
- async serveIndexFile(recipe_name: PrismaField<"Recipes", "recipe_name">) {
212
- const state = this.state;
213
-
214
- // Check request is valid
215
- if (!recipe_name) {
216
- return state.sendEmpty(404);
217
- }
218
-
219
- // const bagTiddlers = await this.getRecipeTiddlersByBag(recipe_name);
220
- const bagTiddlers = await this.prisma.recipe_bags.findMany({
221
- where: { recipe: { recipe_name } },
222
- select: {
223
- position: true,
224
- bag: {
225
- select: {
226
- bag_id: true,
227
- bag_name: true,
228
- is_plugin: true,
229
- tiddlers: {
230
- select: {
231
- title: true,
232
- tiddler_id: true,
233
- is_deleted: true,
234
- attachment_hash: true,
235
- fields: true,
236
- },
237
- where: {
238
- is_deleted: false,
239
- },
240
- }
241
- }
242
- }
243
- },
244
- });
245
-
246
- bagTiddlers.sort((a, b) => a.position - b.position);
247
-
248
- // const recipeTiddlers = await this.getRecipeTiddlers(recipe_name);
249
- const recipeTiddlers = Array.from(new Map(bagTiddlers.flatMap(bag =>
250
- bag.bag.tiddlers.map(tiddler => [tiddler.title, { bag: bag.bag, tiddler }])
251
- )).values());
252
-
253
- const template = readFileSync(resolve(this.state.config.wikiPath, "tiddlywiki5.html"), "utf8");
254
- const hash = createHash('md5');
255
- // Put everything into the hash that could change and invalidate the data that
256
- // the browser already stored. The headers the data and the encoding.
257
- hash.update(template);
258
- hash.update(recipeTiddlers.map(e => e.tiddler.tiddler_id).join("|"));
259
- const contentDigest = hash.digest("hex");
260
-
261
-
262
- state.writeHead(200, {
263
- "Content-Type": "text/html",
264
- "Etag": '"' + contentDigest + '"',
265
- "Cache-Control": "max-age=0, must-revalidate",
266
- });
267
-
268
- if (state.method === "HEAD") return state.end();
269
- // Get the tiddlers in the recipe
270
- // Render the template
271
-
272
- // Splice in our tiddlers
273
- var marker = `<` + `script class="tiddlywiki-tiddler-store" type="application/json">[`,
274
- markerPos = template.indexOf(marker);
275
- if (markerPos === -1) {
276
- throw new Error("Cannot find tiddler store in template");
277
- }
278
- /**
279
- *
280
- * @param {Record<string, string>} tiddlerFields
281
- */
282
- function writeTiddler(tiddlerFields: Record<string, string>) {
283
- state.write(JSON.stringify(tiddlerFields).replace(/</g, "\\u003c"));
284
- state.write(",\n");
285
- }
286
- state.write(template.substring(0, markerPos + marker.length));
287
-
288
-
289
- const
290
- bagInfo: Record<string, string> = {},
291
- revisionInfo: Record<string, string> = {};
292
-
293
- for (const recipeTiddlerInfo of recipeTiddlers) {
294
- // var result = await this.getRecipeTiddler(recipeTiddlerInfo.title, recipe_name);
295
- // const { title, bag_id, tiddler_id } = recipeTiddlerInfo;
296
- // var result = await this.getBagTiddler({ title, bag_id });
297
-
298
- const { title, tiddler_id, attachment_hash, fields } = recipeTiddlerInfo.tiddler;
299
- const { bag_name } = recipeTiddlerInfo.bag;
300
-
301
- const tiddler = Object.fromEntries([
302
- ...fields.map(e => [e.field_name, e.field_value] as const),
303
- ["title", title]
304
- ]) as TiddlerFields;
305
-
306
- const tiddler2 = this.attachService.processOutgoingTiddler({
307
- tiddler, tiddler_id, attachment_hash, bag_name
308
- });
309
-
310
- bagInfo[title] = bag_name;
311
- revisionInfo[title] = tiddler_id.toString();
312
- writeTiddler(tiddler2);
313
- }
314
-
315
- writeTiddler({
316
- title: "$:/state/multiwikiclient/tiddlers/bag",
317
- text: JSON.stringify(bagInfo),
318
- type: "application/json"
319
- });
320
- writeTiddler({
321
- title: "$:/state/multiwikiclient/tiddlers/revision",
322
- text: JSON.stringify(revisionInfo),
323
- type: "application/json"
324
- });
325
- writeTiddler({
326
- title: "$:/config/multiwikiclient/recipe",
327
- text: recipe_name
328
- });
329
- // get the latest tiddler id in the database. It doesn't have to be filtered.
330
- const lastTiddlerId = await this.prisma.tiddlers.aggregate({ _max: { tiddler_id: true } });
331
- writeTiddler({
332
- title: "$:/state/multiwikiclient/recipe/last_tiddler_id",
333
- text: (lastTiddlerId._max.tiddler_id ?? 0).toString()
334
- });
335
- state.write(template.substring(markerPos + marker.length))
336
- // Finish response
337
- return state.end();
338
- }
339
-
340
- }
@@ -1,25 +0,0 @@
1
- import { ok } from "assert";
2
- import { truthy } from "../utils";
3
- import { Commander } from "../commander";
4
-
5
- export async function startupCache(commander: Commander) {
6
- const $tw = commander.$tw;
7
-
8
- const bootTiddlers = $tw.loadTiddlersFromPath($tw.boot.bootPath).flatMap(file => file.tiddlers);
9
- const coreTiddler = $tw.loadPluginFolder($tw.boot.corePath);
10
- ok(coreTiddler);
11
-
12
- // map ( title -> content )
13
- const tiddlerMemoryCache = new Map<string, string>([
14
- ...bootTiddlers.map(e => e.title && [e.title, JSON.stringify(e)] as const).filter(truthy),
15
- [coreTiddler.title as string, JSON.stringify(coreTiddler)] as const,
16
- ]);
17
-
18
- // map ( title -> hash )
19
- // tiddler cache files are named [hash].json
20
- const tiddlerFileCache = new Map<string, string>();
21
-
22
- return { tiddlerFileCache, tiddlerMemoryCache };
23
- }
24
-
25
- export type CacheState = ART<typeof startupCache>;
@@ -1,138 +0,0 @@
1
- import { readdirSync, statSync } from "fs";
2
- import { rootRoute, SiteConfig } from "./router";
3
- import { ZodAssert } from "../utils";
4
- import { Prisma, PrismaClient } from "@prisma/client";
5
- import { ManagerRoutes } from "./managers";
6
- import { TiddlerRouter } from "./managers/router-tiddlers";
7
-
8
- declare global { const ENABLE_UNSAFE_PRISMA_ROUTE: any; }
9
-
10
- export default async function RootRoute(root: rootRoute, config: SiteConfig) {
11
- // TiddlerServer.defineRoutes(root);
12
- TiddlerRouter.defineRoutes(root);
13
- ManagerRoutes(root, config);
14
- if (typeof ENABLE_UNSAFE_PRISMA_ROUTE !== "undefined")
15
- root.defineRoute({
16
- method: ["POST"],
17
- path: /^\/prisma$/,
18
- bodyFormat: "json",
19
- }, async state => {
20
- ZodAssert.data(state, z => z.object({
21
- table: z.string(),
22
- action: z.string(),
23
- arg: z.any(),
24
- }));
25
- return await state.$transaction(async prisma => {
26
- // DEV: this just lets the client directly call the database.
27
- // TODO: it's just for dev purposes and will be removed later.
28
- // DANGER: it circumvents all security and can totally rewrite the ACL.
29
- const p: any = prisma;
30
- const table = p[state.data.table];
31
- if (!table) throw new Error(`No such table`);
32
- const fn = table[state.data.action];
33
- if (!fn) throw new Error(`No such table or action`);
34
- console.log(state.data.arg);
35
- return state.sendJSON(200, await fn.call(table, state.data.arg));
36
- });
37
- });
38
-
39
-
40
- await importEsbuild(root);
41
- }
42
- async function importDir(root: rootRoute, folder: string) {
43
- await Promise.all(readdirSync(`src/routes/${folder}`).map(async (item) => {
44
- const stat = statSync(`src/routes/${folder}/${item}`);
45
- if (stat.isFile()) {
46
- const e = await import(`./${folder}/${item}`);
47
- if (!e.route) throw new Error(`No route defined in ${item}`);
48
- e.route(root, ZodAssert);
49
- } else if (stat.isDirectory()) {
50
- await importDir(root, `${folder}/${item}`);
51
- }
52
- }));
53
- }
54
-
55
- async function importEsbuild(root: rootRoute) {
56
- // "build": "tsc -b; esbuild main=src/main.tsx
57
- // --outdir=public --bundle --target=es2020
58
- // --platform=browser --jsx=automatic"
59
-
60
-
61
- root.defineRoute({
62
- method: ['GET'],
63
- path: /^\/(.*)/,
64
- pathParams: ['reqpath'],
65
- bodyFormat: "stream",
66
- }, async state => {
67
- await state.sendDevServer();
68
- });
69
- }
70
-
71
-
72
- class ProxyPromise {
73
- static getErrorStack(t: ProxyPromise) { return t.#error.stack; }
74
- action: any;
75
- table: any;
76
- arg: any;
77
- #error = new Error("An error occured for this request");
78
- constructor({ action, table, arg }: Omit<ProxyPromise, "#error">) {
79
- this.action = action;
80
- this.table = table;
81
- this.arg = arg;
82
- Object.defineProperty(this, "toJSON", {
83
- configurable: true,
84
- enumerable: true,
85
- value: () => ({
86
- action: this.action,
87
- table: this.table,
88
- arg: this.arg,
89
- dbnulls: this.arg.data && findValueInObject(this.arg.data, e => e === Prisma.DbNull),
90
- jsnulls: this.arg.data && findValueInObject(this.arg.data, e => e === Prisma.JsonNull),
91
- })
92
- })
93
- }
94
- }
95
-
96
- function findValueInObject(val: any, predicate: (val: any) => boolean, keys: string[] = [], paths: string[] = []): any {
97
- if (predicate(val)) {
98
- paths.push(keys.join("/"));
99
- return paths;
100
- }
101
- if (val && typeof val === "object") {
102
- for (const key of Object.keys(val)) {
103
- if (!val.hasOwnProperty(key)) continue;
104
- findValueInObject(val[key], predicate, [...keys, key], paths);
105
- }
106
- }
107
- console.log(paths, keys);
108
- return paths;
109
- }
110
-
111
- function capitalize<T extends string>(table: T): Capitalize<T> {
112
- return table.slice(0, 1).toUpperCase() + table.slice(1) as any;
113
- }
114
-
115
-
116
- const argproxy: {
117
- [Table in keyof PrismaClient]: {
118
- [Action in keyof PrismaClient[Table]]:
119
- PrismaClient[Table][Action] extends (...args: any) => any
120
- ? <T extends Parameters<PrismaClient[Table][Action]>[0]>(arg: T) => {
121
- table: Table,
122
- action: Action,
123
- arg: T,
124
- result: Awaited<ReturnType<PrismaClient[Table][Action]>>,
125
- }
126
- : never
127
- }
128
- } = (() => new Proxy<any>({}, {
129
- get(target: any, table: any) {
130
- return target[table] = target[table] || new Proxy<any>({}, {
131
- get(target: any, action: any) {
132
- return target[action] = target[action] || ((arg: any) => {
133
- return new ProxyPromise({ action, table: capitalize(table) as any, arg });
134
- })
135
- },
136
- })
137
- },
138
- }))();
@@ -1,17 +0,0 @@
1
-
2
- import { RecipeKeyMap, RecipeManager } from "../managers/manager-recipes";
3
- import { UserKeyMap, UserManager } from "../managers/manager-users";
4
- import { SiteConfig } from "../router";
5
-
6
- export { UserManager, UserManagerMap } from "./manager-users";
7
- export { RecipeManager, RecipeManagerMap } from "./manager-recipes";
8
-
9
- function isKeyOf<T extends Record<string, any>>(obj: T, key: string | number | symbol): key is keyof T {
10
- return key in obj;
11
- }
12
-
13
-
14
- export const ManagerRoutes = (root: rootRoute, config: SiteConfig) => {
15
- RecipeManager.defineRoutes(root, config);
16
- UserManager.defineRoutes(root, config);
17
- };