@maiyunnet/kebab 7.4.2 → 7.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * --- 本文件用来定义每个目录实体地址的常量 ---
6
6
  */
7
7
  /** --- 当前系统版本号 --- */
8
- export declare const VER = "7.4.2";
8
+ export declare const VER = "7.6.0";
9
9
  /** --- 框架根目录,以 / 结尾 --- */
10
10
  export declare const ROOT_PATH: string;
11
11
  export declare const LIB_PATH: string;
package/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * --- 本文件用来定义每个目录实体地址的常量 ---
7
7
  */
8
8
  /** --- 当前系统版本号 --- */
9
- export const VER = '7.4.2';
9
+ export const VER = '7.6.0';
10
10
  // --- 服务端用的路径 ---
11
11
  const imu = decodeURIComponent(import.meta.url).replace('file://', '').replace(/^\/(\w:)/, '$1');
12
12
  /** --- /xxx/xxx --- */
package/lib/sql.d.ts CHANGED
@@ -10,6 +10,19 @@ export declare enum ESERVICE {
10
10
  'MYSQL' = 0,
11
11
  'PGSQL' = 1
12
12
  }
13
+ /** --- JSON 查询操作符 --- */
14
+ export declare enum EJSON {
15
+ /** --- 包含 (MySQL: JSON_CONTAINS, PG: @>) --- */
16
+ 'CONTAINS' = "json",
17
+ /** --- 被包含 (MySQL: JSON_CONTAINS, PG: <@) --- */
18
+ 'CONTAINED_BY' = "json_in",
19
+ /** --- 存在 Key (MySQL: JSON_CONTAINS_PATH one, PG: ?) --- */
20
+ 'HAS_KEY' = "json_key",
21
+ /** --- 存在任意 Key (MySQL: JSON_CONTAINS_PATH one, PG: ?|) --- */
22
+ 'HAS_ANY_KEYS' = "json_any",
23
+ /** --- 存在所有 Key (MySQL: JSON_CONTAINS_PATH all, PG: ?&) --- */
24
+ 'HAS_ALL_KEYS' = "json_all"
25
+ }
13
26
  export declare class Sql {
14
27
  /** --- ctr 对象 --- */
15
28
  private readonly _ctr?;
@@ -145,6 +158,7 @@ export declare class Sql {
145
158
  * --- 5. '$or': [{'city': 'bj'}, {'city': 'sh'}, [['age', '>', '10']]], 'type': '2' ---
146
159
  * --- 6. 'city_in': column('city_out') ---
147
160
  * --- 7. ['JSON_CONTAINS(`uid`, ?)', ['hello']] ---
161
+ * --- 8. ['info', 'json', {'a': 1}] ---
148
162
  * @param s 筛选数据
149
163
  */
150
164
  where(s: string | kebab.Json): this;
package/lib/sql.js CHANGED
@@ -8,6 +8,20 @@ export var ESERVICE;
8
8
  ESERVICE[ESERVICE["MYSQL"] = 0] = "MYSQL";
9
9
  ESERVICE[ESERVICE["PGSQL"] = 1] = "PGSQL";
10
10
  })(ESERVICE || (ESERVICE = {}));
11
+ /** --- JSON 查询操作符 --- */
12
+ export var EJSON;
13
+ (function (EJSON) {
14
+ /** --- 包含 (MySQL: JSON_CONTAINS, PG: @>) --- */
15
+ EJSON["CONTAINS"] = "json";
16
+ /** --- 被包含 (MySQL: JSON_CONTAINS, PG: <@) --- */
17
+ EJSON["CONTAINED_BY"] = "json_in";
18
+ /** --- 存在 Key (MySQL: JSON_CONTAINS_PATH one, PG: ?) --- */
19
+ EJSON["HAS_KEY"] = "json_key";
20
+ /** --- 存在任意 Key (MySQL: JSON_CONTAINS_PATH one, PG: ?|) --- */
21
+ EJSON["HAS_ANY_KEYS"] = "json_any";
22
+ /** --- 存在所有 Key (MySQL: JSON_CONTAINS_PATH all, PG: ?&) --- */
23
+ EJSON["HAS_ALL_KEYS"] = "json_all";
24
+ })(EJSON || (EJSON = {}));
11
25
  /** --- field 用 token --- */
