@eifi1/ui-kit 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/README.md +42 -25
  2. package/dist/chart.d.ts +11 -8
  3. package/dist/components/amount-input.d.ts +28 -8
  4. package/dist/components/amount-input.js +3 -1
  5. package/dist/components/amount-input.js.map +1 -1
  6. package/dist/components/autocomplete.d.ts +21 -0
  7. package/dist/components/autocomplete.js +128 -103
  8. package/dist/components/autocomplete.js.map +1 -1
  9. package/dist/components/button-group.d.ts +11 -8
  10. package/dist/components/calculator.d.ts +11 -8
  11. package/dist/components/column-mapper.d.ts +182 -0
  12. package/dist/components/column-mapper.js +375 -0
  13. package/dist/components/column-mapper.js.map +1 -0
  14. package/dist/components/combobox-core.d.ts +39 -2
  15. package/dist/components/combobox-core.js +29 -6
  16. package/dist/components/combobox-core.js.map +1 -1
  17. package/dist/components/combobox.d.ts +80 -2
  18. package/dist/components/combobox.js +448 -311
  19. package/dist/components/combobox.js.map +1 -1
  20. package/dist/components/confirm-dialog.d.ts +11 -8
  21. package/dist/components/confirm-dialog.js +7 -8
  22. package/dist/components/confirm-dialog.js.map +1 -1
  23. package/dist/components/copy-button.d.ts +8 -5
  24. package/dist/components/country-select.d.ts +50 -7
  25. package/dist/components/country-select.js +65 -9
  26. package/dist/components/country-select.js.map +1 -1
  27. package/dist/components/danger-confirm.d.ts +11 -8
  28. package/dist/components/danger-confirm.js +13 -3
  29. package/dist/components/danger-confirm.js.map +1 -1
  30. package/dist/components/date-picker.d.ts +41 -0
  31. package/dist/components/date-picker.js +269 -8
  32. package/dist/components/date-picker.js.map +1 -1
  33. package/dist/components/entity-combobox.d.ts +35 -1
  34. package/dist/components/entity-combobox.js +148 -112
  35. package/dist/components/entity-combobox.js.map +1 -1
  36. package/dist/components/facing-pair.d.ts +11 -8
  37. package/dist/components/field-parts.d.ts +116 -6
  38. package/dist/components/field-parts.js +84 -0
  39. package/dist/components/field-parts.js.map +1 -1
  40. package/dist/components/field-strip.d.ts +105 -0
  41. package/dist/components/field-strip.js +46 -0
  42. package/dist/components/field-strip.js.map +1 -0
  43. package/dist/components/file-button.d.ts +11 -8
  44. package/dist/components/file-dropzone.d.ts +11 -8
  45. package/dist/components/form-actions.d.ts +10 -7
  46. package/dist/components/form-actions.js +8 -2
  47. package/dist/components/form-actions.js.map +1 -1
  48. package/dist/components/iban-input.d.ts +11 -8
  49. package/dist/components/icon-picker.d.ts +10 -1
  50. package/dist/components/icon-picker.js +12 -4
  51. package/dist/components/icon-picker.js.map +1 -1
  52. package/dist/components/language-select.d.ts +11 -8
  53. package/dist/components/money-field.d.ts +14 -8
  54. package/dist/components/money-field.js.map +1 -1
  55. package/dist/components/month-picker.js +2 -1
  56. package/dist/components/month-picker.js.map +1 -1
  57. package/dist/components/multi-entity-combobox.d.ts +26 -1
  58. package/dist/components/multi-entity-combobox.js +143 -102
  59. package/dist/components/multi-entity-combobox.js.map +1 -1
  60. package/dist/components/number-field.d.ts +11 -8
  61. package/dist/components/number-input.d.ts +11 -8
  62. package/dist/components/numpad-sheet.d.ts +11 -8
  63. package/dist/components/phone-input.d.ts +11 -8
  64. package/dist/components/reauth-dialog.d.ts +2 -2
  65. package/dist/components/reauth-dialog.js +6 -8
  66. package/dist/components/reauth-dialog.js.map +1 -1
  67. package/dist/components/series-chart.d.ts +11 -8
  68. package/dist/components/settings-fields.d.ts +11 -8
  69. package/dist/components/share-card.d.ts +10 -7
  70. package/dist/components/swatch-picker.d.ts +17 -1
  71. package/dist/components/swatch-picker.js +10 -4
  72. package/dist/components/swatch-picker.js.map +1 -1
  73. package/dist/components/text-link.d.ts +10 -7
  74. package/dist/components/tile-radio.d.ts +20 -0
  75. package/dist/components/tile-radio.js +25 -8
  76. package/dist/components/tile-radio.js.map +1 -1
  77. package/dist/components/time-input.d.ts +11 -8
  78. package/dist/components/toggle-group.js +2 -2
  79. package/dist/components/toggle-group.js.map +1 -1
  80. package/dist/components/trigger-aria.d.ts +3 -0
  81. package/dist/components/trigger-aria.js +3 -1
  82. package/dist/components/trigger-aria.js.map +1 -1
  83. package/dist/components/ui.d.ts +8 -5
  84. package/dist/components/ui.js +5 -11
  85. package/dist/components/ui.js.map +1 -1
  86. package/dist/feedback/feedback-attachment.d.ts +1 -1
  87. package/dist/feedback/feedback-attachment.js +156 -56
  88. package/dist/feedback/feedback-attachment.js.map +1 -1
  89. package/dist/feedback/feedback-dialog.d.ts +1 -1
  90. package/dist/feedback/feedback-dialog.js.map +1 -1
  91. package/dist/feedback/feedback-inbox.d.ts +3 -2
  92. package/dist/feedback/feedback-inbox.js.map +1 -1
  93. package/dist/feedback/feedback-thread.d.ts +68 -287
  94. package/dist/feedback/feedback-thread.js +8 -1
  95. package/dist/feedback/feedback-thread.js.map +1 -1
  96. package/dist/{kit-labels-R8mIAc9N.d.ts → feedback-BxeQVzwq.d.ts} +405 -22
  97. package/dist/{feedback-attachment-NhUm7Zga.d.ts → feedback-attachment-fGAzZPf0.d.ts} +112 -10
  98. package/dist/feedback.d.ts +66 -2
  99. package/dist/hooks/use-file-drop.d.ts +11 -8
  100. package/dist/i18n/defaults.d.ts +11 -8
  101. package/dist/i18n/defaults.js +3 -1
  102. package/dist/i18n/defaults.js.map +1 -1
  103. package/dist/i18n/german.d.ts +11 -8
  104. package/dist/i18n/german.js +36 -2
  105. package/dist/i18n/german.js.map +1 -1
  106. package/dist/i18n/kit-labels.d.ts +7 -4
  107. package/dist/i18n/kit-labels.js.map +1 -1
  108. package/dist/i18n/languages.d.ts +11 -8
  109. package/dist/i18n/locales/de-CH.d.ts +11 -8
  110. package/dist/i18n/locales/en.d.ts +11 -8
  111. package/dist/i18n/locales/en.js +7 -0
  112. package/dist/i18n/locales/en.js.map +1 -1
  113. package/dist/i18n/locales/es.d.ts +11 -8
  114. package/dist/i18n/locales/es.js +37 -2
  115. package/dist/i18n/locales/es.js.map +1 -1
  116. package/dist/i18n/locales/fr.d.ts +11 -8
  117. package/dist/i18n/locales/fr.js +36 -2
  118. package/dist/i18n/locales/fr.js.map +1 -1
  119. package/dist/i18n/locales/hu.d.ts +11 -8
  120. package/dist/i18n/locales/hu.js +38 -2
  121. package/dist/i18n/locales/hu.js.map +1 -1
  122. package/dist/i18n/locales/it.d.ts +11 -8
  123. package/dist/i18n/locales/it.js +37 -2
  124. package/dist/i18n/locales/it.js.map +1 -1
  125. package/dist/i18n/locales/zh.d.ts +11 -8
  126. package/dist/i18n/locales/zh.js +34 -2
  127. package/dist/i18n/locales/zh.js.map +1 -1
  128. package/dist/i18n/review.d.ts +11 -8
  129. package/dist/i18n/review.js +20 -1
  130. package/dist/i18n/review.js.map +1 -1
  131. package/dist/index.d.ts +7 -3
  132. package/dist/index.js +16 -0
  133. package/dist/index.js.map +1 -1
  134. package/dist/lib/column-mapping.d.ts +81 -0
  135. package/dist/lib/column-mapping.js +108 -0
  136. package/dist/lib/column-mapping.js.map +1 -0
  137. package/dist/lib/table-text.d.ts +109 -1
  138. package/dist/lib/table-text.js +122 -1
  139. package/dist/lib/table-text.js.map +1 -1
  140. package/dist/rhf/fields.d.ts +89 -11
  141. package/dist/rhf/fields.js +124 -0
  142. package/dist/rhf/fields.js.map +1 -1
  143. package/dist/rhf/form.d.ts +11 -8
  144. package/dist/rhf.d.ts +12 -9
  145. package/dist/rhf.js.map +1 -1
  146. package/dist/shell/app-shell.d.ts +10 -7
  147. package/dist/shell/top-bar-brand.d.ts +11 -8
  148. package/dist/shell/topbar-action-menu.d.ts +36 -3
  149. package/dist/shell/topbar-action-menu.js +74 -33
  150. package/dist/shell/topbar-action-menu.js.map +1 -1
  151. package/dist/shell.d.ts +10 -7
  152. package/dist/table-text.d.ts +1 -1
  153. package/dist/wizard/stepper-nav.d.ts +41 -10
  154. package/dist/wizard/stepper-nav.js +4 -0
  155. package/dist/wizard/stepper-nav.js.map +1 -1
  156. package/dist/wizard.d.ts +11 -8
  157. package/package.json +1 -1
  158. package/src/components/amount-input.tsx +20 -1
  159. package/src/components/autocomplete.tsx +172 -111
  160. package/src/components/column-mapper.tsx +666 -0
  161. package/src/components/combobox-core.tsx +82 -9
  162. package/src/components/combobox.tsx +614 -332
  163. package/src/components/confirm-dialog.tsx +12 -8
  164. package/src/components/country-select.tsx +135 -22
  165. package/src/components/danger-confirm.tsx +91 -12
  166. package/src/components/date-picker.tsx +423 -10
  167. package/src/components/entity-combobox.tsx +217 -126
  168. package/src/components/field-parts.tsx +224 -5
  169. package/src/components/field-strip.tsx +149 -0
  170. package/src/components/form-actions.tsx +32 -4
  171. package/src/components/icon-picker.tsx +23 -4
  172. package/src/components/money-field.tsx +3 -0
  173. package/src/components/month-picker.tsx +2 -1
  174. package/src/components/multi-entity-combobox.tsx +207 -120
  175. package/src/components/reauth-dialog.tsx +17 -18
  176. package/src/components/swatch-picker.tsx +29 -5
  177. package/src/components/tile-radio.tsx +74 -13
  178. package/src/components/toggle-group.tsx +2 -2
  179. package/src/components/trigger-aria.ts +5 -0
  180. package/src/components/ui.tsx +7 -33
  181. package/src/feedback/feedback-attachment.tsx +308 -61
  182. package/src/feedback/feedback-dialog.tsx +7 -3
  183. package/src/feedback/feedback-inbox.tsx +3 -2
  184. package/src/feedback/feedback-thread.tsx +47 -4
  185. package/src/i18n/defaults.ts +2 -0
  186. package/src/i18n/german.ts +42 -0
  187. package/src/i18n/kit-labels.tsx +4 -0
  188. package/src/i18n/locales/en.ts +27 -5
  189. package/src/i18n/locales/es.ts +41 -0
  190. package/src/i18n/locales/fr.ts +42 -0
  191. package/src/i18n/locales/hu.ts +37 -0
  192. package/src/i18n/locales/it.ts +41 -0
  193. package/src/i18n/locales/zh.ts +32 -0
  194. package/src/i18n/review.ts +19 -0
  195. package/src/index.ts +18 -0
  196. package/src/lib/column-mapping.ts +234 -0
  197. package/src/lib/table-text.ts +271 -0
  198. package/src/rhf/fields.tsx +254 -0
  199. package/src/rhf.ts +2 -1
  200. package/src/shell/topbar-action-menu.tsx +134 -38
  201. package/src/wizard/stepper-nav.tsx +35 -1
  202. package/dist/components/field-anatomy.d.ts +0 -95
  203. package/dist/components/field-anatomy.js +0 -84
  204. package/dist/components/field-anatomy.js.map +0 -1
  205. package/src/components/field-anatomy.tsx +0 -190
