@brftech/filex-core 0.42.1 → 0.43.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 (233) hide show
  1. package/README.md +14 -2
  2. package/dist/ArchiveViewer-BqU761Wk.js +149 -0
  3. package/dist/ArchiveViewer-BqU761Wk.js.map +1 -0
  4. package/dist/CsvViewer-ByHVK5ga.js +172 -0
  5. package/dist/CsvViewer-ByHVK5ga.js.map +1 -0
  6. package/dist/{DrawioViewer-BuhZOAx6.js → DrawioViewer-Bgrj8-zG.js} +45 -44
  7. package/dist/DrawioViewer-Bgrj8-zG.js.map +1 -0
  8. package/dist/{EpubViewer-DS3mb3k8.js → EpubViewer-DJwUKlHW.js} +50 -50
  9. package/dist/EpubViewer-DJwUKlHW.js.map +1 -0
  10. package/dist/IpynbViewer-Bz0eJ4NS.js +184 -0
  11. package/dist/IpynbViewer-Bz0eJ4NS.js.map +1 -0
  12. package/dist/MermaidViewer-CEDTD_pp.js +136 -0
  13. package/dist/MermaidViewer-CEDTD_pp.js.map +1 -0
  14. package/dist/PsdViewer-CZcxUpZO.js +121 -0
  15. package/dist/PsdViewer-CZcxUpZO.js.map +1 -0
  16. package/dist/TiffViewer-BwI62CbQ.js +141 -0
  17. package/dist/TiffViewer-BwI62CbQ.js.map +1 -0
  18. package/dist/{Viewer3D-CIrOn63I.js → Viewer3D-DoeZcuMm.js} +16 -16
  19. package/dist/Viewer3D-DoeZcuMm.js.map +1 -0
  20. package/dist/filex-core.js +475 -219
  21. package/dist/filex-core.umd.cjs +165 -108
  22. package/dist/filex-core.umd.cjs.map +1 -1
  23. package/dist/fonts/caveat-latin-ext.woff2 +0 -0
  24. package/dist/fonts/caveat-latin.woff2 +0 -0
  25. package/dist/fonts/dancing-script-latin-ext.woff2 +0 -0
  26. package/dist/fonts/dancing-script-latin.woff2 +0 -0
  27. package/dist/fonts/homemade-apple-latin.woff2 +0 -0
  28. package/dist/fonts/source-serif-4-latin-ext.woff2 +0 -0
  29. package/dist/fonts/source-serif-4-latin.woff2 +0 -0
  30. package/dist/index-DuWnGuv5.js +35901 -0
  31. package/dist/index-DuWnGuv5.js.map +1 -0
  32. package/dist/index.d.ts +5241 -553
  33. package/dist/pdf-CaPYsOUo.js +21302 -0
  34. package/dist/pdf-CaPYsOUo.js.map +1 -0
  35. package/dist/style.css +1 -1
  36. package/package.json +1 -1
  37. package/src/FileExplorer.vue +1207 -343
  38. package/src/assets/fonts/LICENSE-Apache-2.0.txt +202 -0
  39. package/src/assets/fonts/LICENSE-OFL-1.1.txt +93 -0
  40. package/src/assets/fonts/README.md +58 -0
  41. package/src/assets/fonts/caveat-latin-ext.woff2 +0 -0
  42. package/src/assets/fonts/caveat-latin.woff2 +0 -0
  43. package/src/assets/fonts/dancing-script-latin-ext.woff2 +0 -0
  44. package/src/assets/fonts/dancing-script-latin.woff2 +0 -0
  45. package/src/assets/fonts/homemade-apple-latin.woff2 +0 -0
  46. package/src/assets/fonts/source-serif-4-latin-ext.woff2 +0 -0
  47. package/src/assets/fonts/source-serif-4-latin.woff2 +0 -0
  48. package/src/components/AdvancedSearch.vue +49 -0
  49. package/src/components/Breadcrumb.vue +5 -5
  50. package/src/components/ChoiceButtons.vue +152 -0
  51. package/src/components/CommandPalette.vue +5 -5
  52. package/src/components/ConnectionGuideView.vue +1 -1
  53. package/src/components/ConnectionNotice.vue +59 -0
  54. package/src/components/ConnectionsPanel.vue +30 -568
  55. package/src/components/ContextMenu.vue +31 -5
  56. package/src/components/DataTable.vue +1331 -0
  57. package/src/components/FilePane.vue +3 -0
  58. package/src/components/FilterBar.vue +12 -5
  59. package/src/components/GalleryView.vue +50 -2
  60. package/src/components/GridView.vue +51 -3
  61. package/src/components/HomeView.vue +18 -6
  62. package/src/components/InspectorPanel.vue +134 -17
  63. package/src/components/ListView.vue +366 -900
  64. package/src/components/LogoMark.vue +57 -0
  65. package/src/components/NFSExportsPanel.vue +76 -28
  66. package/src/components/OnboardingTour.vue +19 -7
  67. package/src/components/OperationsCenter.vue +77 -1
  68. package/src/components/PendingOpsTray.vue +59 -5
  69. package/src/components/PresenceBar.vue +7 -7
  70. package/src/components/ProductVersion.vue +31 -0
  71. package/src/components/QuickLook.vue +10 -14
  72. package/src/components/RecentlyOpened.vue +47 -17
  73. package/src/components/RecoveryKeyModal.vue +1 -1
  74. package/src/components/RowActions.vue +134 -0
  75. package/src/components/S3KeysPanel.vue +98 -56
  76. package/src/components/SSHKeysPanel.vue +79 -26
  77. package/src/components/SideNav.vue +260 -96
  78. package/src/components/StarButton.vue +1 -0
  79. package/src/components/StorageFields.vue +74 -6
  80. package/src/components/StorageTags.vue +62 -0
  81. package/src/components/TabBar.vue +1 -1
  82. package/src/components/TagKindIcon.vue +56 -0
  83. package/src/components/TagPicker.vue +226 -40
  84. package/src/components/ThemePalette.vue +4 -4
  85. package/src/components/TimeZonePicker.vue +29 -10
  86. package/src/components/TokensPanel.vue +82 -27
  87. package/src/components/Toolbar.vue +28 -6
  88. package/src/components/plugin/PluginConfirmModal.vue +54 -0
  89. package/src/components/plugin/PluginInspectorSection.vue +149 -0
  90. package/src/components/plugin/PluginPageView.vue +339 -0
  91. package/src/components/plugin/PluginViewModal.vue +159 -0
  92. package/src/components/plugin/SurfaceConversation.vue +76 -0
  93. package/src/components/plugin/SurfaceFooterButtons.vue +73 -0
  94. package/src/components/plugin/SurfaceRenderer.vue +299 -0
  95. package/src/components/plugin/SurfaceSections.vue +91 -0
  96. package/src/components/plugin/nodes/PdfFieldEditor.vue +509 -0
  97. package/src/components/plugin/nodes/SurfaceFileChooser.vue +94 -0
  98. package/src/components/plugin/nodes/SurfaceForm.vue +82 -0
  99. package/src/components/plugin/nodes/SurfaceList.vue +127 -0
  100. package/src/components/plugin/nodes/SurfacePdfFields.vue +1285 -0
  101. package/src/components/plugin/nodes/SurfacePeoplePicker.vue +213 -0
  102. package/src/components/plugin/nodes/SurfacePinInput.vue +57 -0
  103. package/src/components/plugin/nodes/SurfacePreview.vue +119 -0
  104. package/src/components/plugin/nodes/SurfaceProgress.vue +48 -0
  105. package/src/components/plugin/nodes/SurfaceSignaturePad.vue +472 -0
  106. package/src/components/plugin/nodes/SurfaceSteps.vue +53 -0
  107. package/src/components/public/PublicLanguagePicker.vue +58 -0
  108. package/src/components/public/PublicLinkPage.vue +286 -0
  109. package/src/components/public/PublicLinkPreview.vue +85 -0
  110. package/src/components/public/PublicPinGate.vue +108 -0
  111. package/src/components/public/PublicRequestBody.vue +204 -0
  112. package/src/components/public/PublicShareBody.vue +188 -0
  113. package/src/components/public/PublicShell.vue +332 -0
  114. package/src/composables/useConnections.ts +8 -2
  115. package/src/composables/useFileApi.ts +183 -44
  116. package/src/composables/useLocale.ts +277 -41
  117. package/src/composables/useNFSExports.ts +8 -10
  118. package/src/composables/useOperations.ts +44 -3
  119. package/src/composables/usePendingOps.ts +59 -8
  120. package/src/composables/usePluginActions.ts +121 -0
  121. package/src/composables/usePluginSurface.ts +223 -0
  122. package/src/composables/usePublicBranding.ts +172 -0
  123. package/src/composables/usePublicLink.ts +573 -0
  124. package/src/composables/usePublicPage.ts +61 -0
  125. package/src/composables/useS3Keys.ts +17 -12
  126. package/src/composables/useSSHKeys.ts +8 -10
  127. package/src/composables/useTokens.ts +8 -12
  128. package/src/composables/useUploadChunked.ts +18 -8
  129. package/src/composables/useViewerFetch.ts +6 -3
  130. package/src/index.ts +404 -8
  131. package/src/lib/actionIcons.ts +46 -1
  132. package/src/lib/advSearch.ts +14 -7
  133. package/src/lib/appLock.ts +130 -0
  134. package/src/lib/connection.ts +110 -0
  135. package/src/lib/destinationTree.ts +19 -3
  136. package/src/lib/direction.ts +353 -0
  137. package/src/lib/dragGhost.ts +6 -0
  138. package/src/lib/dropLimits.ts +69 -0
  139. package/src/lib/errorWords.ts +394 -0
  140. package/src/lib/externalReach.ts +5 -1
  141. package/src/lib/fileFilters.ts +7 -2
  142. package/src/lib/fileIcons.ts +4 -0
  143. package/src/lib/internalPaths.ts +77 -0
  144. package/src/lib/listInput.ts +64 -0
  145. package/src/lib/listing.ts +58 -23
  146. package/src/lib/modalStack.ts +26 -0
  147. package/src/lib/nodeRow.ts +121 -0
  148. package/src/lib/pdfFieldRules.ts +126 -0
  149. package/src/lib/pdfFields.ts +589 -0
  150. package/src/lib/pdfFieldsGeom.ts +203 -0
  151. package/src/lib/pdfjsLoader.ts +51 -0
  152. package/src/lib/personName.ts +47 -0
  153. package/src/lib/pluginApplies.ts +123 -0
  154. package/src/lib/pluginLabel.ts +72 -0
  155. package/src/lib/pluginMenu.ts +253 -0
  156. package/src/lib/pluginPage.ts +58 -0
  157. package/src/lib/plural.ts +181 -0
  158. package/src/lib/popupLayer.ts +103 -0
  159. package/src/lib/prefs.ts +563 -0
  160. package/src/lib/productVersion.ts +35 -0
  161. package/src/lib/publicLayout.ts +39 -0
  162. package/src/lib/serviceGate.ts +104 -0
  163. package/src/lib/shareTtl.ts +25 -11
  164. package/src/lib/signFonts.ts +85 -0
  165. package/src/lib/signaturePad.ts +111 -0
  166. package/src/lib/sortOrder.ts +100 -38
  167. package/src/lib/star.ts +7 -1
  168. package/src/lib/surfaceCell.ts +41 -0
  169. package/src/lib/surfaceConditions.ts +156 -0
  170. package/src/lib/surfaceOpen.ts +106 -0
  171. package/src/lib/surfaceSteps.ts +68 -0
  172. package/src/lib/surfaceValues.ts +191 -0
  173. package/src/lib/symlink.ts +150 -0
  174. package/src/lib/tableColumns.ts +493 -0
  175. package/src/lib/tableEnv.ts +34 -0
  176. package/src/lib/tablePrefs.ts +84 -0
  177. package/src/lib/tags.ts +121 -12
  178. package/src/lib/themes.ts +286 -18
  179. package/src/lib/tour.ts +152 -0
  180. package/src/lib/trashEmpty.ts +111 -0
  181. package/src/lib/trashTimeLeft.ts +23 -0
  182. package/src/lib/uiLocales.ts +460 -0
  183. package/src/lib/viewPrefs.ts +696 -660
  184. package/src/lib/viewPrefsHttp.ts +78 -0
  185. package/src/locales/en.ts +522 -119
  186. package/src/locales/resolve.ts +17 -8
  187. package/src/locales/tr.ts +539 -155
  188. package/src/modals/ConvertModal.vue +62 -11
  189. package/src/modals/DestinationPickerModal.vue +59 -16
  190. package/src/modals/Modal.vue +113 -23
  191. package/src/modals/NewDocumentModal.vue +16 -6
  192. package/src/modals/NewFolderModal.vue +8 -0
  193. package/src/modals/PermissionsModal.vue +272 -163
  194. package/src/modals/PreviewModal.vue +119 -20
  195. package/src/modals/RenameModal.vue +37 -1
  196. package/src/styles/base.css +2895 -241
  197. package/src/styles/sign-fonts.css +118 -0
  198. package/src/styles/variables.css +60 -0
  199. package/src/types/Connections.ts +17 -0
  200. package/src/types/ExplorerConfig.ts +117 -1
  201. package/src/types/FileNode.ts +58 -0
  202. package/src/types/Plugins.ts +494 -0
  203. package/src/types/Public.ts +212 -0
  204. package/src/viewers/ArchiveViewer.vue +93 -48
  205. package/src/viewers/CsvViewer.vue +99 -53
  206. package/src/viewers/DrawioViewer.vue +35 -9
  207. package/src/viewers/EpubViewer.vue +7 -8
  208. package/src/viewers/IpynbViewer.vue +3 -1
  209. package/src/viewers/MermaidViewer.vue +4 -5
  210. package/src/viewers/PdfViewer.vue +8 -17
  211. package/src/viewers/PsdViewer.vue +2 -4
  212. package/src/viewers/TiffViewer.vue +4 -5
  213. package/src/viewers/Viewer3D.vue +1 -1
  214. package/LICENSE +0 -21
  215. package/dist/ArchiveViewer-CLNevfPE.js +0 -124
  216. package/dist/ArchiveViewer-CLNevfPE.js.map +0 -1
  217. package/dist/CsvViewer-K8XA3z7N.js +0 -141
  218. package/dist/CsvViewer-K8XA3z7N.js.map +0 -1
  219. package/dist/DrawioViewer-BuhZOAx6.js.map +0 -1
  220. package/dist/EpubViewer-DS3mb3k8.js.map +0 -1
  221. package/dist/IpynbViewer-BTk7p4Dn.js +0 -184
  222. package/dist/IpynbViewer-BTk7p4Dn.js.map +0 -1
  223. package/dist/MermaidViewer-DqucH_io.js +0 -137
  224. package/dist/MermaidViewer-DqucH_io.js.map +0 -1
  225. package/dist/PsdViewer-CwiVENmq.js +0 -122
  226. package/dist/PsdViewer-CwiVENmq.js.map +0 -1
  227. package/dist/TiffViewer-CpewSVmQ.js +0 -142
  228. package/dist/TiffViewer-CpewSVmQ.js.map +0 -1
  229. package/dist/Viewer3D-CIrOn63I.js.map +0 -1
  230. package/dist/index-Dx0IbBcI.js +0 -26831
  231. package/dist/index-Dx0IbBcI.js.map +0 -1
  232. package/dist/useViewerFetch-czqbd2Lj.js +0 -25
  233. package/dist/useViewerFetch-czqbd2Lj.js.map +0 -1