12
26
  let columnToken = '';
13
27
  export class Sql {
@@ -388,6 +402,7 @@ export class Sql {
388
402
  * --- 5. '$or': [{'city': 'bj'}, {'city': 'sh'}, [['age', '>', '10']]], 'type': '2' ---
389
403
  * --- 6. 'city_in': column('city_out') ---
390
404
  * --- 7. ['JSON_CONTAINS(`uid`, ?)', ['hello']] ---
405
+ * --- 8. ['info', 'json', {'a': 1}] ---
391
406
  * @param s 筛选数据
392
407
  */
393
408
  where(s) {
@@ -437,6 +452,55 @@ export class Sql {
437
452
  data.push(...v[1]);
438
453
  }
439
454
  }
455
+ else if (typeof v[1] === 'string' && ['json', 'json_in', 'json_key', 'json_any', 'json_all'].includes(v[1].toLowerCase())) {
456
+ // --- json ---
457
+ const op = v[1].toLowerCase();
458
+ const nv = v[2];
459
+ if (op === 'json') {
460
+ if (this._service === ESERVICE.MYSQL) {
461
+ sql += `JSON_CONTAINS(${this.field(v[0])}, ${this._placeholder()}) AND `;
462
+ data.push(lText.stringifyJson(nv));
463
+ }
464
+ else {
465
+ sql += `${this.field(v[0])} @> ${this._placeholder()} AND `;
466
+ data.push(lText.stringifyJson(nv));
467
+ }
468
+ }
469
+ else if (op === 'json_in') {
470
+ if (this._service === ESERVICE.MYSQL) {
471
+ sql += `JSON_CONTAINS(${this._placeholder()}, ${this.field(v[0])}) AND `;
472
+ data.push(lText.stringifyJson(nv));
473
+ }
474
+ else {
475
+ sql += `${this.field(v[0])} <@ ${this._placeholder()} AND `;
476
+ data.push(lText.stringifyJson(nv));
477
+ }
478
+ }
479
+ else {
480
+ // --- json_key, json_any, json_all ---
481
+ const keys = Array.isArray(nv) ? nv : [nv];
482
+ if (this._service === ESERVICE.MYSQL) {
483
+ const type = op === 'json_all' ? 'all' : 'one';
484
+ let pathSql = '';
485
+ for (const k of keys) {
486
+ pathSql += ', ' + this._placeholder();
487
+ data.push(k.startsWith('$') ? k : `$.${k}`);
488
+ }
489
+ sql += `JSON_CONTAINS_PATH(${this.field(v[0])}, '${type}'${pathSql}) AND `;
490
+ }
491
+ else {
492
+ if (op === 'json_key' && keys.length === 1) {
493
+ sql += `${this.field(v[0])} ? ${this._placeholder()} AND `;
494
+ data.push(keys[0]);
495
+ }
496
+ else {
497
+ const pgOp = op === 'json_all' ? '?&' : '?|';
498
+ sql += `${this.field(v[0])} ${pgOp} ${this._placeholder()} AND `;
499
+ data.push(keys);
500
+ }
501
+ }
502
+ }
503
+ }
440
504
  else if (v[2] === null) {
441
505
  // --- 3: null ---
442
506
  let opera = v[1];
@@ -994,6 +1058,15 @@ export function format(sql, data, service = ESERVICE.MYSQL) {
994
1058
  if (val instanceof Buffer) {
995
1059
  return `'\\x${val.toString('hex')}'`;
996
1060
  }
1061
+ if (Array.isArray(val)) {
1062
+ // --- PGSQL Array ---
1063
+ return `ARRAY[${val.map(v => {
1064
+ if (typeof v === 'string') {
1065
+ return `'${v.replace(/'/g, "''")}'`;
1066
+ }
1067
+ return v;
1068
+ }).join(', ')}]`;
1069
+ }
997
1070
  return `'${lText.stringifyJson(val).replace(/'/g, "''")}'`;
998
1071
  });
999
1072
  }
package/lib/text.d.ts CHANGED
@@ -95,9 +95,20 @@ export declare function isIdCardCN(idcard: string): boolean;
95
95
  /**
96
96
  * --- 将对象转换为 query string ---
97
97
  * @param query 要转换的对象
98
- * @param encode 是否转义
98
+ * @param encode 是否转义,默认为 true
99
99
  */
