nexus-shared 3.2.0 → 4.0.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 (187) hide show
  1. package/CHANGELOG.md +1564 -0
  2. package/README.md +1705 -1123
  3. package/dist/Client.Index.d.ts +14 -0
  4. package/dist/Client.Index.js +14 -0
  5. package/dist/Components/Documents/DocumentFrame.d.ts +50 -0
  6. package/dist/Components/Documents/DocumentFrame.js +83 -0
  7. package/dist/Components/Documents/Menu.d.ts +4 -0
  8. package/dist/Components/Documents/Menu.js +40 -24
  9. package/dist/Components/Documents/PrintDocument.d.ts +95 -0
  10. package/dist/Components/Documents/PrintDocument.js +62 -0
  11. package/dist/Components/Documents/Tabs.d.ts +59 -0
  12. package/dist/Components/Documents/Tabs.js +70 -0
  13. package/dist/Components/Forms/ApiForm.js +19 -2
  14. package/dist/Components/Forms/Crud.d.ts +2 -2
  15. package/dist/Components/Forms/Crud.js +576 -243
  16. package/dist/Components/Forms/CrudActions.d.ts +136 -0
  17. package/dist/Components/Forms/CrudActions.js +184 -0
  18. package/dist/Components/Forms/CrudNarrowPicker.d.ts +31 -0
  19. package/dist/Components/Forms/CrudNarrowPicker.js +180 -0
  20. package/dist/Components/Forms/CrudParts.d.ts +30 -2
  21. package/dist/Components/Forms/CrudParts.js +55 -5
  22. package/dist/Components/Forms/CrudRowForms.d.ts +26 -0
  23. package/dist/Components/Forms/CrudRowForms.js +114 -0
  24. package/dist/Components/Forms/CrudScopes.d.ts +28 -0
  25. package/dist/Components/Forms/CrudScopes.js +76 -0
  26. package/dist/Components/Forms/CrudTree.d.ts +90 -0
  27. package/dist/Components/Forms/CrudTree.js +363 -0
  28. package/dist/Components/Forms/CrudWorkspace.d.ts +63 -0
  29. package/dist/Components/Forms/CrudWorkspace.js +169 -0
  30. package/dist/Components/Forms/ExcelImport.js +3 -3
  31. package/dist/Components/Forms/Form.d.ts +4 -4
  32. package/dist/Components/Forms/Form.js +123 -17
  33. package/dist/Components/Forms/FormLeaveGuard.d.ts +44 -0
  34. package/dist/Components/Forms/FormLeaveGuard.js +65 -0
  35. package/dist/Components/Forms/RecordFlows.d.ts +50 -0
  36. package/dist/Components/Forms/RecordFlows.js +282 -0
  37. package/dist/Components/Forms/SubmitForm.d.ts +37 -2
  38. package/dist/Components/Forms/SubmitForm.js +195 -10
  39. package/dist/Components/Inputs/CheckBox.js +1 -1
  40. package/dist/Components/Inputs/ChoiceField.d.ts +21 -1
  41. package/dist/Components/Inputs/ChoiceField.js +28 -2
  42. package/dist/Components/Inputs/Dropdown.d.ts +13 -0
  43. package/dist/Components/Inputs/Dropdown.js +5 -5
  44. package/dist/Components/Inputs/DropdownParts.d.ts +3 -1
  45. package/dist/Components/Inputs/DropdownParts.js +16 -4
  46. package/dist/Components/Inputs/Field.d.ts +7 -1
  47. package/dist/Components/Inputs/Field.js +2 -2
  48. package/dist/Components/Inputs/GroupForm.d.ts +6 -6
  49. package/dist/Components/Inputs/GroupForm.js +10 -11
  50. package/dist/Components/Inputs/InputRenderer.d.ts +9 -6
  51. package/dist/Components/Inputs/InputRenderer.js +37 -32
  52. package/dist/Components/Inputs/PictureInput.d.ts +41 -0
  53. package/dist/Components/Inputs/PictureInput.js +70 -0
  54. package/dist/Components/Inputs/RadioGroup.d.ts +16 -3
  55. package/dist/Components/Inputs/RadioGroup.js +8 -3
  56. package/dist/Components/Inputs/RowsInput.d.ts +1 -1
  57. package/dist/Components/Inputs/RowsInput.js +18 -3
  58. package/dist/Components/Inputs/TabularForm.d.ts +5 -5
  59. package/dist/Components/Inputs/TabularForm.js +29 -29
  60. package/dist/Components/Inputs/TextArea.d.ts +6 -0
  61. package/dist/Components/Inputs/TextArea.js +11 -2
  62. package/dist/Components/Inputs/TextBoxTypes.d.ts +6 -0
  63. package/dist/Components/Inputs/TextBoxTypes.js +17 -0
  64. package/dist/Components/Inputs/TreeView.d.ts +53 -2
  65. package/dist/Components/Inputs/TreeView.js +147 -13
  66. package/dist/Components/Layouts/MessageBox.d.ts +15 -2
  67. package/dist/Components/Layouts/MessageBox.js +56 -7
  68. package/dist/Components/Layouts/Popup.d.ts +13 -6
  69. package/dist/Components/Layouts/Popup.js +37 -9
  70. package/dist/Components/Layouts/PopupDock.d.ts +3 -1
  71. package/dist/Components/Layouts/PopupDock.js +2 -1
  72. package/dist/Components/Layouts/PopupHost.js +5 -4
  73. package/dist/Components/Viewers/DataTable.js +150 -48
  74. package/dist/Components/Viewers/DataTableFilters.d.ts +1 -1
  75. package/dist/Components/Viewers/DataTableFilters.js +17 -2
  76. package/dist/Components/Viewers/DataTableParts.d.ts +21 -5
  77. package/dist/Components/Viewers/DataTableParts.js +26 -14
  78. package/dist/Components/Viewers/OwnerFiles.d.ts +175 -0
  79. package/dist/Components/Viewers/OwnerFiles.js +589 -0
  80. package/dist/Components/Viewers/QrCodeImage.d.ts +22 -0
  81. package/dist/Components/Viewers/QrCodeImage.js +15 -0
  82. package/dist/Components/Viewers/RecordCards.d.ts +16 -0
  83. package/dist/Components/Viewers/RecordCards.js +509 -0
  84. package/dist/Helpers/BusyHooks.d.ts +6 -0
  85. package/dist/Helpers/BusyHooks.js +39 -0
  86. package/dist/Helpers/CrudBackend.d.ts +20 -2
  87. package/dist/Helpers/CrudBackend.js +46 -2
  88. package/dist/Helpers/CrudColumns.d.ts +53 -6
  89. package/dist/Helpers/CrudColumns.js +98 -7
  90. package/dist/Helpers/CrudDetailsTabs.d.ts +58 -0
  91. package/dist/Helpers/CrudDetailsTabs.js +126 -0
  92. package/dist/Helpers/CrudHelpers.d.ts +81 -2
  93. package/dist/Helpers/CrudHelpers.js +195 -0
  94. package/dist/Helpers/CrudNarrowPick.d.ts +83 -0
  95. package/dist/Helpers/CrudNarrowPick.js +157 -0
  96. package/dist/Helpers/CrudRowForms.d.ts +15 -0
  97. package/dist/Helpers/CrudRowForms.js +43 -0
  98. package/dist/Helpers/CrudScopes.d.ts +32 -0
  99. package/dist/Helpers/CrudScopes.js +79 -0
  100. package/dist/Helpers/DropdownHelpers.d.ts +3 -1
  101. package/dist/Helpers/DropdownHelpers.js +6 -2
  102. package/dist/Helpers/ExcelHelpers.d.ts +1 -1
  103. package/dist/Helpers/ExcelHelpers.js +12 -11
  104. package/dist/Helpers/FileAdapter.d.ts +78 -0
  105. package/dist/Helpers/FileAdapter.js +102 -0
  106. package/dist/Helpers/FilePrimaries.d.ts +48 -0
  107. package/dist/Helpers/FilePrimaries.js +138 -0
  108. package/dist/Helpers/FormConfirm.d.ts +32 -0
  109. package/dist/Helpers/FormConfirm.js +40 -0
  110. package/dist/Helpers/FormStore.d.ts +90 -5
  111. package/dist/Helpers/FormStore.js +318 -22
  112. package/dist/Helpers/InputConditions.d.ts +50 -0
  113. package/dist/Helpers/InputConditions.js +168 -0
  114. package/dist/Helpers/InputMemory.d.ts +106 -0
  115. package/dist/Helpers/InputMemory.js +385 -0
  116. package/dist/Helpers/InputNarrowing.d.ts +18 -0
  117. package/dist/Helpers/InputNarrowing.js +67 -0
  118. package/dist/Helpers/InputParamsHelpers.d.ts +44 -1
  119. package/dist/Helpers/InputParamsHelpers.js +157 -74
  120. package/dist/Helpers/NexusDriveAdapter.d.ts +55 -0
  121. package/dist/Helpers/NexusDriveAdapter.js +207 -0
  122. package/dist/Helpers/NumberHelpers.d.ts +95 -0
  123. package/dist/Helpers/NumberHelpers.js +126 -0
  124. package/dist/Helpers/Permissions.js +2 -1
  125. package/dist/Helpers/QrCode.d.ts +26 -0
  126. package/dist/Helpers/QrCode.js +463 -0
  127. package/dist/Helpers/RecordCards.d.ts +205 -0
  128. package/dist/Helpers/RecordCards.js +378 -0
  129. package/dist/Helpers/RequestCache.d.ts +14 -2
  130. package/dist/Helpers/RequestCache.js +22 -3
  131. package/dist/Helpers/RowsStore.d.ts +37 -2
  132. package/dist/Helpers/RowsStore.js +128 -10
  133. package/dist/Helpers/ServerPreview.d.ts +69 -0
  134. package/dist/Helpers/ServerPreview.js +99 -0
  135. package/dist/Helpers/TableColumns.js +4 -3
  136. package/dist/Helpers/TableExport.d.ts +7 -1
  137. package/dist/Helpers/TableExport.js +9 -3
  138. package/dist/Helpers/TableFilters.d.ts +1 -1
  139. package/dist/Helpers/TableFilters.js +24 -23
  140. package/dist/Helpers/TableHelpers.d.ts +23 -2
  141. package/dist/Helpers/TableHelpers.js +51 -10
  142. package/dist/Helpers/TableJoins.d.ts +13 -0
  143. package/dist/Helpers/TableJoins.js +34 -0
  144. package/dist/Helpers/TableStore.d.ts +21 -1
  145. package/dist/Helpers/TableStore.js +64 -7
  146. package/dist/Helpers/TreeStore.d.ts +81 -0
  147. package/dist/Helpers/TreeStore.js +365 -0
  148. package/dist/Interfaces/CrudInterfaces.d.ts +465 -5
  149. package/dist/Interfaces/FileInterfaces.d.ts +162 -0
  150. package/dist/Interfaces/FileInterfaces.js +4 -0
  151. package/dist/Interfaces/FormInterfaces.d.ts +391 -30
  152. package/dist/Interfaces/FormInterfaces.js +79 -1
  153. package/dist/Interfaces/InputInterfaces.d.ts +15 -3
  154. package/dist/Interfaces/RecordInterfaces.d.ts +367 -0
  155. package/dist/Interfaces/RecordInterfaces.js +1 -0
  156. package/dist/Interfaces/TableInterfaces.d.ts +74 -1
  157. package/dist/Services/ExcelApi.js +5 -0
  158. package/dist/Services/FileApi.d.ts +13 -0
  159. package/dist/Services/FileApi.js +29 -0
  160. package/dist/Services/ImageEditorLoader.d.ts +14 -0
  161. package/dist/Services/ImageEditorLoader.js +51 -0
  162. package/dist/Services/JoinSources.d.ts +37 -5
  163. package/dist/Services/JoinSources.js +90 -14
  164. package/dist/Services/ModuleImport.d.ts +7 -0
  165. package/dist/Services/ModuleImport.js +22 -0
  166. package/dist/Shared.Index.d.ts +16 -0
  167. package/dist/Shared.Index.js +16 -0
  168. package/package.json +7 -2
  169. package/src/Styles/Nexus.Avatar.css +6 -5
  170. package/src/Styles/Nexus.Button.css +10 -4
  171. package/src/Styles/Nexus.Choice.css +16 -0
  172. package/src/Styles/Nexus.Crud.css +383 -9
  173. package/src/Styles/Nexus.Dropdown.css +8 -0
  174. package/src/Styles/Nexus.Files.css +410 -0
  175. package/src/Styles/Nexus.Form.css +5 -2
  176. package/src/Styles/Nexus.Index.css +4 -0
  177. package/src/Styles/Nexus.Input.css +42 -0
  178. package/src/Styles/Nexus.Menu.css +10 -1
  179. package/src/Styles/Nexus.Popup.css +77 -1
  180. package/src/Styles/Nexus.Print.css +288 -0
  181. package/src/Styles/Nexus.QrCode.css +12 -0
  182. package/src/Styles/Nexus.Record.css +430 -0
  183. package/src/Styles/Nexus.Tab.Buttons.css +87 -22
  184. package/src/Styles/Nexus.Table.css +67 -5
  185. package/src/Styles/Nexus.Themes.css +12 -6
  186. package/src/Styles/Nexus.Tokens.css +8 -3
  187. package/src/Styles/Nexus.Tree.css +31 -1
