@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.
Files changed (125) hide show
  1. package/README.md +32 -25
  2. package/dist/ArchiveViewer-DxxjhYZD.js +124 -0
  3. package/dist/ArchiveViewer-DxxjhYZD.js.map +1 -0
  4. package/dist/CsvViewer-CyuwENI_.js +141 -0
  5. package/dist/CsvViewer-CyuwENI_.js.map +1 -0
  6. package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-BIYsTX97.js} +48 -42
  7. package/dist/DrawioViewer-BIYsTX97.js.map +1 -0
  8. package/dist/EpubViewer-K9PjCMmS.js +163 -0
  9. package/dist/EpubViewer-K9PjCMmS.js.map +1 -0
  10. package/dist/IpynbViewer-DYU6Zgz2.js +184 -0
  11. package/dist/IpynbViewer-DYU6Zgz2.js.map +1 -0
  12. package/dist/MermaidViewer-Bp73aId8.js +137 -0
  13. package/dist/MermaidViewer-Bp73aId8.js.map +1 -0
  14. package/dist/PsdViewer-C2eLivw1.js +122 -0
  15. package/dist/PsdViewer-C2eLivw1.js.map +1 -0
  16. package/dist/TiffViewer-DsHg5gcz.js +142 -0
  17. package/dist/TiffViewer-DsHg5gcz.js.map +1 -0
  18. package/dist/Viewer3D-C_dc2HN9.js +75 -0
  19. package/dist/Viewer3D-C_dc2HN9.js.map +1 -0
  20. package/dist/filex-core.js +218 -20361
  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-vxMZpYb-.js +26700 -0
  25. package/dist/index-vxMZpYb-.js.map +1 -0
  26. package/dist/index.d.ts +4212 -862
  27. package/dist/style.css +1 -1
  28. package/package.json +1 -1
  29. package/src/FileExplorer.vue +2154 -506
  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 +39 -2
  35. package/src/components/EncryptedFolderModal.vue +31 -25
  36. package/src/components/FilePane.vue +951 -0
  37. package/src/components/FilterBar.vue +568 -72
  38. package/src/components/GalleryView.vue +172 -56
  39. package/src/components/GridView.vue +331 -82
  40. package/src/components/HomeView.vue +331 -0
  41. package/src/components/InspectorPanel.vue +296 -53
  42. package/src/components/ListView.vue +1173 -194
  43. package/src/components/NFSExportsPanel.vue +7 -4
  44. package/src/components/OnboardingTour.vue +156 -33
  45. package/src/components/QuickLook.vue +26 -1
  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 +6 -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 +135 -0
  58. package/src/components/TimeZonePicker.vue +696 -0
  59. package/src/components/TokensPanel.vue +35 -14
  60. package/src/components/Toolbar.vue +953 -465
  61. package/src/components/ViewSwitcher.vue +9 -4
  62. package/src/composables/useConnections.ts +35 -4
  63. package/src/composables/useExplorerTimeZone.ts +89 -0
  64. package/src/composables/useFileApi.ts +53 -4
  65. package/src/composables/useLocale.ts +382 -26
  66. package/src/composables/useRowTouch.ts +95 -0
  67. package/src/index.ts +177 -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 +31 -2
  78. package/src/lib/sortOrder.ts +459 -0
  79. package/src/lib/themes.ts +151 -116
  80. package/src/lib/timezone.ts +501 -0
  81. package/src/lib/uiProfile.ts +82 -0
  82. package/src/lib/viewPrefs.ts +1036 -0
  83. package/src/locales/en.ts +416 -26
  84. package/src/locales/tr.ts +409 -26
  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 +557 -476
  91. package/src/modals/PreviewModal.vue +516 -156
  92. package/src/styles/base.css +5409 -8
  93. package/src/styles/variables.css +139 -65
  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,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
+ }
@@ -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". That is exactly why these three are honest and
18
- * a People/owner filter is not: `nodes.owner_id` exists for quota accounting,
19
- * is nil for anything a sync discovered, and is serialized by nothing
20
- * (handlers/shared.go: "There is no per-node owner").
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
- export type SizeFilter = 'any' | 'lt1' | '1to10' | '10to100' | 'gt100';
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
- export const EMPTY_FILTERS: DriveFilters = { type: 'any', modified: 'any', size: 'any' };
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 f.type !== 'any' || f.modified !== 'any' || f.size !== 'any';
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 (f.type !== 'any' ? 1 : 0) + (f.modified !== 'any' ? 1 : 0) + (f.size !== 'any' ? 1 : 0);
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
- function matchesModified(n: FileNode, f: ModifiedFilter, now: number): boolean {
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, f: SizeFilter): boolean {
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) && matchesModified(n, f.modified, now) && matchesSize(n, f.size),
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
  }