100
100
  export declare function queryStringify(query: Record<string, any>, encode?: boolean): string;
101
+ /**
102
+ * --- 将对象转换为 query string ---
103
+ * @param query 要转换的对象
104
+ * @param options 选项
105
+ */
106
+ export declare function queryStringify(query: Record<string, any>, options: {
107
+ /** --- 等号分隔符,默认 = --- */
108
+ 'equal'?: string;
109
+ /** --- 连字符分隔符,默认 & --- */
110
+ 'hyphen'?: string;
111
+ }): string;
101
112
  /**
102
113
  * --- 将 query string 转换为对象 ---
103
114
  * @param query 要转换的字符串
package/lib/text.js CHANGED
@@ -336,26 +336,24 @@ export function isIdCardCN(idcard) {
336
336
  // --- 比较校验码 ---
337
337
  return verifyCode === verifyCodeList[Number(mod)];
338
338
  }
339
- /**
340
- * --- 将对象转换为 query string ---
341
- * @param query 要转换的对象
342
- * @param encode 是否转义
343
- */
344
339
  export function queryStringify(query, encode = true) {
345
- if (encode) {
346
- return Object.entries(query).map(([k, v]) => {
347
- if (Array.isArray(v)) {
348
- return v.map((i) => `${encodeURIComponent(k)}=${encodeURIComponent(i)}`).join('&');
349
- }
350
- return `${encodeURIComponent(k)}=${encodeURIComponent(v)}`;
351
- }).join('&');
340
+ let isEncode = true;
341
+ let equal = '=';
342
+ let hyphen = '&';
343
+ if (typeof encode === 'object') {
344
+ equal = encode.equal ?? '=';
345
+ hyphen = encode.hyphen ?? '&';
346
+ }
347
+ else {
348
+ isEncode = encode;
352
349
  }
353
350
  return Object.entries(query).map(([k, v]) => {
351
+ const key = isEncode ? encodeURIComponent(k) : k;
354
352
  if (Array.isArray(v)) {
355
- return v.map((i) => `${k}=${i}`).join('&');
353
+ return v.map(i => `${key}${equal}${isEncode ? encodeURIComponent(i) : i}`).join(hyphen);
356
354
  }
357
- return `${k}=${v}`;
358
- }).join('&');
355
+ return `${key}${equal}${isEncode ? encodeURIComponent(v) : v}`;
356
+ }).join(hyphen);
359
357
  }