@@ -0,0 +1,207 @@
1
+ import { readFetchResponse } from "./ApiResponses.js";
2
+ import { getApiModules } from "./ApiModules.js";
3
+ import { FileAdapter, fileOwnerKey, pictureLimits } from "./FileAdapter.js";
4
+ import { NexusModule } from "../Interfaces/ApiInterfaces.js";
5
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
6
+ const text = (value) => (typeof value === "string" && value.trim() ? value : null);
7
+ const count = (value, fallback = 0) => (typeof value === "number" && Number.isFinite(value) ? value : fallback);
8
+ /** Drive's `EFileKinds` flags, and the MIME type of each. */
9
+ const NEXUS_FILE_KINDS = [
10
+ [1, "image/png"],
11
+ [2, "image/jpeg"],
12
+ [4, "image/webp"],
13
+ [8, "application/pdf"],
14
+ ];
15
+ /** The Drive refusal that says a ticket ran out (DRV_X011): asked again once, with a fresh ticket. */
16
+ const TICKET_RAN_OUT = "DRV_X011";
17
+ /**
18
+ * Why an owner's files are read only when its module refuses a write ticket with a 404 but a read ticket finds it: the
19
+ * owner is not in use now (pending, ended, in the trash or deleted). Its files stay where they are.
20
+ */
21
+ export const OWNER_NOT_IN_USE_REASON = "This record is not in use now, so its files can be seen but not changed.";
22
+ /** A ticket is used until this long before it runs out, so a slow upload never carries one that expires on the way. */
23
+ const TICKET_MARGIN_MS = 60_000;
24
+ /**
25
+ * The Nexus Drive (`Nexus.Drives`, `App/Api/File/...`) behind the owner modules' tickets (`App/Api/Files/...`).
26
+ * Register another with `defineFileAdapter` to keep files elsewhere.
27
+ */
28
+ export class NexusDriveAdapter extends FileAdapter {
29
+ drive;
30
+ editorPointer;
31
+ tickets = new Map();
32
+ client;
33
+ fetcher;
34
+ constructor(options) {
35
+ super();
36
+ this.client = options.client;
37
+ this.fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
38
+ this.drive = options.drive ?? NexusModule.Drive;
39
+ this.editorPointer = options.editorPointer ?? "nexus-image-editor.txt";
40
+ }
41
+ driveEndpoint(method, path, extras = {}) {
42
+ return { module: this.drive, path, method, ...extras };
43
+ }
44
+ /** A write ticket for the owner, kept while it is good. */
45
+ async writeTicket(owner, signal, fresh = false) {
46
+ const key = fileOwnerKey(owner);
47
+ const kept = this.tickets.get(key);
48
+ if (!fresh && kept && kept.until > Date.now())
49
+ return { ok: true, result: kept.ticket };
50
+ const answer = await this.client.send({ module: owner.module, path: "Files/Ticket/{category}/{ownerId}", method: "POST" }, {
51
+ params: { category: owner.category, ownerId: owner.ownerId },
52
+ signal,
53
+ toast: false,
54
+ });
55
+ const ticket = isRecord(answer.result) ? text(answer.result.ticket) : null;
56
+ if (!answer.ok || !ticket) {
57
+ this.tickets.delete(key);
58
+ return { ...answer, ok: false, result: undefined };
59
+ }
60
+ const expires = isRecord(answer.result) ? Date.parse(String(answer.result.expiresAt ?? "")) : NaN;
61
+ // Kept only when the backend's end is ahead of this clock by more than the margin; a clock that is ahead asks each time.
62
+ if (Number.isFinite(expires) && expires - TICKET_MARGIN_MS > Date.now())
63
+ this.tickets.set(key, { ticket, until: expires - TICKET_MARGIN_MS });
64
+ return { ...answer, result: ticket };
65
+ }
66
+ /** Calls Drive with the owner's write ticket, asking for a fresh one once when Drive says it ran out. */
67
+ async withTicket(owner, options, call) {
68
+ for (const fresh of [false, true]) {
69
+ const ticket = await this.writeTicket(owner, options.signal, fresh);
70
+ if (!ticket.ok)
71
+ return ticket;
72
+ const answer = await call(ticket.result);
73
+ if (answer.ok || answer.messageCode !== TICKET_RAN_OUT || fresh)
74
+ return answer;
75
+ }
76
+ return { ok: false };
77
+ }
78
+ /** A read ticket for several owners of one category. */
79
+ async readTicket(module, category, ownerIds, signal) {
80
+ const answer = await this.client.send({ module, path: "Files/Owners/{category}", method: "POST" }, {
81
+ params: { category },
82
+ body: { ownerIds },
83
+ signal,
84
+ toast: false,
85
+ });
86
+ const ticket = isRecord(answer.result) ? text(answer.result.ticket) : null;
87
+ return answer.ok && ticket ? { ...answer, result: ticket } : { ...answer, ok: false, result: undefined };
88
+ }
89
+ async list(owner, options = {}) {
90
+ const read = (ticket) => this.client.send(this.driveEndpoint("POST", "File/List"), { body: { ticket }, signal: options.signal, toast: false });
91
+ let answer = await this.withTicket(owner, options, read);
92
+ // No write ticket for this person (a key they do not hold, an owner they may only see): their files, read only, and
93
+ // the refusal's words as the reason. A 404 is an owner not in use - pending, ended, in the trash or deleted - that a
94
+ // read ticket still finds: "The record was not found." under its files would be untrue, so it says what it is.
95
+ let refused;
96
+ if (!answer.ok && (answer.status === 403 || answer.status === 404) && owner.ownerId !== "me" && !answer.aborted) {
97
+ refused = answer.status === 404 ? OWNER_NOT_IN_USE_REASON : answer.message;
98
+ const ticket = await this.readTicket(owner.module, owner.category, [owner.ownerId], options.signal);
99
+ if (ticket.ok)
100
+ answer = await read(ticket.result);
101
+ }
102
+ if (!answer.ok)
103
+ return answer;
104
+ const files = readOwnerFiles(answer.result);
105
+ if (refused && !files.category.canWrite)
106
+ files.readOnlyReason = refused;
107
+ return { ...answer, result: files };
108
+ }
109
+ async primaries(module, category, ownerIds, options = {}) {
110
+ if (ownerIds.length === 0)
111
+ return { ok: true, result: [] };
112
+ const ticket = await this.readTicket(module, category, ownerIds, options.signal);
113
+ if (!ticket.ok)
114
+ return { ...ticket, result: undefined };
115
+ const answer = await this.client.send(this.driveEndpoint("POST", "File/Primaries"), { body: { ticket: ticket.result }, signal: options.signal, toast: false });
116
+ if (!answer.ok)
117
+ return answer;
118
+ const files = ownerIds.map(() => null);
119
+ for (const item of Array.isArray(answer.result) ? answer.result : []) {
120
+ if (!isRecord(item))
121
+ continue;
122
+ const index = count(item.ownerIndex, -1);
123
+ if (index >= 0 && index < files.length)
124
+ files[index] = readStoredFile(item.file);
125
+ }
126
+ return { ...answer, result: files };
127
+ }
128
+ upload(owner, upload, options = {}) {
129
+ return this.withTicket(owner, options, async (ticket) => {
130
+ const form = new FormData();
131
+ form.append("ticket", ticket);
132
+ form.append("file", upload.file, upload.name);
133
+ if (upload.thumbnail)
134
+ form.append("thumbnail", upload.thumbnail, `thumbnail-${upload.name}`);
135
+ const answer = await this.client.send(this.driveEndpoint("POST", "File/Upload", { bodyType: "form-data" }), { body: form, signal: options.signal, toast: false, timeout: 120_000 });
136
+ const file = answer.ok ? readStoredFile(answer.result) : null;
137
+ return answer.ok && file ? { ...answer, result: file } : { ...answer, ok: answer.ok && file !== null, result: undefined };
138
+ });
139
+ }
140
+ setPrimary(owner, fileId, options = {}) {
141
+ return this.withTicket(owner, options, ticket => this.client.send(this.driveEndpoint("PUT", "File/SetPrimary"), { body: { ticket, id: fileId }, signal: options.signal, toast: false }));
142
+ }
143
+ rename(owner, fileId, name, options = {}) {
144
+ return this.withTicket(owner, options, ticket => this.client.send(this.driveEndpoint("PUT", "File/Rename"), { body: { ticket, id: fileId, fileDisplayName: name }, signal: options.signal, toast: false }));
145
+ }
146
+ remove(owner, fileId, options = {}) {
147
+ return this.withTicket(owner, options, ticket => this.client.send(this.driveEndpoint("DELETE", "File/Delete"), { body: { ticket, id: fileId }, signal: options.signal, toast: false }));
148
+ }
149
+ fileUrl(file, copy) {
150
+ return getApiModules().browserApiUrl(this.driveEndpoint("GET", "File/View/{id}/{size}"), { params: { id: file.id, size: copy === "thumbnail" ? "Thumbnail" : "Original" } });
151
+ }
152
+ /** Reads Drive's pointer file (`Assets/File/nexus-image-editor.txt`), which names the versioned module beside it. */
153
+ async editorModuleUrl(signal) {
154
+ const asset = (file) => getApiModules().browserApiUrl(this.driveEndpoint("GET", "Assets/File/{file}"), { params: { file } });
155
+ const response = await this.fetcher(asset(this.editorPointer), { signal, credentials: "same-origin", cache: "no-cache" });
156
+ if (!response.ok) {
157
+ const answer = await readFetchResponse(response);
158
+ throw new Error(answer.message ?? "The image editor could not be loaded.");
159
+ }
160
+ const name = (await response.text()).trim();
161
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*\.m?js$/.test(name))
162
+ throw new Error("The image editor's address on the server is not valid.");
163
+ return new URL(asset(name), globalThis.location?.href ?? "http://localhost/").href;
164
+ }
165
+ }
166
+ /** Reads one of Drive's `FileResponseContract`s; null when it is not one. */
167
+ export function readStoredFile(value) {
168
+ if (!isRecord(value))
169
+ return null;
170
+ const id = text(value.id);
171
+ if (!id)
172
+ return null;
173
+ return {
174
+ id,
175
+ name: text(value.fileDisplayName) ?? "File",
176
+ contentType: text(value.contentType) ?? "application/octet-stream",
177
+ size: count(value.fileSize),
178
+ width: typeof value.width === "number" ? value.width : null,
179
+ height: typeof value.height === "number" ? value.height : null,
180
+ hasThumbnail: value.hasThumbnail === true,
181
+ isPrimary: value.isPrimary === true,
182
+ createdAt: text(value.creationTime),
183
+ };
184
+ }
185
+ /** Reads Drive's `FileListResponseContract`. */
186
+ export function readOwnerFiles(value) {
187
+ const answer = isRecord(value) ? value : {};
188
+ const kinds = count(answer.fileKinds);
189
+ const maxBytes = count(answer.maxFileBytes);
190
+ // A picture's limits, Drive's settings (`Nexus:Drive:Pictures`); an older Drive says none, and the defaults apply, so
191
+ // every picture is still made small before it is sent.
192
+ const pictures = pictureLimits({ maxBytes, pictureMaxSide: count(answer.pictureMaxSide), pictureQuality: count(answer.pictureQuality), pictureMaxBytes: count(answer.pictureMaxBytes) });
193
+ const category = {
194
+ code: text(answer.categoryCode) ?? "",
195
+ name: text(answer.displayName) ?? "Files",
196
+ types: NEXUS_FILE_KINDS.filter(([flag]) => (kinds & flag) !== 0).map(([, type]) => type),
197
+ maxBytes,
198
+ maxCount: count(answer.maxFileCount),
199
+ thumbnailSize: count(answer.thumbnailSize),
200
+ canWrite: answer.canWrite === true,
201
+ pictureMaxSide: pictures.maxSide,
202
+ pictureQuality: pictures.quality,
203
+ pictureMaxBytes: pictures.maxBytes,
204
+ };
205
+ const files = (Array.isArray(answer.files) ? answer.files : []).map(readStoredFile).filter((file) => file !== null);
206
+ return { category, files };
207
+ }
@@ -28,6 +28,25 @@ export declare function getNumberSymbols(locale?: string): NumberSymbols;
28
28
  * Returns "" for `null`, `undefined`, and `NaN`. Digits are always 0–9, and the minus sign is "-", so the text can be typed back.
