@brftech/filex-core 0.43.2 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/ArchiveViewer-D_tJxJhJ.js +236 -0
  2. package/dist/ArchiveViewer-D_tJxJhJ.js.map +1 -0
  3. package/dist/{CsvViewer-I0AceHh8.js → CsvViewer-B95fArq0.js} +2 -2
  4. package/dist/{CsvViewer-I0AceHh8.js.map → CsvViewer-B95fArq0.js.map} +1 -1
  5. package/dist/{DrawioViewer-B2vl10hY.js → DrawioViewer-BAaPMTge.js} +2 -2
  6. package/dist/{DrawioViewer-B2vl10hY.js.map → DrawioViewer-BAaPMTge.js.map} +1 -1
  7. package/dist/{EpubViewer-L3rxrx5i.js → EpubViewer-B-B7bRW7.js} +2 -2
  8. package/dist/{EpubViewer-L3rxrx5i.js.map → EpubViewer-B-B7bRW7.js.map} +1 -1
  9. package/dist/{IpynbViewer-Do8n0RzJ.js → IpynbViewer-COOTyOcu.js} +2 -2
  10. package/dist/{IpynbViewer-Do8n0RzJ.js.map → IpynbViewer-COOTyOcu.js.map} +1 -1
  11. package/dist/{MermaidViewer-0JqH6k0f.js → MermaidViewer-CjVCmR3s.js} +2 -2
  12. package/dist/{MermaidViewer-0JqH6k0f.js.map → MermaidViewer-CjVCmR3s.js.map} +1 -1
  13. package/dist/{PsdViewer-BnI-NDr8.js → PsdViewer-xsIYL9Gu.js} +2 -2
  14. package/dist/{PsdViewer-BnI-NDr8.js.map → PsdViewer-xsIYL9Gu.js.map} +1 -1
  15. package/dist/{TiffViewer-CrLDXI_f.js → TiffViewer-CtwnTxqL.js} +2 -2
  16. package/dist/{TiffViewer-CrLDXI_f.js.map → TiffViewer-CtwnTxqL.js.map} +1 -1
  17. package/dist/{Viewer3D-CpB1K0-0.js → Viewer3D-xYMwndEy.js} +2 -2
  18. package/dist/{Viewer3D-CpB1K0-0.js.map → Viewer3D-xYMwndEy.js.map} +1 -1
  19. package/dist/filex-core.js +372 -366
  20. package/dist/filex-core.umd.cjs +62 -62
  21. package/dist/filex-core.umd.cjs.map +1 -1
  22. package/dist/{index-D9_EBsqF.js → index-BBToAjwL.js} +16563 -15727
  23. package/dist/index-BBToAjwL.js.map +1 -0
  24. package/dist/index.d.ts +185 -10
  25. package/dist/style.css +1 -1
  26. package/package.json +1 -1
  27. package/src/FileExplorer.vue +363 -23
  28. package/src/components/ContextMenu.vue +17 -0
  29. package/src/components/HomeView.vue +14 -4
  30. package/src/components/InspectorPanel.vue +9 -3
  31. package/src/components/ListView.vue +6 -3
  32. package/src/components/OperationsCenter.vue +6 -0
  33. package/src/components/PendingOpsTray.vue +5 -3
  34. package/src/composables/useFileApi.ts +36 -6
  35. package/src/composables/useLocale.ts +26 -0
  36. package/src/composables/useOperations.ts +7 -1
  37. package/src/composables/usePendingOps.ts +11 -7
  38. package/src/index.ts +15 -0
  39. package/src/lib/actionIcons.ts +6 -0
  40. package/src/lib/archiveFormats.ts +12 -0
  41. package/src/lib/archivePreviewCache.ts +61 -0
  42. package/src/lib/catalogCoverage.ts +137 -0
  43. package/src/locales/en.ts +67 -0
  44. package/src/locales/tr.ts +65 -0
  45. package/src/modals/ArchiveCreateModal.vue +192 -0
  46. package/src/modals/ArchiveExtractModal.vue +57 -0
  47. package/src/modals/ArchivePasswordModal.vue +62 -0
  48. package/src/modals/PreviewModal.vue +15 -1
  49. package/src/styles/base.css +47 -2
  50. package/src/types/ExplorerConfig.ts +9 -0
  51. package/src/types/FileNode.ts +19 -0
  52. package/src/viewers/ArchiveViewer.vue +145 -40
  53. package/dist/ArchiveViewer-Bn-pwgX8.js +0 -149
  54. package/dist/ArchiveViewer-Bn-pwgX8.js.map +0 -1
  55. 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(path: string, members?: string[]): Promise<{ keys: string[]; count: number }> {
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, members }),
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 = 'upload' | 'copy' | 'move' | 'delete' | 'convert' | 'archive' | 'plugin' | 'trash';
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 === 'running'
98
- ? 'running'
99
- : 'pending';
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
@@ -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
+ }