flexdesk 0.17.0 → 0.17.2

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 (45) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +399 -3
  3. package/canvas.js +18 -0
  4. package/charts.js +21 -0
  5. package/core.js +14 -0
  6. package/css/base.css +23 -1
  7. package/css/flexdesk.css +61 -1
  8. package/dist/charts.js +3 -5
  9. package/dist/charts.js.map +1 -1
  10. package/dist/{chunk-Q36EPT2R.js → chunk-LWV23YVE.js} +1614 -138
  11. package/dist/chunk-LWV23YVE.js.map +7 -0
  12. package/dist/{chunk-MCIXFNVF.js → chunk-RMTRWWEO.js} +2 -2
  13. package/dist/flexdesk.css +61 -1
  14. package/dist/flow.js +2 -2
  15. package/dist/flow.js.map +2 -2
  16. package/dist/tiles.js +2 -2
  17. package/dist/widgets.js +186 -55
  18. package/dist/widgets.js.map +2 -2
  19. package/dist/wm.js +90 -10
  20. package/dist/wm.js.map +2 -2
  21. package/editor.js +20 -0
  22. package/flow.js +21 -0
  23. package/host.js +14 -0
  24. package/package.json +10 -1
  25. package/src/flow/canvas_editor/editor.js +1 -1
  26. package/src/tiling/command_palette.js +46 -7
  27. package/src/tiling/content_registry.js +3 -0
  28. package/src/tiling/entity_sources.js +10 -0
  29. package/src/tiling/wm.js +65 -3
  30. package/src/ui/components/cell_editor_host.js +100 -18
  31. package/src/ui/components/cell_editors.js +480 -0
  32. package/src/ui/components/cell_grid.js +121 -8
  33. package/src/ui/components/cell_paste.js +243 -0
  34. package/src/ui/components/data_table.js +96 -10
  35. package/src/ui/components/settings_view.js +192 -51
  36. package/src/ui/components/staged_edits.js +19 -1
  37. package/src/ui/components/tsv.js +74 -0
  38. package/src/ui/notification_center.js +5 -5
  39. package/tiles.js +27 -0
  40. package/widgets.js +69 -0
  41. package/wm.js +28 -0
  42. package/dist/chunk-Q36EPT2R.js.map +0 -7
  43. package/dist/chunk-QWQJUDPD.js +0 -610
  44. package/dist/chunk-QWQJUDPD.js.map +0 -7
  45. /package/dist/{chunk-MCIXFNVF.js.map → chunk-RMTRWWEO.js.map} +0 -0
@@ -13,8 +13,11 @@
13
13
 
14
14
  import { CellSelection } from './cell_selection.js';
15
15
  import { CellEditorHost, createTextEditor } from './cell_editor_host.js';
16
+ import { CELL_EDITORS, editorTypeOf } from './cell_editors.js';
16
17
  import { StagedEdits } from './staged_edits.js';
17
18
  import { FillHandle } from './fill_handle.js';
19
+ import { CellPaste } from './cell_paste.js';
20
+ import { ISO_DATE, formatDate } from './table_values.js';
18
21
 
19
22
  /** Is this a cell somebody filled? What Ctrl+arrow stops at. */
20
23
  export function isFilledValue(value) {
@@ -35,6 +38,27 @@ const ARROWS = {
35
38
  ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1],
36
39
  };
37
40
 