29
29
  */
30
30
  export declare function formatNumber(value: number | null | undefined, options?: NumberFormatOptions): string;
31
+ /** How `formatMoney` writes an amount. */
32
+ export interface MoneyFormatOptions {
33
+ /** Written before the figures with a space: "Rs." gives "Rs. 1,250.00". None: the figures alone. */
34
+ currency?: string | null;
35
+ /** Digits after the decimal point, always shown. Default 2. */
36
+ decimals?: number;
37
+ /** Locale of the separators, as `formatNumber` takes it (`useNumberLocale()` for the person's). Default `en-US`. */
38
+ locale?: string;
39
+ /** What an empty amount (`null`, `undefined`, `NaN`) writes. Default "": a `0` amount is written as one. */
40
+ empty?: string;
41
+ }
42
+ /**
43
+ * Money as a company writes it: its currency, then the amount grouped with a fixed number of decimals, the same in a
44
+ * table's `money` column (`DataTableProps.currency`), a dashboard's figures and a printed receipt.
45
+ *
46
+ * @example formatMoney(1250, { currency: "Rs." }) // "Rs. 1,250.00"
47
+ * @example formatMoney(null, { currency: "Rs.", empty: "-" }) // "-"
48
+ */
49
+ export declare function formatMoney(value: number | null | undefined, options?: MoneyFormatOptions): string;
31
50
  /**
32
51
  * Reads a number written in the locale's format: "1,250.50" gives 1250.5, and in `de-DE` "1.250,50" does.
33
52
  * Returns `null` for empty text (also a lone minus sign or decimal separator) and `NaN` for text that is not a number.
@@ -75,3 +94,79 @@ export interface StepOptions {
75
94
  * step 1 goes up to 3, and stay within `min` and `max`. An empty field starts at 0, or at the limit nearest to 0.
76
95
  */