@@ -0,0 +1,460 @@
1
+ /**
2
+ * uiLocales — the language list is not a constant any more.
3
+ *
4
+ * filex ships English and Turkish. A third source is an app — a LANGUAGE
5
+ * PACK — that carries a language for FILEX ITSELF (`manifest.ui_locales`):
6
+ * the server lists it, the interface offers it (public shell included),
7
+ * marks it with the app it came from, and drops it when the app is removed.
8
+ *
9
+ * ⚠⚠ TWO requests, not one. `GET /api/public/branding` LISTS the languages
10
+ * apps add — code, app, right-to-left or not — and carries no strings;
11
+ * `GET /api/public/ui-locales/{code}` is one language's strings, fetched the
12
+ * first time something asks for that language's table (`localeTable`,
13
+ * `ensureLocaleStrings`). A complete language is ~150 KB, and the branding
14
+ * answer is read by every public page and every panel start.
15
+ *
16
+ * ⚠⚠ The branding field used to be a MAP (`{"es": {key: text}}`) while this
17
+ * file read it as a LIST (`b.ui_locales?.length`): a map has no length, so an
18
+ * installed language joined the picker and then spoke English, and every test
19
+ * stayed green because its fixture was typed this file's way (measured
20
+ * 2026-09-21 with the Spanish pack). The web tests now read the SERVER's own
21
+ * bytes (backend/internal/api/handlers/testdata/wire/public-*.json).
22
+ *
23
+ * ⚠ A language that arrives this way may be a PARTIAL table. Every lookup
24
+ * falls back to English rather than printing a raw key — the rule the
25
+ * built-in catalogues already follow, applied to a table we did not write.
26
+ *
27
+ * ⚠ The strings come from outside. They are rendered as TEXT, never markup,
28
+ * and a key that could walk into an object's machinery (`__proto__`,
29
+ * `constructor`, `prototype`) is dropped here as well as refused by the host.
30
+ *
31
+ * ⚠⚠ Reactive on purpose. A pack can be installed or removed while the window
32
+ * is open, and its strings arrive after the page has painted; `localesVersion`
33
+ * is what every consumer watches, and `hasLocale` reads it so a computed that
34
+ * asked "is `es` offered?" asks again when the answer changes.
35
+ */
36
+ import { ref } from 'vue';
37
+ import type { PublicBranding, PublicLocaleOption } from '../types/Public';
38
+ // ⚠ The two tables directly, not `../locales` (its index re-exports
39
+ // `locales/resolve`, which reads THIS module — an import cycle).
40
+ import { en } from '../locales/en';
41
+ import { tr } from '../locales/tr';
42
+ import type { LocaleCode } from '../types/ExplorerConfig';
43
+
44
+ /** Bumped whenever the list or a table changes; `useLocale` and the pickers watch it. */
45
+ export const localesVersion = ref(0);
46
+
47
+ /**
48
+ * Has the server's list arrived? Until it has, a stored choice that LOOKS like
49
+ * a language (`plausibleLocale`) is held rather than thrown away — the same
50
+ * rule the palette follows for a custom theme whose list is still in flight
51
+ * (`lib/themes` plausibleThemeId). Without it a person who picked a pack's
52
+ * language opened every page in English: the stored `es` was judged against
53
+ * a list that had not been fetched yet, refused, and never looked at again.
54
+ */
55
+ export const localesKnown = ref(false);
56
+
57
+ /** The languages this package ships a full catalogue for. */
58
+ export const BUILTIN_LOCALES: readonly LocaleCode[] = ['en', 'tr'];
59
+
60
+ /**
61
+ * A language's name IN that language — never translated into the viewer's,
62
+ * because somebody looking for their own language is looking for their own
63
+ * word for it. The table covers the common cases without asking `Intl`;
64
+ * anything else asks `Intl.DisplayNames` in its own language, and only a code
65
+ * even that cannot name is shown upper-cased.
66
+ */
67
+ const BUILTIN_LABELS: Record<string, string> = {
68
+ en: 'English',
69
+ tr: 'Türkçe',
70
+ de: 'Deutsch',
71
+ fr: 'Français',
72
+ es: 'Español',
73
+ it: 'Italiano',
74
+ nl: 'Nederlands',
75
+ pt: 'Português',
76
+ pl: 'Polski',
77
+ ru: 'Русский',
78
+ uk: 'Українська',
79
+ ar: 'العربية',
80
+ fa: 'فارسی',
81
+ az: 'Azərbaycanca',
82
+ ja: '日本語',
83
+ ko: '한국어',
84
+ zh: '中文',
85
+ };
86
+
87
+ /** A language's own name for itself. */
88
+ export function localeLabel(code: string): string {
89
+ if (BUILTIN_LABELS[code]) return BUILTIN_LABELS[code];
90
+ try {
91
+ const name = new Intl.DisplayNames([code], { type: 'language' }).of(code);
92
+ if (name && name.toLowerCase() !== code) {
93
+ // Upper-cased in the language's own rules ("español" → "Español").
94
+ return name.charAt(0).toLocaleUpperCase(code) + name.slice(1);
95
+ }
96
+ } catch {
97
+ /* an old runtime or a tag Intl does not know */
98
+ }
99
+ return code.toUpperCase();
100
+ }
101
+
102
+ /** Everything a language added from outside carries. */
103
+ interface ExtraLocale extends Omit<PublicLocaleOption, 'strings'> {
104
+ /** `null` = listed, strings not fetched yet. */
105
+ strings: Record<string, string> | null;
106
+ }
107
+
108
+ const messages: Record<string, Record<string, string>> = { en, tr };
109
+
110
+ const extras = new Map<string, ExtraLocale>();
111
+ const inflight = new Map<string, Promise<boolean>>();
112
+
113
+ /* ── codes ────────────────────────────────────────────────────────────── */
114
+
115
+ const TAG = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/;
116
+
117
+ /**
118
+ * Could this be a language tag at all? `es`, `pt-br`, `zh-hant` — yes;
119
+ * `../etc`, `` — no. What a HELD choice has to be.
120
+ */
121
+ export function plausibleLocale(raw: string | undefined | null): boolean {
122
+ return TAG.test(String(raw ?? '').trim().toLowerCase().replace(/_/g, '-'));
123
+ }
124
+
125
+ /**
126
+ * The code the interface uses for a tag.
127
+ *
128
+ * `tr-TR` and `TR` mean `tr`; `es-MX` means `es` when a pack offers `es`;
129
+ * `pt` means `pt-br` when that is the only Portuguese on offer; a regional
130
+ * pack (`pt-br`, `zh-hant`) keeps its region, because Brazilian and European
131
+ * Portuguese — or the two Chinese scripts — are different tables. A tag
132
+ * nothing offers yet is KEPT whole, so a choice held before the list arrives
133
+ * still means what the person picked.
134
+ */
135
+ export function normalizeLocaleCode(raw: string | undefined | null): string {
136
+ const v = String(raw ?? '').trim().toLowerCase().replace(/_/g, '-');
137
+ if (!TAG.test(v)) return '';
138
+ if (extras.has(v)) return v;
139
+ const primary = v.split('-')[0];
140
+ if (isBuiltinLocale(primary)) return primary;
141
+ if (extras.has(primary)) return primary;
142
+ for (const k of extras.keys()) if (k.startsWith(`${primary}-`)) return k;
143
+ return v;
144
+ }
145
+
146
+ export function isBuiltinLocale(code: string): code is LocaleCode {
147
+ return (BUILTIN_LOCALES as readonly string[]).includes(code);
148
+ }
149
+
150
+ /* ── the registry ─────────────────────────────────────────────────────── */
151
+
152
+ /**
153
+ * The strings a pack sent, minus anything that must not reach an object:
154
+ * non-strings, empty values (untranslated — English shows instead) and keys
155
+ * whose segments name JavaScript's machinery. The host refuses those keys at
156
+ * install already (wire.UILocaleKeyOK); this is the second lock, because the
157
+ * web SPA turns dotted keys into nested objects and `__proto__` would walk
158
+ * that loop into Object.prototype.
159
+ */
160
+ function cleanStrings(raw: Record<string, unknown> | null | undefined): Record<string, string> {
161
+ const out: Record<string, string> = {};
162
+ for (const [k, v] of Object.entries(raw ?? {})) {
163
+ if (typeof v !== 'string' || !v.trim() || !k) continue;
164
+ if (k.split('.').some((s) => s === '__proto__' || s === 'constructor' || s === 'prototype')) continue;
165
+ out[k] = v;
166
+ }
167
+ return out;
168
+ }
169
+
170
+ /**
171
+ * Add (or replace) a language the server offers. `strings` absent = listed
172
+ * only; the table is fetched when first asked for.
173
+ *
174
+ * ⚠ A built-in language is never replaced wholesale — a pack that ships
175
+ * `tr` is OVERLAYING keys onto the catalogue, not taking it over, or one
176
+ * badly-packed app could blank the interface for everybody who reads Turkish.
177
+ */
178
+ export function registerLocale(opt: PublicLocaleOption): void {
179
+ const raw = String(opt?.code ?? '').trim().toLowerCase().replace(/_/g, '-');
180
+ const code = TAG.test(raw) ? raw : '';
181
+ if (!code) return;
182
+ extras.set(code, {
183
+ code,
184
+ label: String(opt.label ?? '').trim() || localeLabel(code),
185
+ source: opt.source === 'plugin' ? 'plugin' : opt.source || 'builtin',
186
+ plugin: opt.plugin,
187
+ rtl: opt.rtl === true,
188
+ strings: opt.strings ? cleanStrings(opt.strings) : null,
189
+ });
190
+ localesVersion.value += 1;
191
+ }
192
+
193
+ /** Replace the whole extra set (what one branding answer says). */
194
+ export function setLocales(list: PublicLocaleOption[] | undefined): void {
195
+ extras.clear();
196
+ inflight.clear();
197
+ for (const o of list ?? []) registerLocale(o);
198
+ localesVersion.value += 1;
199
+ }
200
+
201
+ /** An app was removed: its languages go with it. */
202
+ export function unregisterPluginLocales(plugin: string): void {
203
+ let changed = false;
204
+ for (const [code, v] of extras) {
205
+ if (v.source === 'plugin' && v.plugin === plugin) {
206
+ extras.delete(code);
207
+ changed = true;
208
+ }
209
+ }
210
+ if (changed) localesVersion.value += 1;
211
+ }
212
+
213
+ /** Testing seam / sign-out: back to the two we ship, list not yet known. */
214
+ export function resetLocales(): void {
215
+ extras.clear();
216
+ inflight.clear();
217
+ source = {};
218
+ localesKnown.value = false;
219
+ localesVersion.value += 1;
220
+ }
221
+
222
+ /**
223
+ * Every language on offer, built-ins first and the rest in the order the
224
+ * server listed them — so the two the product ships do not move around when
225
+ * an app is installed. ⚠ THE one list every picker reads (the admin
226
+ * header's switcher, the settings dialog, the public shell).
227
+ */
228
+ export function availableLocales(): PublicLocaleOption[] {
229
+ void localesVersion.value;
230
+ const out: PublicLocaleOption[] = BUILTIN_LOCALES.map((code) => ({
231
+ code,
232
+ label: localeLabel(code),
233
+ source: 'builtin',
234
+ }));
235
+ for (const [code, v] of extras) {
236
+ if (isBuiltinLocale(code)) continue;
237
+ out.push({ code, label: v.label, source: v.source, plugin: v.plugin, rtl: v.rtl });
238
+ }
239
+ return out;
240
+ }
241
+
242
+ /** Is this a language the interface can actually be set to? (Reactive.) */
243
+ export function hasLocale(code: string | undefined | null): boolean {
244
+ void localesVersion.value;
245
+ const c = normalizeLocaleCode(code);
246
+ return !!c && (isBuiltinLocale(c) || extras.has(c));
247
+ }
248
+
249
+ /**
250
+ * May a STORED choice stand? Offered, or — while the list has not arrived —
251
+ * merely plausible. After the list arrives only an offered language stands;
252
+ * the stored value itself is kept, so reinstalling the pack brings it back.
253
+ */
254
+ export function acceptableLocale(code: string | undefined | null): boolean {
255
+ if (hasLocale(code)) return true;
256
+ return !localesKnown.value && plausibleLocale(code);
257
+ }
258
+
259
+ /* ── tables ───────────────────────────────────────────────────────────── */
260
+
261
+ /**
262
+ * One language's table with English merged underneath.
263
+ *
264
+ * Order, and why: English underneath as the last resort, then the built-in
265
+ * catalogue when the language has one, then the pack's table on top. So a
266
+ * pack that translates three strings gets three strings and an otherwise
267
+ * English screen — never a page of raw keys — and a pack that overlays two
268
+ * Turkish strings changes exactly two.
269
+ *
270
+ * ⚠ Not what `t()` reads any more — see `localeOwnTable`. In this merge a
271
+ * key the pack never wrote and a key it did are indistinguishable, and the
272
+ * plural rule needs to tell them apart.
273
+ *
274
+ * ⚠ Asking for a language whose strings have not been fetched STARTS the
275
+ * fetch and answers English meanwhile; `localesVersion` moves when they land,
276
+ * and every computed that read this re-reads it.
277
+ */
278
+ export function localeTable(code: string | undefined): Record<string, string> {
279
+ const c = normalizeLocaleCode(code);
280
+ const builtin = isBuiltinLocale(c) ? messages[c] : undefined;
281
+ const extra = extras.get(c);
282
+ if (extra && extra.strings === null) void ensureLocaleStrings(c);
283
+ if (!extra?.strings || !Object.keys(extra.strings).length) return builtin ?? messages.en;
284
+ return { ...messages.en, ...(builtin ?? {}), ...extra.strings };
285
+ }
286
+
287
+ /** What a language wrote ITSELF, and whose plural rules read it. */
288
+ export interface OwnLocaleTable {
289
+ /** The language whose CLDR plural rules pick a form out of `strings`. */
290
+ lang: string;
291
+ /** Its own words only — English is NOT merged underneath. */
292
+ strings: Readonly<Record<string, string>>;
293
+ }
294
+
295
+ /**
296
+ * The words a language has of its OWN — what `t()` reads first.
297
+ *
298
+ * - a built-in (`en`, `tr`): its catalogue, with a pack that overlays it on
299
+ * top (the overlay IS that language's words);
300
+ * - a pack's language: the pack's strings and nothing else;
301
+ * - a language with no strings yet (not fetched, fetch failed, empty pack,
302
+ * or a code nothing offers): English, AS English — `lang` is `en`.
303
+ *
304
+ * ⚠⚠ Why not `localeTable`. That one merges English under the pack, so "the
305
+ * table has `toast.restored_one`" is true for a Spanish pack that wrote only
306
+ * `toast.restored` — the English singular was sitting underneath — and the
307
+ * count of one printed "1 item restored" in the middle of a Spanish screen.
308
+ * The plural rule has to know which forms the language itself wrote, and
309
+ * fall back to English as a WHOLE (English's form, by English's rule) only
310
+ * when the language wrote none.
311
+ *
312
+ * ⚠ The last case answers `lang: 'en'` on purpose: French puts 0 in `one`,
313
+ * so reading English strings by French rules would print "0 item".
314
+ *
315
+ * Starts the fetch for a listed language whose strings are not in hand, the
316
+ * same as `localeTable`.
317
+ */
318
+ export function localeOwnTable(code: string | undefined): OwnLocaleTable {
319
+ const c = normalizeLocaleCode(code);
320
+ const builtin = isBuiltinLocale(c) ? messages[c] : undefined;
321
+ const extra = extras.get(c);
322
+ if (extra && extra.strings === null) void ensureLocaleStrings(c);
323
+ const pack = extra?.strings && Object.keys(extra.strings).length ? extra.strings : null;
324
+ if (builtin) return { lang: c, strings: pack ? { ...builtin, ...pack } : builtin };
325
+ if (pack) return { lang: c, strings: pack };
326
+ return { lang: 'en', strings: messages.en };
327
+ }
328
+
329
+ /**
330
+ * Just the strings a pack added for this language — no catalogue under them.
331
+ *
332
+ * ⚠ For a host with a catalogue of its own (the admin SPA's `web/src/locales`)
333
+ * that merges the same table into ITS `t()`. Handing it `localeTable` would
334
+ * fold this package's keys into the host's, which is two catalogues becoming
335
+ * one by accident.
336
+ */
337
+ export function localeStrings(code: string | undefined): Record<string, string> {
338
+ return { ...(extras.get(normalizeLocaleCode(code))?.strings ?? {}) };
339
+ }
340
+
341
+ /* ── fetching ─────────────────────────────────────────────────────────── */
342
+
343
+ export interface LoadLocalesOptions {
344
+ /** API origin; empty = same origin. */
345
+ base?: string;
346
+ fetchImpl?: typeof fetch;
347
+ /** Per-call auth for a host that does not ride on cookies. */
348
+ headers?: () => Promise<Record<string, string>> | Record<string, string>;
349
+ }
350
+
351
+ /** Where the list came from — the strings are fetched from the same place. */
352
+ let source: LoadLocalesOptions = {};
353
+
354
+ /**
355
+ * Fetch one added language's strings (`GET /api/public/ui-locales/{code}`).
356
+ * Resolves true when a table is in hand. Idempotent: concurrent askers share
357
+ * one request, and a language already loaded costs nothing.
358
+ *
359
+ * ⚠ A failure leaves the language listed with an EMPTY table (English shows)
360
+ * rather than retrying on every repaint — a pack removed between the list and
361
+ * this request answers 404, and hammering the server over it helps nobody.
362
+ */
363
+ export function ensureLocaleStrings(code: string | undefined): Promise<boolean> {
364
+ const c = normalizeLocaleCode(code);
365
+ const e = extras.get(c);
366
+ if (!e) return Promise.resolve(false);
367
+ if (e.strings !== null) return Promise.resolve(Object.keys(e.strings).length > 0);
368
+ const pending = inflight.get(c);
369
+ if (pending) return pending;
370
+ const doFetch = source.fetchImpl ?? (typeof fetch === 'function' ? fetch : null);
371
+ if (!doFetch) return Promise.resolve(false);
372
+ const base = (source.base ?? '').replace(/\/+$/, '');
373
+ const p = (async () => {
374
+ let strings: Record<string, string> = {};
375
+ try {
376
+ const extra = source.headers ? await source.headers() : {};
377
+ const res = await doFetch(`${base}/api/public/ui-locales/${encodeURIComponent(c)}`, {
378
+ method: 'GET',
379
+ headers: { Accept: 'application/json', ...extra },
380
+ credentials: 'same-origin',
381
+ });
382
+ if (res.ok) {
383
+ const body = (await res.json()) as { strings?: Record<string, unknown> };
384
+ strings = cleanStrings(body?.strings);
385
+ }
386
+ } catch {
387
+ /* English is a perfectly good answer */
388
+ }
389
+ const still = extras.get(c);
390
+ if (still === e) {
391
+ still.strings = strings;
392
+ localesVersion.value += 1;
393
+ }
394
+ inflight.delete(c);
395
+ return Object.keys(strings).length > 0;
396
+ })();
397
+ inflight.set(c, p);
398
+ return p;
399
+ }
400
+
401
+ /**
402
+ * Fold a branding answer's language list into the registry.
403
+ *
404
+ * `locales` is a list of CODES — the built-ins and whatever apps add — and
405
+ * `ui_locales` the added ones as rows (code, app, rtl) WITHOUT strings.
406
+ * ⚠ Registering again drops tables already fetched, so a pack that was
407
+ * upgraded while the page was open is re-read the next time it is used
408
+ * instead of serving yesterday's words until a reload.
409
+ */
410
+ export function setLocalesFromBranding(
411
+ b: PublicBranding | null | undefined,
412
+ opts?: LoadLocalesOptions,
413
+ ): PublicLocaleOption[] {
414
+ if (opts) source = opts;
415
+ if (!b) return availableLocales();
416
+ const rows = Array.isArray(b.ui_locales) ? b.ui_locales : [];
417
+ const listed = new Set(rows.map((r) => String(r?.code ?? '').toLowerCase()));
418
+ // A code on `locales` with no row (an older server) still gets a name.
419
+ const bare = (b.locales ?? [])
420
+ .map((c) => String(c ?? '').trim().toLowerCase())
421
+ .filter((c) => TAG.test(c) && !isBuiltinLocale(c) && !listed.has(c))
422
+ .map((code) => ({ code, source: 'plugin' as const }));
423
+ setLocales([...rows, ...bare]);
424
+ localesKnown.value = true;
425
+ return availableLocales();
426
+ }
427
+
428
+ /**
429
+ * Ask the server which languages this instance offers.
430
+ *
431
+ * ⚠ It asks the BRANDING route, which is the one public answer about how
432
+ * this instance presents itself — and the one the public shell already
433
+ * fetches. ⚠ A failure is not an error: the two built-in languages are
434
+ * already on the picker, and the list is then treated as KNOWN (a held
435
+ * choice falls back) — waiting forever for an answer that will not come
436
+ * would keep somebody's interface in a language nothing can serve.
437
+ */
438
+ export async function loadLocales(opts: LoadLocalesOptions = {}): Promise<PublicLocaleOption[]> {
439
+ source = opts;
440
+ const doFetch = opts.fetchImpl ?? (typeof fetch === 'function' ? fetch : null);
441
+ if (!doFetch) return availableLocales();
442
+ const base = (opts.base ?? '').replace(/\/+$/, '');
443
+ try {
444
+ const extra = opts.headers ? await opts.headers() : {};
445
+ const res = await doFetch(`${base}/api/public/branding`, {
446
+ method: 'GET',
447
+ headers: { Accept: 'application/json', ...extra },
448
+ credentials: 'same-origin',
449
+ });
450
+ if (res.ok) {
451
+ setLocalesFromBranding((await res.json()) as PublicBranding);
452
+ return availableLocales();
453
+ }
454
+ } catch {
455
+ /* the two built-in languages are a perfectly good answer */
456
+ }
457
+ localesKnown.value = true;
458
+ localesVersion.value += 1;
459
+ return availableLocales();
460
+ }