41
+ /**
42
+ * STORY-0085. A cell's raw value as the text a copy carries — never the text
43
+ * it is drawn as (no `formatValue`, typed-column format, rounding or
44
+ * `nullDisplay`): `null`/`undefined` (and an invalid Date) are empty, a
45
+ * number is `String(n)` (1234.5, not "1,234.50"). Dates are ISO 8601: a
46
+ * `Date` is `toISOString()`, except in a column declared `'date'`, where it is
47
+ * the calendar day `YYYY-MM-DD` in the table's zone (`dateTimeZone`); a number
48
+ * in a column declared `'date'` or `'datetime'` is read as epoch ms the same
49
+ * way. Text is copied as it is.
50
+ */
51
+ function copyText(value, { kind, utc }) {
52
+ if (value === null || value === undefined) return '';
53
+ const dated = kind === 'date' || kind === 'datetime';
54
+ if (value instanceof Date || (dated && typeof value === 'number')) {
55
+ const t = value instanceof Date ? value.getTime() : value;
56
+ if (!Number.isFinite(t)) return '';
57
+ return kind === 'date' ? formatDate(t, ISO_DATE, { utc }) : new Date(t).toISOString();
58
+ }
59
+ return String(value);
60
+ }
61
+
38
62
  /**
39
63
  * DataTable's cell mode. Built by the table when `selectionMode` is 'cells';
40
64
  * it reads the table, never the other way round, except through the few hooks
@@ -64,6 +88,7 @@ export class CellGrid {
64
88
  this.editor = new CellEditorHost({
65
89
  onCommit: (s, value, move, reason) => this._committed(s, value, move, reason),
66
90
  onCancel: (s, reason) => this._closed(s, reason),
91
+ onRefuse: (s, error, text, reason) => this._refused(s, error, text, reason),
67
92
  });
68
93
  /** STORY-0088 `staged`: commits are staged here instead of sent. */
69
94
  this.staged = null;
@@ -77,6 +102,12 @@ export class CellGrid {
77
102
  if (this.staged) this.fill = new FillHandle(this);
78
103
  else console.warn('[DataTable] `fillHandle` needs `staged`; ignored.');
79
104
  }
105
+ /** STORY-0090 `pasteable`: a tab-separated block pasted at the cursor. */
106
+ this.paste = null;
107
+ if (this._cfg().pasteable) {
108
+ if (this.staged) this.paste = new CellPaste(this);
109
+ else console.warn('[DataTable] `pasteable` needs `staged`; ignored.');
110
+ }
80
111
  }
81
112
 
82
113
  // ── what the table is, read through its own helpers ──────────────────
@@ -226,6 +257,30 @@ export class CellGrid {
226
257
  this._changed();
227
258
  }
228
259
 
260
+ /**
261
+ * STORY-0085. What a copy of the selection puts on the clipboard: the
262
+ * range's cells as text, one array per row in the order SHOWN (after the
263
+ * sort and the filters), its shown columns left to right (a hidden column
264
+ * inside it is not one of them). With no range beyond the cursor, the
265
+ * cursor cell alone. A cell is its RAW value as text (`copyText`), not
266
+ * the text it is drawn as: the value the cell holds now, a staged,
267
+ * pending or refused one included. Null with nothing selected.
268
+ */
269
+ copyMatrix() {
270
+ this._sync();
271
+ const r = this.sel.range();
272
+ if (!r) return null;
273
+ const t = this.table;
274
+ const utc = t._utc();
275
+ const out = [];
276
+ for (let p = r.top; p <= r.bottom; p++) {
277
+ const rowIdx = this.rowIndexAt(p);
278
+ out.push(r.columns.map((col) =>
279
+ copyText(this.shownValue(rowIdx, col), { kind: t._valueKind(col), utc })));
280
+ }
281
+ return out;
282
+ }
283
+
229
284
  /** New rows (`setData({rows})`): the marks were about the old ones. */