77
96
  export declare function stepNumber(value: number | null, direction: 1 | -1, options?: StepOptions): number;
97
+ /** The figures of a price (`priceAmounts`). Each may be missing. */
98
+ export interface PriceAmountsInput {
99
+ /** The price of one. Missing: 0. */
100
+ price?: number | null;
101
+ /** How many. Missing: 1. */
102
+ quantity?: number | null;
103
+ /** The discount off the amount, in percent: `5` for 5 %. Missing: 0. */
104
+ discountPercentage?: number | null;
105
+ /** The VAT on what is left after the discount, in percent: `13` for 13 %. Missing: 0. */
106
+ vatPercentage?: number | null;
107
+ }
108
+ /** What a price comes to (`priceAmounts`), each figure at the money's places. */
109
+ export interface PriceAmounts {
110
+ /** The price times the quantity. */
111
+ amount: number;
112
+ /** The discount off the amount. */
113
+ discountAmount: number;
114
+ /** The VAT on the amount less the discount. */
115
+ vatAmount: number;
116
+ /** The amount less the discount, plus the VAT: what is paid. */
117
+ netAmount: number;
118
+ }
119
+ /**
120
+ * What a price comes to, by the Nexus server's own rule (`SubscriberRenewals.Price`, 2026-06-20's `FN_CalculateAmount`):
121
+ * the amount is the price times the quantity; the discount comes off it; the VAT is on what is left; each rounded to
122
+ * `places` (default 2), half away from zero, so the lines add up to the net amount exactly as a receipt prints them. The
123
+ * sums are done on exact decimals, never on floating-point numbers, so a browser shows the server's figure to the paisa.
124
+ * A missing, null or NaN figure counts as 0, and a missing quantity as 1.
125
+ *
126
+ * @example priceAmounts({ price: 1000, discountPercentage: 5, vatPercentage: 13 }) // { amount: 1000, discountAmount: 50, vatAmount: 123.5, netAmount: 1073.5 }
127
+ * @example priceAmounts({ price: 500, quantity: 3, discountPercentage: 10, vatPercentage: 13 }).netAmount // 1525.5
128
+ */
129
+ export declare function priceAmounts(input: PriceAmountsInput, places?: number): PriceAmounts;
130
+ /** The fields a row of money keeps its figures in (`priceLadder`). Defaults: the server's `CounterAmounts`. */
131
+ export interface PriceLadderKeys {
132
+ /** The price of one. Default `unitPrice`. */
133
+ rate?: string;
134
+ /** The price times the quantity. Default `amount`. */
135
+ amount?: string;
136
+ /** Default `discountPercentage`. */
137
+ discountPercentage?: string;
138
+ /** The discount off the amount. Default `discountAmount`. */
139
+ discount?: string;
140
+ /** Default `vatPercentage`. */
141
+ vatPercentage?: string;
142
+ /** The VAT on what is left. Default `vatAmount`. */
143
+ vat?: string;
144
+ /** What is paid. Default `netAmount`. */
145
+ total?: string;
146
+ }
147
+ /** One line of a money ladder (`priceLadder`): the row's field it reads, its words, and its figure. */
148
+ export interface PriceLadderLine {
149
+ /** The row's field: `"vatAmount"`. */
150
+ key: string;
151
+ /** `"VAT (13%)"`. */
152
+ label: string;
153
+ value: number;
154
+ }
155
+ /** How `priceLadder` reads a row and words its lines. */
156
+ export interface PriceLadderOptions {
157
+ /** The row's fields, where they are not the server's `CounterAmounts` names. */
158
+ keys?: PriceLadderKeys;
159
+ /** The locale a percentage is written in: the person's (`useNumberLocale()`). Default `en-US`. */
160
+ locale?: string;
161
+ /** The total's words. Default "Calculated amount". */
162
+ totalLabel?: string;
163
+ }
164
+ /**
165
+ * A receipt's money ladder as lines any page can draw: **Rate** (left out when the row has none), **Amount**,
166
+ * **Discount (5%)** and **VAT (13%)** (each left out at 0, as a receipt leaves them out), then the total, "Calculated
167
+ * amount". A percentage is written with up to 4 places and no trailing zeros (5, 2.5, 3.3333) in `locale`, so a
168
+ * person whose decimal mark is a comma reads "Discount (2,5%)". The figures are the row's own, never worked out here.
169
+ *
170
+ * @example priceLadder(line, { locale: useNumberLocale() }).map(item => `${item.label} ${formatMoney(item.value, { locale })}`)
171
+ */
172
+ export declare function priceLadder(row: Readonly<Record<string, unknown>>, options?: PriceLadderOptions): PriceLadderLine[];
@@ -84,6 +84,21 @@ export function formatNumber(value, options = {}) {
84
84
  // The base locales write "," between groups and "." before decimals; a key's own marks take their places.
85
85
  return custom ? text.replace(/[,.]/g, mark => (mark === "," ? custom.group : custom.decimal)) : text;
86
86
  }
