@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.
- package/README.md +14 -2
- package/dist/ArchiveViewer-BqU761Wk.js +149 -0
- package/dist/ArchiveViewer-BqU761Wk.js.map +1 -0
- package/dist/CsvViewer-ByHVK5ga.js +172 -0
- package/dist/CsvViewer-ByHVK5ga.js.map +1 -0
- package/dist/{DrawioViewer-BuhZOAx6.js → DrawioViewer-Bgrj8-zG.js} +45 -44
- package/dist/DrawioViewer-Bgrj8-zG.js.map +1 -0
- package/dist/{EpubViewer-DS3mb3k8.js → EpubViewer-DJwUKlHW.js} +50 -50
- package/dist/EpubViewer-DJwUKlHW.js.map +1 -0
- package/dist/IpynbViewer-Bz0eJ4NS.js +184 -0
- package/dist/IpynbViewer-Bz0eJ4NS.js.map +1 -0
- package/dist/MermaidViewer-CEDTD_pp.js +136 -0
- package/dist/MermaidViewer-CEDTD_pp.js.map +1 -0
- package/dist/PsdViewer-CZcxUpZO.js +121 -0
- package/dist/PsdViewer-CZcxUpZO.js.map +1 -0
- package/dist/TiffViewer-BwI62CbQ.js +141 -0
- package/dist/TiffViewer-BwI62CbQ.js.map +1 -0
- package/dist/{Viewer3D-CIrOn63I.js → Viewer3D-DoeZcuMm.js} +16 -16
- package/dist/Viewer3D-DoeZcuMm.js.map +1 -0
- package/dist/filex-core.js +475 -219
- package/dist/filex-core.umd.cjs +165 -108
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/fonts/caveat-latin-ext.woff2 +0 -0
- package/dist/fonts/caveat-latin.woff2 +0 -0
- package/dist/fonts/dancing-script-latin-ext.woff2 +0 -0
- package/dist/fonts/dancing-script-latin.woff2 +0 -0
- package/dist/fonts/homemade-apple-latin.woff2 +0 -0
- package/dist/fonts/source-serif-4-latin-ext.woff2 +0 -0
- package/dist/fonts/source-serif-4-latin.woff2 +0 -0
- package/dist/index-DuWnGuv5.js +35901 -0
- package/dist/index-DuWnGuv5.js.map +1 -0
- package/dist/index.d.ts +5241 -553
- package/dist/pdf-CaPYsOUo.js +21302 -0
- package/dist/pdf-CaPYsOUo.js.map +1 -0
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +1207 -343
- package/src/assets/fonts/LICENSE-Apache-2.0.txt +202 -0
- package/src/assets/fonts/LICENSE-OFL-1.1.txt +93 -0
- package/src/assets/fonts/README.md +58 -0
- package/src/assets/fonts/caveat-latin-ext.woff2 +0 -0
- package/src/assets/fonts/caveat-latin.woff2 +0 -0
- package/src/assets/fonts/dancing-script-latin-ext.woff2 +0 -0
- package/src/assets/fonts/dancing-script-latin.woff2 +0 -0
- package/src/assets/fonts/homemade-apple-latin.woff2 +0 -0
- package/src/assets/fonts/source-serif-4-latin-ext.woff2 +0 -0
- package/src/assets/fonts/source-serif-4-latin.woff2 +0 -0
- package/src/components/AdvancedSearch.vue +49 -0
- package/src/components/Breadcrumb.vue +5 -5
- package/src/components/ChoiceButtons.vue +152 -0
- package/src/components/CommandPalette.vue +5 -5
- package/src/components/ConnectionGuideView.vue +1 -1
- package/src/components/ConnectionNotice.vue +59 -0
- package/src/components/ConnectionsPanel.vue +30 -568
- package/src/components/ContextMenu.vue +31 -5
- package/src/components/DataTable.vue +1331 -0
- package/src/components/FilePane.vue +3 -0
- package/src/components/FilterBar.vue +12 -5
- package/src/components/GalleryView.vue +50 -2
- package/src/components/GridView.vue +51 -3
- package/src/components/HomeView.vue +18 -6
- package/src/components/InspectorPanel.vue +134 -17
- package/src/components/ListView.vue +366 -900
- package/src/components/LogoMark.vue +57 -0
- package/src/components/NFSExportsPanel.vue +76 -28
- package/src/components/OnboardingTour.vue +19 -7
- package/src/components/OperationsCenter.vue +77 -1
- package/src/components/PendingOpsTray.vue +59 -5
- package/src/components/PresenceBar.vue +7 -7
- package/src/components/ProductVersion.vue +31 -0
- package/src/components/QuickLook.vue +10 -14
- package/src/components/RecentlyOpened.vue +47 -17
- package/src/components/RecoveryKeyModal.vue +1 -1
- package/src/components/RowActions.vue +134 -0
- package/src/components/S3KeysPanel.vue +98 -56
- package/src/components/SSHKeysPanel.vue +79 -26
- package/src/components/SideNav.vue +260 -96
- package/src/components/StarButton.vue +1 -0
- package/src/components/StorageFields.vue +74 -6
- package/src/components/StorageTags.vue +62 -0
- package/src/components/TabBar.vue +1 -1
- package/src/components/TagKindIcon.vue +56 -0
- package/src/components/TagPicker.vue +226 -40
- package/src/components/ThemePalette.vue +4 -4
- package/src/components/TimeZonePicker.vue +29 -10
- package/src/components/TokensPanel.vue +82 -27
- package/src/components/Toolbar.vue +28 -6
- package/src/components/plugin/PluginConfirmModal.vue +54 -0
- package/src/components/plugin/PluginInspectorSection.vue +149 -0
- package/src/components/plugin/PluginPageView.vue +339 -0
- package/src/components/plugin/PluginViewModal.vue +159 -0
- package/src/components/plugin/SurfaceConversation.vue +76 -0
- package/src/components/plugin/SurfaceFooterButtons.vue +73 -0
- package/src/components/plugin/SurfaceRenderer.vue +299 -0
- package/src/components/plugin/SurfaceSections.vue +91 -0
- package/src/components/plugin/nodes/PdfFieldEditor.vue +509 -0
- package/src/components/plugin/nodes/SurfaceFileChooser.vue +94 -0
- package/src/components/plugin/nodes/SurfaceForm.vue +82 -0
- package/src/components/plugin/nodes/SurfaceList.vue +127 -0
- package/src/components/plugin/nodes/SurfacePdfFields.vue +1285 -0
- package/src/components/plugin/nodes/SurfacePeoplePicker.vue +213 -0
- package/src/components/plugin/nodes/SurfacePinInput.vue +57 -0
- package/src/components/plugin/nodes/SurfacePreview.vue +119 -0
- package/src/components/plugin/nodes/SurfaceProgress.vue +48 -0
- package/src/components/plugin/nodes/SurfaceSignaturePad.vue +472 -0
- package/src/components/plugin/nodes/SurfaceSteps.vue +53 -0
- package/src/components/public/PublicLanguagePicker.vue +58 -0
- package/src/components/public/PublicLinkPage.vue +286 -0
- package/src/components/public/PublicLinkPreview.vue +85 -0
- package/src/components/public/PublicPinGate.vue +108 -0
- package/src/components/public/PublicRequestBody.vue +204 -0
- package/src/components/public/PublicShareBody.vue +188 -0
- package/src/components/public/PublicShell.vue +332 -0
- package/src/composables/useConnections.ts +8 -2
- package/src/composables/useFileApi.ts +183 -44
- package/src/composables/useLocale.ts +277 -41
- package/src/composables/useNFSExports.ts +8 -10
- package/src/composables/useOperations.ts +44 -3
- package/src/composables/usePendingOps.ts +59 -8
- package/src/composables/usePluginActions.ts +121 -0
- package/src/composables/usePluginSurface.ts +223 -0
- package/src/composables/usePublicBranding.ts +172 -0
- package/src/composables/usePublicLink.ts +573 -0
- package/src/composables/usePublicPage.ts +61 -0
- package/src/composables/useS3Keys.ts +17 -12
- package/src/composables/useSSHKeys.ts +8 -10
- package/src/composables/useTokens.ts +8 -12
- package/src/composables/useUploadChunked.ts +18 -8
- package/src/composables/useViewerFetch.ts +6 -3
- package/src/index.ts +404 -8
- package/src/lib/actionIcons.ts +46 -1
- package/src/lib/advSearch.ts +14 -7
- package/src/lib/appLock.ts +130 -0
- package/src/lib/connection.ts +110 -0
- package/src/lib/destinationTree.ts +19 -3
- package/src/lib/direction.ts +353 -0
- package/src/lib/dragGhost.ts +6 -0
- package/src/lib/dropLimits.ts +69 -0
- package/src/lib/errorWords.ts +394 -0
- package/src/lib/externalReach.ts +5 -1
- package/src/lib/fileFilters.ts +7 -2
- package/src/lib/fileIcons.ts +4 -0
- package/src/lib/internalPaths.ts +77 -0
- package/src/lib/listInput.ts +64 -0
- package/src/lib/listing.ts +58 -23
- package/src/lib/modalStack.ts +26 -0
- package/src/lib/nodeRow.ts +121 -0
- package/src/lib/pdfFieldRules.ts +126 -0
- package/src/lib/pdfFields.ts +589 -0
- package/src/lib/pdfFieldsGeom.ts +203 -0
- package/src/lib/pdfjsLoader.ts +51 -0
- package/src/lib/personName.ts +47 -0
- package/src/lib/pluginApplies.ts +123 -0
- package/src/lib/pluginLabel.ts +72 -0
- package/src/lib/pluginMenu.ts +253 -0
- package/src/lib/pluginPage.ts +58 -0
- package/src/lib/plural.ts +181 -0
- package/src/lib/popupLayer.ts +103 -0
- package/src/lib/prefs.ts +563 -0
- package/src/lib/productVersion.ts +35 -0
- package/src/lib/publicLayout.ts +39 -0
- package/src/lib/serviceGate.ts +104 -0
- package/src/lib/shareTtl.ts +25 -11
- package/src/lib/signFonts.ts +85 -0
- package/src/lib/signaturePad.ts +111 -0
- package/src/lib/sortOrder.ts +100 -38
- package/src/lib/star.ts +7 -1
- package/src/lib/surfaceCell.ts +41 -0
- package/src/lib/surfaceConditions.ts +156 -0
- package/src/lib/surfaceOpen.ts +106 -0
- package/src/lib/surfaceSteps.ts +68 -0
- package/src/lib/surfaceValues.ts +191 -0
- package/src/lib/symlink.ts +150 -0
- package/src/lib/tableColumns.ts +493 -0
- package/src/lib/tableEnv.ts +34 -0
- package/src/lib/tablePrefs.ts +84 -0
- package/src/lib/tags.ts +121 -12
- package/src/lib/themes.ts +286 -18
- package/src/lib/tour.ts +152 -0
- package/src/lib/trashEmpty.ts +111 -0
- package/src/lib/trashTimeLeft.ts +23 -0
- package/src/lib/uiLocales.ts +460 -0
- package/src/lib/viewPrefs.ts +696 -660
- package/src/lib/viewPrefsHttp.ts +78 -0
- package/src/locales/en.ts +522 -119
- package/src/locales/resolve.ts +17 -8
- package/src/locales/tr.ts +539 -155
- package/src/modals/ConvertModal.vue +62 -11
- package/src/modals/DestinationPickerModal.vue +59 -16
- package/src/modals/Modal.vue +113 -23
- package/src/modals/NewDocumentModal.vue +16 -6
- package/src/modals/NewFolderModal.vue +8 -0
- package/src/modals/PermissionsModal.vue +272 -163
- package/src/modals/PreviewModal.vue +119 -20
- package/src/modals/RenameModal.vue +37 -1
- package/src/styles/base.css +2895 -241
- package/src/styles/sign-fonts.css +118 -0
- package/src/styles/variables.css +60 -0
- package/src/types/Connections.ts +17 -0
- package/src/types/ExplorerConfig.ts +117 -1
- package/src/types/FileNode.ts +58 -0
- package/src/types/Plugins.ts +494 -0
- package/src/types/Public.ts +212 -0
- package/src/viewers/ArchiveViewer.vue +93 -48
- package/src/viewers/CsvViewer.vue +99 -53
- package/src/viewers/DrawioViewer.vue +35 -9
- package/src/viewers/EpubViewer.vue +7 -8
- package/src/viewers/IpynbViewer.vue +3 -1
- package/src/viewers/MermaidViewer.vue +4 -5
- package/src/viewers/PdfViewer.vue +8 -17
- package/src/viewers/PsdViewer.vue +2 -4
- package/src/viewers/TiffViewer.vue +4 -5
- package/src/viewers/Viewer3D.vue +1 -1
- package/LICENSE +0 -21
- package/dist/ArchiveViewer-CLNevfPE.js +0 -124
- package/dist/ArchiveViewer-CLNevfPE.js.map +0 -1
- package/dist/CsvViewer-K8XA3z7N.js +0 -141
- package/dist/CsvViewer-K8XA3z7N.js.map +0 -1
- package/dist/DrawioViewer-BuhZOAx6.js.map +0 -1
- package/dist/EpubViewer-DS3mb3k8.js.map +0 -1
- package/dist/IpynbViewer-BTk7p4Dn.js +0 -184
- package/dist/IpynbViewer-BTk7p4Dn.js.map +0 -1
- package/dist/MermaidViewer-DqucH_io.js +0 -137
- package/dist/MermaidViewer-DqucH_io.js.map +0 -1
- package/dist/PsdViewer-CwiVENmq.js +0 -122
- package/dist/PsdViewer-CwiVENmq.js.map +0 -1
- package/dist/TiffViewer-CpewSVmQ.js +0 -142
- package/dist/TiffViewer-CpewSVmQ.js.map +0 -1
- package/dist/Viewer3D-CIrOn63I.js.map +0 -1
- package/dist/index-Dx0IbBcI.js +0 -26831
- package/dist/index-Dx0IbBcI.js.map +0 -1
- package/dist/useViewerFetch-czqbd2Lj.js +0 -25
- package/dist/useViewerFetch-czqbd2Lj.js.map +0 -1
|
@@ -27,14 +27,41 @@
|
|
|
27
27
|
|
|
28
28
|
import { computed, getCurrentInstance, inject, type Ref } from 'vue';
|
|
29
29
|
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
30
|
-
import {
|
|
30
|
+
import { localeOwnTable, localesVersion, type OwnLocaleTable } from '../lib/uiLocales';
|
|
31
|
+
import { pluralCategory } from '../lib/plural';
|
|
32
|
+
// ⚠ The table itself, not `../locales` (its index re-exports `locales/resolve`,
|
|
33
|
+
// which reads `lib/uiLocales` — the cycle uiLocales avoids the same way).
|
|
34
|
+
import { en as EN } from '../locales/en';
|
|
35
|
+
import { foreignText, isolateLtrRuns, isolateValue, localeDir } from '../lib/direction';
|
|
31
36
|
import { EXPLORER_CLOCK, activeTimeZoneFor, deviceTimeZone } from '../lib/timezone';
|
|
32
37
|
|
|
33
38
|
/* ── the tag ──────────────────────────────────────────────────────────── */
|
|
34
39
|
|
|
35
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* The BCP-47 tag a catalogue code means. ONE mapping, for every surface.
|
|
42
|
+
*
|
|
43
|
+
* ⚠ `tr` → `tr-TR` and `en` (or nothing) → `en-US` are pinned — the two
|
|
44
|
+
* catalogues the product ships, and what every date and size on screen has
|
|
45
|
+
* been measured against. Every OTHER code is a language pack's, and it is
|
|
46
|
+
* its own tag: it used to fall through to `en-US`, so a Spanish interface
|
|
47
|
+
* printed "Sep 12, 2026, 3:25 PM" beside Spanish words (measured 2026-09-21).
|
|
48
|
+
* A pack that needs a region's conventions names itself with the region
|
|
49
|
+
* (`es-mx`, `pt-br`) and gets them; `Intl` canonicalises the case.
|
|
50
|
+
*/
|
|
51
|
+
const tagCache = new Map<string, string>();
|
|
36
52
|
export function localeTag(code: LocaleCode | string | undefined): string {
|
|
37
|
-
|
|
53
|
+
if (!code || code === 'en') return 'en-US';
|
|
54
|
+
if (code === 'tr') return 'tr-TR';
|
|
55
|
+
let tag = tagCache.get(code);
|
|
56
|
+
if (!tag) {
|
|
57
|
+
try {
|
|
58
|
+
tag = Intl.getCanonicalLocales(code)[0] || 'en-US';
|
|
59
|
+
} catch {
|
|
60
|
+
tag = 'en-US';
|
|
61
|
+
}
|
|
62
|
+
tagCache.set(code, tag);
|
|
63
|
+
}
|
|
64
|
+
return tag;
|
|
38
65
|
}
|
|
39
66
|
|
|
40
67
|
/* ── bytes ────────────────────────────────────────────────────────────── */
|
|
@@ -110,9 +137,11 @@ export interface ByteSizeOptions {
|
|
|
110
137
|
* back as "10 GB" and a stored volume matches the invoice beside it.
|
|
111
138
|
*
|
|
112
139
|
* `base: 1024` exists for exactly one reason: mirroring a number some other
|
|
113
|
-
* program already printed.
|
|
114
|
-
*
|
|
115
|
-
*
|
|
140
|
+
* program already printed. Nothing in the product needs it today — the share
|
|
141
|
+
* message used it to copy the server's e-mail (`humanSize()`, base 1024), and
|
|
142
|
+
* the server writes sizes by THESE rules now (backend/internal/srvtext
|
|
143
|
+
* `Bytes`), so the message, the mail and the listing say one thing. If a
|
|
144
|
+
* mirror is ever needed again it is an argument here, not a fifth copy.
|
|
116
145
|
*/
|
|
117
146
|
export function formatByteSize(
|
|
118
147
|
bytes: number | null | undefined,
|
|
@@ -215,6 +244,37 @@ export function formatInstant(
|
|
|
215
244
|
}
|
|
216
245
|
}
|
|
217
246
|
|
|
247
|
+
const DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
248
|
+
const dayFormatters = new Map<string, Intl.DateTimeFormat>();
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* A calendar DAY (`YYYY-MM-DD`) for a human: the same words formatDate's date
|
|
252
|
+
* part prints ("29 Eyl 2026"), but read as that day, not as an instant — it is
|
|
253
|
+
* formatted in UTC, so no viewer's time zone can move a due date to the day
|
|
254
|
+
* before. '' for anything that is not such a day.
|
|
255
|
+
*
|
|
256
|
+
* ⚠ Here, beside formatInstant, because this file is the home of "a date for
|
|
257
|
+
* a human" (web/tests/quality/duplication.test.ts, `datetime-format`): an
|
|
258
|
+
* app's date column (lib/surfaceCell) must not grow a formatter of its own.
|
|
259
|
+
*/
|
|
260
|
+
export function formatCalendarDay(day: string, code: LocaleCode | string | undefined): string {
|
|
261
|
+
const m = DAY.exec(day.trim());
|
|
262
|
+
if (!m) return '';
|
|
263
|
+
const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
|
|
264
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
265
|
+
const tag = localeTag(code);
|
|
266
|
+
let fmt = dayFormatters.get(tag);
|
|
267
|
+
try {
|
|
268
|
+
if (!fmt) {
|
|
269
|
+
fmt = new Intl.DateTimeFormat(tag, { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
|
|
270
|
+
dayFormatters.set(tag, fmt);
|
|
271
|
+
}
|
|
272
|
+
return fmt.format(d);
|
|
273
|
+
} catch {
|
|
274
|
+
return '';
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
218
278
|
/** Seconds or milliseconds — the backend has sent both. Normalised once. */
|
|
219
279
|
function toDate(ms: number | undefined | null): Date | null {
|
|
220
280
|
if (!ms) return null;
|
|
@@ -222,6 +282,67 @@ function toDate(ms: number | undefined | null): Date | null {
|
|
|
222
282
|
return Number.isNaN(d.getTime()) ? null : d;
|
|
223
283
|
}
|
|
224
284
|
|
|
285
|
+
/** Anything a date arrives as: epoch seconds or milliseconds, an ISO string,
|
|
286
|
+
* a Date. Null for nothing or for something that is not an instant. */
|
|
287
|
+
export type WhenInput = number | string | Date | null | undefined;
|
|
288
|
+
|
|
289
|
+
function instantOf(value: WhenInput): Date | null {
|
|
290
|
+
if (value == null || value === '') return null;
|
|
291
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
|
292
|
+
if (typeof value === 'number') return toDate(value);
|
|
293
|
+
const d = new Date(value);
|
|
294
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const DATE_PART: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' };
|
|
298
|
+
// ⚠ The clock is the LANGUAGE's short time, not a field list: `hour: 'numeric'`
|
|
299
|
+
// printed Turkish "8:05" where Turkish writes "08:05", and `'2-digit'` would
|
|
300
|
+
// print English "08:05 AM". Only noticed on CI: its zone is UTC, so the
|
|
301
|
+
// test's 08:05Z stayed before ten there and became 11:05 at +3 (2026-09-24).
|
|
302
|
+
const TIME_PART: Intl.DateTimeFormatOptions = { timeStyle: 'short' };
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* An instant as a person reads it — THE date format of the product: the
|
|
306
|
+
* explorer's, "Sep 21, 2026, 2:50 PM" / "21 Eyl 2026, 14:50", in the viewer's
|
|
307
|
+
* language and on the viewer's chosen clock. `time: true` adds the clock.
|
|
308
|
+
*
|
|
309
|
+
* ⚠⚠ One definition for every surface (QA, 2026-09-21): the explorer printed
|
|
310
|
+
* "21 Eyl 2026, 14:50", the admin tables and the share dialog "21 Eyl 2026
|
|
311
|
+
* 15:02" (a `dateStyle: 'medium'` pair and a `day: '2-digit', hour: '2-digit'`
|
|
312
|
+
* set of their own), and English times read "2:50 PM" on one screen and
|
|
313
|
+
* "02:51 PM" on the next. The admin panel's `formatDate`, `useLocale`'s
|
|
314
|
+
* `formatDate` and the share lines all call this now; nothing else builds a
|
|
315
|
+
* date formatter for a person to read (scripts/dup-scan.mjs, `datetime-format`).
|
|
316
|
+
*/
|
|
317
|
+
export function formatWhen(
|
|
318
|
+
value: WhenInput,
|
|
319
|
+
code: LocaleCode | string | undefined,
|
|
320
|
+
opts: { time?: boolean } = {},
|
|
321
|
+
/** The explorer whose clock applies (EXPLORER_CLOCK); omitted = page-wide. */
|
|
322
|
+
owner?: symbol,
|
|
323
|
+
): string {
|
|
324
|
+
const d = instantOf(value);
|
|
325
|
+
if (!d) return '';
|
|
326
|
+
const date = formatInstant(d, code, DATE_PART, owner);
|
|
327
|
+
return opts.time ? `${date}, ${formatInstant(d, code, TIME_PART, owner)}` : date;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The whole truth, for a tooltip behind a short date: full date, seconds, the
|
|
332
|
+
* zone's name AND its IANA id — "whose 14:05 is this?" answered. The IANA id
|
|
333
|
+
* rides along because half the world's zones display as "GMT+3".
|
|
334
|
+
*/
|
|
335
|
+
export function formatWhenFull(
|
|
336
|
+
value: WhenInput,
|
|
337
|
+
code: LocaleCode | string | undefined,
|
|
338
|
+
owner?: symbol,
|
|
339
|
+
): string {
|
|
340
|
+
const d = instantOf(value);
|
|
341
|
+
if (!d) return '';
|
|
342
|
+
const zone = activeTimeZoneFor(owner) ?? deviceTimeZone();
|
|
343
|
+
return `${formatInstant(d, code, { dateStyle: 'full', timeStyle: 'long' }, owner)} (${zone})`;
|
|
344
|
+
}
|
|
345
|
+
|
|
225
346
|
/* ── counted messages ─────────────────────────────────────────────────── */
|
|
226
347
|
|
|
227
348
|
/**
|
|
@@ -231,8 +352,14 @@ function toDate(ms: number | undefined | null): Date | null {
|
|
|
231
352
|
export const COUNT_VARS = ['count', 'n', 'days'] as const;
|
|
232
353
|
|
|
233
354
|
/**
|
|
234
|
-
* Which
|
|
235
|
-
*
|
|
355
|
+
* Which key of ONE table a counted message reads: `key_<category>` when the
|
|
356
|
+
* count falls in a category other than `other` in `lang` (CLDR, via
|
|
357
|
+
* `lib/plural`) and the table has that form, otherwise the plain `key` — which
|
|
358
|
+
* IS the `other` form (there is no `_other` key).
|
|
359
|
+
*
|
|
360
|
+
* The forms: `_zero`, `_one`, `_two`, `_few`, `_many`. A language writes the
|
|
361
|
+
* ones it has — English and Turkish `_one`, Russian `_one` `_few` `_many`,
|
|
362
|
+
* Arabic all five — and never `_other`.
|
|
236
363
|
*
|
|
237
364
|
* ⚠⚠ ONE rule, here, for every counted string in the package. The catalogue
|
|
238
365
|
* is a flat table with no plural machinery, so a `{n} items` message printed
|
|
@@ -242,43 +369,162 @@ export const COUNT_VARS = ['count', 'n', 'days'] as const;
|
|
|
242
369
|
* and the empty-trash confirmation); every other counted message had not, and
|
|
243
370
|
* a few hedged with "item(s)". A rule each caller has to remember is a rule
|
|
244
371
|
* the next caller forgets, so `t()` applies it and the catalogue only has to
|
|
245
|
-
* SAY the
|
|
372
|
+
* SAY the forms.
|
|
373
|
+
*
|
|
374
|
+
* ⚠ Until v0.43.0 the rule was English's alone ("`_one` for exactly 1"),
|
|
375
|
+
* which no language with few/many can be written in — the Arabic pack could
|
|
376
|
+
* not say "2 files" at all.
|
|
246
377
|
*
|
|
247
378
|
* ⚠ Turkish does not inflect a noun after a number ("1 öğe", "5 öğe"), so its
|
|
248
379
|
* `_one` entries read exactly like the plain ones. They exist for key parity,
|
|
249
|
-
* not because the language needs them. `web/tests/i18n/
|
|
380
|
+
* not because the language needs them. `web/tests/i18n/plurals.test.ts`
|
|
250
381
|
* fails an English counted message that has no singular.
|
|
382
|
+
*
|
|
383
|
+
* `lang` defaults to English — the rule this function had before it had the
|
|
384
|
+
* argument. `t()` does not call this on a merged table; see `pickMessage`.
|
|
251
385
|
*/
|
|
252
386
|
export function countedKey(
|
|
253
387
|
key: string,
|
|
254
388
|
vars: Record<string, string | number>,
|
|
255
389
|
has: (key: string) => boolean,
|
|
390
|
+
lang = 'en',
|
|
256
391
|
): string {
|
|
257
392
|
const counter = COUNT_VARS.find((v) => v in vars);
|
|
258
|
-
if (counter === undefined
|
|
259
|
-
const
|
|
260
|
-
|
|
393
|
+
if (counter === undefined) return key;
|
|
394
|
+
const cat = pluralCategory(lang, Number(vars[counter]));
|
|
395
|
+
if (cat === 'other') return key;
|
|
396
|
+
const form = `${key}_${cat}`;
|
|
397
|
+
return has(form) ? form : key;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** A catalogue value, or undefined — never an `Object.prototype` member. */
|
|
401
|
+
function entry(table: Readonly<Record<string, string>>, key: string): string | undefined {
|
|
402
|
+
const v = table[key];
|
|
403
|
+
return typeof v === 'string' && v ? v : undefined;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* The raw (uninterpolated) message a key reads in a language.
|
|
408
|
+
*
|
|
409
|
+
* 1. the form the count's category calls for, if the language WROTE it;
|
|
410
|
+
* 2. else the language's own plain `key` (its `other` form);
|
|
411
|
+
* 3. else English as a whole — English's form by English's rule
|
|
412
|
+
* (`key_one` for 1), then English's plain `key`;
|
|
413
|
+
* 4. else the key itself.
|
|
414
|
+
*
|
|
415
|
+
* ⚠⚠ Step 2 before step 3, and `own` must be the language's OWN words
|
|
416
|
+
* (`localeOwnTable`), not the English-under-pack merge (`localeTable`). With
|
|
417
|
+
* the merge, a Spanish pack that wrote only `toast.restored` still "had"
|
|
418
|
+
* `toast.restored_one` — the English one underneath — and a count of one
|
|
419
|
+
* printed "1 item restored" in the middle of a Spanish screen. A language's
|
|
420
|
+
* own plain form, even if it reads as a plural for 1, is its translator's
|
|
421
|
+
* choice; an English word in its place is nobody's.
|
|
422
|
+
*/
|
|
423
|
+
export function pickMessage(
|
|
424
|
+
own: OwnLocaleTable,
|
|
425
|
+
key: string,
|
|
426
|
+
vars: Record<string, string | number> = {},
|
|
427
|
+
): string {
|
|
428
|
+
const mine = own.strings;
|
|
429
|
+
const form = countedKey(key, vars, (k) => entry(mine, k) !== undefined, own.lang);
|
|
430
|
+
const said = entry(mine, form) ?? entry(mine, key);
|
|
431
|
+
if (said !== undefined) return said;
|
|
432
|
+
if (mine === EN) return key;
|
|
433
|
+
const enForm = countedKey(key, vars, (k) => entry(EN, k) !== undefined, 'en');
|
|
434
|
+
return entry(EN, enForm) ?? entry(EN, key) ?? key;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** `{name}` placeholders, replaced verbatim. */
|
|
438
|
+
function interpolate(raw: string, vars: Record<string, string | number>): string {
|
|
439
|
+
return Object.entries(vars).reduce(
|
|
440
|
+
(acc, [k, v]) => acc.replaceAll(`{${k}}`, String(v)),
|
|
441
|
+
raw,
|
|
442
|
+
);
|
|
261
443
|
}
|
|
262
444
|
|
|
263
|
-
|
|
264
|
-
|
|
445
|
+
/**
|
|
446
|
+
* One message, rendered: the plural form picked on the ORIGINAL vars (a count
|
|
447
|
+
* wrapped in isolation marks is no longer a number to pick on), the values
|
|
448
|
+
* put in, and — RTL only — a name interpolated into the sentence isolated so
|
|
449
|
+
* its own letters decide its direction (`isolateValue`, strings only), and a
|
|
450
|
+
* `3 / 10` or a `tag:…` kept reading left to right (`isolateLtrRuns`). A
|
|
451
|
+
* left-to-right string comes back exactly as it always did.
|
|
452
|
+
*
|
|
453
|
+
* ⚠ The one path for `t()` and `translate()`, so a sentence built outside a
|
|
454
|
+
* component (lib/shareTtl) reads the same way under Arabic as one inside.
|
|
455
|
+
*/
|
|
456
|
+
function render(
|
|
457
|
+
table: OwnLocaleTable,
|
|
458
|
+
key: string,
|
|
459
|
+
vars: Record<string, string | number>,
|
|
460
|
+
rtl: boolean,
|
|
461
|
+
): string {
|
|
462
|
+
const raw = pickMessage(table, key, vars);
|
|
463
|
+
if (!rtl) return interpolate(raw, vars);
|
|
464
|
+
const isolated: Record<string, string | number> = {};
|
|
465
|
+
for (const [k, v] of Object.entries(vars)) isolated[k] = typeof v === 'string' ? isolateValue(v) : v;
|
|
466
|
+
return isolateLtrRuns(interpolate(raw, isolated));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* `t()` for code that is not a component (`lib/shareTtl`): the same lookup,
|
|
471
|
+
* the same plural rule, the same placeholders. ⚠ Not reactive — a caller that
|
|
472
|
+
* renders the result in a template must read `localesVersion` itself.
|
|
473
|
+
*/
|
|
474
|
+
export function translate(
|
|
475
|
+
code: LocaleCode | string | undefined,
|
|
476
|
+
key: string,
|
|
477
|
+
vars: Record<string, string | number> = {},
|
|
478
|
+
): string {
|
|
479
|
+
return render(localeOwnTable(code), key, vars, localeDir(code) === 'rtl');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export function useLocale(
|
|
483
|
+
/**
|
|
484
|
+
* ⚠ Widened past `LocaleCode` on purpose (v3 §5). The list of languages
|
|
485
|
+
* the interface offers is no longer the two this package ships: an app
|
|
486
|
+
* plugin may add one (`lib/uiLocales`), so the active code can be a tag
|
|
487
|
+
* this file has never heard of. It resolves through `localeOwnTable`, and
|
|
488
|
+
* every key the language does not have reads English.
|
|
489
|
+
*/
|
|
490
|
+
localeRef: Ref<LocaleCode | string> | (() => LocaleCode | string),
|
|
491
|
+
) {
|
|
492
|
+
const code = (): string =>
|
|
265
493
|
typeof localeRef === 'function' ? localeRef() : localeRef.value;
|
|
266
494
|
|
|
267
|
-
|
|
495
|
+
/**
|
|
496
|
+
* ⚠ `localesVersion` is READ here so the table is re-derived when an app
|
|
497
|
+
* installs or removes a language. Without it a screen already on the page
|
|
498
|
+
* would keep the table it was mounted with — an app's own words would
|
|
499
|
+
* arrive one navigation late, which is the kind of miss nobody reports as
|
|
500
|
+
* a bug and everybody works around.
|
|
501
|
+
*/
|
|
502
|
+
const lookup = computed(() => {
|
|
503
|
+
void localesVersion.value;
|
|
504
|
+
return localeOwnTable(code());
|
|
505
|
+
});
|
|
268
506
|
|
|
269
507
|
// The explorer this component sits in, whose clock its dates are read on
|
|
270
508
|
// (lib/timezone, timeZoneSourcesFor). Outside a component, or outside any
|
|
271
509
|
// explorer, the page-wide answer.
|
|
272
510
|
const clock = getCurrentInstance() ? inject(EXPLORER_CLOCK, undefined) : undefined;
|
|
273
511
|
|
|
512
|
+
/** ⚠ RTL — which way this surface's language is written (lib/direction). */
|
|
513
|
+
const dir = computed(() => localeDir(code()));
|
|
514
|
+
|
|
274
515
|
function t(key: string, vars: Record<string, string | number> = {}): string {
|
|
275
|
-
|
|
276
|
-
const raw = table[countedKey(key, vars, (k) => k in table)] ?? key;
|
|
277
|
-
return Object.entries(vars).reduce(
|
|
278
|
-
(acc, [k, v]) => acc.replaceAll(`{${k}}`, String(v)),
|
|
279
|
-
raw,
|
|
280
|
-
);
|
|
516
|
+
return render(lookup.value, key, vars, dir.value === 'rtl');
|
|
281
517
|
}
|
|
518
|
+
/**
|
|
519
|
+
* ⚠⚠ Text filex did NOT write, said the way this surface's language needs:
|
|
520
|
+
* the server's own sentence, an installed app's words, a notification built
|
|
521
|
+
* from what happened. None of it passes through `t()`, so none of it was
|
|
522
|
+
* isolated — see lib/direction `foreignText`. It rides ON `t` so that the
|
|
523
|
+
* helpers a component hands its translator to (lib/errorWords `sayFailure`,
|
|
524
|
+
* `jobFailure`, `opFailure`) get the reader's direction without five more
|
|
525
|
+
* signatures to thread a locale code through.
|
|
526
|
+
*/
|
|
527
|
+
t.foreign = (text: string): string => foreignText(code(), text);
|
|
282
528
|
|
|
283
529
|
/** A byte count in the viewer's language and number format. */
|
|
284
530
|
function formatSize(bytes: number | undefined | null): string {
|
|
@@ -328,20 +574,7 @@ export function useLocale(localeRef: Ref<LocaleCode> | (() => LocaleCode)) {
|
|
|
328
574
|
* device's zone, resolved live by Intl rather than frozen at first run.
|
|
329
575
|
*/
|
|
330
576
|
function formatDate(ms: number | undefined | null, opts: { time?: boolean } = {}): string {
|
|
331
|
-
|
|
332
|
-
if (!d) return '';
|
|
333
|
-
const date = formatInstant(
|
|
334
|
-
d,
|
|
335
|
-
code(),
|
|
336
|
-
{
|
|
337
|
-
month: 'short',
|
|
338
|
-
day: 'numeric',
|
|
339
|
-
year: 'numeric',
|
|
340
|
-
},
|
|
341
|
-
clock,
|
|
342
|
-
);
|
|
343
|
-
if (!opts.time) return date;
|
|
344
|
-
return `${date}, ${formatInstant(d, code(), { hour: 'numeric', minute: '2-digit' }, clock)}`;
|
|
577
|
+
return formatWhen(ms, code(), opts, clock);
|
|
345
578
|
}
|
|
346
579
|
|
|
347
580
|
/**
|
|
@@ -356,11 +589,7 @@ export function useLocale(localeRef: Ref<LocaleCode> | (() => LocaleCode)) {
|
|
|
356
589
|
* zones render as "GMT+3".
|
|
357
590
|
*/
|
|
358
591
|
function formatDateFull(ms: number | undefined | null): string {
|
|
359
|
-
|
|
360
|
-
if (!d) return '';
|
|
361
|
-
const zone = activeTimeZoneFor(clock) ?? deviceTimeZone();
|
|
362
|
-
const stamp = formatInstant(d, code(), { dateStyle: 'full', timeStyle: 'long' }, clock);
|
|
363
|
-
return `${stamp} (${zone})`;
|
|
592
|
+
return formatWhenFull(ms, code(), clock);
|
|
364
593
|
}
|
|
365
594
|
|
|
366
595
|
/**
|
|
@@ -414,5 +643,12 @@ export function useLocale(localeRef: Ref<LocaleCode> | (() => LocaleCode)) {
|
|
|
414
643
|
*/
|
|
415
644
|
toDate,
|
|
416
645
|
nodeDisplayName,
|
|
646
|
+
/**
|
|
647
|
+
* ⚠ RTL — which way this surface's language is written (`lib/direction`,
|
|
648
|
+
* the server's one list). Bind it as `dir` on anything Teleported out of
|
|
649
|
+
* the explorer's root: under `<body>` a menu inherits the HOST page's
|
|
650
|
+
* direction, not the language its own words are in.
|
|
651
|
+
*/
|
|
652
|
+
dir,
|
|
417
653
|
};
|
|
418
654
|
}
|
|
@@ -11,6 +11,7 @@ import type { ExplorerConfig } from '../types/ExplorerConfig';
|
|
|
11
11
|
import type { NFSConnection, NFSExport, NFSExportCreated } from '../types/NFSExports';
|
|
12
12
|
import { connectionsBase } from './useConnections';
|
|
13
13
|
import { useFileApi } from './useFileApi';
|
|
14
|
+
import { serverWords } from '../lib/errorWords';
|
|
14
15
|
|
|
15
16
|
export function useNFSExports(config: ExplorerConfig) {
|
|
16
17
|
const api = useFileApi(config);
|
|
@@ -26,17 +27,14 @@ export function useNFSExports(config: ExplorerConfig) {
|
|
|
26
27
|
/** The path, held only while the user is looking at it. */
|
|
27
28
|
const revealed = ref<NFSExportCreated | null>(null);
|
|
28
29
|
|
|
30
|
+
/** A refusal, said — lib/errorWords `serverWords`, the one rule every
|
|
31
|
+
* connection panel shares (it used to print the server's `error` field as
|
|
32
|
+
* it came: an environment variable, to a regular user). ⚠ The locale is
|
|
33
|
+
* passed because what comes back may be the SERVER's own sentence: it
|
|
34
|
+
* never met a translator, and an Arabic panel needs its machine runs
|
|
35
|
+
* isolated (lib/direction `foreignText`). */
|
|
29
36
|
function messageOf(e: unknown): string {
|
|
30
|
-
|
|
31
|
-
if (err?.detail) {
|
|
32
|
-
try {
|
|
33
|
-
const parsed = JSON.parse(err.detail) as { error?: string };
|
|
34
|
-
if (parsed?.error) return parsed.error;
|
|
35
|
-
} catch {
|
|
36
|
-
/* not JSON */
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return err?.message || String(e);
|
|
37
|
+
return serverWords(e, config.locale);
|
|
40
38
|
}
|
|
41
39
|
|
|
42
40
|
function statusOf(e: unknown): number | undefined {
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
|
|
26
26
|
import { computed, getCurrentScope, onScopeDispose, ref } from 'vue';
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
/** `plugin` — an app-plugin job (docs/APP-PLUGINS-API.md); also what any
|
|
29
|
+
* queue kind this package has no drawing for is shown as. */
|
|
30
|
+
export type OperationKind = 'upload' | 'copy' | 'move' | 'delete' | 'convert' | 'archive' | 'plugin' | 'trash';
|
|
29
31
|
export type OperationStatus = 'running' | 'done' | 'error' | 'aborted';
|
|
30
32
|
|
|
31
33
|
/** What a publisher hands to `sync()` for one row. */
|
|
@@ -38,7 +40,11 @@ export interface OperationInput {
|
|
|
38
40
|
/** 0..100, or null for indeterminate (renderer shows a spinner). */
|
|
39
41
|
percent: number | null;
|
|
40
42
|
status: OperationStatus;
|
|
43
|
+
/** The failure, SAID (lib/errorWords) — never a raw server string. */
|
|
41
44
|
error?: string | null;
|
|
45
|
+
/** The raw words behind it — set only for a caller who administers the
|
|
46
|
+
* instance; drawn as a second line under the sentence. */
|
|
47
|
+
errorDetail?: string | null;
|
|
42
48
|
/** Queue op accepted but not started yet ("Queued"). */
|
|
43
49
|
queued?: boolean;
|
|
44
50
|
/** Progress counters for queue ops (3/5 items). */
|
|
@@ -49,6 +55,20 @@ export interface OperationInput {
|
|
|
49
55
|
totalBytes?: number;
|
|
50
56
|
cancellable?: boolean;
|
|
51
57
|
retryable?: boolean;
|
|
58
|
+
/** A plugin job's last progress message — shown under the name while it runs. */
|
|
59
|
+
message?: string | null;
|
|
60
|
+
/** Files a finished job produced (adapter-qualified paths); the row offers "Open". */
|
|
61
|
+
outputs?: string[];
|
|
62
|
+
/**
|
|
63
|
+
* An app-plugin job's output mode — `sibling` (a new file beside the
|
|
64
|
+
* original) or `version` (the same file, a new version of it).
|
|
65
|
+
*
|
|
66
|
+
* ⚠ It is on the row because the two are indistinguishable once the job
|
|
67
|
+
* lands: "Open" navigates to `outputs[0]`, which in `version` mode IS the
|
|
68
|
+
* input the person started from, so without the chip the tray reads as if
|
|
69
|
+
* nothing was produced. Absent for every other kind.
|
|
70
|
+
*/
|
|
71
|
+
outputMode?: string | null;
|
|
52
72
|
}
|
|
53
73
|
|
|
54
74
|
export interface OperationActions {
|
|
@@ -56,6 +76,8 @@ export interface OperationActions {
|
|
|
56
76
|
/** Clean the row out of the OWNING source list (called on retire/dismiss). */
|
|
57
77
|
dismiss?: () => void;
|
|
58
78
|
retry?: () => void;
|
|
79
|
+
/** Reveal one of the row's outputs in the listing. */
|
|
80
|
+
open?: (path: string) => void;
|
|
59
81
|
}
|
|
60
82
|
|
|
61
83
|
export interface Operation {
|
|
@@ -66,6 +88,7 @@ export interface Operation {
|
|
|
66
88
|
percent: number | null;
|
|
67
89
|
status: OperationStatus;
|
|
68
90
|
error: string | null;
|
|
91
|
+
errorDetail: string | null;
|
|
69
92
|
queued: boolean;
|
|
70
93
|
doneCount: number | null;
|
|
71
94
|
totalCount: number | null;
|
|
@@ -73,6 +96,9 @@ export interface Operation {
|
|
|
73
96
|
totalBytes: number | null;
|
|
74
97
|
cancellable: boolean;
|
|
75
98
|
retryable: boolean;
|
|
99
|
+
message: string | null;
|
|
100
|
+
outputs: string[];
|
|
101
|
+
outputMode: string | null;
|
|
76
102
|
startedAt: number;
|
|
77
103
|
settledAt: number | null;
|
|
78
104
|
}
|
|
@@ -89,6 +115,7 @@ function toOperation(key: string, input: OperationInput, prev?: Operation): Oper
|
|
|
89
115
|
percent: input.percent,
|
|
90
116
|
status: input.status,
|
|
91
117
|
error: input.error ?? null,
|
|
118
|
+
errorDetail: input.errorDetail ?? null,
|
|
92
119
|
queued: input.queued ?? false,
|
|
93
120
|
doneCount: input.doneCount ?? null,
|
|
94
121
|
totalCount: input.totalCount ?? null,
|
|
@@ -96,6 +123,9 @@ function toOperation(key: string, input: OperationInput, prev?: Operation): Oper
|
|
|
96
123
|
totalBytes: input.totalBytes ?? null,
|
|
97
124
|
cancellable: input.cancellable ?? false,
|
|
98
125
|
retryable: input.retryable ?? false,
|
|
126
|
+
message: input.message ?? null,
|
|
127
|
+
outputs: input.outputs ?? [],
|
|
128
|
+
outputMode: input.outputMode ?? null,
|
|
99
129
|
startedAt: prev?.startedAt ?? Date.now(),
|
|
100
130
|
settledAt: prev?.settledAt ?? (terminal ? Date.now() : null),
|
|
101
131
|
};
|
|
@@ -204,8 +234,11 @@ export function useOperations() {
|
|
|
204
234
|
}
|
|
205
235
|
for (const key of [...actions.keys()]) {
|
|
206
236
|
if (key.startsWith(prefix) && !present.has(key) && !active.value.some((o) => o.key === key)) {
|
|
207
|
-
// keep actions for rows still on screen (sticky errors)
|
|
208
|
-
|
|
237
|
+
// keep actions for rows still on screen (sticky errors) and for
|
|
238
|
+
// history rows that can still be opened (a finished job's outputs)
|
|
239
|
+
if (next.some((o) => o.key === key)) continue;
|
|
240
|
+
if (history.value.some((o) => o.key === key && o.outputs.length > 0)) continue;
|
|
241
|
+
actions.delete(key);
|
|
209
242
|
}
|
|
210
243
|
}
|
|
211
244
|
|
|
@@ -229,6 +262,13 @@ export function useOperations() {
|
|
|
229
262
|
acts?.dismiss?.();
|
|
230
263
|
}
|
|
231
264
|
|
|
265
|
+
/** Reveal a finished row's output — the first one unless a path is named. */
|
|
266
|
+
function open(key: string, path?: string) {
|
|
267
|
+
const row = active.value.find((o) => o.key === key) ?? history.value.find((o) => o.key === key);
|
|
268
|
+
const target = path ?? row?.outputs[0];
|
|
269
|
+
if (target) actions.get(key)?.open?.(target);
|
|
270
|
+
}
|
|
271
|
+
|
|
232
272
|
function clearHistory() {
|
|
233
273
|
history.value = [];
|
|
234
274
|
}
|
|
@@ -284,6 +324,7 @@ export function useOperations() {
|
|
|
284
324
|
cancel,
|
|
285
325
|
retry,
|
|
286
326
|
dismiss,
|
|
327
|
+
open,
|
|
287
328
|
clearHistory,
|
|
288
329
|
};
|
|
289
330
|
}
|