@brftech/filex-core 0.39.1 → 0.41.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 (125) hide show
  1. package/README.md +32 -25
  2. package/dist/ArchiveViewer-BNqukFg8.js +124 -0
  3. package/dist/ArchiveViewer-BNqukFg8.js.map +1 -0
  4. package/dist/CsvViewer-ii_-MgmG.js +141 -0
  5. package/dist/CsvViewer-ii_-MgmG.js.map +1 -0
  6. package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-B2tuu4rX.js} +48 -42
  7. package/dist/DrawioViewer-B2tuu4rX.js.map +1 -0
  8. package/dist/EpubViewer-p4B8iiWb.js +163 -0
  9. package/dist/EpubViewer-p4B8iiWb.js.map +1 -0
  10. package/dist/IpynbViewer-D_qYRJhu.js +184 -0
  11. package/dist/IpynbViewer-D_qYRJhu.js.map +1 -0
  12. package/dist/MermaidViewer-3ZkfNM8v.js +137 -0
  13. package/dist/MermaidViewer-3ZkfNM8v.js.map +1 -0
  14. package/dist/PsdViewer-CuV-OPSd.js +122 -0
  15. package/dist/PsdViewer-CuV-OPSd.js.map +1 -0
  16. package/dist/TiffViewer-BLeoF4Be.js +142 -0
  17. package/dist/TiffViewer-BLeoF4Be.js.map +1 -0
  18. package/dist/Viewer3D-DHhhU76E.js +75 -0
  19. package/dist/Viewer3D-DHhhU76E.js.map +1 -0
  20. package/dist/filex-core.js +217 -20077
  21. package/dist/filex-core.js.map +1 -1
  22. package/dist/filex-core.umd.cjs +61 -62
  23. package/dist/filex-core.umd.cjs.map +1 -1
  24. package/dist/index-BraG7Cz4.js +26664 -0
  25. package/dist/index-BraG7Cz4.js.map +1 -0
  26. package/dist/index.d.ts +4276 -851
  27. package/dist/style.css +1 -1
  28. package/package.json +1 -1
  29. package/src/FileExplorer.vue +2170 -492
  30. package/src/components/AdvancedSearch.vue +870 -0
  31. package/src/components/Breadcrumb.vue +191 -19
  32. package/src/components/CommandPalette.vue +66 -22
  33. package/src/components/ConnectionsPanel.vue +6 -2
  34. package/src/components/ContextMenu.vue +57 -2
  35. package/src/components/EncryptedFolderModal.vue +31 -25
  36. package/src/components/FilePane.vue +939 -0
  37. package/src/components/FilterBar.vue +568 -72
  38. package/src/components/GalleryView.vue +162 -30
  39. package/src/components/GridView.vue +322 -58
  40. package/src/components/HomeView.vue +331 -0
  41. package/src/components/InspectorPanel.vue +296 -53
  42. package/src/components/ListView.vue +1158 -167
  43. package/src/components/NFSExportsPanel.vue +7 -4
  44. package/src/components/OnboardingTour.vue +156 -33
  45. package/src/components/QuickLook.vue +34 -5
  46. package/src/components/RecentlyOpened.vue +17 -2
  47. package/src/components/S3KeysPanel.vue +7 -4
  48. package/src/components/SSHKeysPanel.vue +7 -4
  49. package/src/components/ShortcutSettings.vue +43 -2
  50. package/src/components/ShortcutsHelp.vue +4 -1
  51. package/src/components/SideNav.vue +206 -140
  52. package/src/components/StorageFields.vue +19 -1
  53. package/src/components/TabBar.vue +87 -1
  54. package/src/components/TagPicker.vue +77 -6
  55. package/src/components/ThemeGallery.vue +13 -198
  56. package/src/components/ThemePalette.vue +244 -0
  57. package/src/components/TimeZoneDialog.vue +127 -0
  58. package/src/components/TimeZonePicker.vue +696 -0
  59. package/src/components/TokensPanel.vue +16 -4
  60. package/src/components/Toolbar.vue +965 -469
  61. package/src/components/ViewSwitcher.vue +9 -4
  62. package/src/composables/useConnections.ts +35 -4
  63. package/src/composables/useExplorerTimeZone.ts +85 -0
  64. package/src/composables/useFileApi.ts +53 -4
  65. package/src/composables/useKeyboardShortcuts.ts +122 -0
  66. package/src/composables/useLocale.ts +365 -25
  67. package/src/index.ts +182 -3
  68. package/src/lib/actionIcons.ts +330 -0
  69. package/src/lib/advSearch.ts +162 -0
  70. package/src/lib/dateGroups.ts +197 -0
  71. package/src/lib/destinationTree.ts +235 -0
  72. package/src/lib/downloadSelection.ts +154 -0
  73. package/src/lib/fileFilters.ts +319 -12
  74. package/src/lib/fileIcons.ts +299 -5
  75. package/src/lib/filePreview.ts +605 -0
  76. package/src/lib/listing.ts +72 -0
  77. package/src/lib/shareTtl.ts +11 -1
  78. package/src/lib/sortOrder.ts +459 -0
  79. package/src/lib/themes.ts +152 -23
  80. package/src/lib/timezone.ts +456 -0
  81. package/src/lib/uiProfile.ts +82 -0
  82. package/src/lib/viewPrefs.ts +1036 -0
  83. package/src/locales/en.ts +432 -25
  84. package/src/locales/tr.ts +425 -25
  85. package/src/modals/ConvertModal.vue +23 -3
  86. package/src/modals/DestinationPickerModal.vue +341 -0
  87. package/src/modals/Modal.vue +18 -4
  88. package/src/modals/NewDocumentModal.vue +525 -0
  89. package/src/modals/NewFolderModal.vue +15 -9
  90. package/src/modals/PermissionsModal.vue +543 -473
  91. package/src/modals/PreviewModal.vue +516 -156
  92. package/src/styles/base.css +5435 -8
  93. package/src/styles/variables.css +140 -60
  94. package/src/types/Connections.ts +9 -0
  95. package/src/types/ExplorerConfig.ts +144 -39
  96. package/src/types/FileNode.ts +28 -0
  97. package/src/viewers/ArchiveViewer.vue +46 -8
  98. package/src/viewers/CsvViewer.vue +19 -2
  99. package/src/viewers/DrawioViewer.vue +12 -1
  100. package/src/viewers/EpubViewer.vue +50 -6
  101. package/src/viewers/IpynbViewer.vue +19 -2
  102. package/src/viewers/MermaidViewer.vue +20 -3
  103. package/src/viewers/PdfViewer.vue +24 -4
  104. package/src/viewers/PsdViewer.vue +28 -4
  105. package/src/viewers/TiffViewer.vue +19 -2
  106. package/src/viewers/Viewer3D.vue +36 -2
  107. package/dist/ArchiveViewer-Cdfv9LFC.js +0 -99
  108. package/dist/ArchiveViewer-Cdfv9LFC.js.map +0 -1
  109. package/dist/CsvViewer-CqWeV8VO.js +0 -131
  110. package/dist/CsvViewer-CqWeV8VO.js.map +0 -1
  111. package/dist/DrawioViewer-BNALOB04.js.map +0 -1
  112. package/dist/EpubViewer-BPmlImG7.js +0 -145
  113. package/dist/EpubViewer-BPmlImG7.js.map +0 -1
  114. package/dist/IpynbViewer-CDlQmuCV.js +0 -175
  115. package/dist/IpynbViewer-CDlQmuCV.js.map +0 -1
  116. package/dist/MermaidViewer-sfZWdR-d.js +0 -127
  117. package/dist/MermaidViewer-sfZWdR-d.js.map +0 -1
  118. package/dist/PsdViewer-DwuySZsb.js +0 -112
  119. package/dist/PsdViewer-DwuySZsb.js.map +0 -1
  120. package/dist/TiffViewer-BDQ3d3CQ.js +0 -132
  121. package/dist/TiffViewer-BDQ3d3CQ.js.map +0 -1
  122. package/dist/Viewer3D-CJJJgkuv.js +0 -60
  123. package/dist/Viewer3D-CJJJgkuv.js.map +0 -1
  124. package/src/components/SecondaryPane.vue +0 -447
  125. package/src/modals/ShareModal.vue +0 -139
