@brftech/filex-core 0.40.0 → 0.41.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -25
- package/dist/ArchiveViewer-DxxjhYZD.js +124 -0
- package/dist/ArchiveViewer-DxxjhYZD.js.map +1 -0
- package/dist/CsvViewer-CyuwENI_.js +141 -0
- package/dist/CsvViewer-CyuwENI_.js.map +1 -0
- package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-BIYsTX97.js} +48 -42
- package/dist/DrawioViewer-BIYsTX97.js.map +1 -0
- package/dist/EpubViewer-K9PjCMmS.js +163 -0
- package/dist/EpubViewer-K9PjCMmS.js.map +1 -0
- package/dist/IpynbViewer-DYU6Zgz2.js +184 -0
- package/dist/IpynbViewer-DYU6Zgz2.js.map +1 -0
- package/dist/MermaidViewer-Bp73aId8.js +137 -0
- package/dist/MermaidViewer-Bp73aId8.js.map +1 -0
- package/dist/PsdViewer-C2eLivw1.js +122 -0
- package/dist/PsdViewer-C2eLivw1.js.map +1 -0
- package/dist/TiffViewer-DsHg5gcz.js +142 -0
- package/dist/TiffViewer-DsHg5gcz.js.map +1 -0
- package/dist/Viewer3D-C_dc2HN9.js +75 -0
- package/dist/Viewer3D-C_dc2HN9.js.map +1 -0
- package/dist/filex-core.js +218 -20361
- 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-vxMZpYb-.js +26700 -0
- package/dist/index-vxMZpYb-.js.map +1 -0
- package/dist/index.d.ts +4212 -862
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +2154 -506
- 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 +39 -2
- package/src/components/EncryptedFolderModal.vue +31 -25
- package/src/components/FilePane.vue +951 -0
- package/src/components/FilterBar.vue +568 -72
- package/src/components/GalleryView.vue +172 -56
- package/src/components/GridView.vue +331 -82
- package/src/components/HomeView.vue +331 -0
- package/src/components/InspectorPanel.vue +296 -53
- package/src/components/ListView.vue +1173 -194
- package/src/components/NFSExportsPanel.vue +7 -4
- package/src/components/OnboardingTour.vue +156 -33
- package/src/components/QuickLook.vue +26 -1
- 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 +6 -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 +135 -0
- package/src/components/TimeZonePicker.vue +696 -0
- package/src/components/TokensPanel.vue +35 -14
- package/src/components/Toolbar.vue +953 -465
- package/src/components/ViewSwitcher.vue +9 -4
- package/src/composables/useConnections.ts +35 -4
- package/src/composables/useExplorerTimeZone.ts +89 -0
- package/src/composables/useFileApi.ts +53 -4
- package/src/composables/useLocale.ts +382 -26
- package/src/composables/useRowTouch.ts +95 -0
- package/src/index.ts +177 -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 +31 -2
- package/src/lib/sortOrder.ts +459 -0
- package/src/lib/themes.ts +151 -116
- package/src/lib/timezone.ts +501 -0
- package/src/lib/uiProfile.ts +82 -0
- package/src/lib/viewPrefs.ts +1036 -0
- package/src/locales/en.ts +416 -26
- package/src/locales/tr.ts +409 -26
- 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 +557 -476
- package/src/modals/PreviewModal.vue +516 -156
- package/src/styles/base.css +5409 -8
- package/src/styles/variables.css +139 -65
- 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,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
|
+
}
|
package/src/lib/fileFilters.ts
CHANGED
|
@@ -14,10 +14,20 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Because the endpoint has no `limit`/`offset` either, the listing in hand IS
|
|
16
16
|
* the folder — so filtering it client-side is complete for the folder, not a
|
|
17
|
-
* filter over "the first page".
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* filter over "the first page".
|
|
18
|
+
*
|
|
19
|
+
* ⚠ This file used to end "…and a People/owner filter is not, because
|
|
20
|
+
* `nodes.owner_id` exists for quota accounting and is serialized by nothing".
|
|
21
|
+
* That was true and is no longer: migration 00038 made ownership a real fact
|
|
22
|
+
* on the row (who put it here, who touched it last, whether it arrived through
|
|
23
|
+
* an anonymous drop link), and the listing projection now carries `owner_id`,
|
|
24
|
+
* `owner_name` and `owner_self` on every row that has an owner. The People
|
|
25
|
+
* filter therefore answers from a field the row ALREADY carries, exactly like
|
|
26
|
+
* the other four — the rule did not bend, the data arrived.
|
|
27
|
+
*
|
|
28
|
+
* ⚠ A row with NO owner key is SYSTEM: nobody put it there through filex. That
|
|
29
|
+
* is the honest word for ownerless and it is a real choice in the pill, not a
|
|
30
|
+
* blank.
|
|
21
31
|
*/
|
|
22
32
|
import type { FileNode } from '../types/FileNode';
|
|
23
33
|
import { iconFamilyFor, type IconFamily } from './fileIcons';
|
|
@@ -35,24 +45,153 @@ export type TypeFilter =
|
|
|
35
45
|
| 'archive'
|
|
36
46
|
| 'code';
|
|
37
47
|
|
|
38
|
-
export type ModifiedFilter = 'any' | 'today' | '7d' | '30d' | 'year';
|
|
48
|
+
export type ModifiedFilter = 'any' | 'today' | '7d' | '30d' | 'year' | 'around';
|
|
49
|
+
|
|
50
|
+
export type SizeFilter = 'any' | 'lt1' | '1to10' | '10to100' | 'gt100' | 'range';
|
|
51
|
+
|
|
52
|
+
/** gorunum:v1-advsearch — how wide "around" reaches on each side of the anchor. */
|
|
53
|
+
export type AroundSpan = 'h1' | 'd1' | 'w1';
|
|
39
54
|
|
|
40
|
-
|
|
55
|
+
/** gorunum:v1-advsearch — what the path choice does to a row outside `pathBase`. */
|
|
56
|
+
export type PathMode = 'any' | 'here' | 'skip';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Who a row belongs to. `any` is the neutral member every pill group needs;
|
|
60
|
+
* `me` is the signed-in account, `system` is an ownerless row, and `u:<id>`
|
|
61
|
+
* names one of the other accounts that actually appear in this listing.
|
|
62
|
+
*
|
|
63
|
+
* ⚠ The account members are NOT a fixed list. Offering every account on the
|
|
64
|
+
* install would put names in the menu that cannot narrow anything here and
|
|
65
|
+
* would leak the user directory into a folder view; `peopleOptions` derives
|
|
66
|
+
* the choices from the rows in hand.
|
|
67
|
+
*/
|
|
68
|
+
export type PeopleFilter = 'any' | 'me' | 'system' | `u:${number}`;
|
|
41
69
|
|
|
42
70
|
export interface DriveFilters {
|
|
43
71
|
type: TypeFilter;
|
|
44
72
|
modified: ModifiedFilter;
|
|
45
73
|
size: SizeFilter;
|
|
74
|
+
/**
|
|
75
|
+
* gorunum:v1 — "Filter in this folder…", the input the filter row carries
|
|
76
|
+
* beside the chips. A substring of the NAME, matched over the rows in hand.
|
|
77
|
+
*
|
|
78
|
+
* ⚠ Not a second search box. The header field runs `action=search` on the
|
|
79
|
+
* server and REPLACES the listing with hits (`isSearchResult`, results carry
|
|
80
|
+
* their parent path and can come from subfolders); this one never leaves the
|
|
81
|
+
* folder you are looking at and never issues a request — it narrows what is
|
|
82
|
+
* already on screen, the same rows the chips narrow, which is why it belongs
|
|
83
|
+
* to the same model and not to `searchQuery`.
|
|
84
|
+
*
|
|
85
|
+
* Optional so every existing `{ type, modified, size }` literal — embedders
|
|
86
|
+
* included — still type-checks and behaves exactly as before.
|
|
87
|
+
*/
|
|
88
|
+
name?: string;
|
|
89
|
+
|
|
90
|
+
/* ── gorunum:v1-advsearch — the three the advanced-search dialog adds ─────
|
|
91
|
+
*
|
|
92
|
+
* All optional, so every existing `{ type, modified, size }` literal still
|
|
93
|
+
* type-checks and behaves exactly as it did — the same contract `name` was
|
|
94
|
+
* added under. They live HERE and not in a second model because the filter
|
|
95
|
+
* row and the dialog narrow the same rows with the same predicate: two
|
|
96
|
+
* definitions of "modified in the last 7 days" is two products.
|
|
97
|
+
*
|
|
98
|
+
* ⚠ They are client-side for the same reason the three above are, and the
|
|
99
|
+
* reason is stated at the top of this file: the listing and search endpoints
|
|
100
|
+
* read no date, size or path parameter. What makes them honest is that the
|
|
101
|
+
* rows in hand are the complete answer for a FOLDER listing; over a SEARCH
|
|
102
|
+
* result they narrow the hits the server returned, which is capped
|
|
103
|
+
* (`/api/files/search` limit, the manager search's 250) — so a size range
|
|
104
|
+
* over a truncated hit list is a filter over what came back, not over the
|
|
105
|
+
* storage. The dialog says so rather than implying a full scan. */
|
|
106
|
+
|
|
107
|
+
/** Anchor for `modified: 'around'`. `YYYY-MM-DD` or `YYYY-MM-DDTHH:mm`,
|
|
108
|
+
* read as LOCAL time (a date alone means that day's 00:00 local). */
|
|
109
|
+
aroundDate?: string;
|
|
110
|
+
/** Half-width of the `around` window. */
|
|
111
|
+
aroundSpan?: AroundSpan;
|
|
112
|
+
/** Inclusive floor for `size: 'range'`, in BYTES. null = open-ended. */
|
|
113
|
+
sizeMin?: number | null;
|
|
114
|
+
/** Inclusive ceiling for `size: 'range'`, in BYTES. null = open-ended. */
|
|
115
|
+
sizeMax?: number | null;
|
|
116
|
+
/** `here` keeps only rows under `pathBase`; `skip` keeps only rows outside it. */
|
|
117
|
+
pathMode?: PathMode;
|
|
118
|
+
/**
|
|
119
|
+
* Who the row belongs to. Optional, under the same contract as `name` and
|
|
120
|
+
* the advanced-search three: every existing `{ type, modified, size }`
|
|
121
|
+
* literal still type-checks and behaves exactly as before.
|
|
122
|
+
*/
|
|
123
|
+
people?: PeopleFilter;
|
|
124
|
+
|
|
125
|
+
/** Adapter-qualified folder the path choice is measured against, e.g.
|
|
126
|
+
* `qldemo://Documents`. Captured when the dialog opens, because a search
|
|
127
|
+
* rebases the explorer to the storage root and the folder the user meant
|
|
128
|
+
* would otherwise be gone by the time the filter runs. */
|
|
129
|
+
pathBase?: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const EMPTY_FILTERS: DriveFilters = {
|
|
133
|
+
type: 'any',
|
|
134
|
+
modified: 'any',
|
|
135
|
+
size: 'any',
|
|
136
|
+
name: '',
|
|
137
|
+
aroundDate: '',
|
|
138
|
+
aroundSpan: 'd1',
|
|
139
|
+
sizeMin: null,
|
|
140
|
+
sizeMax: null,
|
|
141
|
+
pathMode: 'any',
|
|
142
|
+
pathBase: '',
|
|
143
|
+
people: 'any',
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** Case- and accent-folded, so "İstanbul" answers to "ist" and "Ödev" to "od".
|
|
147
|
+
* ⚠ `toLowerCase()` alone maps `İ` to `i` + a combining dot, which then
|
|
148
|
+
* matches nothing the user typed; stripping the marks is what makes the two
|
|
149
|
+
* sides comparable. */
|
|
150
|
+
function fold(s: string): string {
|
|
151
|
+
return s
|
|
152
|
+
.normalize('NFD')
|
|
153
|
+
.replace(/\p{M}+/gu, '')
|
|
154
|
+
.toLowerCase();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The trimmed needle, or '' when the input is empty/whitespace. */
|
|
158
|
+
export function nameNeedle(f: DriveFilters): string {
|
|
159
|
+
return (f.name ?? '').trim();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** gorunum:v1-advsearch — `here`/`skip` only mean something with a base to
|
|
163
|
+
* measure against; without one the choice is inert and must not count as an
|
|
164
|
+
* active filter, or the empty state would blame a filter that filters nothing. */
|
|
165
|
+
function pathActive(f: DriveFilters): boolean {
|
|
166
|
+
return (f.pathMode ?? 'any') !== 'any' && !!(f.pathBase ?? '');
|
|
46
167
|
}
|
|
47
168
|
|
|
48
|
-
|
|
169
|
+
/** The People choice, defaulted. Kept in one place so the four call sites
|
|
170
|
+
* below cannot disagree about what "no choice" means. */
|
|
171
|
+
function peopleOf(f: DriveFilters): PeopleFilter {
|
|
172
|
+
return f.people ?? 'any';
|
|
173
|
+
}
|
|
49
174
|
|
|
50
175
|
export function filtersActive(f: DriveFilters): boolean {
|
|
51
|
-
return
|
|
176
|
+
return (
|
|
177
|
+
f.type !== 'any' ||
|
|
178
|
+
f.modified !== 'any' ||
|
|
179
|
+
f.size !== 'any' ||
|
|
180
|
+
nameNeedle(f) !== '' ||
|
|
181
|
+
pathActive(f) ||
|
|
182
|
+
peopleOf(f) !== 'any'
|
|
183
|
+
);
|
|
52
184
|
}
|
|
53
185
|
|
|
54
186
|
export function activeFilterCount(f: DriveFilters): number {
|
|
55
|
-
return (
|
|
187
|
+
return (
|
|
188
|
+
(f.type !== 'any' ? 1 : 0) +
|
|
189
|
+
(f.modified !== 'any' ? 1 : 0) +
|
|
190
|
+
(f.size !== 'any' ? 1 : 0) +
|
|
191
|
+
(nameNeedle(f) !== '' ? 1 : 0) +
|
|
192
|
+
(pathActive(f) ? 1 : 0) +
|
|
193
|
+
(peopleOf(f) !== 'any' ? 1 : 0)
|
|
194
|
+
);
|
|
56
195
|
}
|
|
57
196
|
|
|
58
197
|
/** Families a type choice accepts. `iconFamilyFor` is the taxonomy the icons
|
|
@@ -93,13 +232,46 @@ function matchesType(n: FileNode, t: TypeFilter): boolean {
|
|
|
93
232
|
}
|
|
94
233
|
|
|
95
234
|
/** `now` is a parameter so a test can pin the clock instead of sleeping. */
|
|
96
|
-
|
|
235
|
+
/** gorunum:v1-advsearch — half-width of each "around" window, in ms. */
|
|
236
|
+
const AROUND_MS: Record<AroundSpan, number> = {
|
|
237
|
+
h1: 3_600_000,
|
|
238
|
+
d1: 86_400_000,
|
|
239
|
+
w1: 7 * 86_400_000,
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* gorunum:v1-advsearch — read the anchor as LOCAL time.
|
|
244
|
+
*
|
|
245
|
+
* ⚠ `Date.parse('2026-09-12')` is UTC by spec while
|
|
246
|
+
* `Date.parse('2026-09-12T00:00')` is local, so a bare date would slide the
|
|
247
|
+
* whole window by the viewer's offset — in Istanbul (UTC+3) a ±1 hour search
|
|
248
|
+
* "around the 12th" would have covered 03:00, not midnight. Appending the time
|
|
249
|
+
* makes both forms mean the same clock the user is reading.
|
|
250
|
+
*/
|
|
251
|
+
function parseAnchor(raw: string | undefined): number | null {
|
|
252
|
+
const s = (raw ?? '').trim();
|
|
253
|
+
if (!s) return null;
|
|
254
|
+
const ms = Date.parse(s.includes('T') ? s : `${s}T00:00`);
|
|
255
|
+
return Number.isNaN(ms) ? null : ms;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function matchesModified(n: FileNode, g: DriveFilters, now: number): boolean {
|
|
259
|
+
const f = g.modified;
|
|
97
260
|
if (f === 'any') return true;
|
|
98
261
|
const ms = typeof n.last_modified === 'number' ? n.last_modified : 0;
|
|
99
262
|
// No timestamp = no answer. Dropping the row would hide files whose driver
|
|
100
263
|
// gave us nothing; keeping it would put them in "Today". Hiding is the
|
|
101
264
|
// honest one: the row does not satisfy "modified today", it is unknown.
|
|
102
265
|
if (!ms) return false;
|
|
266
|
+
if (f === 'around') {
|
|
267
|
+
const anchor = parseAnchor(g.aroundDate);
|
|
268
|
+
// No anchor typed yet: "around nothing" is not a window, so the choice is
|
|
269
|
+
// inert rather than empty. Narrowing to zero rows the moment the user
|
|
270
|
+
// picks the mode — before they have picked a date — reads as a broken
|
|
271
|
+
// search, not as an unfinished one.
|
|
272
|
+
if (anchor === null) return true;
|
|
273
|
+
return Math.abs(ms - anchor) <= AROUND_MS[g.aroundSpan ?? 'd1'];
|
|
274
|
+
}
|
|
103
275
|
if (f === 'year') {
|
|
104
276
|
return new Date(ms).getFullYear() === new Date(now).getFullYear();
|
|
105
277
|
}
|
|
@@ -114,7 +286,8 @@ function matchesModified(n: FileNode, f: ModifiedFilter, now: number): boolean {
|
|
|
114
286
|
|
|
115
287
|
const MB = 1024 * 1024;
|
|
116
288
|
|
|
117
|
-
function matchesSize(n: FileNode,
|
|
289
|
+
function matchesSize(n: FileNode, g: DriveFilters): boolean {
|
|
290
|
+
const f = g.size;
|
|
118
291
|
if (f === 'any') return true;
|
|
119
292
|
// ⚠ Folders drop out of every size choice rather than passing through. A
|
|
120
293
|
// directory row's `size` is 0 from the projector, so "under 1 MB" would
|
|
@@ -122,12 +295,140 @@ function matchesSize(n: FileNode, f: SizeFilter): boolean {
|
|
|
122
295
|
// measurement and is not one.
|
|
123
296
|
if (n.type === 'dir') return false;
|
|
124
297
|
const s = typeof n.size === 'number' ? n.size : 0;
|
|
298
|
+
if (f === 'range') {
|
|
299
|
+
// Either end may be left open. Both open = "any size" wearing another
|
|
300
|
+
// name, which is exactly what an untouched custom range is.
|
|
301
|
+
const lo = typeof g.sizeMin === 'number' ? g.sizeMin : null;
|
|
302
|
+
const hi = typeof g.sizeMax === 'number' ? g.sizeMax : null;
|
|
303
|
+
if (lo !== null && s < lo) return false;
|
|
304
|
+
if (hi !== null && s > hi) return false;
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
125
307
|
if (f === 'lt1') return s < MB;
|
|
126
308
|
if (f === '1to10') return s >= MB && s < 10 * MB;
|
|
127
309
|
if (f === '10to100') return s >= 10 * MB && s < 100 * MB;
|
|
128
310
|
return s >= 100 * MB;
|
|
129
311
|
}
|
|
130
312
|
|
|
313
|
+
/**
|
|
314
|
+
* gorunum:v1 — the name input's predicate, over a bare STRING.
|
|
315
|
+
*
|
|
316
|
+
* ⚠ Exported over a string rather than over a `FileNode`, because the name box
|
|
317
|
+
* now narrows things that are not nodes: `surucu:d1-scope` puts the same box on
|
|
318
|
+
* the multi-storage root (rows ARE nodes there, synthesized ones) and on Home,
|
|
319
|
+
* whose storage cards are `HomeStorage` records with a `label`. Case- and
|
|
320
|
+
* accent-folding is the part that must not be re-typed — a second `toLowerCase`
|
|
321
|
+
* somewhere else is how "Ödev" stops answering to "od" on one surface and keeps
|
|
322
|
+
* answering on another.
|
|
323
|
+
*/
|
|
324
|
+
export function nameMatches(name: string, needle: string): boolean {
|
|
325
|
+
if (!needle) return true;
|
|
326
|
+
return fold(name || '').includes(fold(needle));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** The same predicate over a listing row. Folders take part like any other row:
|
|
330
|
+
* hiding the folder whose name you just typed would be the one result you
|
|
331
|
+
* meant. */
|
|
332
|
+
function matchesName(n: FileNode, needle: string): boolean {
|
|
333
|
+
return nameMatches(n.basename || '', needle);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* gorunum:v1-advsearch — "only in this folder" / "skip this folder".
|
|
338
|
+
*
|
|
339
|
+
* Both sides are answered from `path`, which every search hit already carries
|
|
340
|
+
* adapter-qualified (`qldemo://Documents/report.sql`). The base is compared
|
|
341
|
+
* with a trailing slash so `Documents` cannot claim `Documents-old`, and the
|
|
342
|
+
* folder row itself counts as inside it.
|
|
343
|
+
*/
|
|
344
|
+
function matchesPath(n: FileNode, g: DriveFilters): boolean {
|
|
345
|
+
const mode = g.pathMode ?? 'any';
|
|
346
|
+
const base = (g.pathBase ?? '').replace(/\/+$/, '');
|
|
347
|
+
if (mode === 'any' || !base) return true;
|
|
348
|
+
const p = n.path || '';
|
|
349
|
+
const inside = p === base || p.startsWith(`${base}/`);
|
|
350
|
+
return mode === 'here' ? inside : !inside;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/* ── Ownership, read off the row ──────────────────────────────────────────
|
|
354
|
+
*
|
|
355
|
+
* The backend omits every owner key it has nothing to say about, so a SYSTEM
|
|
356
|
+
* row simply has no `owner_id` — which is why these read through the FileNode
|
|
357
|
+
* index signature rather than named fields, the same way `snippet` and
|
|
358
|
+
* `matched` are read in ListView. */
|
|
359
|
+
|
|
360
|
+
/** The row's owner id, or null for a SYSTEM row (nobody put it here). */
|
|
361
|
+
export function ownerIdOf(n: FileNode): number | null {
|
|
362
|
+
const raw = (n as Record<string, unknown>).owner_id;
|
|
363
|
+
return typeof raw === 'number' && raw > 0 ? raw : null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** True when the signed-in account owns this row. Answered by the SERVER
|
|
367
|
+
* (`owner_self`), because the embeddable core has no idea which filex account
|
|
368
|
+
* the host's session belongs to. */
|
|
369
|
+
export function ownedByViewer(n: FileNode): boolean {
|
|
370
|
+
return (n as Record<string, unknown>).owner_self === true;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** The owner's display name, or '' for a system row / a name the server could
|
|
374
|
+
* not resolve. The caller decides what to show instead. */
|
|
375
|
+
export function ownerNameOf(n: FileNode): string {
|
|
376
|
+
const raw = (n as Record<string, unknown>).owner_name;
|
|
377
|
+
return typeof raw === 'string' ? raw : '';
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** True when the row arrived through an anonymous drop link. The OWNER is
|
|
381
|
+
* still the person who created the link — this only says they did not put it
|
|
382
|
+
* there themselves. */
|
|
383
|
+
export function arrivedFromOutside(n: FileNode): boolean {
|
|
384
|
+
return (n as Record<string, unknown>).external_upload === true;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** One entry of the People pill. `name` is present only for the `u:<id>`
|
|
388
|
+
* members — the three fixed ones are named by the locale, not by data. */
|
|
389
|
+
export interface PeopleOption {
|
|
390
|
+
value: PeopleFilter;
|
|
391
|
+
name?: string;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The People pill's menu for a given listing: `any`, `me`, `system`, and then
|
|
396
|
+
* every OTHER account that actually owns something on screen, by name.
|
|
397
|
+
*
|
|
398
|
+
* ⚠ `me` and `system` are offered unconditionally, and that is deliberate:
|
|
399
|
+
* a menu whose entries appear and disappear as you navigate is a menu you
|
|
400
|
+
* cannot learn. The per-account entries are the ones that would be inert
|
|
401
|
+
* elsewhere, so they are the ones derived from the rows.
|
|
402
|
+
*/
|
|
403
|
+
export function peopleOptions(files: FileNode[]): PeopleOption[] {
|
|
404
|
+
const out: PeopleOption[] = [{ value: 'any' }, { value: 'me' }, { value: 'system' }];
|
|
405
|
+
const seen = new Set<number>();
|
|
406
|
+
for (const n of files) {
|
|
407
|
+
const id = ownerIdOf(n);
|
|
408
|
+
if (id === null || ownedByViewer(n) || seen.has(id)) continue;
|
|
409
|
+
seen.add(id);
|
|
410
|
+
out.push({ value: `u:${id}`, name: ownerNameOf(n) });
|
|
411
|
+
}
|
|
412
|
+
// Stable, readable order for the derived half; the fixed three stay put.
|
|
413
|
+
const fixed = out.slice(0, 3);
|
|
414
|
+
const rest = out
|
|
415
|
+
.slice(3)
|
|
416
|
+
.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '', undefined, { sensitivity: 'base' }));
|
|
417
|
+
return [...fixed, ...rest];
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Rows belonging to the chosen person. A folder takes part like any other
|
|
421
|
+
* row: somebody made it, and "show me what Ada put here" that silently keeps
|
|
422
|
+
* everyone's folders would be answering a different question. */
|
|
423
|
+
function matchesPeople(n: FileNode, f: DriveFilters): boolean {
|
|
424
|
+
const want = peopleOf(f);
|
|
425
|
+
if (want === 'any') return true;
|
|
426
|
+
if (want === 'me') return ownedByViewer(n);
|
|
427
|
+
if (want === 'system') return ownerIdOf(n) === null;
|
|
428
|
+
const id = Number(want.slice(2));
|
|
429
|
+
return Number.isFinite(id) && ownerIdOf(n) === id;
|
|
430
|
+
}
|
|
431
|
+
|
|
131
432
|
export function applyFilters(
|
|
132
433
|
files: FileNode[],
|
|
133
434
|
f: DriveFilters,
|
|
@@ -136,8 +437,14 @@ export function applyFilters(
|
|
|
136
437
|
// No active filter → the SAME array reference, so an unfiltered explorer
|
|
137
438
|
// renders exactly what it rendered before this file existed.
|
|
138
439
|
if (!filtersActive(f)) return files;
|
|
440
|
+
const needle = nameNeedle(f);
|
|
139
441
|
return files.filter(
|
|
140
442
|
(n) =>
|
|
141
|
-
matchesType(n, f.type) &&
|
|
443
|
+
matchesType(n, f.type) &&
|
|
444
|
+
matchesModified(n, f, now) &&
|
|
445
|
+
matchesSize(n, f) &&
|
|
446
|
+
matchesName(n, needle) &&
|
|
447
|
+
matchesPath(n, f) &&
|
|
448
|
+
matchesPeople(n, f),
|
|
142
449
|
);
|
|
143
450
|
}
|