360
358
  /**
361
359
  * --- 将 query string 转换为对象 ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maiyunnet/kebab",
3
- "version": "7.4.2",
3
+ "version": "7.6.0",
4
4
  "description": "Simple, easy-to-use, and fully-featured Node.js framework that is ready-to-use out of the box.",
5
5
  "type": "module",
6
6
  "keywords": [
package/sys/child.js CHANGED
@@ -107,7 +107,9 @@ async function run() {
107
107
  const host = (req.headers[':authority'] ?? req.headers['host'] ?? '');
108
108
  if (!host) {
109
109
  lCore.writeHead(res, 403);
110
- res.end('403 Forbidden');
110
+ res.end('403 Forbidden', () => {
111
+ req.socket.destroy();
112
+ });
111
113
  return;
112
114
  }
113
115
  wrapWithLinkCount(host + req.url, () => requestHandler(req, res, true), '[CHILD][http2][request]');
@@ -126,7 +128,9 @@ async function run() {
126
128
  if (!host) {
127
129
  res.setHeader('x-kebab-error', '0');
128
130
  lCore.writeHead(res, 403);
129
- res.end();
131
+ res.end('403 Forbidden', () => {
132
+ req.socket.destroy();
133
+ });
130
134
  return;
131
135
  }
132
136
  wrapWithLinkCount(host + (req.url ?? ''), () => requestHandler(req, res, false), '[CHILD][http][request]');
@@ -188,7 +192,9 @@ async function requestHandler(req, res, https) {
188
192
  if (!vhost) {
189
193
  res.setHeader('x-kebab-error', '1');
190
194
  lCore.writeHead(res, 403);
191
- res.end();
195
+ res.end('403 Forbidden', () => {
196
+ req.socket.destroy();
197
+ });
192
198
  return;
193
199
  /*
194
200
  const text = '<h1>Kebab: No permissions</h1>host: ' + (req.headers[':authority'] as string | undefined ?? req.headers['host'] ?? '') + '<br>url: ' + (lText.htmlescape(req.url ?? ''));
package/sys/master.js CHANGED
@@ -238,7 +238,7 @@ function createRpcListener() {
238
238
  // --- 特殊文件不能覆盖 ---
239
239
  continue;
240
240
  }
241
- if (fname.endsWith('.js.map') || fname.endsWith('.ts') || fname.endsWith('.gitignore')) {
241
+ if (fname.endsWith('.js.map') || fname.endsWith('.ts') || fname.endsWith('.gitignore') || fname.endsWith('.DS_Store')) {
242
242
  // --- 测试或开发文件不覆盖 ---
243
243
  continue;
244
244
  }
@@ -92,7 +92,7 @@ export default class extends sCtr.Ctr {
92
92
  consistentDistributed(): string;
93
93
  consistentMigration(): string;
94
94
  consistentFast(): string;
95
- text(): string;
95
+ text(): Promise<string>;
96
96
  time(): string;
97
97
  wsServer(): string;
98
98
  wsClient(): Promise<string>;
@@ -187,6 +187,7 @@ export default class extends sCtr.Ctr {
187
187
  `<br><a href="${this._config.const.urlBase}test/sql?type=upsert">View "test/sql?type=upsert"</a> <a href="${this._config.const.urlBase}test/sql?type=upsert&s=pgsql">pgsql</a>`,
188
188
  `<br><a href="${this._config.const.urlBase}test/sql?type=delete">View "test/sql?type=delete"</a> <a href="${this._config.const.urlBase}test/sql?type=delete&s=pgsql">pgsql</a>`,
189
189
  `<br><a href="${this._config.const.urlBase}test/sql?type=where">View "test/sql?type=where"</a> <a href="${this._config.const.urlBase}test/sql?type=where&s=pgsql">pgsql</a>`,
190
+ `<br><a href="${this._config.const.urlBase}test/sql?type=json">View "test/sql?type=json"</a> <a href="${this._config.const.urlBase}test/sql?type=json&s=pgsql">pgsql</a>`,
190
191
  `<br><a href="${this._config.const.urlBase}test/sql?type=having">View "test/sql?type=having"</a> <a href="${this._config.const.urlBase}test/sql?type=having&s=pgsql">pgsql</a>`,
191
192
  `<br><a href="${this._config.const.urlBase}test/sql?type=by">View "test/sql?type=by"</a> <a href="${this._config.const.urlBase}test/sql?type=by&s=pgsql">pgsql</a>`,
192
193
  `<br><a href="${this._config.const.urlBase}test/sql?type=field">View "test/sql?type=field"</a> <a href="${this._config.const.urlBase}test/sql?type=field&s=pgsql">pgsql</a>`,
@@ -2561,6 +2562,44 @@ Result:<pre id="result">Nothing.</pre>`);
2561
2562
  echo.push(`<pre>sql.delete('user').where({ 'id': '1' });</pre>
2562
2563
  <b>getSql() :</b> ${s}<br>
2563
2564
  <b>getData():</b> <pre>${JSON.stringify(sd, undefined, 4)}</pre>
2565
+ <b>format() :</b> ${sql.format(s, sd)}`);
2566
+ break;
2567
+ }
2568
+ case 'json': {
2569
+ // --- json contains ---
2570
+ let s = sql.select('*', 'user').where([['info', lSql.EJSON.CONTAINS, { 'a': 1 }]]).getSql();
2571
+ let sd = sql.getData();
2572
+ echo.push(`<pre>sql.select('*', 'user').where([['info', lSql.EJSON.CONTAINS, { 'a': 1 }]]);</pre>
2573
+ <b>getSql() :</b> ${s}<br>
2574
+ <b>getData():</b> <pre>${JSON.stringify(sd, undefined, 4)}</pre>
2575
+ <b>format() :</b> ${sql.format(s, sd)}<hr>`);
2576
+ // --- json contained by ---
2577
+ s = sql.select('*', 'user').where([['info', lSql.EJSON.CONTAINED_BY, { 'a': 1, 'b': 2 }]]).getSql();
2578
+ sd = sql.getData();
2579
+ echo.push(`<pre>sql.select('*', 'user').where([['info', lSql.EJSON.CONTAINED_BY, { 'a': 1, 'b': 2 }]]);</pre>
2580
+ <b>getSql() :</b> ${s}<br>
2581
+ <b>getData():</b> <pre>${JSON.stringify(sd, undefined, 4)}</pre>
2582
+ <b>format() :</b> ${sql.format(s, sd)}<hr>`);
2583
+ // --- json key exists ---
2584
+ s = sql.select('*', 'user').where([['info', lSql.EJSON.HAS_KEY, 'age']]).getSql();
2585
+ sd = sql.getData();
2586
+ echo.push(`<pre>sql.select('*', 'user').where([['info', lSql.EJSON.HAS_KEY, 'age']]);</pre>
2587
+ <b>getSql() :</b> ${s}<br>
2588
+ <b>getData():</b> <pre>${JSON.stringify(sd, undefined, 4)}</pre>
2589
+ <b>format() :</b> ${sql.format(s, sd)}<hr>`);
2590
+ // --- json any key exists ---
2591
+ s = sql.select('*', 'user').where([['info', lSql.EJSON.HAS_ANY_KEYS, ['age', 'name']]]).getSql();
2592
+ sd = sql.getData();
2593
+ echo.push(`<pre>sql.select('*', 'user').where([['info', lSql.EJSON.HAS_ANY_KEYS, ['age', 'name']]]);</pre>
2594
+ <b>getSql() :</b> ${s}<br>
2595
+ <b>getData():</b> <pre>${JSON.stringify(sd, undefined, 4)}</pre>
2596
+ <b>format() :</b> ${sql.format(s, sd)}<hr>`);
2597
+ // --- json all keys exist ---
2598
+ s = sql.select('*', 'user').where([['info', lSql.EJSON.HAS_ALL_KEYS, ['age', 'name']]]).getSql();
2599
+ sd = sql.getData();
2600
+ echo.push(`<pre>sql.select('*', 'user').where([['info', lSql.EJSON.HAS_ALL_KEYS, ['age', 'name']]]);</pre>
2601
+ <b>getSql() :</b> ${s}<br>
2602
+ <b>getData():</b> <pre>${JSON.stringify(sd, undefined, 4)}</pre>
2564
2603
  <b>format() :</b> ${sql.format(s, sd)}`);
2565
2604
  break;
2566
2605
  }
@@ -2648,6 +2687,16 @@ Result:<pre id="result">Nothing.</pre>`);
2648
2687
  echo.push(`<pre>sql.select('*', 'user').where([{ 'city': 'la', 'area': null }, ['age', '>', '10'], ['soft', '<>', null], ['ware', 'IS', null]]);</pre>
2649
2688
  <b>getSql() :</b> ${s}<br>
2650
2689
  <b>getData():</b> <pre>${JSON.stringify(sd, undefined, 4)}</pre>
2690
+ <b>format() :</b> ${sql.format(s, sd)}<hr>`);
2691
+ s = sql.select('*', 'user').where([
2692
+ ['info', 'json', { 'a': 1 }]
2693
+ ]).getSql();
2694
+ sd = sql.getData();
2695
+ echo.push(`<pre>sql.select('*', 'user').where([
2696
+ ['info', 'json', { 'a': 1 }]
2697
+ ]);</pre>
2698
+ <b>getSql() :</b> ${s}<br>
2699
+ <b>getData():</b> <pre>${JSON.stringify(sd, undefined, 4)}</pre>
2651
2700
  <b>format() :</b> ${sql.format(s, sd)}`);
2652
2701
  break;
2653
2702
  }
@@ -2825,7 +2874,7 @@ const rtn = cons.migration(rows, newTables);</pre>`);
2825
2874
  echo.push(JSON.stringify(rtn));
2826
2875
  return echo.join('') + '<br><br>' + this._getEnd();
2827
2876
  }
2828
- text() {
2877
+ async text() {
2829
2878
  const echo = `<pre>json_encode(lText.parseUrl('HtTp://uSer:pAss@sUBDom.TopdOm23.CoM:29819/Adm@xw2Ksiz/dszas?Mdi=KdiMs1&a=JDd#hehHe'))</pre>
2830
2879
  ${lText.htmlescape(JSON.stringify(lText.parseUrl('HtTp://uSer:pAss@sUBDom.TopdOm23.CoM:29819/Adm@xw2Ksiz/dszas?Mdi=KdiMs1&a=JDd#hehHe')))}
2831
2880
  <pre>json_encode(lText.parseUrl('HtTp://uSer@sUBDom.TopdOm23.CoM/Admx%20w2Ksiz/dszas'))</pre>
@@ -2891,19 +2940,29 @@ ${JSON.stringify(lText.isDomain('www.xxx.com.cn'))}
2891
2940
  <pre>lText.isDomain('com');</pre>
2892
2941
  ${JSON.stringify(lText.isDomain('com'))}
2893
2942
  <pre>lText.parseDomain('www.xxx.com.cn');</pre>
2894
- ${JSON.stringify(lText.parseDomain('www.xxx.com.cn'))}
2943
+ ${JSON.stringify(await lText.parseDomain('www.xxx.com.cn'))}
2895
2944
  <pre>lText.parseDomain('www.xxx.us');</pre>
2896
- ${JSON.stringify(lText.parseDomain('www.xxx.us'))}
2945
+ ${JSON.stringify(await lText.parseDomain('www.xxx.us'))}
2897
2946
  <pre>lText.parseDomain('xxx.co.jp');</pre>
2898
- ${JSON.stringify(lText.parseDomain('xxx.co.jp'))}
2947
+ ${JSON.stringify(await lText.parseDomain('xxx.co.jp'))}
2899
2948
  <pre>lText.parseDomain('js.cn');</pre>
2900
- ${JSON.stringify(lText.parseDomain('js.cn'))}
2949
+ ${JSON.stringify(await lText.parseDomain('js.cn'))}
2901
2950
  <pre>lText.parseDomain('xxx.cn');</pre>
2902
- ${JSON.stringify(lText.parseDomain('xxx.cn'))}
2951
+ ${JSON.stringify(await lText.parseDomain('xxx.cn'))}
2903
2952
  <pre>lText.parseJson('{"num":90071992547409993149,"num2":3242354,"num3":"16565","str":"abc","bool":false}');</pre>
2904
2953
  ${lText.stringifyJson(lText.parseJson('{"num":90071992547409993149,"num2":3242354,"num3":"16565","str":"abc","bool":false}'))}
2905
2954
  <pre>lText.isIdCardCN('110101200007284901')</pre>
2906
- ${JSON.stringify(lText.isIdCardCN('110101200007284901'))}`;
2955
+ ${JSON.stringify(lText.isIdCardCN('110101200007284901'))}
2956
+ <pre>lText.queryStringify({'a': 1, 'b': '2'});</pre>
2957
+ ${lText.queryStringify({ 'a': 1, 'b': '2' })}
2958
+ <pre>lText.queryStringify({ 'a': 1, 'b': '2' }, false);</pre>
2959
+ ${lText.queryStringify({ 'a': 1, 'b': '2' }, false)}
2960
+ <pre>lText.queryStringify({ 'a': 1, 'b': '2' }, { 'equal': ':', 'hyphen': '|' });</pre>
2961
+ ${lText.queryStringify({ 'a': 1, 'b': '2' }, { 'equal': ':', 'hyphen': '|' })}
2962
+ <pre>lText.queryStringify({ 'a': [1, 2], 'b': '3' }, { 'equal': ':', 'hyphen': '|' });</pre>
2963
+ ${lText.queryStringify({ 'a': [1, 2], 'b': '3' }, { 'equal': ':', 'hyphen': '|' })}
2964
+ <pre>lText.queryStringify({ 'a': [1, 2], 'b': '3' }, { 'equal': '', 'hyphen': '' });</pre>
2965
+ ${lText.queryStringify({ 'a': [1, 2], 'b': '3' }, { 'equal': '', 'hyphen': '' })}`;
2907
2966
  return echo + '<br><br>' + this._getEnd();
2908
2967
  }
2909
2968
  time() {