87
+ /**
88
+ * Money as a company writes it: its currency, then the amount grouped with a fixed number of decimals, the same in a
89
+ * table's `money` column (`DataTableProps.currency`), a dashboard's figures and a printed receipt.
90
+ *
91
+ * @example formatMoney(1250, { currency: "Rs." }) // "Rs. 1,250.00"
92
+ * @example formatMoney(null, { currency: "Rs.", empty: "-" }) // "-"
93
+ */
94
+ export function formatMoney(value, options = {}) {
95
+ const { currency, decimals = 2, locale, empty = "" } = options;
96
+ if (value === null || value === undefined || !Number.isFinite(value))
97
+ return empty;
98
+ const figures = formatNumber(value, { decimals, fixedDecimals: true, grouping: true, locale });
99
+ const before = (currency ?? "").trim();
100
+ return before ? `${before} ${figures}` : figures;
101
+ }
87
102
  /**
88
103
  * Reads a number written in the locale's format: "1,250.50" gives 1250.5, and in `de-DE` "1.250,50" does.
89
104
  * Returns `null` for empty text (also a lone minus sign or decimal separator) and `NaN` for text that is not a number.
@@ -238,3 +253,114 @@ export function stepNumber(value, direction, options = {}) {
238
253
  function escapeRegExp(text) {
239
254
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
240
255
  }
256
+ const BIG_ZERO = BigInt(0);
257
+ const BIG_ONE = BigInt(1);
258
+ const BIG_TWO = BigInt(2);
259
+ const BIG_TEN = BigInt(10);
260
+ /** A number as the exact decimal its shortest text writes (1.005 is 1.005, never 1.00499…): a figure that is not finite is `missing`. */
261
+ function exactDecimal(value, missing) {
262
+ const figure = typeof value === "number" && Number.isFinite(value) ? value : missing;
263
+ const [mantissa = "0", exponent = "0"] = String(figure).toLowerCase().split("e");
264
+ const negative = mantissa.startsWith("-");
265
+ const [whole = "0", fraction = ""] = mantissa.replace("-", "").split(".");
266
+ let units = BigInt(`${whole}${fraction}`);
267
+ let scale = fraction.length - Number(exponent);
268
+ if (scale < 0) {
269
+ units *= BIG_TEN ** BigInt(-scale);
270
+ scale = 0;
271
+ }
272
+ return { units: negative ? -units : units, scale };
273
+ }
274
+ /** Rounded to `places`, half away from zero (0.005 is 0.01, -0.005 is -0.01), as .NET's `MidpointRounding.AwayFromZero`. */
275
+ function roundExact(value, places) {
276
+ if (value.scale <= places)
277
+ return { units: value.units * BIG_TEN ** BigInt(places - value.scale), scale: places };
278
+ const divisor = BIG_TEN ** BigInt(value.scale - places);
279
+ const negative = value.units < BIG_ZERO;
280
+ const size = negative ? -value.units : value.units;
281
+ let units = size / divisor;
282
+ if ((size % divisor) * BIG_TWO >= divisor)
283
+ units += BIG_ONE;
284
+ return { units: negative ? -units : units, scale: places };
285
+ }
286
+ /** The exact decimal as a number: through its text, so 1073.50 is the number 1073.5 exactly as typed. */
287
+ function exactNumber(value) {
288
+ const negative = value.units < BIG_ZERO;
289
+ const digits = (negative ? -value.units : value.units).toString().padStart(value.scale + 1, "0");
290
+ const text = value.scale ? `${digits.slice(0, -value.scale)}.${digits.slice(-value.scale)}` : digits;
291
+ return Number(`${negative ? "-" : ""}${text}`) || 0;
292
+ }
293
+ /**
294
+ * What a price comes to, by the Nexus server's own rule (`SubscriberRenewals.Price`, 2026-06-20's `FN_CalculateAmount`):
295
+ * the amount is the price times the quantity; the discount comes off it; the VAT is on what is left; each rounded to
296
+ * `places` (default 2), half away from zero, so the lines add up to the net amount exactly as a receipt prints them. The
297
+ * sums are done on exact decimals, never on floating-point numbers, so a browser shows the server's figure to the paisa.
298
+ * A missing, null or NaN figure counts as 0, and a missing quantity as 1.
299
+ *
300
+ * @example priceAmounts({ price: 1000, discountPercentage: 5, vatPercentage: 13 }) // { amount: 1000, discountAmount: 50, vatAmount: 123.5, netAmount: 1073.5 }
301
+ * @example priceAmounts({ price: 500, quantity: 3, discountPercentage: 10, vatPercentage: 13 }).netAmount // 1525.5
302
+ */
303
+ export function priceAmounts(input, places = 2) {
304
+ const digits = Math.max(0, Math.min(MAX_NUMBER_DIGITS, Math.trunc(places) || 0));
305
+ const price = exactDecimal(input.price, 0);
306
+ const quantity = exactDecimal(input.quantity, 1);
307
+ const discountRate = exactDecimal(input.discountPercentage, 0);
308
+ const vatRate = exactDecimal(input.vatPercentage, 0);
309
+ // A percent of an exact decimal: the product, two places further down.
310
+ const percentOf = (base, rate) => ({ units: base.units * rate.units, scale: base.scale + rate.scale + 2 });
311
+ const amount = roundExact({ units: price.units * quantity.units, scale: price.scale + quantity.scale }, digits);
312
+ const discount = roundExact(percentOf(amount, discountRate), digits);
313
+ const vat = roundExact(percentOf({ units: amount.units - discount.units, scale: digits }, vatRate), digits);
314
+ const net = { units: amount.units - discount.units + vat.units, scale: digits };
315
+ return { amount: exactNumber(amount), discountAmount: exactNumber(discount), vatAmount: exactNumber(vat), netAmount: exactNumber(net) };
316
+ }
317
+ /** Most places a percentage keeps (12.3456 %), as a price list keeps it; trailing zeros are not written. */
318
+ const PERCENT_PLACES = 4;
319
+ /** A row's figure as a number: a number or the text of one; anything else is missing. */
320
+ function figureOf(row, key) {
321
+ const value = row[key];
322
+ if (typeof value === "number")
323
+ return Number.isFinite(value) ? value : null;
324
+ if (typeof value === "string" && value.trim() !== "") {
325
+ const number = Number(value);
326
+ return Number.isFinite(number) ? number : null;
327
+ }
328
+ return null;
329
+ }
330
+ /**
331
+ * A receipt's money ladder as lines any page can draw: **Rate** (left out when the row has none), **Amount**,
332
+ * **Discount (5%)** and **VAT (13%)** (each left out at 0, as a receipt leaves them out), then the total, "Calculated
333
+ * amount". A percentage is written with up to 4 places and no trailing zeros (5, 2.5, 3.3333) in `locale`, so a
334
+ * person whose decimal mark is a comma reads "Discount (2,5%)". The figures are the row's own, never worked out here.
335
+ *
336
+ * @example priceLadder(line, { locale: useNumberLocale() }).map(item => `${item.label} ${formatMoney(item.value, { locale })}`)
337
+ */
338
+ export function priceLadder(row, options = {}) {
339
+ const { keys = {}, locale, totalLabel = "Calculated amount" } = options;
340
+ const key = {
341
+ rate: keys.rate ?? "unitPrice",
342
+ amount: keys.amount ?? "amount",
343
+ discountPercentage: keys.discountPercentage ?? "discountPercentage",
344
+ discount: keys.discount ?? "discountAmount",
345
+ vatPercentage: keys.vatPercentage ?? "vatPercentage",
346
+ vat: keys.vat ?? "vatAmount",
347
+ total: keys.total ?? "netAmount",
348
+ };
349
+ const percent = (field, words) => {
350
+ const rate = figureOf(row, field);
351
+ return rate === null ? words : `${words} (${formatNumber(rate, { decimals: PERCENT_PLACES, locale })}%)`;
352
+ };
353
+ const lines = [];
354
+ const rate = figureOf(row, key.rate);
355
+ if (rate !== null)
356
+ lines.push({ key: key.rate, label: "Rate", value: rate });
357
+ lines.push({ key: key.amount, label: "Amount", value: figureOf(row, key.amount) ?? 0 });
358
+ const discount = figureOf(row, key.discount);
359
+ if (discount)
360
+ lines.push({ key: key.discount, label: percent(key.discountPercentage, "Discount"), value: discount });
361
+ const vat = figureOf(row, key.vat);
362
+ if (vat)
363
+ lines.push({ key: key.vat, label: percent(key.vatPercentage, "VAT"), value: vat });
364
+ lines.push({ key: key.total, label: totalLabel, value: figureOf(row, key.total) ?? 0 });
365
+ return lines;
366
+ }
@@ -1,6 +1,7 @@
1
1
  /*
2
2
  * Permissions: may the user call this endpoint? Components ask before they show a button or a list (the CRUD's Add,
3
- * Delete, and its Trash list), so a user sees only what they may do; the server still checks every call.
3
+ * Delete, and its Trash list, a table's spreadsheet items), so a user sees only what they may do; the server still
4
+ * checks every call.
4
5
  *
5
6
  * `Permissions` is the interface an app implements, for its own backend's rules; `NexusPermissions` reads the Nexus
6
7
  * backend's access actions. Register one with `definePermissions`. Until then everything is allowed, which suits an
@@ -0,0 +1,26 @@
1
+ export type QrLevel = "L" | "M" | "Q" | "H";
2
+ /** A finished QR code. Modules are addressed by column x and row y, from the top-left corner. */
3
+ export interface QrCode {
4
+ /** 1 to 40: the code is 17 + 4 x version modules a side. */
5
+ readonly version: number;
6
+ /** Modules a side, without the quiet zone. */
7
+ readonly size: number;
8
+ readonly level: QrLevel;
9
+ /** The mask pattern, 0 to 7, that scored best. */
10
+ readonly mask: number;
11
+ /** Whether the module at column x, row y is dark. Outside the code, in its quiet zone, every module is light. */
12
+ isDark(x: number, y: number): boolean;
13
+ }
14
+ /**
15
+ * The text as a QR code at the level asked (M unless told), in the smallest version that holds it. Throws when the
16
+ * text is too long even for version 40: 2,953 bytes of UTF-8 at L, 2,331 at M, 1,663 at Q, 1,273 at H.
17
+ */
18
+ export declare function encodeQr(text: string, level?: QrLevel): QrCode;
19
+ /**
20
+ * The code as one SVG path in a square viewBox, with `border` light modules around it (4, the quiet zone the standard
21
+ * asks for). Each run of dark modules along a row is one rectangle.
22
+ */
23
+ export declare function qrPath(code: QrCode, border?: number): {
24
+ viewBox: string;
25
+ d: string;
26
+ };