@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.
- package/README.md +32 -25
- package/dist/ArchiveViewer-BNqukFg8.js +124 -0
- package/dist/ArchiveViewer-BNqukFg8.js.map +1 -0
- package/dist/CsvViewer-ii_-MgmG.js +141 -0
- package/dist/CsvViewer-ii_-MgmG.js.map +1 -0
- package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-B2tuu4rX.js} +48 -42
- package/dist/DrawioViewer-B2tuu4rX.js.map +1 -0
- package/dist/EpubViewer-p4B8iiWb.js +163 -0
- package/dist/EpubViewer-p4B8iiWb.js.map +1 -0
- package/dist/IpynbViewer-D_qYRJhu.js +184 -0
- package/dist/IpynbViewer-D_qYRJhu.js.map +1 -0
- package/dist/MermaidViewer-3ZkfNM8v.js +137 -0
- package/dist/MermaidViewer-3ZkfNM8v.js.map +1 -0
- package/dist/PsdViewer-CuV-OPSd.js +122 -0
- package/dist/PsdViewer-CuV-OPSd.js.map +1 -0
- package/dist/TiffViewer-BLeoF4Be.js +142 -0
- package/dist/TiffViewer-BLeoF4Be.js.map +1 -0
- package/dist/Viewer3D-DHhhU76E.js +75 -0
- package/dist/Viewer3D-DHhhU76E.js.map +1 -0
- package/dist/filex-core.js +217 -20077
- package/dist/filex-core.js.map +1 -1
- package/dist/filex-core.umd.cjs +61 -62
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index-BraG7Cz4.js +26664 -0
- package/dist/index-BraG7Cz4.js.map +1 -0
- package/dist/index.d.ts +4276 -851
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +2170 -492
- package/src/components/AdvancedSearch.vue +870 -0
- package/src/components/Breadcrumb.vue +191 -19
- package/src/components/CommandPalette.vue +66 -22
- package/src/components/ConnectionsPanel.vue +6 -2
- package/src/components/ContextMenu.vue +57 -2
- package/src/components/EncryptedFolderModal.vue +31 -25
- package/src/components/FilePane.vue +939 -0
- package/src/components/FilterBar.vue +568 -72
- package/src/components/GalleryView.vue +162 -30
- package/src/components/GridView.vue +322 -58
- package/src/components/HomeView.vue +331 -0
- package/src/components/InspectorPanel.vue +296 -53
- package/src/components/ListView.vue +1158 -167
- package/src/components/NFSExportsPanel.vue +7 -4
- package/src/components/OnboardingTour.vue +156 -33
- package/src/components/QuickLook.vue +34 -5
- package/src/components/RecentlyOpened.vue +17 -2
- package/src/components/S3KeysPanel.vue +7 -4
- package/src/components/SSHKeysPanel.vue +7 -4
- package/src/components/ShortcutSettings.vue +43 -2
- package/src/components/ShortcutsHelp.vue +4 -1
- package/src/components/SideNav.vue +206 -140
- package/src/components/StorageFields.vue +19 -1
- package/src/components/TabBar.vue +87 -1
- package/src/components/TagPicker.vue +77 -6
- package/src/components/ThemeGallery.vue +13 -198
- package/src/components/ThemePalette.vue +244 -0
- package/src/components/TimeZoneDialog.vue +127 -0
- package/src/components/TimeZonePicker.vue +696 -0
- package/src/components/TokensPanel.vue +16 -4
- package/src/components/Toolbar.vue +965 -469
- package/src/components/ViewSwitcher.vue +9 -4
- package/src/composables/useConnections.ts +35 -4
- package/src/composables/useExplorerTimeZone.ts +85 -0
- package/src/composables/useFileApi.ts +53 -4
- package/src/composables/useKeyboardShortcuts.ts +122 -0
- package/src/composables/useLocale.ts +365 -25
- package/src/index.ts +182 -3
- package/src/lib/actionIcons.ts +330 -0
- package/src/lib/advSearch.ts +162 -0
- package/src/lib/dateGroups.ts +197 -0
- package/src/lib/destinationTree.ts +235 -0
- package/src/lib/downloadSelection.ts +154 -0
- package/src/lib/fileFilters.ts +319 -12
- package/src/lib/fileIcons.ts +299 -5
- package/src/lib/filePreview.ts +605 -0
- package/src/lib/listing.ts +72 -0
- package/src/lib/shareTtl.ts +11 -1
- package/src/lib/sortOrder.ts +459 -0
- package/src/lib/themes.ts +152 -23
- package/src/lib/timezone.ts +456 -0
- package/src/lib/uiProfile.ts +82 -0
- package/src/lib/viewPrefs.ts +1036 -0
- package/src/locales/en.ts +432 -25
- package/src/locales/tr.ts +425 -25
- package/src/modals/ConvertModal.vue +23 -3
- package/src/modals/DestinationPickerModal.vue +341 -0
- package/src/modals/Modal.vue +18 -4
- package/src/modals/NewDocumentModal.vue +525 -0
- package/src/modals/NewFolderModal.vue +15 -9
- package/src/modals/PermissionsModal.vue +543 -473
- package/src/modals/PreviewModal.vue +516 -156
- package/src/styles/base.css +5435 -8
- package/src/styles/variables.css +140 -60
- package/src/types/Connections.ts +9 -0
- package/src/types/ExplorerConfig.ts +144 -39
- package/src/types/FileNode.ts +28 -0
- package/src/viewers/ArchiveViewer.vue +46 -8
- package/src/viewers/CsvViewer.vue +19 -2
- package/src/viewers/DrawioViewer.vue +12 -1
- package/src/viewers/EpubViewer.vue +50 -6
- package/src/viewers/IpynbViewer.vue +19 -2
- package/src/viewers/MermaidViewer.vue +20 -3
- package/src/viewers/PdfViewer.vue +24 -4
- package/src/viewers/PsdViewer.vue +28 -4
- package/src/viewers/TiffViewer.vue +19 -2
- package/src/viewers/Viewer3D.vue +36 -2
- package/dist/ArchiveViewer-Cdfv9LFC.js +0 -99
- package/dist/ArchiveViewer-Cdfv9LFC.js.map +0 -1
- package/dist/CsvViewer-CqWeV8VO.js +0 -131
- package/dist/CsvViewer-CqWeV8VO.js.map +0 -1
- package/dist/DrawioViewer-BNALOB04.js.map +0 -1
- package/dist/EpubViewer-BPmlImG7.js +0 -145
- package/dist/EpubViewer-BPmlImG7.js.map +0 -1
- package/dist/IpynbViewer-CDlQmuCV.js +0 -175
- package/dist/IpynbViewer-CDlQmuCV.js.map +0 -1
- package/dist/MermaidViewer-sfZWdR-d.js +0 -127
- package/dist/MermaidViewer-sfZWdR-d.js.map +0 -1
- package/dist/PsdViewer-DwuySZsb.js +0 -112
- package/dist/PsdViewer-DwuySZsb.js.map +0 -1
- package/dist/TiffViewer-BDQ3d3CQ.js +0 -132
- package/dist/TiffViewer-BDQ3d3CQ.js.map +0 -1
- package/dist/Viewer3D-CJJJgkuv.js +0 -60
- package/dist/Viewer3D-CJJJgkuv.js.map +0 -1
- package/src/components/SecondaryPane.vue +0 -447
- 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
|
+
}
|