@brftech/filex-core 0.43.2 → 0.44.1
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/dist/ArchiveViewer-D_tJxJhJ.js +236 -0
- package/dist/ArchiveViewer-D_tJxJhJ.js.map +1 -0
- package/dist/{CsvViewer-I0AceHh8.js → CsvViewer-B95fArq0.js} +2 -2
- package/dist/{CsvViewer-I0AceHh8.js.map → CsvViewer-B95fArq0.js.map} +1 -1
- package/dist/{DrawioViewer-B2vl10hY.js → DrawioViewer-BAaPMTge.js} +2 -2
- package/dist/{DrawioViewer-B2vl10hY.js.map → DrawioViewer-BAaPMTge.js.map} +1 -1
- package/dist/{EpubViewer-L3rxrx5i.js → EpubViewer-B-B7bRW7.js} +2 -2
- package/dist/{EpubViewer-L3rxrx5i.js.map → EpubViewer-B-B7bRW7.js.map} +1 -1
- package/dist/{IpynbViewer-Do8n0RzJ.js → IpynbViewer-COOTyOcu.js} +2 -2
- package/dist/{IpynbViewer-Do8n0RzJ.js.map → IpynbViewer-COOTyOcu.js.map} +1 -1
- package/dist/{MermaidViewer-0JqH6k0f.js → MermaidViewer-CjVCmR3s.js} +2 -2
- package/dist/{MermaidViewer-0JqH6k0f.js.map → MermaidViewer-CjVCmR3s.js.map} +1 -1
- package/dist/{PsdViewer-BnI-NDr8.js → PsdViewer-xsIYL9Gu.js} +2 -2
- package/dist/{PsdViewer-BnI-NDr8.js.map → PsdViewer-xsIYL9Gu.js.map} +1 -1
- package/dist/{TiffViewer-CrLDXI_f.js → TiffViewer-CtwnTxqL.js} +2 -2
- package/dist/{TiffViewer-CrLDXI_f.js.map → TiffViewer-CtwnTxqL.js.map} +1 -1
- package/dist/{Viewer3D-CpB1K0-0.js → Viewer3D-xYMwndEy.js} +2 -2
- package/dist/{Viewer3D-CpB1K0-0.js.map → Viewer3D-xYMwndEy.js.map} +1 -1
- package/dist/filex-core.js +372 -366
- package/dist/filex-core.umd.cjs +62 -62
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/{index-D9_EBsqF.js → index-BBToAjwL.js} +16563 -15727
- package/dist/index-BBToAjwL.js.map +1 -0
- package/dist/index.d.ts +185 -10
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +363 -23
- package/src/components/ContextMenu.vue +17 -0
- package/src/components/HomeView.vue +14 -4
- package/src/components/InspectorPanel.vue +9 -3
- package/src/components/ListView.vue +6 -3
- package/src/components/OperationsCenter.vue +6 -0
- package/src/components/PendingOpsTray.vue +5 -3
- package/src/composables/useFileApi.ts +36 -6
- package/src/composables/useLocale.ts +26 -0
- package/src/composables/useOperations.ts +7 -1
- package/src/composables/usePendingOps.ts +11 -7
- package/src/index.ts +15 -0
- package/src/lib/actionIcons.ts +6 -0
- package/src/lib/archiveFormats.ts +12 -0
- package/src/lib/archivePreviewCache.ts +61 -0
- package/src/lib/catalogCoverage.ts +137 -0
- package/src/locales/en.ts +67 -0
- package/src/locales/tr.ts +65 -0
- package/src/modals/ArchiveCreateModal.vue +192 -0
- package/src/modals/ArchiveExtractModal.vue +57 -0
- package/src/modals/ArchivePasswordModal.vue +62 -0
- package/src/modals/PreviewModal.vue +15 -1
- package/src/styles/base.css +47 -2
- package/src/types/ExplorerConfig.ts +9 -0
- package/src/types/FileNode.ts +19 -0
- package/src/viewers/ArchiveViewer.vue +145 -40
- package/dist/ArchiveViewer-Bn-pwgX8.js +0 -149
- package/dist/ArchiveViewer-Bn-pwgX8.js.map +0 -1
- package/dist/index-D9_EBsqF.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brftech/filex-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.1",
|
|
4
4
|
"description": "filex core — Vue 3 source of truth for the filex file manager (FileExplorer + ConnectionsPanel SFCs, composables, types)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/filex-core.umd.cjs",
|
package/src/FileExplorer.vue
CHANGED
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
ViewMode,
|
|
23
23
|
ClipboardState,
|
|
24
24
|
Capabilities,
|
|
25
|
+
ArchiveCreateFormat,
|
|
25
26
|
} from './types/FileNode';
|
|
26
27
|
import { isExternalUsable } from './types/FileNode';
|
|
27
28
|
import { useFileApi, type GlobalSearchHit, type ManagerResponse } from './composables/useFileApi';
|
|
@@ -67,7 +68,7 @@ import {
|
|
|
67
68
|
import { attachViewPrefsHttp } from './lib/viewPrefsHttp';
|
|
68
69
|
import { provideTableEnv } from './lib/tableEnv';
|
|
69
70
|
import { gateOnService, isOfficeExt, legacyConvertGate } from './lib/serviceGate';
|
|
70
|
-
import { opFailure } from './lib/errorWords';
|
|
71
|
+
import { opFailure, sayFailure } from './lib/errorWords';
|
|
71
72
|
import { resolveUiProfile } from './lib/uiProfile';
|
|
72
73
|
import RecentlyOpened from './components/RecentlyOpened.vue';
|
|
73
74
|
import {
|
|
@@ -96,6 +97,7 @@ import {
|
|
|
96
97
|
} from './lib/advSearch' /* gorunum:v1-advsearch */;
|
|
97
98
|
import ShortcutsHelp from './components/ShortcutsHelp.vue';
|
|
98
99
|
/* /cila:c wiring */
|
|
100
|
+
import { coverageByStorage, coverageNotice, type CatalogCoverage } from './lib/catalogCoverage';
|
|
99
101
|
/* wiring:c1 — tema galerisi */
|
|
100
102
|
import ThemeGallery from './components/ThemeGallery.vue';
|
|
101
103
|
import {
|
|
@@ -201,6 +203,9 @@ import {
|
|
|
201
203
|
} from './lib/dragOut';
|
|
202
204
|
|
|
203
205
|
import NewFolderModal from './modals/NewFolderModal.vue';
|
|
206
|
+
import ArchiveCreateModal from './modals/ArchiveCreateModal.vue';
|
|
207
|
+
import ArchiveExtractModal from './modals/ArchiveExtractModal.vue';
|
|
208
|
+
import ArchivePasswordModal from './modals/ArchivePasswordModal.vue';
|
|
204
209
|
import NewDocumentModal from './modals/NewDocumentModal.vue'; /* belge:n1 */
|
|
205
210
|
import RenameModal from './modals/RenameModal.vue';
|
|
206
211
|
import DeleteConfirmModal from './modals/DeleteConfirmModal.vue';
|
|
@@ -346,11 +351,16 @@ const pendingOps = usePendingOps(props.config, api, {
|
|
|
346
351
|
opUndo.delete(op.id);
|
|
347
352
|
/* "Empty the trash" says how it ended itself (emptyTrash below), and a
|
|
348
353
|
* row somebody cancelled is not announced as done. */
|
|
349
|
-
if (op.op_type === 'trash-empty'
|
|
354
|
+
if (op.op_type === 'trash-empty') {
|
|
350
355
|
void load();
|
|
351
356
|
return;
|
|
352
357
|
}
|
|
353
|
-
if (op.status === '
|
|
358
|
+
if (op.status === 'cancelled') {
|
|
359
|
+
const message = op.op_type === 'archive-extract'
|
|
360
|
+
? t('archive.extraction_cancelled', { count: op.progress_done })
|
|
361
|
+
: t('opc.status.aborted');
|
|
362
|
+
flashToast(message);
|
|
363
|
+
} else if (op.status === 'error') {
|
|
354
364
|
// Said, not printed: the server's error text is English and sometimes
|
|
355
365
|
// plumbing ("engine libreoffice is not installed on this host").
|
|
356
366
|
flashToast(opFailure(op, t).text);
|
|
@@ -361,12 +371,16 @@ const pendingOps = usePendingOps(props.config, api, {
|
|
|
361
371
|
flashToast(op.message || t('plugin.done', { label: pluginOpLabel(op) }));
|
|
362
372
|
} else {
|
|
363
373
|
const verb =
|
|
364
|
-
op.op_type === '
|
|
365
|
-
? t('
|
|
366
|
-
: op.op_type === '
|
|
367
|
-
? t('
|
|
368
|
-
:
|
|
369
|
-
|
|
374
|
+
op.op_type === 'archive-create'
|
|
375
|
+
? t('archive.created')
|
|
376
|
+
: op.op_type === 'archive-extract'
|
|
377
|
+
? t('archive.extraction_completed')
|
|
378
|
+
: op.op_type === 'copy'
|
|
379
|
+
? t('toast.copied')
|
|
380
|
+
: op.op_type === 'move'
|
|
381
|
+
? t('toast.moved')
|
|
382
|
+
: t('toast.deleted');
|
|
383
|
+
flashToast(op.op_type.startsWith('archive-') ? verb : `${verb} (${op.progress_total})`);
|
|
370
384
|
}
|
|
371
385
|
void load();
|
|
372
386
|
void splitPaneRef.value?.reload(); /* wiring:d1 — refresh the secondary pane too */
|
|
@@ -450,6 +464,14 @@ const searchTruncated = ref(false);
|
|
|
450
464
|
* (handlers.Manager, managerSearchPage) — the page an older server's full
|
|
451
465
|
* answer is recognised by. */
|
|
452
466
|
const MANAGER_SEARCH_PAGE = 250;
|
|
467
|
+
/**
|
|
468
|
+
* How much of each storage its catalog covers, from the last response that
|
|
469
|
+
* said (`storage_info` on every listing and search — lib/catalogCoverage).
|
|
470
|
+
* Kept across loads: the content search (`/api/files/search`) does not carry
|
|
471
|
+
* it, and its banner still has to name a storage the catalog does not cover.
|
|
472
|
+
*/
|
|
473
|
+
const coverageMap = ref<Record<string, CatalogCoverage | null>>({});
|
|
474
|
+
const catalogAllBusy = ref(false);
|
|
453
475
|
// trashMode — true while viewing the filex trash (soft-deleted nodes from the
|
|
454
476
|
// backend trash endpoint), entered by opening the virtual `.trash` row and
|
|
455
477
|
// exited by any normal navigation (load() resets it). Replaces a brittle
|
|
@@ -1285,16 +1307,6 @@ async function onSurfaceOpen(plugin: string, req: { path: string; action?: strin
|
|
|
1285
1307
|
await openAppTarget({ plugin, action: req.action, view: req.view, path: req.path });
|
|
1286
1308
|
}
|
|
1287
1309
|
|
|
1288
|
-
/** Cancel from the operations center — the server stops the job. */
|
|
1289
|
-
async function onCancelPendingOp(id: number) {
|
|
1290
|
-
try {
|
|
1291
|
-
await api.opsCancel(id);
|
|
1292
|
-
await pendingOps.poll();
|
|
1293
|
-
} catch {
|
|
1294
|
-
flashToast(t('plugin.cancel_failed'));
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
1310
|
/**
|
|
1299
1311
|
* "Open" on a finished job: go to the output's folder and select it. The
|
|
1300
1312
|
* output is an adapter-qualified path (`docs://reports/nda-signed.pdf`); the
|
|
@@ -1633,6 +1645,45 @@ const showNewDocument = ref(false); /* belge:n1 */
|
|
|
1633
1645
|
const showRename = ref(false);
|
|
1634
1646
|
const showDelete = ref(false);
|
|
1635
1647
|
const showPreview = ref(false);
|
|
1648
|
+
const showArchiveCreate = ref(false);
|
|
1649
|
+
const showArchiveExtract = ref(false);
|
|
1650
|
+
const showArchivePassword = ref(false);
|
|
1651
|
+
const archiveTargets = ref<FileNode[]>([]);
|
|
1652
|
+
const archiveTarget = ref<FileNode | null>(null);
|
|
1653
|
+
const archiveDestinationDir = ref('');
|
|
1654
|
+
const archiveBusy = ref(false);
|
|
1655
|
+
const archiveError = ref('');
|
|
1656
|
+
const archiveInPane = ref(false);
|
|
1657
|
+
const archivePasswordError = ref('');
|
|
1658
|
+
const archivePasswordAction = ref<{
|
|
1659
|
+
target: FileNode;
|
|
1660
|
+
dest: string;
|
|
1661
|
+
inPane: boolean;
|
|
1662
|
+
} | null>(null);
|
|
1663
|
+
|
|
1664
|
+
const archiveSuggestedName = computed(() => {
|
|
1665
|
+
if (archiveTargets.value.length !== 1) return 'archive';
|
|
1666
|
+
return archiveTargets.value[0]?.basename.replace(/\.[^.]+$/, '') || 'archive';
|
|
1667
|
+
});
|
|
1668
|
+
const ARCHIVE_CREATE_FORMATS: ArchiveCreateFormat[] = ['zip', '7z', 'tar', 'tar.gz', 'tar.bz2', 'tar.xz'];
|
|
1669
|
+
/* The formats THIS server can make (capabilities.archive): every one but a
|
|
1670
|
+
* plain ZIP needs 7-Zip there, so a server without it offers ZIP alone. A
|
|
1671
|
+
* server with no `archive` block predates archive creation: nothing is
|
|
1672
|
+
* offered, and "Create archive…" is not in the menu. */
|
|
1673
|
+
const archiveAllowedFormats = computed<ArchiveCreateFormat[]>(() => {
|
|
1674
|
+
const configured = capabilitiesData.value?.archive?.allowed_formats ?? [];
|
|
1675
|
+
return configured.filter((format): format is ArchiveCreateFormat => ARCHIVE_CREATE_FORMATS.includes(format));
|
|
1676
|
+
});
|
|
1677
|
+
const archiveDefaultFormat = computed<ArchiveCreateFormat>(() => {
|
|
1678
|
+
const configured = capabilitiesData.value?.archive?.default_format;
|
|
1679
|
+
if (configured && archiveAllowedFormats.value.includes(configured)) return configured;
|
|
1680
|
+
return archiveAllowedFormats.value.includes('7z') ? '7z' : archiveAllowedFormats.value[0] ?? 'zip';
|
|
1681
|
+
});
|
|
1682
|
+
const archiveEncryption = computed(() => capabilitiesData.value?.archive?.encryption !== false);
|
|
1683
|
+
const archiveSuggestedFolder = computed(() =>
|
|
1684
|
+
(archiveTarget.value?.basename || 'archive')
|
|
1685
|
+
.replace(/\.(tar\.(gz|bz2|xz)|zip|7z|rar|tgz|gz|bz2|xz)$/i, '') || 'archive',
|
|
1686
|
+
);
|
|
1636
1687
|
const renameTarget = ref<FileNode | null>(null);
|
|
1637
1688
|
/* Why the last rename did not happen, shown in the dialog. A failure used to be
|
|
1638
1689
|
* only EMITTED, which the stock web app logs to the console: the dialog stayed
|
|
@@ -2364,7 +2415,16 @@ const homeLoading = ref(false);
|
|
|
2364
2415
|
* pixels to the left is rendering that same array — a Home that asked for its
|
|
2365
2416
|
* own copy could show a drive the panel beside it hides.
|
|
2366
2417
|
*/
|
|
2367
|
-
|
|
2418
|
+
/* A drive's figure is a lower bound while its catalog does not cover all of
|
|
2419
|
+
it: the host says so (`usedPartial`, from the usage endpoint's `coverage`),
|
|
2420
|
+
or else the last listing did (coverageMap). */
|
|
2421
|
+
const homeStorages = computed(() =>
|
|
2422
|
+
(props.config.storages ?? []).map((s) =>
|
|
2423
|
+
s.usedPartial !== undefined || !(s.name in coverageMap.value)
|
|
2424
|
+
? s
|
|
2425
|
+
: { ...s, usedPartial: coverageMap.value[s.name] !== null },
|
|
2426
|
+
),
|
|
2427
|
+
);
|
|
2368
2428
|
|
|
2369
2429
|
async function loadHome() {
|
|
2370
2430
|
homeLoading.value = true;
|
|
@@ -2776,6 +2836,50 @@ function showToast(state: ToastState, ms: number) {
|
|
|
2776
2836
|
function flashToast(msg: string) {
|
|
2777
2837
|
showToast({ message: msg }, 2500);
|
|
2778
2838
|
}
|
|
2839
|
+
|
|
2840
|
+
/**
|
|
2841
|
+
* The catalog-coverage strip over a listing and over search results
|
|
2842
|
+
* (lib/catalogCoverage): what search, folder sizes and usage leave out, and
|
|
2843
|
+
* why. Not over a view that is not a storage's (Home, Recent, the trash) or a
|
|
2844
|
+
* dead link. A search typed at a storage's root spans every storage — the
|
|
2845
|
+
* server's own rule (handlers.Manager vfSearch) — so it names each one that is
|
|
2846
|
+
* not fully cataloged.
|
|
2847
|
+
*/
|
|
2848
|
+
const coverageShown = computed(() => {
|
|
2849
|
+
if (navView.value || trashActive.value || notFoundPath.value) return null;
|
|
2850
|
+
const searching = !!searchQuery.value;
|
|
2851
|
+
const atStorageRoot = stripAdapter(dirname.value).replace(/^\/+|\/+$/g, '') === '';
|
|
2852
|
+
return coverageNotice({
|
|
2853
|
+
map: coverageMap.value,
|
|
2854
|
+
adapter: adapter.value,
|
|
2855
|
+
searching,
|
|
2856
|
+
crossStorage: searching && atStorageRoot && Object.keys(coverageMap.value).length > 1,
|
|
2857
|
+
admin: callerAdmin.value,
|
|
2858
|
+
});
|
|
2859
|
+
});
|
|
2860
|
+
|
|
2861
|
+
/**
|
|
2862
|
+
* "Catalog everything" (an administrator, a storage cataloged only on open):
|
|
2863
|
+
* the ordinary full sync of that storage, the one the admin panel's
|
|
2864
|
+
* "Sync now" starts. The storage's id is asked for at click time — the
|
|
2865
|
+
* listing carries names only, and nobody but an administrator needs the id.
|
|
2866
|
+
*/
|
|
2867
|
+
async function catalogAll(storage: string | undefined) {
|
|
2868
|
+
if (!storage || catalogAllBusy.value) return;
|
|
2869
|
+
catalogAllBusy.value = true;
|
|
2870
|
+
try {
|
|
2871
|
+
const base = connectionsBase(props.config);
|
|
2872
|
+
const rows = await api.jsonFetch<Array<{ id: number; name: string }>>(`${base}/api/admin/storages`);
|
|
2873
|
+
const row = Array.isArray(rows) ? rows.find((r) => r.name === storage) : undefined;
|
|
2874
|
+
if (!row) throw new Error('no such storage');
|
|
2875
|
+
await api.jsonFetch(`${base}/api/admin/storages/${row.id}/sync`, { method: 'POST' });
|
|
2876
|
+
flashToast(t('coverage.catalog_started'));
|
|
2877
|
+
} catch (err) {
|
|
2878
|
+
flashToast(sayFailure(err, t('toast.failed'), { t, callerAdmin: callerAdmin.value }).text);
|
|
2879
|
+
} finally {
|
|
2880
|
+
catalogAllBusy.value = false;
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2779
2883
|
function undoToast(message: string, undo: () => Promise<void>) {
|
|
2780
2884
|
showToast({ message, actionLabel: t('toast.undo'), action: undo }, 8000);
|
|
2781
2885
|
}
|
|
@@ -3019,6 +3123,7 @@ async function load(path?: string) {
|
|
|
3019
3123
|
// it leaves the full-page guess the advanced search count always made.
|
|
3020
3124
|
searchTruncated.value =
|
|
3021
3125
|
!!searchQuery.value && advSearchTruncated(resp.files.length, MANAGER_SEARCH_PAGE, resp.truncated);
|
|
3126
|
+
if (Array.isArray(resp.storage_info)) coverageMap.value = coverageByStorage(resp.storage_info);
|
|
3022
3127
|
adapter.value = resp.adapter;
|
|
3023
3128
|
dirname.value = resp.dirname;
|
|
3024
3129
|
dirPerm.value = (resp.perm as string) || '';
|
|
@@ -4402,6 +4507,7 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
|
4402
4507
|
}
|
|
4403
4508
|
const isFile = single && sel[0]?.type === 'file';
|
|
4404
4509
|
const tagsLabel = t('ctx.tags_menu');
|
|
4510
|
+
const isArchive = single && isArchiveFile(sel[0]);
|
|
4405
4511
|
const singleHasId = single && typeof sel[0]?.id === 'number';
|
|
4406
4512
|
/* yildiz:s1 */
|
|
4407
4513
|
const canStar = starableNodes(sel).length > 0;
|
|
@@ -4457,6 +4563,9 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
|
4457
4563
|
hidden: !single || convertGate.hidden === true || !w || e2eActive.value /* wiring:e2 — convert is meaningless on ciphertext */,
|
|
4458
4564
|
disabled: !isFile || convertGate.disabled === true,
|
|
4459
4565
|
},
|
|
4566
|
+
{ key: 'archive-create', label: t('ctx.archive_create'), hidden: !any || (single && isArchive) || !w || e2eActive.value || archiveAllowedFormats.value.length === 0, disabled: !any },
|
|
4567
|
+
{ key: 'archive-extract', label: t('ctx.archive_extract'), hidden: !isArchive || !w || e2eActive.value, disabled: !isArchive },
|
|
4568
|
+
{ key: 'archive-extract-here', icon: 'archive-extract', label: t('archive.extract_here'), hidden: !isArchive || !w || e2eActive.value, disabled: !isArchive },
|
|
4460
4569
|
/* tasi:m1 — VISIBLE and grey above a multi-selection, not gone. Sharing
|
|
4461
4570
|
really is one item at a time (a share link addresses one node), and the
|
|
4462
4571
|
row now says so in its tooltip; vanishing taught the reader that filex
|
|
@@ -4515,6 +4624,12 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
|
4515
4624
|
];
|
|
4516
4625
|
}
|
|
4517
4626
|
|
|
4627
|
+
const ARCHIVE_EXTENSIONS = new Set(['zip', '7z', 'rar', 'tar', 'gz', 'tgz', 'bz2', 'tbz2', 'xz', 'txz']);
|
|
4628
|
+
|
|
4629
|
+
function isArchiveFile(node: FileNode | undefined): boolean {
|
|
4630
|
+
return !!node && node.type === 'file' && ARCHIVE_EXTENSIONS.has((node.extension || '').toLowerCase());
|
|
4631
|
+
}
|
|
4632
|
+
|
|
4518
4633
|
// toolbarActions — what the top toolbar shows. Mirrors the context menu so the
|
|
4519
4634
|
// two stay identical for a selection; the empty/trash/virtual-root cases match
|
|
4520
4635
|
// the context menu's special branches.
|
|
@@ -4524,7 +4639,7 @@ const toolbarActions = computed<ContextAction[]>(() => {
|
|
|
4524
4639
|
* menu offers (`contextActions`, ctxMode 'pane'). The three special branches
|
|
4525
4640
|
* below describe the MAIN listing's state — the trash, the multi-storage
|
|
4526
4641
|
* root — and the split pane reaches neither through this computed. */
|
|
4527
|
-
if (paneIsActive.value) return sel.length ? selectionActionList(sel) : [];
|
|
4642
|
+
if (paneIsActive.value) return sel.length ? selectionActionList(sel).filter((a) => a.key !== 'archive-extract-here') : [];
|
|
4528
4643
|
if (trashActive.value) {
|
|
4529
4644
|
if (sel.length === 0) return [];
|
|
4530
4645
|
return [
|
|
@@ -4537,7 +4652,7 @@ const toolbarActions = computed<ContextAction[]>(() => {
|
|
|
4537
4652
|
return sel.length === 1 ? [{ key: 'open', label: t('ctx.open') }] : [];
|
|
4538
4653
|
}
|
|
4539
4654
|
if (sel.length === 0) return [];
|
|
4540
|
-
return selectionActionList(sel);
|
|
4655
|
+
return selectionActionList(sel).filter((a) => a.key !== 'archive-extract-here');
|
|
4541
4656
|
});
|
|
4542
4657
|
|
|
4543
4658
|
async function onContextAction(action: ContextAction, targets: FileNode[]) {
|
|
@@ -4607,6 +4722,32 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
|
|
|
4607
4722
|
case 'download':
|
|
4608
4723
|
await downloadSelection(targets);
|
|
4609
4724
|
break;
|
|
4725
|
+
case 'archive-create':
|
|
4726
|
+
if (targets.length === 0) break;
|
|
4727
|
+
archiveTargets.value = targets;
|
|
4728
|
+
archiveInPane.value = actingInPane();
|
|
4729
|
+
archiveDestinationDir.value = archiveInPane.value
|
|
4730
|
+
? qualify(splitPaneRef.value?.getPath() ?? '')
|
|
4731
|
+
: qualify(currentPath.value);
|
|
4732
|
+
archiveError.value = '';
|
|
4733
|
+
showArchiveCreate.value = true;
|
|
4734
|
+
break;
|
|
4735
|
+
case 'archive-extract':
|
|
4736
|
+
if (!targets[0] || !isArchiveFile(targets[0])) break;
|
|
4737
|
+
archiveTarget.value = targets[0];
|
|
4738
|
+
archiveInPane.value = actingInPane();
|
|
4739
|
+
archiveDestinationDir.value = wireParent(targets[0].path);
|
|
4740
|
+
archiveError.value = '';
|
|
4741
|
+
showArchiveExtract.value = true;
|
|
4742
|
+
break;
|
|
4743
|
+
case 'archive-extract-here':
|
|
4744
|
+
if (!targets[0] || !isArchiveFile(targets[0])) break;
|
|
4745
|
+
archiveTarget.value = targets[0];
|
|
4746
|
+
archiveInPane.value = actingInPane();
|
|
4747
|
+
archiveDestinationDir.value = wireParent(targets[0].path);
|
|
4748
|
+
archiveError.value = '';
|
|
4749
|
+
await startArchiveExtraction(targets[0], archiveDestinationDir.value, archiveInPane.value);
|
|
4750
|
+
break;
|
|
4610
4751
|
/* tasi:m1 — the same dialog, twice; only `mode` differs. */
|
|
4611
4752
|
case 'move-to':
|
|
4612
4753
|
case 'copy-to':
|
|
@@ -4913,6 +5054,152 @@ async function onDocumentCreated(file: { path: string; name: string; ext: string
|
|
|
4913
5054
|
void markRecent(node);
|
|
4914
5055
|
}
|
|
4915
5056
|
|
|
5057
|
+
function archiveRequestCode(err: unknown): string | undefined {
|
|
5058
|
+
const detail = (err as { detail?: string })?.detail;
|
|
5059
|
+
if (!detail) return undefined;
|
|
5060
|
+
try {
|
|
5061
|
+
return (JSON.parse(detail) as { code?: string }).code;
|
|
5062
|
+
} catch {
|
|
5063
|
+
return undefined;
|
|
5064
|
+
}
|
|
5065
|
+
}
|
|
5066
|
+
|
|
5067
|
+
function archiveRequestError(err: unknown): string {
|
|
5068
|
+
const detail = (err as { detail?: string })?.detail;
|
|
5069
|
+
if (detail) {
|
|
5070
|
+
try {
|
|
5071
|
+
const body = JSON.parse(detail) as { code?: string; error?: string };
|
|
5072
|
+
const byCode: Record<string, string> = {
|
|
5073
|
+
PASSWORD_REQUIRED: 'archive.password_required',
|
|
5074
|
+
BAD_PASSWORD: 'archive.bad_password',
|
|
5075
|
+
TARGET_EXISTS: 'archive.target_exists',
|
|
5076
|
+
PASSWORD_CHARSET: 'archive.password_charset',
|
|
5077
|
+
UNSUPPORTED_FORMAT: 'archive.error_unsupported',
|
|
5078
|
+
ARCHIVE_LIMIT_EXCEEDED: 'archive.error_limit',
|
|
5079
|
+
PROVIDER_UNAVAILABLE: 'archive.error_unavailable',
|
|
5080
|
+
};
|
|
5081
|
+
if (body.code && byCode[body.code]) return t(byCode[body.code]);
|
|
5082
|
+
if (body.error) return body.error;
|
|
5083
|
+
} catch {
|
|
5084
|
+
// The common API wrapper still supplies a friendly status message.
|
|
5085
|
+
}
|
|
5086
|
+
}
|
|
5087
|
+
return (err as Error)?.message || t('toast.failed');
|
|
5088
|
+
}
|
|
5089
|
+
|
|
5090
|
+
async function submitArchiveCreate(value: {
|
|
5091
|
+
name: string;
|
|
5092
|
+
format: ArchiveCreateFormat;
|
|
5093
|
+
password?: string;
|
|
5094
|
+
encrypt_filenames: boolean;
|
|
5095
|
+
compression: number;
|
|
5096
|
+
solid?: boolean;
|
|
5097
|
+
dictionary_size_mb?: number;
|
|
5098
|
+
}) {
|
|
5099
|
+
archiveBusy.value = true;
|
|
5100
|
+
archiveError.value = '';
|
|
5101
|
+
try {
|
|
5102
|
+
const { op } = await api.archiveCreate({
|
|
5103
|
+
dest: wireJoin(archiveDestinationDir.value, value.name),
|
|
5104
|
+
sources: archiveTargets.value.map((node) => node.path),
|
|
5105
|
+
format: value.format,
|
|
5106
|
+
password: value.password,
|
|
5107
|
+
encrypt_filenames: value.encrypt_filenames,
|
|
5108
|
+
compression: value.compression,
|
|
5109
|
+
solid: value.solid,
|
|
5110
|
+
dictionary_size_mb: value.dictionary_size_mb,
|
|
5111
|
+
});
|
|
5112
|
+
pendingOps.register(op);
|
|
5113
|
+
showArchiveCreate.value = false;
|
|
5114
|
+
flashToast(t('archive.queued'));
|
|
5115
|
+
} catch (err) {
|
|
5116
|
+
archiveError.value = archiveRequestError(err);
|
|
5117
|
+
emit('error', { message: archiveError.value, context: { op: 'archive-create' } });
|
|
5118
|
+
} finally {
|
|
5119
|
+
archiveBusy.value = false;
|
|
5120
|
+
}
|
|
5121
|
+
}
|
|
5122
|
+
|
|
5123
|
+
async function startArchiveExtraction(
|
|
5124
|
+
target: FileNode,
|
|
5125
|
+
dest: string,
|
|
5126
|
+
inPane: boolean,
|
|
5127
|
+
password?: string,
|
|
5128
|
+
) {
|
|
5129
|
+
archiveBusy.value = true;
|
|
5130
|
+
archiveError.value = '';
|
|
5131
|
+
try {
|
|
5132
|
+
const result = await api.archiveExtract(target.path, {
|
|
5133
|
+
dest,
|
|
5134
|
+
password,
|
|
5135
|
+
});
|
|
5136
|
+
showArchiveExtract.value = false;
|
|
5137
|
+
showArchivePassword.value = false;
|
|
5138
|
+
archivePasswordAction.value = null;
|
|
5139
|
+
archivePasswordError.value = '';
|
|
5140
|
+
if (result.op) {
|
|
5141
|
+
pendingOps.register(result.op);
|
|
5142
|
+
flashToast(t('archive.extraction_queued'));
|
|
5143
|
+
return;
|
|
5144
|
+
}
|
|
5145
|
+
if (inPane) await splitPaneRef.value?.reload();
|
|
5146
|
+
else await load();
|
|
5147
|
+
flashToast(t('archive.extracted', { count: result.count ?? 0 }));
|
|
5148
|
+
} catch (err) {
|
|
5149
|
+
const code = archiveRequestCode(err);
|
|
5150
|
+
if (code === 'PASSWORD_REQUIRED' || code === 'BAD_PASSWORD') {
|
|
5151
|
+
archivePasswordAction.value = { target, dest, inPane };
|
|
5152
|
+
archivePasswordError.value = code === 'BAD_PASSWORD' ? t('archive.bad_password') : '';
|
|
5153
|
+
showArchiveExtract.value = false;
|
|
5154
|
+
showArchivePassword.value = true;
|
|
5155
|
+
return;
|
|
5156
|
+
}
|
|
5157
|
+
const message = archiveRequestError(err);
|
|
5158
|
+
if (showArchivePassword.value) archivePasswordError.value = message;
|
|
5159
|
+
else if (showArchiveExtract.value) archiveError.value = message;
|
|
5160
|
+
/* "Extract here" opens no dialog, so an error written into one was never
|
|
5161
|
+
* seen: a refused archive (a link inside, too large, a format this server
|
|
5162
|
+
* cannot read) looked like a click that did nothing. */
|
|
5163
|
+
else showToast({ message }, 6000);
|
|
5164
|
+
} finally {
|
|
5165
|
+
archiveBusy.value = false;
|
|
5166
|
+
}
|
|
5167
|
+
}
|
|
5168
|
+
|
|
5169
|
+
async function submitArchiveExtract(value: { folder: string }) {
|
|
5170
|
+
const target = archiveTarget.value;
|
|
5171
|
+
if (!target) return;
|
|
5172
|
+
await startArchiveExtraction(
|
|
5173
|
+
target,
|
|
5174
|
+
wireJoin(archiveDestinationDir.value, value.folder),
|
|
5175
|
+
archiveInPane.value,
|
|
5176
|
+
);
|
|
5177
|
+
}
|
|
5178
|
+
|
|
5179
|
+
async function submitArchivePassword(password: string) {
|
|
5180
|
+
const action = archivePasswordAction.value;
|
|
5181
|
+
if (!action) return;
|
|
5182
|
+
archivePasswordError.value = '';
|
|
5183
|
+
await startArchiveExtraction(action.target, action.dest, action.inPane, password);
|
|
5184
|
+
}
|
|
5185
|
+
|
|
5186
|
+
function closeArchivePassword() {
|
|
5187
|
+
showArchivePassword.value = false;
|
|
5188
|
+
archivePasswordAction.value = null;
|
|
5189
|
+
archivePasswordError.value = '';
|
|
5190
|
+
}
|
|
5191
|
+
|
|
5192
|
+
async function cancelPendingOp(id: number) {
|
|
5193
|
+
try {
|
|
5194
|
+
await api.opsCancel(id);
|
|
5195
|
+
await pendingOps.poll();
|
|
5196
|
+
} catch (err) {
|
|
5197
|
+
const op = pendingOps.ops.value.find((candidate) => candidate.id === id);
|
|
5198
|
+
const archive = op?.op_type === 'archive-create' || op?.op_type === 'archive-extract';
|
|
5199
|
+
flashToast(archive ? archiveRequestError(err) : t('plugin.cancel_failed'));
|
|
5200
|
+
}
|
|
5201
|
+
}
|
|
5202
|
+
|
|
4916
5203
|
async function submitNewFolder(name: string) {
|
|
4917
5204
|
const inPane = mutationInPane.value; /* ui-fix — new folder in the side pane */
|
|
4918
5205
|
try {
|
|
@@ -7271,6 +7558,26 @@ function closeRecoveryKey() {
|
|
|
7271
7558
|
role="status"
|
|
7272
7559
|
>{{ t('search.truncated') }}</div>
|
|
7273
7560
|
|
|
7561
|
+
<!-- The catalog does not cover all of this storage yet (a first sync, a
|
|
7562
|
+
lazily cataloged storage): search, folder sizes and usage leave part
|
|
7563
|
+
of it out, and this says so and why (lib/catalogCoverage). -->
|
|
7564
|
+
<div
|
|
7565
|
+
v-if="coverageShown"
|
|
7566
|
+
class="fe-coverage"
|
|
7567
|
+
role="status"
|
|
7568
|
+
data-testid="catalog-coverage"
|
|
7569
|
+
>
|
|
7570
|
+
<span class="fe-coverage__text">{{ t(coverageShown.key, coverageShown.vars) }}</span>
|
|
7571
|
+
<button
|
|
7572
|
+
v-if="coverageShown.offerCatalogAll"
|
|
7573
|
+
type="button"
|
|
7574
|
+
class="fe-coverage__action"
|
|
7575
|
+
data-testid="catalog-coverage-all"
|
|
7576
|
+
:disabled="catalogAllBusy"
|
|
7577
|
+
@click="catalogAll(coverageShown.storage)"
|
|
7578
|
+
>{{ t('coverage.catalog_all') }}</button>
|
|
7579
|
+
</div>
|
|
7580
|
+
|
|
7274
7581
|
<!-- Live presence: who else is viewing this folder (empty → nothing shown).
|
|
7275
7582
|
When the live socket is unavailable the same strip carries a small
|
|
7276
7583
|
degraded-connection badge instead (presence is empty in fallback);
|
|
@@ -7854,8 +8161,8 @@ function closeRecoveryKey() {
|
|
|
7854
8161
|
:center="opsCenter"
|
|
7855
8162
|
:output-mode-of="pluginOutputModeOf"
|
|
7856
8163
|
:caller-admin="callerAdmin"
|
|
8164
|
+
@cancel="cancelPendingOp"
|
|
7857
8165
|
@dismiss="(id) => pendingOps.dismiss(id)"
|
|
7858
|
-
@cancel="onCancelPendingOp"
|
|
7859
8166
|
@open="onOpenOpOutput"
|
|
7860
8167
|
/>
|
|
7861
8168
|
|
|
@@ -7937,6 +8244,39 @@ function closeRecoveryKey() {
|
|
|
7937
8244
|
@submit="submitNewFolder"
|
|
7938
8245
|
@encrypted="showNewFolder = false; showEncFolder = true /* wiring:e2 */"
|
|
7939
8246
|
/>
|
|
8247
|
+
<ArchiveCreateModal
|
|
8248
|
+
:open="showArchiveCreate"
|
|
8249
|
+
:locale="locale"
|
|
8250
|
+
:count="archiveTargets.length"
|
|
8251
|
+
:suggested-name="archiveSuggestedName"
|
|
8252
|
+
:default-format="archiveDefaultFormat"
|
|
8253
|
+
:allowed-formats="archiveAllowedFormats"
|
|
8254
|
+
:encryption="archiveEncryption"
|
|
8255
|
+
:busy="archiveBusy"
|
|
8256
|
+
:request-error="archiveError"
|
|
8257
|
+
@close="showArchiveCreate = false"
|
|
8258
|
+
@submit="submitArchiveCreate"
|
|
8259
|
+
/>
|
|
8260
|
+
<ArchiveExtractModal
|
|
8261
|
+
:open="showArchiveExtract"
|
|
8262
|
+
:locale="locale"
|
|
8263
|
+
:archive-name="archiveTarget?.basename || ''"
|
|
8264
|
+
:suggested-folder="archiveSuggestedFolder"
|
|
8265
|
+
:busy="archiveBusy"
|
|
8266
|
+
:error="archiveError"
|
|
8267
|
+
@close="showArchiveExtract = false"
|
|
8268
|
+
@submit="submitArchiveExtract"
|
|
8269
|
+
/>
|
|
8270
|
+
<ArchivePasswordModal
|
|
8271
|
+
:open="showArchivePassword"
|
|
8272
|
+
:locale="locale"
|
|
8273
|
+
:archive-name="archivePasswordAction?.target.basename || archiveTarget?.basename || ''"
|
|
8274
|
+
:busy="archiveBusy"
|
|
8275
|
+
:error="archivePasswordError"
|
|
8276
|
+
@close="closeArchivePassword"
|
|
8277
|
+
@submit="submitArchivePassword"
|
|
8278
|
+
/>
|
|
8279
|
+
|
|
7940
8280
|
<!-- tasi:m1 — "Şuraya taşı…" / "Şuraya kopyala…". ONE dialog for both, and
|
|
7941
8281
|
the same one anything else that has to ask for a folder mounts (the
|
|
7942
8282
|
new-document flow does): a second private folder browser is how two
|
|
@@ -154,10 +154,27 @@ async function show(ev: { clientX: number; clientY: number }, nodes: FileNode[])
|
|
|
154
154
|
function clampToViewport() {
|
|
155
155
|
const el = menuEl.value;
|
|
156
156
|
if (!el || typeof window === 'undefined') return;
|
|
157
|
+
/* Measure the natural height: drop a cap an earlier opening left behind. */
|
|
158
|
+
el.style.maxHeight = '';
|
|
159
|
+
el.style.overflowY = '';
|
|
157
160
|
const rect = el.getBoundingClientRect();
|
|
158
161
|
const margin = 8;
|
|
159
162
|
const vw = window.innerWidth;
|
|
160
163
|
const vh = window.innerHeight;
|
|
164
|
+
/* ⚠⚠ A menu taller than the window scrolls inside itself. Neither a flip nor
|
|
165
|
+
* a clamp can fit it, and without a height cap the items past the bottom edge
|
|
166
|
+
* are simply unreachable: installing a few apps (each adds its actions) put
|
|
167
|
+
* "Lock probe" below a 720px window and nothing could click it (0.44.0's gate
|
|
168
|
+
* run, spec 95, 2026-09-25). Set on the element here rather than in the
|
|
169
|
+
* template's :style, whose line the RTL audit allow-lists word for word. */
|
|
170
|
+
const room = vh - 2 * margin;
|
|
171
|
+
if (rect.height > room) {
|
|
172
|
+
el.style.maxHeight = room + 'px';
|
|
173
|
+
el.style.overflowY = 'auto';
|
|
174
|
+
y.value = margin;
|
|
175
|
+
x.value = openAlongInline(anchorX, rect.width, vw, dir.value, margin);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
161
178
|
/* wiring:c4 — edge flip: overflowing the bottom/right edge re-anchors the
|
|
162
179
|
* menu above/left of the cursor when there's room; clamping stays as the
|
|
163
180
|
* fallback for tiny viewports. */
|
|
@@ -46,6 +46,9 @@ export interface HomeStorage {
|
|
|
46
46
|
readOnly?: boolean;
|
|
47
47
|
/** Bytes this storage holds, when the server reported one. */
|
|
48
48
|
usedBytes?: number;
|
|
49
|
+
/** The figure counts only part of the storage: its catalog does not cover
|
|
50
|
+
* all of it yet (lib/catalogCoverage). Drawn as a lower bound. */
|
|
51
|
+
usedPartial?: boolean;
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
const props = defineProps<{
|
|
@@ -145,9 +148,12 @@ const storageTile = fileIconTile({ type: 'dir', mime_type: 'inode/storage' });
|
|
|
145
148
|
* with the wrong size line says something false.
|
|
146
149
|
*/
|
|
147
150
|
function storageCaption(s: HomeStorage): string {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
+
if (typeof s.usedBytes !== 'number' || s.usedBytes < 0) return t('conn.guide.storage');
|
|
152
|
+
// ⚠ A figure the catalog does not stand behind in full is a lower bound —
|
|
153
|
+
// "at least 1.2 GB used" — the same rule the listing's folder sizes follow.
|
|
154
|
+
return s.usedPartial
|
|
155
|
+
? t('drive.storage.used_partial', { used: formatSize(s.usedBytes) })
|
|
156
|
+
: t('drive.storage.used_unlimited', { used: formatSize(s.usedBytes) });
|
|
151
157
|
}
|
|
152
158
|
|
|
153
159
|
/**
|
|
@@ -199,7 +205,11 @@ function openNode(n: FileNode) {
|
|
|
199
205
|
<span class="fe-home__storage-tile" aria-hidden="true" v-html="storageTile"></span>
|
|
200
206
|
<span class="fe-home__storage-main">
|
|
201
207
|
<span class="fe-home__storage-label">{{ s.label || s.name }}</span>
|
|
202
|
-
<span
|
|
208
|
+
<span
|
|
209
|
+
class="fe-home__storage-meta"
|
|
210
|
+
data-testid="home-storage-used"
|
|
211
|
+
:data-partial="s.usedPartial ? 'true' : undefined"
|
|
212
|
+
>{{ storageCaption(s) }}</span>
|
|
203
213
|
<!-- A read-only mount says so on its card too (issue #30) — on a
|
|
204
214
|
line of its own, the panel's own mark (StorageTags, the one
|
|
205
215
|
read-only tag of every surface). ⚠ It used to be glued to
|
|
@@ -182,7 +182,7 @@ const emit = defineEmits<{
|
|
|
182
182
|
(e: 'plugin-open', payload: { plugin: string; req: { path: string; action?: string; view?: string } }): void;
|
|
183
183
|
}>();
|
|
184
184
|
|
|
185
|
-
const { t, formatSize, formatDate: formatDateOf, nodeDisplayName } = useLocale(
|
|
185
|
+
const { t, formatSize, formatNodeSize, nodeSizeHint, formatDate: formatDateOf, nodeDisplayName } = useLocale(
|
|
186
186
|
() => props.locale,
|
|
187
187
|
);
|
|
188
188
|
|
|
@@ -209,6 +209,12 @@ const nodeId = computed<number | null>(() =>
|
|
|
209
209
|
const multiTotal = computed(() =>
|
|
210
210
|
props.nodes.reduce((acc, n) => acc + (typeof n.size === 'number' ? n.size : 0), 0),
|
|
211
211
|
);
|
|
212
|
+
/** The selection's total, as a size: a lower bound when any selected folder's
|
|
213
|
+
* own size is one (`size_partial`, drawn by useLocale formatNodeSize). */
|
|
214
|
+
const multiSize = computed(() => ({
|
|
215
|
+
size: multiTotal.value,
|
|
216
|
+
size_partial: props.nodes.some((n) => n.size_partial === true),
|
|
217
|
+
}));
|
|
212
218
|
const etag = computed<string | null>(() => {
|
|
213
219
|
const v = single.value?.etag;
|
|
214
220
|
return typeof v === 'string' && v !== '' ? v : null;
|
|
@@ -249,7 +255,7 @@ const headName = computed<string>(() => {
|
|
|
249
255
|
|
|
250
256
|
/** The line under it — "Folder", "TypeScript · 4.8 KB", "12.4 MB in total". */
|
|
251
257
|
const headCaption = computed<string>(() => {
|
|
252
|
-
if (isMulti.value) return
|
|
258
|
+
if (isMulti.value) return formatNodeSize(multiSize.value);
|
|
253
259
|
const n = single.value;
|
|
254
260
|
if (n) {
|
|
255
261
|
const kind = typeLabelFor(n, t);
|
|
@@ -798,7 +804,7 @@ watch(
|
|
|
798
804
|
<dl v-if="isMulti" class="fe-inspector__meta">
|
|
799
805
|
<div class="fe-inspector__row">
|
|
800
806
|
<dt>{{ t('inspector.size') }}</dt>
|
|
801
|
-
<dd>{{
|
|
807
|
+
<dd :title="nodeSizeHint(multiSize)">{{ formatNodeSize(multiSize) }}</dd>
|
|
802
808
|
</div>
|
|
803
809
|
</dl>
|
|
804
810
|
|
|
@@ -177,7 +177,8 @@ const emit = defineEmits<{
|
|
|
177
177
|
|
|
178
178
|
const {
|
|
179
179
|
t,
|
|
180
|
-
|
|
180
|
+
formatNodeSize,
|
|
181
|
+
nodeSizeHint,
|
|
181
182
|
formatDate,
|
|
182
183
|
formatDateFull,
|
|
183
184
|
formatMonthYear,
|
|
@@ -568,8 +569,10 @@ const columns = computed<DataColumn<FileNode>[]>(() => [
|
|
|
568
569
|
label: t('col.size'),
|
|
569
570
|
sortable: true,
|
|
570
571
|
class: 'fe-list__col--size',
|
|
571
|
-
|
|
572
|
-
|
|
572
|
+
// A folder the catalog does not cover in full says so: "≥ 1.2 GB", or
|
|
573
|
+
// "—" when nothing below it is known (useLocale formatNodeSize).
|
|
574
|
+
format: (n) => formatNodeSize(n),
|
|
575
|
+
title: (n) => nodeSizeHint(n),
|
|
573
576
|
},
|
|
574
577
|
{ id: 'star', label: t('col.star'), class: 'fe-list__col--star', headerLabel: false },
|
|
575
578
|
]);
|