@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
|
@@ -70,6 +70,8 @@ const KIND_ICONS: Record<Operation['kind'], string> = {
|
|
|
70
70
|
/* An app-plugin job — the puzzle piece its menu row wears (lib/actionIcons). */
|
|
71
71
|
plugin:
|
|
72
72
|
'M9.5 4.5a2 2 0 1 1 4 0h3a1.5 1.5 0 0 1 1.5 1.5v3a2 2 0 1 1 0 4v3a1.5 1.5 0 0 1-1.5 1.5h-3a2 2 0 1 1-4 0h-3A1.5 1.5 0 0 1 5 16v-3a2 2 0 1 1 0-4V6a1.5 1.5 0 0 1 1.5-1.5z',
|
|
73
|
+
'archive-create': 'M4 8V5h16v3zM5 8h14v12H5zM10 12h4',
|
|
74
|
+
'archive-extract': 'M4 8V5h16v3zM5 8h14v12H5zM10 12h4',
|
|
73
75
|
};
|
|
74
76
|
|
|
75
77
|
function kindLabel(o: Operation): string {
|
|
@@ -100,6 +102,7 @@ function outputModeTitle(o: Operation): string {
|
|
|
100
102
|
|
|
101
103
|
function statusText(o: Operation): string {
|
|
102
104
|
if (o.status === 'running') {
|
|
105
|
+
if (o.cancelling) return t('opc.cancelling');
|
|
103
106
|
if (o.queued) return t('opc.queued');
|
|
104
107
|
// A plugin job says what it is doing in its own words (`job_progress`
|
|
105
108
|
// message); that beats a bare count.
|
|
@@ -107,6 +110,9 @@ function statusText(o: Operation): string {
|
|
|
107
110
|
if (o.totalBytes && o.totalBytes > 0) {
|
|
108
111
|
return `${formatSize(o.uploadedBytes ?? 0)} / ${formatSize(o.totalBytes)}`;
|
|
109
112
|
}
|
|
113
|
+
if ((o.kind === 'archive-create' || o.kind === 'archive-extract') && o.percent !== null) {
|
|
114
|
+
return t('opc.percent', { n: o.percent });
|
|
115
|
+
}
|
|
110
116
|
if (o.totalCount && o.totalCount > 0) {
|
|
111
117
|
return `${o.doneCount ?? 0}/${o.totalCount}`;
|
|
112
118
|
}
|
|
@@ -56,12 +56,12 @@ const emit = defineEmits<{
|
|
|
56
56
|
|
|
57
57
|
function mapStatus(op: PendingOp): OperationStatus {
|
|
58
58
|
if (op.status === 'done') return 'done';
|
|
59
|
-
if (op.status === 'error') return 'error';
|
|
60
59
|
if (op.status === 'cancelled') return 'aborted';
|
|
60
|
+
if (op.status === 'error') return 'error';
|
|
61
61
|
return 'running'; // pending | running
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
const DRAWN_KINDS: ReadonlySet<string> = new Set(['copy', 'move', 'delete', 'plugin']);
|
|
64
|
+
const DRAWN_KINDS: ReadonlySet<string> = new Set(['copy', 'move', 'delete', 'plugin', 'archive-create', 'archive-extract']);
|
|
65
65
|
|
|
66
66
|
/** A queue kind the center can draw. Anything it has no glyph for is shown as
|
|
67
67
|
* a generic job (`plugin`), never folded into delete. */
|
|
@@ -79,6 +79,7 @@ function isPluginJob(op: PendingOp): boolean {
|
|
|
79
79
|
* starts — for an administrator. */
|
|
80
80
|
function mayCancel(op: PendingOp): boolean {
|
|
81
81
|
if (op.status !== 'pending' && op.status !== 'running') return false;
|
|
82
|
+
if (op.op_type === 'archive-create' || op.op_type === 'archive-extract') return op.cancellable;
|
|
82
83
|
if (isPluginJob(op)) return true;
|
|
83
84
|
return op.op_type === 'trash-empty' && props.callerAdmin === true;
|
|
84
85
|
}
|
|
@@ -103,6 +104,7 @@ watch(
|
|
|
103
104
|
error: op.status === 'error' ? failureOf(op).text : null,
|
|
104
105
|
errorDetail: op.status === 'error' ? (failureOf(op).detail ?? null) : null,
|
|
105
106
|
queued: op.status === 'pending',
|
|
107
|
+
cancelling: op.status === 'cancelling',
|
|
106
108
|
doneCount: op.progress_done,
|
|
107
109
|
totalCount: op.progress_total,
|
|
108
110
|
cancellable: mayCancel(op),
|
|
@@ -112,8 +114,8 @@ watch(
|
|
|
112
114
|
outputMode: (isPluginJob(op) ? props.outputModeOf?.(op) : undefined) ?? null,
|
|
113
115
|
},
|
|
114
116
|
actions: {
|
|
115
|
-
dismiss: () => emit('dismiss', op.id),
|
|
116
117
|
cancel: () => emit('cancel', op.id),
|
|
118
|
+
dismiss: () => emit('dismiss', op.id),
|
|
117
119
|
open: (path: string) => emit('open', op.id, path),
|
|
118
120
|
},
|
|
119
121
|
})),
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* thinks about CSRF vs Bearer vs Basic — it just calls `index(path)`.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import type { StorageInfo } from '../lib/catalogCoverage';
|
|
24
25
|
import type { ExplorerConfig, AuthConfig, EndpointMap } from '../types/ExplorerConfig';
|
|
25
26
|
import { resolveLocale } from '../locales/resolve';
|
|
26
27
|
import { listingAddress } from '../lib/internalPaths';
|
|
@@ -36,6 +37,7 @@ import type {
|
|
|
36
37
|
UploadLimits,
|
|
37
38
|
Capabilities,
|
|
38
39
|
ArchiveEntry,
|
|
40
|
+
ArchiveCreateFormat,
|
|
39
41
|
TrashEntry,
|
|
40
42
|
} from '../types/FileNode';
|
|
41
43
|
import type {
|
|
@@ -49,14 +51,15 @@ import type {
|
|
|
49
51
|
/** Server-side PendingOp DTO (mirror of Modules\FishApp\Models\PendingOp::toApiArray). */
|
|
50
52
|
export interface PendingOpDto {
|
|
51
53
|
id: number;
|
|
52
|
-
op_type: 'copy' | 'move' | 'delete';
|
|
53
|
-
status: 'pending' | 'running' | 'done' | 'error';
|
|
54
|
+
op_type: 'copy' | 'move' | 'delete' | 'archive-create' | 'archive-extract';
|
|
55
|
+
status: 'pending' | 'running' | 'cancelling' | 'done' | 'error' | 'cancelled';
|
|
54
56
|
progress_total: number;
|
|
55
57
|
progress_done: number;
|
|
56
58
|
target_path: string | null;
|
|
57
59
|
source_dir: string | null;
|
|
58
60
|
source_count: number;
|
|
59
61
|
error_message: string | null;
|
|
62
|
+
cancellable: boolean;
|
|
60
63
|
started_at: string | null;
|
|
61
64
|
finished_at: string | null;
|
|
62
65
|
created_at: string | null;
|
|
@@ -94,6 +97,10 @@ export interface ManagerResponse {
|
|
|
94
97
|
* list is not the whole answer. Absent on other actions and on servers
|
|
95
98
|
* older than the flag. */
|
|
96
99
|
truncated?: boolean;
|
|
100
|
+
/** Every storage the caller can open, with how much of it the catalog
|
|
101
|
+
* covers (`coverage`, absent when all of it — lib/catalogCoverage). Absent
|
|
102
|
+
* on servers older than the lazy catalog. */
|
|
103
|
+
storage_info?: StorageInfo[];
|
|
97
104
|
}
|
|
98
105
|
|
|
99
106
|
/** A single ACL grant row (RBAC permissions panel). */
|
|
@@ -256,6 +263,7 @@ export function resolveEndpoints(config: ExplorerConfig): EndpointMap {
|
|
|
256
263
|
capabilities: derive(config.capabilities, '/api/files/capabilities'),
|
|
257
264
|
archiveList: derive(config.archiveList, '/api/files/archive/list'),
|
|
258
265
|
archiveExtract: derive(config.archiveExtract, '/api/files/archive/extract'),
|
|
266
|
+
archiveCreate: derive(config.archiveCreate, '/api/files/archive/create'),
|
|
259
267
|
archiveAdd: derive(config.archiveAdd, '/api/files/archive/add'),
|
|
260
268
|
copy: derive(config.copy, '/api/files/copy'),
|
|
261
269
|
moveAsync: derive(config.moveAsync, '/api/files/move'),
|
|
@@ -979,14 +987,14 @@ export function useFileApi(config: ExplorerConfig) {
|
|
|
979
987
|
return jsonFetch(url, { method: 'DELETE' });
|
|
980
988
|
}
|
|
981
989
|
|
|
982
|
-
async function archiveList(path: string): Promise<{ entries: ArchiveEntry[] }> {
|
|
990
|
+
async function archiveList(path: string, password?: string): Promise<{ entries: ArchiveEntry[] }> {
|
|
983
991
|
if (!endpoints.archiveList) throw new Error('archiveList endpoint not configured');
|
|
984
992
|
const raw = await jsonFetch<{ entries: Array<{ name: string; size: number; is_dir: boolean; mtime?: number }> }>(
|
|
985
993
|
endpoints.archiveList,
|
|
986
994
|
{
|
|
987
995
|
method: 'POST',
|
|
988
996
|
headers: { 'Content-Type': 'application/json' },
|
|
989
|
-
body: JSON.stringify({ path }),
|
|
997
|
+
body: JSON.stringify({ path, password }),
|
|
990
998
|
},
|
|
991
999
|
);
|
|
992
1000
|
return {
|
|
@@ -999,12 +1007,33 @@ export function useFileApi(config: ExplorerConfig) {
|
|
|
999
1007
|
};
|
|
1000
1008
|
}
|
|
1001
1009
|
|
|
1002
|
-
async function archiveExtract(
|
|
1010
|
+
async function archiveExtract(
|
|
1011
|
+
path: string,
|
|
1012
|
+
options: { members?: string[]; password?: string; dest?: string } = {},
|
|
1013
|
+
): Promise<{ op?: PendingOpDto; keys?: string[]; count?: number }> {
|
|
1003
1014
|
if (!endpoints.archiveExtract) throw new Error('archiveExtract endpoint not configured');
|
|
1004
1015
|
return jsonFetch(endpoints.archiveExtract, {
|
|
1005
1016
|
method: 'POST',
|
|
1006
1017
|
headers: { 'Content-Type': 'application/json' },
|
|
1007
|
-
body: JSON.stringify({ path,
|
|
1018
|
+
body: JSON.stringify({ path, ...options }),
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
async function archiveCreate(payload: {
|
|
1023
|
+
dest: string;
|
|
1024
|
+
sources: string[];
|
|
1025
|
+
format: ArchiveCreateFormat;
|
|
1026
|
+
password?: string;
|
|
1027
|
+
encrypt_filenames?: boolean;
|
|
1028
|
+
compression?: number;
|
|
1029
|
+
solid?: boolean;
|
|
1030
|
+
dictionary_size_mb?: number;
|
|
1031
|
+
}): Promise<{ op: PendingOpDto }> {
|
|
1032
|
+
if (!endpoints.archiveCreate) throw new Error('archiveCreate endpoint not configured');
|
|
1033
|
+
return jsonFetch(endpoints.archiveCreate, {
|
|
1034
|
+
method: 'POST',
|
|
1035
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1036
|
+
body: JSON.stringify(payload),
|
|
1008
1037
|
});
|
|
1009
1038
|
}
|
|
1010
1039
|
|
|
@@ -1147,6 +1176,7 @@ export function useFileApi(config: ExplorerConfig) {
|
|
|
1147
1176
|
revokeShare,
|
|
1148
1177
|
archiveList,
|
|
1149
1178
|
archiveExtract,
|
|
1179
|
+
archiveCreate,
|
|
1150
1180
|
archiveAdd,
|
|
1151
1181
|
// Version history (koru:k1 inspector)
|
|
1152
1182
|
listVersions,
|
|
@@ -536,6 +536,30 @@ export function useLocale(
|
|
|
536
536
|
});
|
|
537
537
|
}
|
|
538
538
|
|
|
539
|
+
/**
|
|
540
|
+
* A listed item's size, the way every view draws it (list, inspector,
|
|
541
|
+
* multi-selection total).
|
|
542
|
+
*
|
|
543
|
+
* ⚠ `size_partial` is the server saying the catalog does not cover all of a
|
|
544
|
+
* folder yet (docs/LAZY-CATALOGUE.md): a lazily cataloged folder that is not
|
|
545
|
+
* cataloged below, or any folder while its storage's first sync runs. Its
|
|
546
|
+
* size is then a lower bound — "≥ 1.2 GB" — or, when nothing below it is
|
|
547
|
+
* known, not a number at all ("—"): printing "0 B" for a folder full of
|
|
548
|
+
* files nobody has cataloged yet would be a fact that is false. One helper,
|
|
549
|
+
* so the list and the inspector cannot disagree about it.
|
|
550
|
+
*/
|
|
551
|
+
function formatNodeSize(n: { size?: number | null; size_partial?: boolean } | null | undefined): string {
|
|
552
|
+
if (!n) return formatSize(null);
|
|
553
|
+
if (!n.size_partial) return formatSize(n.size);
|
|
554
|
+
return typeof n.size === 'number' && n.size > 0 ? t('size.at_least', { size: formatSize(n.size) }) : '—';
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** The hover text of a size `formatNodeSize` drew as partial, or undefined. */
|
|
558
|
+
function nodeSizeHint(n: { size?: number | null; size_partial?: boolean } | null | undefined): string | undefined {
|
|
559
|
+
if (!n?.size_partial) return undefined;
|
|
560
|
+
return typeof n.size === 'number' && n.size > 0 ? t('size.partial_hint') : t('size.unknown_hint');
|
|
561
|
+
}
|
|
562
|
+
|
|
539
563
|
/**
|
|
540
564
|
* Render a FileNode's basename with locale-aware overrides:
|
|
541
565
|
* - `.trash` directory → "Çöp Kutusu" / "Trash"
|
|
@@ -627,6 +651,8 @@ export function useLocale(
|
|
|
627
651
|
return {
|
|
628
652
|
t,
|
|
629
653
|
formatSize,
|
|
654
|
+
formatNodeSize,
|
|
655
|
+
nodeSizeHint,
|
|
630
656
|
formatDate,
|
|
631
657
|
formatDateFull,
|
|
632
658
|
formatMonthYear,
|
|
@@ -27,7 +27,9 @@ 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
29
|
* queue kind this package has no drawing for is shown as. */
|
|
30
|
-
export type OperationKind =
|
|
30
|
+
export type OperationKind =
|
|
31
|
+
| 'upload' | 'copy' | 'move' | 'delete' | 'convert'
|
|
32
|
+
| 'archive' | 'archive-create' | 'archive-extract' | 'plugin' | 'trash';
|
|
31
33
|
export type OperationStatus = 'running' | 'done' | 'error' | 'aborted';
|
|
32
34
|
|
|
33
35
|
/** What a publisher hands to `sync()` for one row. */
|
|
@@ -47,6 +49,8 @@ export interface OperationInput {
|
|
|
47
49
|
errorDetail?: string | null;
|
|
48
50
|
/** Queue op accepted but not started yet ("Queued"). */
|
|
49
51
|
queued?: boolean;
|
|
52
|
+
/** Cancellation was accepted and the worker is stopping/cleaning up. */
|
|
53
|
+
cancelling?: boolean;
|
|
50
54
|
/** Progress counters for queue ops (3/5 items). */
|
|
51
55
|
doneCount?: number;
|
|
52
56
|
totalCount?: number;
|
|
@@ -90,6 +94,7 @@ export interface Operation {
|
|
|
90
94
|
error: string | null;
|
|
91
95
|
errorDetail: string | null;
|
|
92
96
|
queued: boolean;
|
|
97
|
+
cancelling: boolean;
|
|
93
98
|
doneCount: number | null;
|
|
94
99
|
totalCount: number | null;
|
|
95
100
|
uploadedBytes: number | null;
|
|
@@ -117,6 +122,7 @@ function toOperation(key: string, input: OperationInput, prev?: Operation): Oper
|
|
|
117
122
|
error: input.error ?? null,
|
|
118
123
|
errorDetail: input.errorDetail ?? null,
|
|
119
124
|
queued: input.queued ?? false,
|
|
125
|
+
cancelling: input.cancelling ?? false,
|
|
120
126
|
doneCount: input.doneCount ?? null,
|
|
121
127
|
totalCount: input.totalCount ?? null,
|
|
122
128
|
uploadedBytes: input.uploadedBytes ?? null,
|
|
@@ -33,7 +33,7 @@ export interface PendingOp {
|
|
|
33
33
|
id: number;
|
|
34
34
|
op_type: PendingOpType;
|
|
35
35
|
/** `cancelled` — somebody stopped it (POST /ops/{id}/cancel). */
|
|
36
|
-
status: 'pending' | 'running' | 'done' | 'error' | 'cancelled';
|
|
36
|
+
status: 'pending' | 'running' | 'cancelling' | 'done' | 'error' | 'cancelled';
|
|
37
37
|
progress_total: number;
|
|
38
38
|
progress_done: number;
|
|
39
39
|
/** Running cross-storage transfer's bytes (issue #27); absent otherwise. */
|
|
@@ -48,6 +48,7 @@ export interface PendingOp {
|
|
|
48
48
|
error_code?: string;
|
|
49
49
|
/** The engine a job needed and the server lacks (`error_code: engine_missing`). */
|
|
50
50
|
error_engine?: string;
|
|
51
|
+
cancellable: boolean;
|
|
51
52
|
started_at: string | null;
|
|
52
53
|
finished_at: string | null;
|
|
53
54
|
created_at: string | null;
|
|
@@ -94,9 +95,11 @@ export function normalizeOp(raw: Record<string, unknown>): PendingOp {
|
|
|
94
95
|
? 'error'
|
|
95
96
|
: rawStatus === 'cancelled'
|
|
96
97
|
? 'cancelled'
|
|
97
|
-
: rawStatus === '
|
|
98
|
-
? '
|
|
99
|
-
: '
|
|
98
|
+
: rawStatus === 'cancelling'
|
|
99
|
+
? 'cancelling'
|
|
100
|
+
: rawStatus === 'running'
|
|
101
|
+
? 'running'
|
|
102
|
+
: 'pending';
|
|
100
103
|
const sources = Array.isArray(raw.sources) ? raw.sources : [];
|
|
101
104
|
// ⚠ Unknown kinds pass through. This used to fold everything that was not
|
|
102
105
|
// copy/move into `delete`, which drew a bin beside a plugin job and read
|
|
@@ -124,6 +127,7 @@ export function normalizeOp(raw: Record<string, unknown>): PendingOp {
|
|
|
124
127
|
error_message: str(raw.error_message, raw.error),
|
|
125
128
|
error_code: str(raw.error_code) ?? undefined,
|
|
126
129
|
error_engine: str(raw.error_engine) ?? undefined,
|
|
130
|
+
cancellable: raw.cancellable === true,
|
|
127
131
|
started_at: str(raw.started_at),
|
|
128
132
|
finished_at: str(raw.finished_at),
|
|
129
133
|
created_at: str(raw.created_at),
|
|
@@ -155,7 +159,7 @@ export function usePendingOps(
|
|
|
155
159
|
let firstPollDone = false;
|
|
156
160
|
|
|
157
161
|
const hasActive = computed(() =>
|
|
158
|
-
ops.value.some((o) => o.status === 'pending' || o.status === 'running'),
|
|
162
|
+
ops.value.some((o) => o.status === 'pending' || o.status === 'running' || o.status === 'cancelling'),
|
|
159
163
|
);
|
|
160
164
|
|
|
161
165
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
@@ -197,7 +201,7 @@ export function usePendingOps(
|
|
|
197
201
|
// is currently seeing them in the local tray.
|
|
198
202
|
const localIds = new Set(ops.value.map((o) => o.id));
|
|
199
203
|
const visibleIncoming = incoming.filter((op) => {
|
|
200
|
-
if (op.status === 'pending' || op.status === 'running') return true;
|
|
204
|
+
if (op.status === 'pending' || op.status === 'running' || op.status === 'cancelling') return true;
|
|
201
205
|
if (!firstPollDone) return false;
|
|
202
206
|
return localIds.has(op.id);
|
|
203
207
|
});
|
|
@@ -211,7 +215,7 @@ export function usePendingOps(
|
|
|
211
215
|
// Sweep: drop terminal ops past RETAIN_MS.
|
|
212
216
|
const now = Date.now();
|
|
213
217
|
const after = merged.filter((o) => {
|
|
214
|
-
if (o.status === 'pending' || o.status === 'running') return true;
|
|
218
|
+
if (o.status === 'pending' || o.status === 'running' || o.status === 'cancelling') return true;
|
|
215
219
|
const at = settledAt.get(o.id);
|
|
216
220
|
return at !== undefined && now - at < RETAIN_MS;
|
|
217
221
|
});
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,17 @@ export { default as StarButton } from './components/StarButton.vue';
|
|
|
32
32
|
export { default as TagPicker } from './components/TagPicker.vue';
|
|
33
33
|
export { default as TagKindIcon } from './components/TagKindIcon.vue';
|
|
34
34
|
export { tagItemsOf, tagKey, isTagKind, type TagItem, type TagKind } from './lib/tags';
|
|
35
|
+
export {
|
|
36
|
+
coverageByStorage,
|
|
37
|
+
coverageMessage,
|
|
38
|
+
coverageNotice,
|
|
39
|
+
coveragePercent,
|
|
40
|
+
incomplete as coverageIncomplete,
|
|
41
|
+
type CatalogCoverage,
|
|
42
|
+
type CoverageNotice,
|
|
43
|
+
type CoverageReason,
|
|
44
|
+
type StorageInfo,
|
|
45
|
+
} from './lib/catalogCoverage';
|
|
35
46
|
// tablo:t3 — the row's ONE action control ("Actions" / "Aksiyon"). DataTable
|
|
36
47
|
// draws it for every table (`rowActions`); it stays exported for a host that
|
|
37
48
|
// needs the same control outside a table. A second menu built beside
|
|
@@ -333,6 +344,10 @@ export type { NavApp } from './components/SideNav.vue';
|
|
|
333
344
|
export { opPercent } from './lib/opProgress';
|
|
334
345
|
export type { OpProgressLike } from './lib/opProgress';
|
|
335
346
|
|
|
347
|
+
/* #48 — how an archive format is written (ZIP, TAR.GZ, 7z), for the explorer's
|
|
348
|
+
* create dialog and a host's own archive settings alike. */
|
|
349
|
+
export { archiveFormatLabel } from './lib/archiveFormats';
|
|
350
|
+
|
|
336
351
|
/* tasi:m1 — "download the selection as one archive". Exported so a host that
|
|
337
352
|
* draws its own selection bar gets the real two-step flow (authorized mint,
|
|
338
353
|
* then a navigation that streams) instead of reaching for window.open per
|
package/src/lib/actionIcons.ts
CHANGED
|
@@ -66,6 +66,12 @@ const GLYPHS: Record<string, string> = {
|
|
|
66
66
|
'<path d="M9.5 4.5v5"/>',
|
|
67
67
|
preview: EYE,
|
|
68
68
|
download: TRAY + '<path d="M12 4v11.5"/><path d="M7.5 11L12 15.5 16.5 11"/>',
|
|
69
|
+
'archive-create':
|
|
70
|
+
'<path d="M5 8.5h14v11H5z"/><path d="M4 4h16v4.5H4z"/>' +
|
|
71
|
+
'<path d="M12 11v5"/><path d="M9.5 13.5h5"/>',
|
|
72
|
+
'archive-extract':
|
|
73
|
+
'<path d="M5 8.5h14v11H5z"/><path d="M4 4h16v4.5H4z"/>' +
|
|
74
|
+
'<path d="M12 10.5v6"/><path d="M9.5 14l2.5 2.5 2.5-2.5"/>',
|
|
69
75
|
upload:
|
|
70
76
|
'<path d="M4.5 15.5v3A2 2 0 0 0 6.5 20.5h11a2 2 0 0 0 2-2v-3"/>' +
|
|
71
77
|
'<path d="M12 15.5V4"/><path d="M7.5 8.5L12 4l4.5 4.5"/>',
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* archiveFormatLabel — how an archive format is written wherever a person
|
|
3
|
+
* reads it: the create dialog, the admin Archives page, its provider cards.
|
|
4
|
+
*
|
|
5
|
+
* Upper case (ZIP, TAR.GZ, RAR), except 7z, which its own project writes in
|
|
6
|
+
* lower case. One rule in one place: the dialog wrote "7z" while the admin
|
|
7
|
+
* page wrote "7Z" for the same format (measured, 0.44.0 integration of #48).
|
|
8
|
+
*/
|
|
9
|
+
export function archiveFormatLabel(format: string): string {
|
|
10
|
+
const f = format.trim().toLowerCase();
|
|
11
|
+
return f === '7z' ? '7z' : f.toUpperCase();
|
|
12
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export interface CachedArchiveEntry {
|
|
2
|
+
name: string;
|
|
3
|
+
size: number;
|
|
4
|
+
mtime?: string;
|
|
5
|
+
is_dir?: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ArchivePreviewCache {
|
|
9
|
+
recall(path: string): CachedArchiveEntry[] | undefined;
|
|
10
|
+
remember(path: string, entries: CachedArchiveEntry[]): void;
|
|
11
|
+
forget(path: string): void;
|
|
12
|
+
clear(): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const ARCHIVE_PREVIEW_CACHE_TTL_MS = 2 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
export function createArchivePreviewCache(
|
|
18
|
+
ttlMs = ARCHIVE_PREVIEW_CACHE_TTL_MS,
|
|
19
|
+
now: () => number = Date.now,
|
|
20
|
+
): ArchivePreviewCache {
|
|
21
|
+
type Cached = {
|
|
22
|
+
entries: CachedArchiveEntry[];
|
|
23
|
+
expiresAt: number;
|
|
24
|
+
timer: ReturnType<typeof setTimeout>;
|
|
25
|
+
};
|
|
26
|
+
const values = new Map<string, Cached>();
|
|
27
|
+
|
|
28
|
+
function forget(path: string): void {
|
|
29
|
+
const cached = values.get(path);
|
|
30
|
+
if (cached) clearTimeout(cached.timer);
|
|
31
|
+
values.delete(path);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
recall(path) {
|
|
36
|
+
const cached = values.get(path);
|
|
37
|
+
if (!cached) return undefined;
|
|
38
|
+
if (cached.expiresAt <= now()) {
|
|
39
|
+
forget(path);
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
return cached.entries.map((entry) => ({ ...entry }));
|
|
43
|
+
},
|
|
44
|
+
remember(path, entries) {
|
|
45
|
+
forget(path);
|
|
46
|
+
const cached: Cached = {
|
|
47
|
+
entries: entries.map((entry) => ({ ...entry })),
|
|
48
|
+
expiresAt: now() + ttlMs,
|
|
49
|
+
timer: setTimeout(() => {
|
|
50
|
+
if (values.get(path) === cached) values.delete(path);
|
|
51
|
+
}, ttlMs),
|
|
52
|
+
};
|
|
53
|
+
values.set(path, cached);
|
|
54
|
+
},
|
|
55
|
+
forget,
|
|
56
|
+
clear() {
|
|
57
|
+
for (const cached of values.values()) clearTimeout(cached.timer);
|
|
58
|
+
values.clear();
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* catalogCoverage — how much of a storage its catalog covers, as the server
|
|
3
|
+
* says it, and the one set of rules every view draws that by.
|
|
4
|
+
*
|
|
5
|
+
* filex keeps a catalog of every storage, and search, folder sizes and drive
|
|
6
|
+
* usage are answered from it. While it does not cover a storage in full the
|
|
7
|
+
* server says so (docs/LAZY-CATALOGUE.md → "Coverage in the UI"):
|
|
8
|
+
*
|
|
9
|
+
* - `first_scan` — any storage whose first sync has not finished;
|
|
10
|
+
* - `lazy_filling` — a lazily cataloged storage whose background pass is
|
|
11
|
+
* still going (behaviour A);
|
|
12
|
+
* - `lazy_on_open` — a lazily cataloged storage that catalogs only the
|
|
13
|
+
* folders people open (behaviour B).
|
|
14
|
+
*
|
|
15
|
+
* The listing and search responses carry it per storage in `storage_info`,
|
|
16
|
+
* drive usage per row. A folder row whose own size leaves something out
|
|
17
|
+
* carries `size_partial` (see useLocale `formatNodeSize`).
|
|
18
|
+
*
|
|
19
|
+
* ⚠ One module for the whole explorer: the banner over a listing, the banner
|
|
20
|
+
* over search results and Home's drive cards read the SAME object through the
|
|
21
|
+
* SAME rules, so they cannot tell a person two different things about one
|
|
22
|
+
* storage.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Why a storage's catalog is not complete. */
|
|
26
|
+
export type CoverageReason = 'first_scan' | 'lazy_filling' | 'lazy_on_open';
|
|
27
|
+
|
|
28
|
+
/** The server's `coverage` object (sync.CatalogueCoverage). */
|
|
29
|
+
export interface CatalogCoverage {
|
|
30
|
+
complete: boolean;
|
|
31
|
+
reason?: CoverageReason | string;
|
|
32
|
+
/** `lazy` for a lazily cataloged storage. */
|
|
33
|
+
mode?: string;
|
|
34
|
+
/** The lazy behaviour: `background` (A) or `on_open` (B). */
|
|
35
|
+
fill?: string;
|
|
36
|
+
catalogued_folders?: number;
|
|
37
|
+
pending_folders?: number;
|
|
38
|
+
watched_folders?: number;
|
|
39
|
+
max_watches?: number;
|
|
40
|
+
filler?: string;
|
|
41
|
+
held_back_folders?: number;
|
|
42
|
+
reconciles?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One entry of a listing's `storage_info`. */
|
|
46
|
+
export interface StorageInfo {
|
|
47
|
+
name: string;
|
|
48
|
+
read_only?: boolean;
|
|
49
|
+
coverage?: CatalogCoverage | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A coverage object worth telling anybody about, or null. */
|
|
53
|
+
export function incomplete(c: CatalogCoverage | null | undefined): CatalogCoverage | null {
|
|
54
|
+
if (!c || typeof c !== 'object' || c.complete === true) return null;
|
|
55
|
+
return c;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The share of the folders found so far that are cataloged, 0–99 while any
|
|
60
|
+
* is still waiting (a floor, so "100%" is never shown beside a notice that
|
|
61
|
+
* says the work is not done).
|
|
62
|
+
*/
|
|
63
|
+
export function coveragePercent(c: CatalogCoverage): number {
|
|
64
|
+
const done = Math.max(0, c.catalogued_folders ?? 0);
|
|
65
|
+
const pending = Math.max(0, c.pending_folders ?? 0);
|
|
66
|
+
if (done + pending === 0) return 0;
|
|
67
|
+
const pct = Math.floor((done * 100) / (done + pending));
|
|
68
|
+
return pending > 0 ? Math.min(pct, 99) : pct;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The sentence one storage's coverage is told in: a catalog key and its values. */
|
|
72
|
+
export function coverageMessage(c: CatalogCoverage): { key: string; vars: Record<string, number> } {
|
|
73
|
+
switch (c.reason) {
|
|
74
|
+
case 'lazy_on_open':
|
|
75
|
+
return { key: 'coverage.lazy_on_open', vars: {} };
|
|
76
|
+
case 'lazy_filling':
|
|
77
|
+
return { key: 'coverage.lazy_filling', vars: { pct: coveragePercent(c) } };
|
|
78
|
+
default:
|
|
79
|
+
return { key: 'coverage.first_scan', vars: {} };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `storage_info` → storage name → its incomplete coverage (null = complete). */
|
|
84
|
+
export function coverageByStorage(info: unknown): Record<string, CatalogCoverage | null> {
|
|
85
|
+
const out: Record<string, CatalogCoverage | null> = {};
|
|
86
|
+
if (!Array.isArray(info)) return out;
|
|
87
|
+
for (const row of info as StorageInfo[]) {
|
|
88
|
+
if (!row || typeof row.name !== 'string' || row.name === '') continue;
|
|
89
|
+
out[row.name] = incomplete(row.coverage ?? null);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** What the banner over a listing or a search says, and whether it offers an action. */
|
|
95
|
+
export interface CoverageNotice {
|
|
96
|
+
key: string;
|
|
97
|
+
vars: Record<string, string | number>;
|
|
98
|
+
/** The storage the notice is about, when it is about one. */
|
|
99
|
+
storage?: string;
|
|
100
|
+
/** Offer "Catalog everything" (an administrator, behaviour B). */
|
|
101
|
+
offerCatalogAll: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The notice for what is on screen.
|
|
106
|
+
*
|
|
107
|
+
* - A listing, or a search inside one storage: that storage's coverage.
|
|
108
|
+
* - A search that spans every storage (the server searches them all when
|
|
109
|
+
* the query is typed at a storage's root and there is more than one):
|
|
110
|
+
* the names of the ones not fully cataloged.
|
|
111
|
+
*
|
|
112
|
+
* `admin` decides the action only; everybody reads the same sentence.
|
|
113
|
+
*/
|
|
114
|
+
export function coverageNotice(opts: {
|
|
115
|
+
map: Record<string, CatalogCoverage | null>;
|
|
116
|
+
adapter: string;
|
|
117
|
+
searching: boolean;
|
|
118
|
+
crossStorage: boolean;
|
|
119
|
+
admin: boolean;
|
|
120
|
+
}): CoverageNotice | null {
|
|
121
|
+
const { map, adapter, searching, crossStorage, admin } = opts;
|
|
122
|
+
if (searching && crossStorage) {
|
|
123
|
+
const names = Object.keys(map)
|
|
124
|
+
.filter((n) => map[n] !== null)
|
|
125
|
+
.sort((a, b) => a.localeCompare(b));
|
|
126
|
+
if (names.length === 0) return null;
|
|
127
|
+
if (names.length === 1) return single(names[0], map[names[0]] as CatalogCoverage, admin);
|
|
128
|
+
return { key: 'coverage.search_some', vars: { names: names.join(', ') }, offerCatalogAll: false };
|
|
129
|
+
}
|
|
130
|
+
const c = map[adapter];
|
|
131
|
+
return c ? single(adapter, c, admin) : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function single(storage: string, c: CatalogCoverage, admin: boolean): CoverageNotice {
|
|
135
|
+
const m = coverageMessage(c);
|
|
136
|
+
return { key: m.key, vars: m.vars, storage, offerCatalogAll: admin && c.reason === 'lazy_on_open' };
|
|
137
|
+
}
|