230
285
  newRows() {
231
286
  this.generation++;
@@ -235,6 +290,7 @@ export class CellGrid {
235
290
  this.rowsWaiting.clear();
236
291
  this.staged?.clear();
237
292
  this.fill?.clearMarks();
293
+ this.paste?.clearMarks();
238
294
  }
239
295
 
240
296
  isEditing() {
@@ -284,6 +340,7 @@ export class CellGrid {
284
340
  if (cursor) td.setAttribute('aria-selected', 'true');
285
341
  else td.removeAttribute('aria-selected');
286
342
  this.fill?.paintCell(td, pos, col); // STORY-0089
343
+ this.paste?.paintCell(td, col); // STORY-0090
287
344
  }
288
345
 
289
346
  paint() {
@@ -303,6 +360,7 @@ export class CellGrid {
303
360
  _changed({ reveal = false } = {}) {
304
361
  const t = this.table;
305
362
  this.fill?.clearMarks(); // STORY-0089: a skipped mark lasts until the selection moves
363
+ this.paste?.clearMarks(); // STORY-0090: and so does a paste's
306
364
  if (reveal && this.sel.focus) t.scrollToRow(this.rowIndexAt(this.sel.focus.row));
307
365
  this.paint();
308
366
  const before = t.getSelection().join();
@@ -375,6 +433,19 @@ export class CellGrid {
375
433
  ev.stopImmediatePropagation();
376
434
  }
377
435
  });
436
+ // STORY-0090. Ctrl+V on the focused table: the browser's paste event
437
+ // carries the clipboard's text, with no permission to ask for. In an
438
+ // editor (or any field) the paste is the field's.
439
+ if (this.paste) {
440
+ on(table, 'paste', (ev) => {
441
+ if (inEditor(ev.target)) return;
442
+ if (ev.target !== table && ev.target?.closest?.('input, select, textarea, [contenteditable="true"]')) return;
443
+ const text = ev.clipboardData?.getData?.('text/plain');
444
+ if (typeof text !== 'string') return;
445
+ ev.preventDefault();
446
+ this.paste.paste(text);
447
+ });
448
+ }
378
449
  // An editor that was open across a render of its row (an `updateRow`)
379
450
  // is still the editor; the paint follows the model.
380
451
  this.paint();
@@ -457,21 +528,41 @@ export class CellGrid {
457
528
  return false;
458
529
  }
459
530
 
460
- /** The column's type, as `editors` is keyed by: the declared one, else the
461
- * detected one. */
462
- _typeOf(col) {
531
+ /** STORY-0087. The column's `editTypes` entry, as `{type, ...}`, or null. */
532
+ _editSpec(col) {
533
+ const all = this._cfg().editTypes;
534
+ let spec = all && typeof all === 'object' ? all[col] : null;
535
+ if (typeof spec === 'string') spec = { type: spec };
536
+ return spec && typeof spec === 'object' ? spec : null;
537
+ }
538
+
539
+ /** The column's type as it is named: `editTypes`', else the declared one
540
+ * (`columnTypes`), else the detected one. */
541
+ _rawType(col) {
542
+ const spec = this._editSpec(col);
543
+ if (spec?.type) return String(spec.type);
463
544
  const declared = this._cfg().columnTypes?.[col];
464
545
  return declared || this.table._columnTypes?.[col] || 'text';
465
546
  }
466
547
 
548
+ /** The column's type as `editors` and the built-in editors know it:
549
+ * 'num' is 'number', 'date' is 'datetime'. */
550
+ _typeOf(col) {
551
+ return editorTypeOf(this._rawType(col));
552
+ }
553
+
554
+ /** By column index, then by type (as named, then as the built-ins name
555
+ * it), then the built-in editor for the type, else the text box. */
467
556
  _factoryFor(col) {
468
557
  const editors = this._cfg().editors;
469
558
  if (editors && typeof editors === 'object') {
470
- if (typeof editors[col] === 'function') return editors[col];
471
- const type = this._typeOf(col);
472
- if (typeof editors[type] === 'function') return editors[type];
559
+ for (const k of [col, this._rawType(col), this._typeOf(col)]) {
560
+ if (typeof editors[k] === 'function') return editors[k];
561
+ }
473
562
  }
474
- return createTextEditor;
563
+ const builtIn = Object.prototype.hasOwnProperty.call(CELL_EDITORS, this._typeOf(col))
564
+ ? CELL_EDITORS[this._typeOf(col)] : null;
565
+ return builtIn ? builtIn.create : createTextEditor;
475
566
  }
476
567
 
477
568
  /**
@@ -494,7 +585,12 @@ export class CellGrid {
494
585
  pos, colIdx: col, rowIdx, row,
495
586
  generation: this.generation,
496
587
  value: this.shownValue(rowIdx, col),
497
- column: { index: col, header: headers[col], type: this._typeOf(col) },
588
+ column: { ...this._editSpec(col), index: col, header: headers[col], type: this._typeOf(col) },
589
+ args: {
590
+ locale: this._cfg().locale ?? undefined,
591
+ utc: this.table._utc(),
592
+ messages: this._cfg().editorMessages ?? undefined,
593
+ },
498
594
  initialText,
499
595
  });
500
596
  }
@@ -509,6 +605,7 @@ export class CellGrid {
509
605
 
510
606
  dispose() {
511
607
  this.fill?.dispose();
608
+ this.paste?.dispose();
512
609
  this.editor.cancel('dispose');
513
610
  this.renderWaiting = false;
514
611
  this.rowsWaiting.clear();
@@ -568,6 +665,22 @@ export class CellGrid {
568
665
  t.updateRow(rowIdx, row);
569
666
  }
570
667
 
668
+ /** STORY-0087. The editor closed holding text that does not parse (the
669
+ * focus left it, or its row left the window): nothing is committed, and
670
+ * the cell keeps the text, marked with the message, as a refused commit's
671
+ * would be. */
672
+ _refused(s, error, text, reason) {
673
+ if (s.generation === this.generation) {
674
+ const key = `${s.rowIdx}:${s.colIdx}`;
675
+ this.errors.set(key, { value: text, message: error });
676
+ // Any older commit of the cell still out no longer owns its marks.
677
+ this.latest.set(key, {});
678
+ this.pending.delete(key);
679
+ this._redraw(s.rowIdx, s.colIdx);
680
+ }
681
+ this._closed(s, reason);
682
+ }
683
+
571
684
  _committed(s, value, move, reason) {
572
685
  if (move) {
573
686
  this._sync();
@@ -0,0 +1,243 @@
1
+ /**
2
+ * cell_paste.js — STORY-0090. DataTable's PASTE (`pasteable: true`, with
3
+ * `staged`). INTERNAL to the table, like `cell_grid.js` and `fill_handle.js`:
4
+ * built by the cell grid, and reading the table through it.
5
+ *
6
+ * A block of tab-separated text (`tsv.js` `fromTSV`, what Excel and Google
7
+ * Sheets put on the clipboard) lands at the CURSOR cell and fills right, over
8
+ * the columns shown, and down, over the rows in the order shown. Each value is
9
+ * read by its column's editor's parse (`cell_editors.js`, or an injected
10
+ * editor's own `parse`), and every cell that reads is STAGED
11
+ * (`staged_edits.js`) as ONE undo entry.
12
+ *
13
+ * - Columns past the last one shown are dropped, with a warning naming how
14
+ * many.
15
+ * - Rows past the last loaded row ask `onPasteOverrun({rows: n})` first:
16
+ * anything but `true` (or a Promise of it) stages nothing; `true` stages
17
+ * the rows that fit and hands the extra ones to `onRowsAppend`.
18
+ * - A value that does not parse marks its cell (`twm-dt-cell--error`, the
19
+ * message as `title` and `data-error`, the text kept in the cell, as an
20
+ * editor's refusal is) and the rest of the block still stages.
21
+ * - A cell `editable` refuses is skipped — nothing is staged in it — and
22
+ * drawn `twm-dt-cell--paste-skipped` until the selection moves, the next
23
+ * paste or fill, or new rows.
24
+ */
25
+
26
+ import { fromTSV } from './tsv.js';
27
+ import { CELL_EDITORS } from './cell_editors.js';
28
+
29
+ const key = (rowIdx, colIdx) => `${rowIdx}:${colIdx}`;
30
+
31
+ export class CellPaste {
32
+ /** @param {import('./cell_grid.js').CellGrid} grid */
33
+ constructor(grid) {
34
+ this.grid = grid;
35
+ /** `rowIdx:colIdx` of the cells the last paste skipped. */
36
+ this.skipped = new Set();
37
+ /** True while a paste waits for `onPasteOverrun` or `onRowsAppend`. */
38
+ this.busy = false;
39
+ }
40
+
41
+ // ── painting (called by the grid for every cell it paints) ───────────
42
+
43
+ paintCell(td, col) {
44
+ const rowIdx = td.parentNode?.__rowIndex;
45
+ td.classList.toggle('twm-dt-cell--paste-skipped', this.skipped.has(key(rowIdx, col)));
46
+ }
47
+
48
+ /** The selection moved, or new rows came: the skipped marks are over. */
49
+ clearMarks() {
50
+ this.skipped.clear();
51
+ }
52
+
53
+ // ── reading a value ──────────────────────────────────────────────────
54
+
55
+ /**
56
+ * The parse a column's pasted text is read with: an injected editor's
57
+ * own (`editors[k].parse`, looked up as the editor is), else the built-in
58
+ * editor's for the column's type, else the text as it is.
59
+ */
60
+ _parserFor(col) {
61
+ const g = this.grid;
62
+ const editors = g._cfg().editors;
63
+ if (editors && typeof editors === 'object') {
64
+ for (const k of [col, g._rawType(col), g._typeOf(col)]) {
65
+ const f = editors[k];
66
+ if (typeof f === 'function') {
67
+ if (typeof f.parse === 'function') return f.parse;
68
+ break;
69
+ }
70
+ }
71
+ }
72
+ const type = g._typeOf(col);
73
+ return Object.prototype.hasOwnProperty.call(CELL_EDITORS, type)
74
+ ? CELL_EDITORS[type].parse : CELL_EDITORS.text.parse;
75
+ }
76
+
77
+ /** One column's reader: `(text) => {ok: true, value} | {ok: false, error}`. */
78
+ _reader(col) {
79
+ const g = this.grid;
80
+ const cfg = g._cfg();
81
+ const spec = g._editSpec(col) || {};
82
+ const parse = this._parserFor(col);
83
+ const ctx = {
84
+ locale: cfg.locale ?? undefined,
85
+ utc: g.table._utc(),
86
+ messages: cfg.editorMessages ?? undefined,
87
+ options: spec.options,
88
+ allowOther: !!spec.allowOther,
89
+ };
90
+ return (text) => {
91
+ let r;
92
+ try { r = parse(text, ctx); } catch (err) {
93
+ return { ok: false, error: err?.message || String(err ?? 'Not pasted') };
94
+ }
95
+ if (r && r.ok === true) return { ok: true, value: r.value };
96
+ if (r && r.ok === false) return { ok: false, error: String(r.error ?? 'Not pasted') };
97
+ return { ok: false, error: 'Not pasted' };
98
+ };
99
+ }
100
+
101
+ /** The position of the last loaded row: the rows held in the order shown
102
+ * (all of them filtered and sorted; a server's page, the rows it sent). */
103
+ _lastLoaded() {
104
+ const t = this.grid.table;
105
+ if (t._isServerSide()) return this.grid.span()[1];
106
+ return t._getProcessedRows().length - 1;
107
+ }
108
+
109
+ _warn(message) {
110
+ console.warn(`[DataTable] paste: ${message}`);
111
+ this.grid.table._notify('Paste', message, 'warn');
112
+ }
113
+
114
+ // ── the paste ────────────────────────────────────────────────────────
115
+
116
+ /**
117
+ * Paste tab-separated `text` at the cursor. Resolves `{rows, cols, staged,
118
+ * skipped, refused, droppedColumns, appended}` — `staged` the NUMBER of
119
+ * cells that changed, `skipped` the cells `editable` refused and `refused`
120
+ * the ones whose text did not parse (`{row, col, rowIdx}`, `refused` with
121
+ * its `text` and `error`), `droppedColumns` the columns past the last one,
122
+ * `appended` the rows handed to `onRowsAppend` — or null when nothing was
123
+ * pasted: empty text, no cursor, an editor open, a commit or another paste
124
+ * out, an overrun declined, or new rows while `onPasteOverrun` was asked.
125
+ */
126
+ async paste(text) {
127
+ const g = this.grid;
128
+ const matrix = fromTSV(text);
129
+ if (!matrix.length || this.busy) return null;
130
+ g._sync();
131
+ const at = g.sel.focus ? { ...g.sel.focus } : null;
132
+ if (!at || g.isEditing() || g.staged.committing) return null;
133
+ const cfg = g._cfg();
134
+ const cols = g.columns();
135
+ const width = Math.max(...matrix.map((line) => line.length));
136
+ const targetCols = cols.slice(cols.indexOf(at.col), cols.indexOf(at.col) + width);
137
+ const droppedColumns = width - targetCols.length;
138
+ const last = this._lastLoaded();
139
+ const fit = Math.max(0, Math.min(matrix.length, last - at.row + 1));
140
+ const extra = matrix.length - fit;
141
+ const generation = g.generation;
142
+
143
+ if (extra > 0) {
144
+ const ask = cfg.onPasteOverrun;
145
+ let answer = false;
146
+ if (typeof ask === 'function') {
147
+ this.busy = true;
148
+ try { answer = await ask({ rows: extra }); } catch (err) {
149
+ console.error('[DataTable] onPasteOverrun threw', err);
150
+ answer = false;
151
+ } finally {
152
+ this.busy = false;
153
+ }
154
+ } else {
155
+ console.warn(`[DataTable] paste: ${extra} row(s) past the last loaded row and no \`onPasteOverrun\`; nothing pasted.`);
156
+ }
157
+ if (answer !== true) return null;
158
+ if (generation !== g.generation || g.isEditing() || g.staged.committing) return null;
159
+ }
160
+
161
+ // What lands in the loaded rows: each cell read, or skipped.
162
+ const cells = [];
163
+ const skipped = [];
164
+ const refused = [];
165
+ const readers = new Map(targetCols.map((col) => [col, this._reader(col)]));
166
+ for (let r = 0; r < fit; r++) {
167
+ const pos = at.row + r;
168
+ const rowIdx = g.rowIndexAt(pos);
169
+ targetCols.forEach((col, c) => {
170
+ if (c >= matrix[r].length) return; // a short line: nothing there
171
+ if (!g.canEdit(pos, col)) { skipped.push({ row: pos, col, rowIdx }); return; }
172
+ const text = matrix[r][c];
173
+ const read = readers.get(col)(text);
174
+ if (read.ok) cells.push({ rowIdx, colIdx: col, value: read.value });
175
+ else refused.push({ row: pos, col, rowIdx, text, error: read.error });
176
+ });
177
+ }
178
+
179
+ // The rows past the last loaded one, as data rows: every pasted
180
+ // column read; one that does not read is null, and listed.
181
+ let appended = 0;
182
+ if (extra > 0) {
183
+ const n = (cfg.headers || []).length;
184
+ const rows = [];
185
+ const errors = [];
186
+ for (let r = fit; r < matrix.length; r++) {
187
+ const row = new Array(n).fill(null);
188
+ targetCols.forEach((col, c) => {
189
+ if (c >= matrix[r].length) return;
190
+ const text = matrix[r][c];
191
+ const read = readers.get(col)(text);
192
+ if (read.ok) row[col] = read.value;
193
+ else errors.push({ row: rows.length, colIdx: col, text, error: read.error });
194
+ });
195
+ rows.push(row);
196
+ }
197
+ const append = cfg.onRowsAppend;
198
+ if (typeof append === 'function') {
199
+ appended = rows.length;
200
+ this.busy = true;
201
+ try { await append({ rows, errors }); } catch (err) {
202
+ console.error('[DataTable] onRowsAppend threw', err);
203
+ } finally {
204
+ this.busy = false;
205
+ }
206
+ } else {
207
+ this._warn(`${rows.length} row(s) past the last loaded row were not pasted (no \`onRowsAppend\`).`);
208
+ }
209
+ }
210
+
211
+ const staged = g.staged.stageMany(cells, `paste ${cells.length} cell(s)`);
212
+
213
+ // The range covers the block that landed, the cursor where it began.
214
+ const lastCol = targetCols[targetCols.length - 1] ?? at.col;
215
+ const lastRow = at.row + Math.max(0, fit - 1);
216
+ g.set({ row: at.row, col: at.col }, { row: lastRow, col: lastCol });
217
+
218
+ // Marked after the selection moved, which clears the last marks.
219
+ for (const s of skipped) this.skipped.add(key(s.rowIdx, s.col));
220
+ const redraw = new Set();
221
+ for (const f of refused) {
222
+ const k = key(f.rowIdx, f.col);
223
+ g.errors.set(k, { value: f.text, message: f.error });
224
+ // Any older commit of the cell still out no longer owns its marks.
225
+ g.latest.set(k, {});
226
+ g.pending.delete(k);
227
+ redraw.add(f.rowIdx);
228
+ }
229
+ for (const rowIdx of redraw) g._redraw(rowIdx);
230
+ if (skipped.length) g.paint();
231
+
232
+ if (droppedColumns > 0) {
233
+ this._warn(`${droppedColumns} column(s) past the last column were not pasted.`);
234
+ }
235
+ return {
236
+ rows: matrix.length, cols: width, staged, skipped, refused, droppedColumns, appended,
237
+ };
238
+ }
239
+
240
+ dispose() {
241
+ this.skipped.clear();
242
+ }
243
+ }