@@ -0,0 +1,197 @@
1
+ /**
2
+ * dateGroups — ONE date ladder, for every view that draws one.
3
+ *
4
+ * ⚠⚠ WHY THIS FILE EXISTS. The ladder used to live inside `ListView.vue` as a
5
+ * private `bucketFor()`, and only the list drew headings at all: the grid and
6
+ * the gallery showed the same rows in the same date order with nothing naming
7
+ * the days. The owner asked for the headings in every view ("Ve bunu her
8
+ * görünümde de yapması lazım"), and the shape of that request is exactly the
9
+ * shape this repository keeps getting wrong — a rule copied into a second view
10
+ * agrees with the first on the day it is written and on no day after it.
11
+ * `lib/sortOrder` and `lib/listing.byFoldersFirst` are here for the same
12
+ * reason, after the grid and the list had already drifted once (filex lesson
13
+ * #67). So the ladder is written once, and List, Grid and Gallery all read it.
14
+ *
15
+ * ⚠ THE HEADING IS A CLAIM ABOUT THE ORDER. "Today / Yesterday / March 2026"
16
+ * over rows that are not in date order is not a grouping, it is a lie: the
17
+ * same heading would reappear wherever the real order happened to interleave
18
+ * two days. `groupingActive()` is that rule, and it is the reason grouping
19
+ * follows the SORT KEY rather than a switch of its own — a switch would create
20
+ * a state in which the key says one thing and the grouping another, and no
21
+ * view could honestly draw both (`lib/viewPrefs`'s header says the same).
22
+ *
23
+ * ⚠ zaman:z1 — every boundary here is a boundary in the VIEWER's zone, never
24
+ * the device's. A file touched at 01:00 in Istanbul belongs under "Yesterday"
25
+ * for a reader on UTC and under "Today" for a reader on Istanbul, and the
26
+ * header has to agree with the date the cell four pixels away prints. That is
27
+ * why the labels and the month id are taken from the caller (`useLocale`,
28
+ * which reads `lib/timezone`) instead of being formatted here.
29
+ */
30
+ import { zonedDayNumber } from './timezone';
31
+
32
+ import type { ListingOrder, SortKey } from './sortOrder';
33
+
34
+ /** One rung: a stable id to compare runs by, and the words to draw. */
35
+ export interface DateBucket {
36
+ id: string;
37
+ label: string;
38
+ }
39
+
40
+ /**
41
+ * What a view lends this module so the headings speak its language and read
42
+ * its clock. All three come straight off `useLocale(...)`.
43
+ */
44
+ export interface DateGroupLabels {
45
+ t: (key: string) => string;
46
+ /** "September 2026", in the viewer's locale and zone. */
47
+ formatMonthYear: (value: number | Date | undefined | null) => string;
48
+ /** `YYYY-MM` in the viewer's zone — a bucket id, not a label. */
49
+ zonedYearMonth: (value: number | Date | undefined | null) => string;
50
+ }
51
+
52
+ /**
53
+ * THE LADDER.
54
+ *
55
+ * Today · Yesterday · This Week · This Month · then one rung per month,
56
+ * named ("September 2026"). Undated rows land in "No date", which the sort
57
+ * already parks at the end in both directions (`lib/sortOrder.byActiveKey`).
58
+ *
59
+ * Measured against the reference the owner pointed at — filex.blipnova.com,
60
+ * 2026-09-13, the `/recent` page, which is the only surface there that groups.
61
+ * Its ladder is `Today · Yesterday · <that day's date>`: one heading per
62
+ * calendar day for ever, taken from the browser's own clock. Ours keeps its
63
+ * first two rungs verbatim and then collapses, deliberately:
64
+ *
65
+ * - a heading per day is not a grouping once a listing is long — a folder
66
+ * touched file-by-file over a month draws thirty headings over thirty
67
+ * single rows;
68
+ * - the owner's own words name the rungs we keep: "son bir hafta, dün,
69
+ * bugün, bu ay içinde vb." (last week, yesterday, today, within this
70
+ * month, and so on);
71
+ * - and the reference reads the DEVICE's calendar, which is the bug
72
+ * `lib/timezone` exists to have fixed here.
73
+ *
74
+ * ⚠ "This Week" is the six days before yesterday (`day > today - 7`), NOT a
75
+ * calendar week: a Monday listing with a calendar rule would put Sunday —
76
+ * eighteen hours ago — under a month heading.
77
+ *
78
+ * ⚠ A stamp in the future reads best as "today". Clock skew between a storage
79
+ * and the reader is ordinary, and "September 2027" over the file somebody just
80
+ * saved is worse than a day's imprecision.
81
+ */
82
+ export function dateBucketFor(d: Date | null, l: DateGroupLabels): DateBucket {
83
+ if (!d) return { id: 'none', label: l.t('group.no_date') };
84
+ const now = new Date();
85
+ const today = zonedDayNumber(now);
86
+ const day = zonedDayNumber(d);
87
+ if (day >= today) return { id: 'today', label: l.t('group.today') };
88
+ if (day === today - 1) return { id: 'yesterday', label: l.t('group.yesterday') };
89
+ if (day > today - 7) return { id: 'week', label: l.t('group.this_week') };
90
+ const ym = l.zonedYearMonth(d);
91
+ if (ym === l.zonedYearMonth(now)) return { id: 'month', label: l.t('group.this_month') };
92
+ return { id: `m-${ym}`, label: l.formatMonthYear(d) };
93
+ }
94
+
95
+ /**
96
+ * Is a date heading honest over THIS listing?
97
+ *
98
+ * Both halves are required and neither is cosmetic:
99
+ * - the key has to be `modified`, because that is the only order in which
100
+ * consecutive rows share a day;
101
+ * - the listing must not be the server's RANKED answer, where the key is
102
+ * still `modified` (nothing cleared it) and the rows simply are not
103
+ * obeying it.
104
+ *
105
+ * Sorting by name with date headings on would be exactly the lie above, so
106
+ * every other key draws no headings at all — in all three views.
107
+ */
108
+ export function groupingActive(key: SortKey, order: ListingOrder | undefined): boolean {
109
+ return order !== 'relevance' && key === 'modified';
110
+ }
111
+
112
+ /** A contiguous run of items that share a heading. `label: null` = no heading. */
113
+ export interface DateRun<T> {
114
+ id: string;
115
+ label: string | null;
116
+ items: T[];
117
+ }
118
+
119
+ /** An item that is not filed under a date, and the run it belongs to instead. */
120
+ export interface AsideRun {
121
+ id: string;
122
+ /** A heading for that run, or null/absent for a run drawn without one. */
123
+ label?: string | null;
124
+ }
125
+
126
+ export interface DateGroupOptions<T> {
127
+ /** `groupingActive(...)`. False → one unlabelled run holding everything. */
128
+ active: boolean;
129
+ /** The instant this item is filed under, or null for "no date". */
130
+ dateOf: (item: T) => Date | null;
131
+ /**
132
+ * Items that are NOT filed under a date — return the run they belong to, or
133
+ * null to bucket them by date like everything else.
134
+ *
135
+ * ⚠ This is how "folders first" survives a date sort. Folders are given a
136
+ * run of their own at the top rather than floating to the top of each
137
+ * bucket, because floating would split a bucket in two and draw "Today"
138
+ * twice — once over the folders, once over the files. A folder has no
139
+ * meaningful modification date to file under anyway. The list uses it for
140
+ * its pinned storage rows as well.
141
+ */
142
+ aside?: (item: T) => AsideRun | null;
143
+ labels: DateGroupLabels;
144
+ }
145
+
146
+ /**
147
+ * The grouping, in the two shapes the three views need.
148
+ *
149
+ * `runs` is for a view that renders segment by segment (the list, whose rows
150
+ * are already nested under a heading element). `headingBefore` is for a view
151
+ * that renders one flat sequence of cards and inserts a full-width heading
152
+ * item in front of the card that starts a run (the grid and the gallery).
153
+ *
154
+ * ⚠ They are two readings of ONE pass, not two implementations: the map is
155
+ * built from the runs. A view that used its own "did the bucket change since
156
+ * the previous card" test would be the second copy this module exists to
157
+ * prevent.
158
+ */
159
+ export interface DateGrouping<T> {
160
+ /** Mirrors the `active` that was asked for — views branch on it. */
161
+ active: boolean;
162
+ runs: DateRun<T>[];
163
+ /** The heading to draw immediately before this item, or null. */
164
+ headingBefore: (item: T) => string | null;
165
+ }
166
+
167
+ export function groupByDate<T>(items: readonly T[], opts: DateGroupOptions<T>): DateGrouping<T> {
168
+ const runs: DateRun<T>[] = [];
169
+ if (!opts.active) {
170
+ runs.push({ id: 'all', label: null, items: [...items] });
171
+ } else {
172
+ let cur: DateRun<T> | null = null;
173
+ for (const item of items) {
174
+ const side = opts.aside?.(item) ?? null;
175
+ const b: { id: string; label: string | null } = side
176
+ ? { id: side.id, label: side.label ?? null }
177
+ : dateBucketFor(opts.dateOf(item), opts.labels);
178
+ if (!cur || cur.id !== b.id) {
179
+ cur = { id: b.id, label: b.label, items: [] };
180
+ runs.push(cur);
181
+ }
182
+ cur.items.push(item);
183
+ }
184
+ }
185
+ /* Identity-keyed: the items are the very objects the template loops over in
186
+ * the same render, so no key function has to be invented — and inventing one
187
+ * is how two views end up disagreeing about what "the same row" means. */
188
+ const firsts = new Map<T, string>();
189
+ for (const r of runs) {
190
+ if (r.label && r.items.length) firsts.set(r.items[0], r.label);
191
+ }
192
+ return {
193
+ active: opts.active,
194
+ runs,
195
+ headingBefore: (item: T) => firsts.get(item) ?? null,
196
+ };
197
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * destinationTree — the rules behind "choose a folder".
3
+ *
4
+ * filex had no folder chooser. Copy and move existed (cut/paste, drag between
5
+ * panes) but both required the destination to already be on screen, which is
6
+ * why there was no "Move to…" or "Copy to…" anywhere: the verb was never the
7
+ * missing part, the picker was. `NewDocumentModal` grew a private one-level
8
+ * browser for its own "where should this document go?" question, and a second
9
+ * private copy for Move/Copy would have been the moment the two started
10
+ * disagreeing about what a writable folder is.
11
+ *
12
+ * Everything here is DOM-free and synchronous so the rules can be tested
13
+ * without mounting anything. The component
14
+ * (`modals/DestinationPickerModal.vue`) owns the reactive state and the
15
+ * listing calls; this module owns the answers.
16
+ *
17
+ * # Wire paths
18
+ *
19
+ * A destination is an adapter-qualified wire path — `main://belgeler/2026` —
20
+ * exactly what `api.copy` / `api.moveAsync` take and what the backend resolves
21
+ * to a storage. The tree therefore spans storages, because copy and move
22
+ * already do: `internal/ops` streams bytes between two drivers when the source
23
+ * and destination storages differ. A picker that could only offer folders in
24
+ * the current storage would hide a capability the product already has.
25
+ */
26
+
27
+ import type { FileNode } from '../types/FileNode';
28
+
29
+ /**
30
+ * The browser's top level: the list of drives rather than a folder.
31
+ *
32
+ * It cannot collide with a real destination because every wire path contains
33
+ * `://` and this string does not. (The same sentinel and the same reasoning as
34
+ * NewDocumentModal's private browser, which this module is meant to replace.)
35
+ */
36
+ export const DRIVES = '@@drives';
37
+
38
+ /** Split `adapter://rel` into its two halves; a bare path has no adapter. */
39
+ export function splitWire(p: string): [string, string] {
40
+ const i = p.indexOf('://');
41
+ if (i < 0) return ['', trimSlashes(p)];
42
+ return [p.slice(0, i), trimSlashes(p.slice(i + 3))];
43
+ }
44
+
45
+ /** Rebuild `adapter://rel`. An empty rel is the storage root. */
46
+ export function joinWire(adapter: string, rel: string): string {
47
+ const r = trimSlashes(rel);
48
+ return r ? `${adapter}://${r}` : `${adapter}://`;
49
+ }
50
+
51
+ function trimSlashes(s: string): string {
52
+ return s.replace(/^\/+|\/+$/g, '');
53
+ }
54
+
55
+ /**
56
+ * The wire path one level up, or null when there is nowhere further up.
57
+ *
58
+ * `multiDrive` decides what sits above a storage root: with more than one
59
+ * storage the answer is the list of drives, with exactly one there is nothing
60
+ * above the root and the Up button must be disabled rather than land the user
61
+ * on a one-row list of the drive they are already in.
62
+ */
63
+ export function parentOfWire(p: string, multiDrive: boolean): string | null {
64
+ if (p === DRIVES) return null;
65
+ const [adapter, rel] = splitWire(p);
66
+ if (!adapter) return null;
67
+ if (!rel) return multiDrive ? DRIVES : null;
68
+ const parts = rel.split('/');
69
+ parts.pop();
70
+ return joinWire(adapter, parts.join('/'));
71
+ }
72
+
73
+ /** The last segment of a wire path — what a breadcrumb or a row shows. */
74
+ export function labelOfWire(p: string, drivesLabel: string): string {
75
+ if (p === DRIVES) return drivesLabel;
76
+ const [adapter, rel] = splitWire(p);
77
+ if (!rel) return adapter || drivesLabel;
78
+ const parts = rel.split('/');
79
+ return parts[parts.length - 1] || adapter;
80
+ }
81
+
82
+ /** Every step from the storage root down to `p`, for a clickable breadcrumb. */
83
+ export function crumbsOfWire(p: string): Array<{ label: string; path: string }> {
84
+ if (p === DRIVES || !p) return [];
85
+ const [adapter, rel] = splitWire(p);
86
+ if (!adapter) return [];
87
+ const out = [{ label: adapter, path: joinWire(adapter, '') }];
88
+ if (!rel) return out;
89
+ let acc = '';
90
+ for (const seg of rel.split('/')) {
91
+ acc = acc ? `${acc}/${seg}` : seg;
92
+ out.push({ label: seg, path: joinWire(adapter, acc) });
93
+ }
94
+ return out;
95
+ }
96
+
97
+ /**
98
+ * May the caller write into a folder with this `perm`?
99
+ *
100
+ * ⚠ `undefined` AND `''` both mean "this storage does not enforce ACL", which
101
+ * is the pre-RBAC default and full access. The declared type says one of four
102
+ * levels, but the wire really does carry `''` for an unenforced storage, so
103
+ * reading both as allowed is the truthful reading and not a convenience.
104
+ *
105
+ * This is deliberately the same predicate as `FileExplorer.permCanEdit` and
106
+ * `NewDocumentModal.permAllowsWrite`. Two answers to "may I write here" is how
107
+ * a dialog ends up offering a destination the toolbar hides.
108
+ */
109
+ export function permAllowsWrite(perm: string | undefined | null): boolean {
110
+ return perm === undefined || perm === null || perm === '' || perm === 'editor' || perm === 'owner';
111
+ }
112
+
113
+ /** Is `child` the same as, or inside, `ancestor`? Both are wire paths. */
114
+ export function isAtOrInside(child: string, ancestor: string): boolean {
115
+ if (child === DRIVES || ancestor === DRIVES) return false;
116
+ const [ca, cr] = splitWire(child);
117
+ const [aa, ar] = splitWire(ancestor);
118
+ if (ca !== aa) return false;
119
+ if (ar === '') return true;
120
+ return cr === ar || cr.startsWith(ar + '/');
121
+ }
122
+
123
+ /**
124
+ * Why this folder cannot be the destination, or null when it can.
125
+ *
126
+ * `'self'` and `'descendant'` are kept apart because they need different
127
+ * sentences. "You cannot move a folder into itself" and "…into a folder inside
128
+ * it" are the two halves of the mistake, and a single message covering both
129
+ * reads as a riddle at the moment the person is trying to work out what they
130
+ * did wrong.
131
+ *
132
+ * ⚠ This is a courtesy, not a control. The same refusal exists in
133
+ * `internal/ops.SubmitTo` and answers 400 whatever the picker offered —
134
+ * measured on a live local storage 2026-09-13, where the API accepted the
135
+ * request and the queue worker failed it minutes later with an errno.
136
+ */
137
+ export function blockedReason(
138
+ candidate: string,
139
+ moving: string[] | undefined,
140
+ ): 'self' | 'descendant' | null {
141
+ if (!moving || moving.length === 0) return null;
142
+ if (candidate === DRIVES) return null;
143
+ const norm = (p: string) => {
144
+ const [a, r] = splitWire(p);
145
+ return joinWire(a, r);
146
+ };
147
+ const c = norm(candidate);
148
+ for (const src of moving) {
149
+ if (!src) continue;
150
+ // Order matters: equality is also "inside", and the two need different
151
+ // sentences.
152
+ if (c === norm(src)) return 'self';
153
+ if (isAtOrInside(candidate, src)) return 'descendant';
154
+ }
155
+ return null;
156
+ }
157
+
158
+ /** One row in the picker's list. */
159
+ export interface DestinationRow {
160
+ /** Wire path of the folder. */
161
+ path: string;
162
+ /** What to show — the folder's own name. */
163
+ label: string;
164
+ /** May the caller write into it? Drives the disabled Choose button. */
165
+ writable: boolean;
166
+ /** Set when this row is one of the folders being moved, or inside one. */
167
+ blocked: 'self' | 'descendant' | null;
168
+ }
169
+
170
+ /**
171
+ * Turn a listing into rows.
172
+ *
173
+ * ⚠ An unwritable folder is LISTED and stays OPENABLE. The grant that allows
174
+ * writing may live on a subfolder, so hiding the parent would make the
175
+ * destination unreachable; hiding it also tells the user their folder is gone
176
+ * rather than that they may not write into it. Same rule NewDocumentModal
177
+ * settled on.
178
+ *
179
+ * ⚠ A blocked folder (the one being moved, or inside it) is also listed rather
180
+ * than hidden: seeing it greyed out with a reason is how a person understands
181
+ * the refusal. It is not openable, because everything under it is blocked too
182
+ * and walking into a dead end is not navigation.
183
+ */
184
+ export function destinationRows(
185
+ files: FileNode[] | undefined,
186
+ moving?: string[],
187
+ ): DestinationRow[] {
188
+ const out: DestinationRow[] = [];
189
+ for (const f of files ?? []) {
190
+ if (f.type !== 'dir') continue;
191
+ out.push({
192
+ path: f.path,
193
+ label: f.basename,
194
+ writable: permAllowsWrite(f.perm as string | undefined),
195
+ blocked: blockedReason(f.path, moving),
196
+ });
197
+ }
198
+ return out;
199
+ }
200
+
201
+ /** The drives level: one row per storage, each its own root. */
202
+ export function driveRows(storages: string[] | undefined, moving?: string[]): DestinationRow[] {
203
+ return (storages ?? []).map((name) => {
204
+ const path = joinWire(name, '');
205
+ return {
206
+ path,
207
+ label: name,
208
+ // A storage root's own writability is not knowable from the drive list;
209
+ // it is answered by the listing when the user opens it. Offering it as
210
+ // writable here and letting the listing correct that is the honest
211
+ // order — the alternative greys out every drive until each is opened.
212
+ writable: true,
213
+ blocked: blockedReason(path, moving),
214
+ };
215
+ });
216
+ }
217
+
218
+ /**
219
+ * Where the picker should open.
220
+ *
221
+ * The current folder, because that is where the person is standing and most
222
+ * destinations are near it. Falls back to the drives list when there is more
223
+ * than one storage and no current folder, and to the single storage's root
224
+ * when there is only one.
225
+ */
226
+ export function initialLocation(
227
+ current: string | undefined,
228
+ storages: string[] | undefined,
229
+ ): string {
230
+ const multi = (storages?.length ?? 0) > 1;
231
+ if (current && current.includes('://')) return current;
232
+ if (multi) return DRIVES;
233
+ const only = storages?.[0];
234
+ return only ? joinWire(only, '') : DRIVES;
235
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * downloadSelection — hand the browser ONE archive for a set of files.
3
+ *
4
+ * # What was there before
5
+ *
6
+ * `downloadFile(n)` is `window.open(api.downloadUrl(n.path), '_blank')` — one
7
+ * tab, one body, one file. Five selected rows would have been five
8
+ * `window.open` calls, and the browser stops the second one: they are popups
9
+ * once the user gesture has been spent. That is the whole reason the selection
10
+ * bar HID Download the moment a second row was selected, rather than doing
11
+ * something bad with it.
12
+ *
13
+ * # Why two steps
14
+ *
15
+ * A download has to be a NAVIGATION. `fetch()` + `URL.createObjectURL(blob)`
16
+ * buffers the entire archive in the tab before a single byte reaches disk,
17
+ * which is exactly what a 4 GB selection cannot survive. Only a navigation
18
+ * streams.
19
+ *
20
+ * But a navigation is a GET: it carries its arguments in the URL, where 300
21
+ * selected paths do not fit, and it carries no `Authorization` header, which is
22
+ * how the embedded hosts (work.example.com, fishapp) authenticate. So the server
23
+ * splits it — an authenticated POST that resolves and authorizes the member
24
+ * list and returns a short ticket URL, then a plain navigation to that URL.
25
+ * `POST /api/files/archive/download` → `GET /z/<ticket>`.
26
+ *
27
+ * # Why an iframe and not window.open
28
+ *
29
+ * ⚠ The navigation happens AFTER an await. The user's gesture token is spent by
30
+ * then, so `window.open` at that point is a popup and gets blocked — the same
31
+ * failure the whole feature exists to avoid, moved one step later where it is
32
+ * harder to notice. `location.href = url` is not blocked, but if the server
33
+ * ever answers with a text error instead of an attachment the app navigates
34
+ * away and the user loses their place. A hidden iframe is neither: an
35
+ * attachment response is handed to the download manager, and an error body
36
+ * lands somewhere invisible.
37
+ */
38
+
39
+ import type { FileApi } from '../composables/useFileApi';
40
+
41
+ /** What the mint returns. Mirrors `archiveDownloadInfo` in the Go handler. */
42
+ export interface ArchiveTicket {
43
+ /** Path to navigate to. Server-relative, e.g. `/z/<token>`. */
44
+ url: string;
45
+ ticket: string;
46
+ /** Filename the browser will save, e.g. `Faturalar.zip`. */
47
+ name: string;
48
+ /** How many files the archive will contain, after the server expanded any
49
+ * selected folders and dropped what the caller may not read. */
50
+ files: number;
51
+ /** Uncompressed total. NOT the archive's size — see the Go handler on why
52
+ * there is no such number until the last member is deflated. */
53
+ bytes: number;
54
+ expires_at: string;
55
+ }
56
+
57
+ /**
58
+ * The mint endpoint for a given manager URL.
59
+ *
60
+ * Derived by swapping the trailing `/manager` segment, which is how every
61
+ * route outside the endpoint map is built in useFileApi (`/permissions`,
62
+ * `/versions`, `/comments`, `/ws-ticket`). Doing it this way rather than
63
+ * hardcoding `/api/files/...` is what keeps a cross-origin embed working: the
64
+ * host configured `apiBase`, and the archive call has to land on the same
65
+ * origin as the rest of the explorer.
66
+ */
67
+ export function archiveTicketUrl(managerUrl: string): string {
68
+ const base = managerUrl.split('?')[0];
69
+ return base.replace(/\/manager$/, '/archive/download');
70
+ }
71
+
72
+ /**
73
+ * Where the ticket URL should be fetched from.
74
+ *
75
+ * The server answers a server-relative `/z/<token>`, which is correct for the
76
+ * same-origin case and wrong for an embed whose API lives on another host. The
77
+ * origin comes from the manager URL, which is the one thing that is always
78
+ * right.
79
+ */
80
+ export function absoluteTicketUrl(managerUrl: string, ticketUrl: string): string {
81
+ if (/^https?:\/\//i.test(ticketUrl)) return ticketUrl;
82
+ const base = managerUrl.split('?')[0];
83
+ const m = /^(https?:\/\/[^/]+)/i.exec(base);
84
+ return m ? m[1] + ticketUrl : ticketUrl;
85
+ }
86
+
87
+ /**
88
+ * Ask the server to prepare an archive of `paths` and return the ticket.
89
+ *
90
+ * Throws the same shaped error every other call in useFileApi throws — a
91
+ * localized `message` plus `status`/`detail` — so a caller can toast
92
+ * `err.message` and branch on `err.status` (409 = nothing readable in the
93
+ * selection, 413 = too many files).
94
+ */
95
+ export async function requestArchive(
96
+ api: Pick<FileApi, 'jsonFetch' | 'endpoints'>,
97
+ paths: string[],
98
+ name?: string,
99
+ ): Promise<ArchiveTicket> {
100
+ const url = archiveTicketUrl(api.endpoints.manager);
101
+ return api.jsonFetch<ArchiveTicket>(url, {
102
+ method: 'POST',
103
+ headers: { 'Content-Type': 'application/json' },
104
+ body: JSON.stringify(name ? { paths, name } : { paths }),
105
+ });
106
+ }
107
+
108
+ /**
109
+ * How long a download iframe is left in the document.
110
+ *
111
+ * It only has to outlive the moment the browser reads the response headers and
112
+ * hands the body to its download manager; the transfer itself continues after
113
+ * the element is gone. Sixty seconds is slack for a storage that takes a while
114
+ * to produce the first byte, and is short enough that a session of repeated
115
+ * downloads does not accumulate frames.
116
+ */
117
+ export const DOWNLOAD_FRAME_TTL_MS = 60_000;
118
+
119
+ /**
120
+ * Navigate to `url` in a way that saves a file instead of moving the page.
121
+ *
122
+ * Exported separately from `downloadArchive` so a caller that already has a
123
+ * ticket (a retry, a test) can trigger it, and so the DOM half can be replaced
124
+ * in an environment that has no document.
125
+ */
126
+ export function triggerFileNavigation(url: string, doc: Document = document): void {
127
+ const frame = doc.createElement('iframe');
128
+ frame.hidden = true;
129
+ frame.setAttribute('aria-hidden', 'true');
130
+ frame.style.display = 'none';
131
+ frame.src = url;
132
+ doc.body.appendChild(frame);
133
+ setTimeout(() => frame.remove(), DOWNLOAD_FRAME_TTL_MS);
134
+ }
135
+
136
+ /**
137
+ * Mint an archive for `paths` and start downloading it.
138
+ *
139
+ * Resolves with the ticket once the download has been STARTED — not once it has
140
+ * finished, which the page cannot observe. A caller that wants to say "12 files"
141
+ * in a toast reads it off the returned ticket.
142
+ */
143
+ export async function downloadArchive(
144
+ api: Pick<FileApi, 'jsonFetch' | 'endpoints'>,
145
+ paths: string[],
146
+ opts: { name?: string; doc?: Document } = {},
147
+ ): Promise<ArchiveTicket> {
148
+ const ticket = await requestArchive(api, paths, opts.name);
149
+ triggerFileNavigation(
150
+ absoluteTicketUrl(api.endpoints.manager, ticket.url),
151
+ opts.doc ?? document,
152
+ );
153
+ return ticket;
154
+ }