package/src/index.ts CHANGED
@@ -129,6 +129,8 @@ export type {
129
129
  TypedConfirmFieldLabels,
130
130
  CurrentPasswordInputProps,
131
131
  CurrentPasswordInputLabels,
132
+ // 0.23.0: onConfirm's second argument — the guards' values (keksdose G4b).
133
+ DangerConfirmValues,
132
134
  } from "./components/danger-confirm";
133
135
  // 0.18: re-authentication before a sensitive action (Kurvenschmiede 4).
134
136
  export { ReauthDialog, DEFAULT_REAUTH_DIALOG_LABELS } from "./components/reauth-dialog";
@@ -456,3 +458,19 @@ export {
456
458
  isE164,
457
459
  } from "./lib/phone";
458
460
  export type { PhoneCountryCode, PhoneCountry, ParsedPhone } from "./lib/phone";
461
+
462
+ // ── 0.23.0: the apps' 0.22 adoption round (kastlan, keksdose, Kurvenschmiede) ──
463
+ // The label strip over custom content (keksdose G8).
464
+ export * from "./components/field-strip";
465
+ // Paste or drop a table, give each column a role (Kurvenschmiede's columns input,
466
+ // keksdose's import map step). The lexer itself stays in `@eifi1/ui-kit/table-text`.
467
+ export * from "./components/column-mapper";
468
+ export {
469
+ assignColumnRole,
470
+ guessMapping,
471
+ missingRoles,
472
+ readMappedTable,
473
+ readTextFile,
474
+ roleOfColumn,
475
+ } from "./lib/column-mapping";
476
+ export type { ColumnMapperResult, ColumnMapping, ColumnRole } from "./lib/column-mapping";
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Which column of a table plays which part — the headless half of {@link ColumnMapper}.
3
+ *
4
+ * Two apps built this by hand, the same way. Kurvenschmiede's columns input
5
+ * (`features/control/columns-input.tsx`) reads a recorder's export and asks which
6
+ * column is the time, the setpoint and the actual; keksdose's bank-file import
7
+ * (`import-map-step.tsx`, `use-file-import.ts`) shows a bank's CSV and asks which
8
+ * column is the booking date, the amount, the payee. Both then have the same four
9
+ * rules, and keksdose wrote them down after getting each one wrong once:
10
+ *
11
+ * * **A role sits on one column at most, and a column carries one role.** Picking a
12
+ * role for a column MOVES it there from wherever it was ({@link assignColumnRole});
13
+ * the column it left falls back to "ignore" in the same step. There is no error
14
+ * state, because there is nothing wrong to report.
15
+ * * **What is still missing is named**, not left to a disabled button
16
+ * ({@link missingRoles}). A required role can be one of a group: a bank export that
17
+ * splits debit and credit has no amount column and needs none, so `required` may
18
+ * name a group, satisfied by any one of its members (keksdose's "money").
19
+ * * **A mapping is indexes into ONE table.** When the column count changes, the
20
+ * indexes mean nothing any more and are guessed afresh; when it does not (the
21
+ * header line toggled), they still point where they did — keksdose's `keepRoles`.
22
+ * * **The rows a role cannot read are reported by line**, not dropped in silence
23
+ * ({@link readMappedTable}): a footer under a recording, a total under a statement.
24
+ *
25
+ * Pure functions over plain data, so a two-step wizard can hold the mapping in its own
26
+ * state between steps, and a table the server sniffed (keksdose's) can be mapped with
27
+ * the same rules as one parsed here.
28
+ */
29
+ import { tableNumber } from "./table-text";
30
+ import type { TableLine, TextTable } from "./table-text";
31
+
32
+ /** One part a column can play, defined by the caller. */
33
+ export interface ColumnRole<R extends string = string> {
34
+ /** The key the mapping is stored under. */
35
+ value: R;
36
+ /** What the role select shows — translated by the caller. */
37
+ label: string;
38
+ /**
39
+ * `true`: the table cannot be used until a column plays this role. A string names a
40
+ * GROUP: the table needs any one of the roles that share it — keksdose's amount,
41
+ * debit and credit, any of which carries the money.
42
+ */
43
+ required?: boolean | string;
44
+ /**
45
+ * Every cell this role reads must be a number in the table's decimal convention
46
+ * ({@link tableNumber}). A row where one is not — a footer, a units line, a total
47
+ * with a grouping mark — is moved to `unread` with its line number instead of being
48
+ * handed over. Kurvenschmiede's time, setpoint and actual. The cells stay strings:
49
+ * converting is the caller's, with `tableNumber(cell, result.decimalComma)`.
50
+ */
51
+ numeric?: boolean;
52
+ }
53
+
54
+ /** Role → column index. A role that is absent or `null` sits on no column — so a
55
+ * mapping with nullable fields (keksdose's `CsvMapping`) is one as it stands. */
56
+ export type ColumnMapping<R extends string = string> = Partial<Record<R, number | null>>;
57
+
58
+ /** What the table is, read through a mapping. */
59
+ export interface ColumnMapperResult<R extends string = string> extends TextTable {
60
+ mapping: ColumnMapping<R>;
61
+ /** What the table still needs before it can be used: one entry per gap, each the
62
+ * roles any of which would fill it (one role, or a required group). */
63
+ missing: R[][];
64
+ /** Every required role placed, and at least one row read. */
65
+ complete: boolean;
66
+ }
67
+
68
+ const placedAt = <R extends string>(mapping: ColumnMapping<R>, role: R, width?: number): number | null => {
69
+ const column = mapping[role];
70
+ if (typeof column !== "number") return null;
71
+ return width === undefined || column < width ? column : null;
72
+ };
73
+
74
+ /** The role on this column, or `null` for an ignored one. */
75
+ export function roleOfColumn<R extends string>(
76
+ roles: readonly ColumnRole<R>[],
77
+ mapping: ColumnMapping<R>,
78
+ column: number,
79
+ ): R | null {
80
+ return roles.find((role) => mapping[role.value] === column)?.value ?? null;
81
+ }
82
+
83
+ /**
84
+ * Put `role` on `column` — or, with `null`, take the column's role off it.
85
+ *
86
+ * Whatever this column played before stops, and the role moves here from any other
87
+ * column, in one new mapping (keksdose's `assignColumn`: "both halves are patches to
88
+ * the same object"). Other fields of the mapping are kept as they are.
89
+ */
90
+ export function assignColumnRole<R extends string, M extends ColumnMapping<R>>(
91
+ roles: readonly ColumnRole<R>[],
92
+ mapping: M,
93
+ column: number,
94
+ role: R | null,
95
+ ): M {
96
+ const next = { ...mapping } as Record<string, unknown>;
97
+ for (const { value } of roles) {
98
+ if (mapping[value] === column) next[value] = null;
99
+ }
100
+ if (role !== null) next[role] = column;
101
+ return next as M;
102
+ }
103
+
104
+ /**
105
+ * The gaps: each required role with no column, and each required group none of whose
106
+ * roles has one. In the order of `roles`. A column index past `width` is no column.
107
+ */
108
+ export function missingRoles<R extends string>(
109
+ roles: readonly ColumnRole<R>[],
110
+ mapping: ColumnMapping<R>,
111
+ width?: number,
112
+ ): R[][] {
113
+ const gaps: R[][] = [];
114
+ const groups = new Set<string>();
115
+ for (const role of roles) {
116
+ if (role.required === true) {
117
+ if (placedAt(mapping, role.value, width) === null) gaps.push([role.value]);
118
+ } else if (typeof role.required === "string" && !groups.has(role.required)) {
119
+ groups.add(role.required);
120
+ const members = roles.filter((other) => other.required === role.required);
121
+ if (members.every((member) => placedAt(mapping, member.value, width) === null)) {
122
+ gaps.push(members.map((member) => member.value));
123
+ }
124
+ }
125
+ }
126
+ return gaps;
127
+ }
128
+
129
+ const normalised = (text: string) => text.trim().toLowerCase();
130
+
131
+ /**
132
+ * A first mapping for a table nobody has mapped yet.
133
+ *
134
+ * A column whose header IS a role's label or key gets that role. The required roles
135
+ * left over then take the free columns in order — Kurvenschmiede's "time, setpoint,
136
+ * actual are the first three columns", which is how a recorder writes them. Optional
137
+ * roles are not guessed by position: in a bank
138
+ * export of twelve columns, "the fourth role is the fourth column" is a wrong answer
139
+ * presented as a suggestion, and an "ignore" is the honest one.
140
+ */
141
+ export function guessMapping<R extends string>(
142
+ roles: readonly ColumnRole<R>[],
143
+ header: readonly string[] | null,
144
+ width: number,
145
+ ): ColumnMapping<R> {
146
+ const mapping: ColumnMapping<R> = {};
147
+ const taken = new Set<number>();
148
+ if (header) {
149
+ for (const role of roles) {
150
+ const names = [normalised(role.label), normalised(role.value)];
151
+ const column = header.findIndex((name, index) => !taken.has(index) && names.includes(normalised(name)));
152
+ if (column >= 0) {
153
+ mapping[role.value] = column;
154
+ taken.add(column);
155
+ }
156
+ }
157
+ }
158
+ const free = Array.from({ length: width }, (_, index) => index).filter((index) => !taken.has(index));
159
+ for (const role of roles) {
160
+ if (role.required !== true || typeof mapping[role.value] === "number") continue;
161
+ const column = free.shift();
162
+ if (column === undefined) break;
163
+ mapping[role.value] = column;
164
+ }
165
+ return mapping;
166
+ }
167
+
168
+ /**
169
+ * The table read through the mapping: the rows every `numeric` role can read, the
170
+ * others moved to `unread` (in line order, with the parse's own), what is missing,
171
+ * and whether the table can be used. Column indexes past the table's width count as
172
+ * unplaced.
173
+ */
174
+ export function readMappedTable<R extends string>(
175
+ table: TextTable,
176
+ roles: readonly ColumnRole<R>[],
177
+ mapping: ColumnMapping<R>,
178
+ ): ColumnMapperResult<R> {
179
+ const numeric = roles
180
+ .filter((role) => role.numeric)
181
+ .map((role) => placedAt(mapping, role.value, table.width))
182
+ .filter((column): column is number => column !== null);
183
+ const rows: string[][] = [];
184
+ const lines: TableLine[] = [];
185
+ const refused: TableLine[] = [];
186
+ table.rows.forEach((row, index) => {
187
+ if (numeric.every((column) => Number.isFinite(tableNumber(row[column] ?? "", table.decimalComma)))) {
188
+ rows.push(row);
189
+ lines.push(table.lines[index]);
190
+ } else {
191
+ refused.push(table.lines[index]);
192
+ }
193
+ });
194
+ const unread = refused.length ? [...table.unread, ...refused].sort((a, b) => a.line - b.line) : table.unread;
195
+ const missing = missingRoles(roles, mapping, table.width);
196
+ return {
197
+ ...table,
198
+ rows,
199
+ lines,
200
+ unread,
201
+ mapping,
202
+ missing,
203
+ complete: missing.length === 0 && rows.length > 0,
204
+ };
205
+ }
206
+
207
+ /**
208
+ * A dropped or chosen file's text — UTF-8 when it is UTF-8, else Windows-1252.
209
+ *
210
+ * A German bank or bench export is very often written in Windows-1252, and reading it
211
+ * as UTF-8 turns every "ä" in a column name into "�" — no error, just a header nobody
212
+ * recognises. So the bytes are tried as strict UTF-8 first and read as Windows-1252
213
+ * when that fails (keksdose's server sniffs the encoding for the same reason; a file
214
+ * read in the browser had nothing doing it). Falls back to `FileReader` where a `File`
215
+ * has no `arrayBuffer` (Kurvenschmiede's `readText`).
216
+ */
217
+ export async function readTextFile(file: Blob): Promise<string> {
218
+ const bytes = await bytesOf(file);
219
+ try {
220
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
221
+ } catch {
222
+ return new TextDecoder("windows-1252").decode(bytes);
223
+ }
224
+ }
225
+
226
+ function bytesOf(file: Blob): Promise<ArrayBuffer> {
227
+ if (typeof file.arrayBuffer === "function") return file.arrayBuffer();
228
+ return new Promise((resolve, reject) => {
229
+ const reader = new FileReader();
230
+ reader.onload = () => resolve(reader.result as ArrayBuffer);
231
+ reader.onerror = () => reject(reader.error ?? new Error("read failed"));
232
+ reader.readAsArrayBuffer(file);
233
+ });
234
+ }
@@ -46,6 +46,11 @@
46
46
  * * **Quoted fields.** No `"a;b"` escaping. These are numbers; the only text in them
47
47
  * is the header line, and a header field with a separator in it costs a column
48
48
  * name, not a measurement.
49
+ *
50
+ * Both of those are the NUMERIC lexer's limits. A table whose cells are text — a bank
51
+ * export, read for a column mapper — needs both, and {@link parseTextTable} (0.23) is
52
+ * that door: empty cells held in place, quoted cells, and the same separator and comma
53
+ * rules underneath.
49
54
  */
50
55
 
51
56
  /** A digit, a comma, a digit — a comma that can only be a decimal mark or a
@@ -263,3 +268,269 @@ function fieldsOf(line: string, separator: Separator): string[] {
263
268
  * field with two of them is a grouped number and this module does not read
264
269
  * those — it reports the line instead. */
265
270
  const point = (field: string) => field.replace(",", ".");
271
+
272
+ // ── A table of text ────────────────────────────────────────────────────────────
273
+
274
+ /**
275
+ * What separates the columns of a {@link TextTable}, named. `" "` is a run of
276
+ * whitespace — one separator however many spaces or tabs it is made of — as in a
277
+ * printed or MATLAB-dumped table.
278
+ */
279
+ export type TableSeparator = "\t" | ";" | "," | " ";
280
+
281
+ /** One line of the text as it was handed over: its 1-based number (blank lines
282
+ * counted, so a reader can count to it in the box they pasted into) and its text,
283
+ * trimmed. */
284
+ export interface TableLine {
285
+ line: number;
286
+ text: string;
287
+ }
288
+
289
+ export interface ParseTextTableOptions {
290
+ /** The separator, decided by the caller — a "separated by" override, a format
291
+ * the app already knows. Default: detected (see {@link parseTextTable}). */
292
+ separator?: TableSeparator;
293
+ /** Whether a comma between two digits is a decimal mark. Default: the
294
+ * `"whole-text"` rule, decided once for the file. */
295
+ decimalComma?: boolean;
296
+ /** Whether the table's first line names its columns. Default: detected — a
297
+ * word over a column of values ("Amount" over `-12,50`) is a header. */
298
+ header?: boolean;
299
+ }
300
+
301
+ /**
302
+ * A table read as text: every cell a string, as written.
303
+ *
304
+ * The *file* door's decisions, for a table that is not only numbers. Everything a
305
+ * reader has to know to trust it is in the answer — the separator and the decimal
306
+ * mark that were assumed, and the lines that were not read, with their number and
307
+ * their text — because a guess nobody can see is a guess nobody can correct.
308
+ */
309
+ export interface TextTable {
310
+ /** The first line's cells, when it names the columns; `null` when it is data. */
311
+ header: string[] | null;
312
+ /** Each read row, `width` cells long. An empty cell is `""`, kept in its place. */
313
+ rows: string[][];
314
+ /** Where each row came from — row-aligned with `rows`. */
315
+ lines: TableLine[];
316
+ /** How many columns the table has. */
317
+ width: number;
318
+ separator: TableSeparator;
319
+ /** Whether a comma is this table's decimal mark — the convention its numbers are
320
+ * written in, to read them with {@link tableNumber} and to say back to the reader. */
321
+ decimalComma: boolean;
322
+ /** Lines that are not a row of this table: a title above it, a total under it, a
323
+ * row with a cell too many or too few. */
324
+ unread: TableLine[];
325
+ }
326
+
327
+ /**
328
+ * A pasted or dropped table, read as text — the lexer's file door for a table whose
329
+ * cells are not all numbers (a bank export: a date, a payee, an amount), and the
330
+ * parse under {@link ColumnMapper}.
331
+ *
332
+ * Moved under the same roof as {@link parseTable} because it has the same three
333
+ * questions — what separates the columns, what the comma is, whether the first
334
+ * line is a header — and answering them a second time somewhere else is how the
335
+ * three readers this module replaced came to disagree. Kurvenschmiede's columns
336
+ * input and keksdose's bank-file import both read a table whose columns the reader
337
+ * then assigns, and both state what was assumed; this is that reading, once.
338
+ *
339
+ * Where it differs from {@link parseTable}, and why:
340
+ *
341
+ * * **Cells stay text.** Which cell is a number is the caller's question (its
342
+ * column roles), so nothing here converts. {@link tableNumber} reads one in the
343
+ * table's own convention.
344
+ * * **An empty cell keeps its place.** A bank row with no memo has `;;` in it, and
345
+ * dropping that field — which the numeric lexer does — would move the amount into
346
+ * the memo's column. A trailing separator that ends most lines (`1;2;3;`, which
347
+ * spreadsheets write) is a line ending, not a column.
348
+ * * **Quoted cells.** `"Example Ltd; branch 2"` is one cell, and `""` inside quotes
349
+ * is a quote. Text has separators in it where numbers never do. A quoted cell that
350
+ * runs over a line break is not joined — the line then has the wrong number of
351
+ * cells and is reported.
352
+ * * **The width is the file's.** The column count most lines share. A line with
353
+ * another count is reported in `unread` rather than padded or cut: a row with a
354
+ * cell too many is a row whose columns have slid, and reading it would put a payee
355
+ * in the amount.
356
+ * * **The separator is voted on**, over the first lines, rather than taken from the
357
+ * second: an export that opens with a title block (a bank's account name and
358
+ * period over the table) would otherwise be read with the title's separator.
359
+ * * **The header is detected by its words.** The numeric lexer's rule — "a line
360
+ * that is not numbers" — cannot work where the rows are text too. A first line is
361
+ * a header when one of its cells is a word and every cell under it, as far as the
362
+ * first rows go, is a value (digits and the marks numbers and dates are written
363
+ * with): "Amount" over `-12,50`, "Date" over `01.02.2026`. A table of words only
364
+ * is read without one; `options.header` says otherwise.
365
+ *
366
+ * Blank lines and `#` banners are skipped, as by the numeric lexer.
367
+ */
368
+ export function parseTextTable(text: string, options: ParseTextTableOptions = {}): TextTable {
369
+ const content = textLines(text);
370
+ if (!content.length) {
371
+ return { header: null, rows: [], lines: [], width: 0, separator: options.separator ?? ",", decimalComma: false, unread: [] };
372
+ }
373
+ const separator = options.separator ?? votedSeparator(content);
374
+ const decimalComma =
375
+ options.decimalComma ?? (separator !== "," && DECIMAL_COMMA.test(content.map((entry) => entry.line).join("\n")));
376
+
377
+ // A trailing separator is a line ending only where it is the file's habit: in a
378
+ // file where most lines end in one. Elsewhere it closes an empty last cell.
379
+ const trailing =
380
+ separator !== " " && content.filter(({ line }) => line.endsWith(separator)).length * 2 > content.length;
381
+ const split = content.map(({ line, number }) => {
382
+ const cells = cellsOf(line, separator);
383
+ if (trailing && line.endsWith(separator) && cells[cells.length - 1] === "") cells.pop();
384
+ return { cells, entry: { line: number, text: line } };
385
+ });
386
+
387
+ const width = commonWidth(split.map(({ cells }) => cells.length));
388
+ const first = split.findIndex(({ cells }) => cells.length === width);
389
+ const unread: TableLine[] = split.slice(0, first).map(({ entry }) => entry);
390
+ const body = split.slice(first);
391
+ const fitting = body.filter(({ cells }) => cells.length === width);
392
+ const header = options.header ?? looksLikeHeader(fitting[0].cells, fitting.slice(1, 21).map(({ cells }) => cells));
393
+
394
+ const rows: string[][] = [];
395
+ const lines: TableLine[] = [];
396
+ body.forEach(({ cells, entry }, index) => {
397
+ if (index === 0 && header) return;
398
+ if (cells.length !== width) {
399
+ unread.push(entry);
400
+ return;
401
+ }
402
+ rows.push(cells);
403
+ lines.push(entry);
404
+ });
405
+
406
+ return { header: header ? body[0].cells : null, rows, lines, width, separator, decimalComma, unread };
407
+ }
408
+
409
+ /**
410
+ * One cell of a {@link TextTable} as a number, read in the table's convention.
411
+ *
412
+ * Not {@link cellNumber}, which reads every comma as a decimal mark — right for a
413
+ * cell typed on its own, wrong for a cell of a file written with decimal points,
414
+ * where `1,234` is a grouped thousand and reading it as 1.234 would be off by a
415
+ * factor of a thousand without a word. Here a comma is a decimal mark only in a
416
+ * table whose convention it is, and a cell with both marks is `NaN` either way: the
417
+ * lexer does not read grouped numbers, it reports them. An empty cell is `NaN` too —
418
+ * a hole in the table, not a zero.
419
+ */
420
+ export function tableNumber(cell: string, decimalComma: boolean): number {
421
+ const trimmed = cell.trim();
422
+ if (trimmed === "") return Number.NaN;
423
+ return Number(decimalComma ? point(trimmed) : trimmed);
424
+ }
425
+
426
+ const SEPARATOR_TRUST: readonly TableSeparator[] = ["\t", ";", ",", " "];
427
+
428
+ /** {@link contentLines} for a table of text: spaces come off the ends, but a TAB
429
+ * stays — in a tab-separated export a leading tab is an empty first cell, and
430
+ * trimming it would slide the whole row one column to the left. */
431
+ function textLines(text: string): { line: string; number: number }[] {
432
+ return text
433
+ .split(/\r?\n/)
434
+ .map((line, index) => ({ line: line.replace(/^ +| +$/g, ""), number: index + 1 }))
435
+ .filter(({ line }) => line.trim() !== "" && !line.trimStart().startsWith("#"));
436
+ }
437
+
438
+ /** The separator most of the first lines are written with — a title block above the
439
+ * table loses the vote to the table under it. A tie goes to the more trusted one. */
440
+ function votedSeparator(lines: { line: string }[]): TableSeparator {
441
+ const votes = new Map<TableSeparator, number>();
442
+ for (const { line } of lines.slice(0, 50)) {
443
+ const found = separatorOf(withoutQuoted(line));
444
+ const named: TableSeparator = typeof found === "string" ? (found as TableSeparator) : " ";
445
+ votes.set(named, (votes.get(named) ?? 0) + 1);
446
+ }
447
+ let best: TableSeparator = SEPARATOR_TRUST[0];
448
+ let bestCount = -1;
449
+ for (const candidate of SEPARATOR_TRUST) {
450
+ const count = votes.get(candidate) ?? 0;
451
+ if (count > bestCount) {
452
+ best = candidate;
453
+ bestCount = count;
454
+ }
455
+ }
456
+ return best;
457
+ }
458
+
459
+ /** A line with its quoted parts emptied, so a separator inside quotes does not vote. */
460
+ const withoutQuoted = (line: string) => line.replace(/"(?:[^"]|"")*"/g, '""');
461
+
462
+ /**
463
+ * One line into cells: trimmed, quotes taken off, empty cells kept in their place.
464
+ * A run of whitespace is one separator and makes no empty cells.
465
+ */
466
+ function cellsOf(line: string, separator: TableSeparator): string[] {
467
+ if (separator === " ") return line.split(/\s+/).filter((field) => field !== "").map(unquote);
468
+ const cells: string[] = [];
469
+ let cell = "";
470
+ let quoted = false;
471
+ let started = false;
472
+ for (let index = 0; index < line.length; index += 1) {
473
+ const char = line[index];
474
+ if (quoted) {
475
+ if (char === '"' && line[index + 1] === '"') {
476
+ cell += '"';
477
+ index += 1;
478
+ } else if (char === '"') {
479
+ quoted = false;
480
+ } else {
481
+ cell += char;
482
+ }
483
+ } else if (char === separator) {
484
+ cells.push(cell.trim());
485
+ cell = "";
486
+ started = false;
487
+ } else if (char === '"' && !started) {
488
+ quoted = true;
489
+ started = true;
490
+ cell = "";
491
+ } else {
492
+ if (char.trim() !== "") started = true;
493
+ cell += char;
494
+ }
495
+ }
496
+ cells.push(cell.trim());
497
+ return cells;
498
+ }
499
+
500
+ /** A whitespace-separated field with its quotes off. */
501
+ function unquote(field: string): string {
502
+ return field.length >= 2 && field.startsWith('"') && field.endsWith('"') ? field.slice(1, -1).replace(/""/g, '"') : field;
503
+ }
504
+
505
+ /** The column count most lines share; a tie goes to the wider table. */
506
+ function commonWidth(counts: number[]): number {
507
+ const tally = new Map<number, number>();
508
+ for (const count of counts) tally.set(count, (tally.get(count) ?? 0) + 1);
509
+ let width = 0;
510
+ let best = 0;
511
+ for (const [count, times] of tally) {
512
+ if (times > best || (times === best && count > width)) {
513
+ width = count;
514
+ best = times;
515
+ }
516
+ }
517
+ return width;
518
+ }
519
+
520
+ /** A cell with a letter in it — a name, not a value. */
521
+ const isWord = (cell: string) => /\p{L}/u.test(cell);
522
+ /** A cell made of digits and the marks numbers, dates and times are written with. */
523
+ const isValue = (cell: string) => /\d/.test(cell) && !isWord(cell);
524
+
525
+ /**
526
+ * Whether a first line names the columns under it. A word over a column of values
527
+ * says so; with no rows to compare against, a line of words alone does.
528
+ */
529
+ function looksLikeHeader(first: string[], below: string[][]): boolean {
530
+ if (!below.length) return first.some((cell) => cell !== "") && first.every((cell) => cell === "" || isWord(cell));
531
+ return first.some((cell, column) => {
532
+ if (!isWord(cell)) return false;
533
+ const values = below.map((row) => row[column]).filter((value) => value !== "");
534
+ return values.length > 0 && values.every(isValue);
535
+ });
536
+ }