pinokiod 8.0.40 → 8.0.41

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.
@@ -0,0 +1,1835 @@
1
+ const COPY = {
2
+ views: "VIEWS",
3
+ locations: "LOCATIONS",
4
+ all: "All tracked files",
5
+ duplicates: "Duplicates",
6
+ shared: "Shared",
7
+ skipped: "Skipped",
8
+ reclaimable: "Unused files",
9
+ activity: "Activity",
10
+ add_external_folder: "Add external folder",
11
+ remove_external_folder: "Remove external folder",
12
+ files_region: "Files",
13
+ folder_picker_error: "The folder picker could not be opened.",
14
+ external_added: "Added to Locations. Run a scan when you’re ready.",
15
+ external_exists: "That folder is already in Locations.",
16
+ external_other_disk: "Added to Locations. It can be scanned, but files on this disk cannot share storage with your Pinokio folder.",
17
+ external_removed: "Removed from Locations. No files in the folder were changed.",
18
+ pinokio_folder: "Pinokio folder",
19
+ save_space: "Save space",
20
+ disk_space_saved: "of disk space saved",
21
+ saved_for_app: "{size} saved for this app",
22
+ no_space_saved: "No disk space is currently being saved",
23
+ at_least_saved: "At least {size} saved",
24
+ no_verified_savings: "No verified savings in scanned locations",
25
+ before: "Before",
26
+ before_help: "Estimated space if every app stored its own copy.",
27
+ after: "After",
28
+ effective_help: "Shared files are divided evenly among every location using them.",
29
+ no_more_matches: "No more matches in files {size} and larger",
30
+ more_can_be_saved: "{size} more can be saved",
31
+ available_to_save: "{size} available to save",
32
+ at_least_can_be_saved: "At least {size} can be saved",
33
+ no_matches_in_scanned_locations: "No matches found in scanned locations",
34
+ review_files: "Review files",
35
+ scanned: "Scanned",
36
+ not_scanned: "Not scanned yet",
37
+ find_savings: "Scan to find duplicate files and save disk space",
38
+ find_app_savings: "Scan this app to find duplicate files and save disk space",
39
+ storage_details: "Storage details",
40
+ last_scanned: "Last scanned",
41
+ never: "Never",
42
+ scan: "Scan now",
43
+ scan_app: "Scan this app",
44
+ scan_again: "Scan again",
45
+ apply_and_scan: "Apply & scan",
46
+ check_files: "Check files",
47
+ and_larger: "and larger",
48
+ scanning: "Scanning…",
49
+ scanning_elsewhere: "Another location is being scanned",
50
+ scanning_elsewhere_hint: "This app can be scanned when the current scan finishes.",
51
+ vault_options: "Save space options",
52
+ repair_index: "Repair index",
53
+ repair_action: "Repair index",
54
+ repair_description: "Reconstruct the internal index if tracked files or sharing status look incorrect. This does not scan for new duplicates.",
55
+ repairing: "Repairing…",
56
+ repair_done: "Index repaired",
57
+ scan_counting: "Counting files",
58
+ scan_progress: "Scanning your configured locations",
59
+ scan_location: "Scanning {location}",
60
+ scan_queued: "Waiting to start scan",
61
+ scan_analyzing: "Analyzing large files",
62
+ scan_finishing: "Finishing scan",
63
+ scan_estimate_help: "Overall progress combines file scanning and large-file analysis",
64
+ scan_counting_help: "The first scan cannot know its total until this pass finishes",
65
+ scan_count_estimate_help: "Estimated from the exact file total and timing of the last completed scan",
66
+ scan_file_progress_help: "This pass uses the exact current file total; overall timing is estimated from the last scan",
67
+ scan_hash_progress_help: "File discovery is complete; the remaining large-file count is exact",
68
+ scan_finishing_help: "File analysis is complete; sharing records are being verified",
69
+ about_percent: "About {percent} percent.",
70
+ exact_percent: "{percent} percent.",
71
+ scan_checked: "{done} of {total} large files checked",
72
+ scan_files_checked: "{done} of {total} files checked",
73
+ scan_folders: "folders checked",
74
+ scan_files: "files checked",
75
+ scan_folders_found: "folders found",
76
+ scan_files_found: "files found",
77
+ analyzing: "analyzing",
78
+ waiting: "files waiting",
79
+ scan_complete: "Scan complete",
80
+ scan_incomplete: "Scan incomplete",
81
+ scan_unreadable_path: "path could not be read",
82
+ scan_unreadable_paths: "paths could not be read",
83
+ scan_partial_rest: "The rest of the scan completed.",
84
+ view_unreadable_path: "View path",
85
+ view_unreadable_paths: "View paths",
86
+ scan_not_analyzed: "could not be analyzed",
87
+ found_in: "found in",
88
+ locations_lower: "locations",
89
+ can_be_saved: "can be saved",
90
+ review: "Review",
91
+ search_all: "Search tracked files",
92
+ search_duplicates: "Search duplicates",
93
+ search_shared: "Search shared files",
94
+ search_skipped: "Search skipped files",
95
+ search_activity: "Search activity",
96
+ search_in: "Search in {location}",
97
+ status_request_failed: "Couldn’t load Save space status ({status})",
98
+ action_request_failed: "The Save space action failed ({status})",
99
+ all_statuses: "All statuses",
100
+ by_location: "By location",
101
+ name: "Name",
102
+ location_column: "Location",
103
+ size: "Size",
104
+ sort_largest: "Sort by size, largest first",
105
+ sort_smallest: "Sort by size, smallest first",
106
+ folders: "Folders",
107
+ files_mode: "Files",
108
+ display_mode: "Display mode",
109
+ sorted_largest: "sorted largest first",
110
+ sorted_smallest: "sorted smallest first",
111
+ status: "Sharing status",
112
+ matches: "Matches",
113
+ can_save: "Can save",
114
+ can_free: "Can free",
115
+ duplicate: "Duplicate",
116
+ tracked: "Tracked",
117
+ different_disk: "Different disk",
118
+ can_save_suffix: "can save",
119
+ unavailable: "Unavailable",
120
+ sharing_unavailable: "Sharing is unavailable on this disk",
121
+ permissions_differ: "File permissions differ",
122
+ changed_since_scan: "Some files changed since the scan. Scan again before deduplicating them.",
123
+ separate_locked: "Stop the app before undoing sharing for this file.",
124
+ separate_changed: "This file changed since it was scanned. Scan again, then try again.",
125
+ separate_conflict: "A temporary file already exists next to this file. Nothing was changed.",
126
+ separate_not_found: "This file is no longer tracked. Scan again to refresh this view.",
127
+ undo_incomplete: "Some files could not be separated. No existing files were overwritten.",
128
+ insufficient_space: "There is not enough free space to turn sharing off. No files were changed.",
129
+ capacity_unavailable: "Available disk space could not be checked. No files were changed.",
130
+ action_not_completed: "The action could not be completed. No existing files were overwritten.",
131
+ activity_write_failed: "The file action completed, but some activity history could not be recorded.",
132
+ persistence_write_failed: "The file action completed, but its updated record could not be saved yet. It will retry automatically.",
133
+ scan_persistence_write_failed: "The scan completed, but its updated index could not be saved yet. It will retry automatically.",
134
+ files_still_waiting: "{count} still waiting for review",
135
+ cloud_sync_warning: "Cloud syncing with {provider} can make shared files use separate disk space again.",
136
+ dismiss: "Dismiss",
137
+ just_now: "Just now",
138
+ minutes_ago: "m ago",
139
+ hours_ago: "h ago",
140
+ event: "event",
141
+ events: "events",
142
+ unknown_location: "Unknown location",
143
+ expand: "Expand",
144
+ collapse: "Collapse",
145
+ file: "file",
146
+ files: "files",
147
+ location: "location",
148
+ deduplicate: "Deduplicate",
149
+ deduplicate_all: "Deduplicate all",
150
+ deduplicate_queued: "Queued",
151
+ deduplicating_files: "Deduplicating {done} of {total}",
152
+ location_not_processed: "location could not be processed",
153
+ locations_not_processed: "locations could not be processed",
154
+ skip: "Skip",
155
+ kept_separate: "Kept separate",
156
+ allow_storage_sharing: "Allow {file} to share storage",
157
+ undo_storage_sharing: "Undo sharing for {file}",
158
+ undoing_sharing: "Undoing sharing…",
159
+ queued_to_undo: "Queued to undo",
160
+ copy_progress: "{copied} of {total}",
161
+ reclaim: "Delete",
162
+ reclaim_all: "Delete all",
163
+ undo: "Undo",
164
+ identical_contents_at: "Identical contents at",
165
+ no_files: "Nothing tracked yet",
166
+ no_files_hint: "Run a scan to check files {size} and larger. Scanning never changes them.",
167
+ scan_waiting: "Waiting for scan results",
168
+ scan_waiting_hint: "Tracked files will appear here when this scan finishes.",
169
+ no_duplicates: "Everything is already shared",
170
+ no_duplicates_hint: "There are no files waiting for your review.",
171
+ no_shared: "Nothing is shared yet",
172
+ no_shared_hint: "Shared files will appear here after you review duplicates.",
173
+ no_skipped: "Nothing skipped",
174
+ no_skipped_hint: "Files you skip will appear here.",
175
+ no_reclaimable: "No unused files",
176
+ no_reclaimable_hint: "Copies no longer used by any configured location will appear here.",
177
+ no_activity: "No activity yet",
178
+ no_activity_hint: "Scans and actions will be recorded here.",
179
+ view_all: "View all tracked files",
180
+ show_all_locations: "Show all locations",
181
+ tracked_note: "Scans check files {size} and larger. Previously shared files remain available here.",
182
+ duplicate_note: "Only files waiting for review are shown.",
183
+ reclaimable_note: "These copies are no longer used by any configured location. Deleting them frees disk space.",
184
+ converted: "Deduplicated",
185
+ skipped_action: "Skipped",
186
+ sharing_undone: "Sharing undone",
187
+ reclaimed: "Deleted",
188
+ included_in_scans: "Included in scans",
189
+ event_convert: "Deduplicated",
190
+ event_found: "Duplicate found",
191
+ event_adopt: "Tracked",
192
+ event_reclaim: "Deleted unused file",
193
+ event_undo: "Undid deduplication",
194
+ event_diverged: "Changed by an app — no longer shared",
195
+ event_detach: "Sharing undone",
196
+ event_skip: "Skipped",
197
+ event_reshare: "Included in scans"
198
+ }
199
+
200
+ const SCOPE_ID = document.body.dataset.vaultScope || null
201
+ const IS_APP_MODE = document.body.dataset.vaultMode === "app" && !!SCOPE_ID
202
+ const statusUrl = (progress = false) => {
203
+ const query = new URLSearchParams()
204
+ if (progress) query.set("progress", "1")
205
+ if (SCOPE_ID) query.set("scope_id", SCOPE_ID)
206
+ const suffix = query.toString()
207
+ return `/info/dedup${suffix ? `?${suffix}` : ""}`
208
+ }
209
+ const reviewedScanKey = `pinokio:vault:reviewed-scan:${SCOPE_ID || "global"}`
210
+
211
+ const state = {
212
+ data: null,
213
+ view: "all",
214
+ sourceId: SCOPE_ID,
215
+ query: "",
216
+ statusFilter: "all",
217
+ displayMode: "folders",
218
+ sizeSort: null,
219
+ collapsedSources: new Set(),
220
+ collapsedDirs: new Set(),
221
+ expandedFiles: new Set(),
222
+ scanRequested: false,
223
+ scanBaseline: null,
224
+ scanResult: null,
225
+ scanProblemsOpen: false,
226
+ feedback: null,
227
+ candidateMinBytesDraft: null,
228
+ fileActions: new Map(),
229
+ deduplicateActions: new Map(),
230
+ fileActionPollRunning: false,
231
+ refreshRequestId: 0,
232
+ actionSequence: -1
233
+ }
234
+
235
+ const el = (id) => document.getElementById(id)
236
+ const esc = (value) => String(value == null ? "" : value).replace(/[&<>"']/g, (char) => ({
237
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
238
+ }[char]))
239
+ const attr = esc
240
+ const fmt = window.PinokioFormatStorageSize
241
+ const DEFAULT_CANDIDATE_MIN_BYTES = 100 * 1000 * 1000
242
+ const DEFAULT_CANDIDATE_SIZE_OPTIONS = [10, 50, 100, 500, 1000]
243
+ .map((value) => value * 1000 * 1000)
244
+ const countLabel = (count, singular = COPY.file, plural = COPY.files) => `${count} ${count === 1 ? singular : plural}`
245
+ const basename = (value) => String(value || "").split(/[\\/]/).filter(Boolean).pop() || ""
246
+ const dirname = (value) => {
247
+ const parts = String(value || "").split("/").filter(Boolean)
248
+ parts.pop()
249
+ return parts.join(" / ")
250
+ }
251
+ const timeAgo = (ts) => {
252
+ if (!ts) return COPY.never
253
+ const seconds = Math.max(0, Math.floor((Date.now() - ts) / 1000))
254
+ if (seconds < 60) return COPY.just_now
255
+ const minutes = Math.floor(seconds / 60)
256
+ if (minutes < 60) return `${minutes}${COPY.minutes_ago}`
257
+ const hours = Math.floor(minutes / 60)
258
+ if (hours < 24) return `${hours}${COPY.hours_ago}`
259
+ return new Date(ts).toLocaleDateString()
260
+ }
261
+ const candidateMinBytes = () => Number(state.data && state.data.candidate_min_bytes) ||
262
+ DEFAULT_CANDIDATE_MIN_BYTES
263
+ const scannedCandidateMinBytes = () => {
264
+ const scan = state.data && (state.data.last_attempt || state.data.last_scan)
265
+ return Number(scan && scan.candidate_min_bytes) || DEFAULT_CANDIDATE_MIN_BYTES
266
+ }
267
+ const candidateSizeOptions = () => {
268
+ const options = state.data && state.data.candidate_size_options
269
+ return Array.isArray(options) && options.length ? options : DEFAULT_CANDIDATE_SIZE_OPTIONS
270
+ }
271
+ const post = async (payload) => {
272
+ const response = await fetch("/vault/action", {
273
+ method: "POST",
274
+ headers: { "Content-Type": "application/json" },
275
+ body: JSON.stringify(payload)
276
+ })
277
+ let result = null
278
+ try { result = await response.json() } catch (error) {}
279
+ if (!response.ok) {
280
+ throw new Error((result && result.error) || COPY.action_request_failed.replace("{status}", response.status))
281
+ }
282
+ if (!result) throw new Error(COPY.action_request_failed.replace("{status}", response.status))
283
+ return result
284
+ }
285
+ const sourceById = (id) => (state.data.sources || []).find((source) => source.id === id)
286
+ const sourceChildren = (id) => (state.data.sources || []).filter((source) => source.parent_id === id)
287
+ const sourcePath = (source) => {
288
+ if (!source) return ""
289
+ if (source.kind === "pinokio") return `~/${basename(source.root)}`
290
+ if (source.kind === "external") return source.target_path || source.display_path || ""
291
+ if (source.kind === "app") return IS_APP_MODE ? (source.display_path || source.root || "") : ""
292
+ return source.display_path || source.root || ""
293
+ }
294
+ const isDescendantSource = (candidateId, parentId) => {
295
+ if (!parentId) return true
296
+ if (candidateId === parentId) return true
297
+ let current = sourceById(candidateId)
298
+ const seen = new Set()
299
+ while (current && current.parent_id && !seen.has(current.id)) {
300
+ if (current.parent_id === parentId) return true
301
+ seen.add(current.id)
302
+ current = sourceById(current.parent_id)
303
+ }
304
+ return false
305
+ }
306
+
307
+ const buildItems = () => {
308
+ const data = state.data
309
+ const items = []
310
+ const duplicatePaths = new Set(data.duplicates.map((item) => item.path))
311
+ const excludedPaths = new Set(data.excluded.map((item) => item.path))
312
+ for (const blob of data.blobs) {
313
+ const visibleNames = IS_APP_MODE
314
+ ? blob.names.filter((name) => name.source_id === SCOPE_ID)
315
+ : blob.names
316
+ const sharingLocations = Number.isFinite(blob.sharing_locations)
317
+ ? blob.sharing_locations
318
+ : blob.names.filter((name) => name.mode === "link").length
319
+ for (const name of visibleNames) {
320
+ if (duplicatePaths.has(name.path) || excludedPaths.has(name.path)) continue
321
+ const shared = !name.unverified && sharingLocations >= 2
322
+ items.push({
323
+ path: name.path, relative_path: name.relative_path || basename(name.path),
324
+ source_id: name.source_id, source_label: name.source_label,
325
+ size: blob.size || 0,
326
+ status: name.unverified ? "unavailable" : shared ? "shared" : "tracked",
327
+ unverified: !!name.unverified,
328
+ locations: blob.names
329
+ })
330
+ }
331
+ }
332
+ for (const duplicate of data.duplicates) {
333
+ items.push({
334
+ path: duplicate.path, relative_path: duplicate.relative_path || basename(duplicate.path),
335
+ source_id: duplicate.source_id, source_label: duplicate.source_label,
336
+ size: duplicate.size || 0, status: "duplicate",
337
+ unverified: !!duplicate.unverified,
338
+ shareable: duplicate.shareable !== false, unavailable_reason: duplicate.unavailable_reason || null,
339
+ match: duplicate.match || null
340
+ })
341
+ }
342
+ for (const independent of data.excluded) {
343
+ items.push({
344
+ path: independent.path, relative_path: independent.relative_path || basename(independent.path),
345
+ source_id: independent.source_id, source_label: independent.source_label,
346
+ size: independent.size || 0, status: "independent",
347
+ unverified: !!independent.unverified
348
+ })
349
+ }
350
+ return items
351
+ }
352
+
353
+ const getCounts = (items) => ({
354
+ all: items.length,
355
+ duplicates: items.filter((item) => item.status === "duplicate" && item.shareable).length,
356
+ shared: items.filter((item) => item.status === "shared").length,
357
+ independent: items.filter((item) => item.status === "independent").length,
358
+ reclaimable: state.data.blobs.filter((blob) => blob.orphan).length,
359
+ activity: state.data.events.length
360
+ })
361
+
362
+ const sourceCounts = (items) => {
363
+ const counts = new Map()
364
+ for (const item of items) {
365
+ let id = item.source_id
366
+ const seen = new Set()
367
+ while (id && !seen.has(id)) {
368
+ counts.set(id, (counts.get(id) || 0) + 1)
369
+ seen.add(id)
370
+ const source = sourceById(id)
371
+ id = source ? source.parent_id : null
372
+ }
373
+ }
374
+ return counts
375
+ }
376
+ const activeItems = (items) => {
377
+ if (state.view === "activity") return activityItems()
378
+ if (state.view === "reclaimable") return state.data.blobs.filter((blob) => blob.orphan)
379
+ let result = items
380
+ if (state.view === "duplicates") {
381
+ result = result.filter((item) => item.status === "duplicate" && item.shareable)
382
+ }
383
+ if (state.view === "shared") result = result.filter((item) => item.status === "shared")
384
+ if (state.view === "independent") result = result.filter((item) => item.status === "independent")
385
+ if (state.sourceId) result = result.filter((item) => isDescendantSource(item.source_id, state.sourceId))
386
+ if (state.view === "all" && state.statusFilter !== "all") result = result.filter((item) => item.status === state.statusFilter)
387
+ const query = state.query.trim().toLowerCase()
388
+ if (query) result = result.filter((item) => `${item.relative_path} ${item.path} ${item.source_label || ""}`.toLowerCase().includes(query))
389
+ return result
390
+ }
391
+
392
+ const viewIcon = {
393
+ all: "fa-regular fa-file-lines",
394
+ duplicates: "fa-regular fa-copy",
395
+ shared: "fa-solid fa-link",
396
+ independent: "fa-solid fa-circle-minus",
397
+ reclaimable: "fa-regular fa-trash-can",
398
+ activity: "fa-solid fa-wave-square"
399
+ }
400
+ const viewLabel = {
401
+ all: COPY.all,
402
+ duplicates: COPY.duplicates,
403
+ shared: COPY.shared,
404
+ independent: COPY.skipped,
405
+ reclaimable: COPY.reclaimable,
406
+ activity: COPY.activity
407
+ }
408
+ const eventLabels = {
409
+ convert: COPY.event_convert,
410
+ found: COPY.event_found,
411
+ adopt: COPY.event_adopt,
412
+ reclaim: COPY.event_reclaim,
413
+ undo: COPY.event_undo,
414
+ diverged: COPY.event_diverged,
415
+ detach: COPY.event_detach,
416
+ skip: COPY.event_skip,
417
+ reshare: COPY.event_reshare
418
+ }
419
+ const activityItems = () => {
420
+ const query = state.query.toLowerCase()
421
+ return state.data.events.filter((event) => !query ||
422
+ `${eventLabels[event.kind] || event.kind} ${event.path || ""}`.toLowerCase().includes(query))
423
+ }
424
+
425
+ const renderViews = (items) => {
426
+ const counts = getCounts(items)
427
+ el("views-label").textContent = COPY.views
428
+ const views = IS_APP_MODE
429
+ ? ["all", "duplicates", "shared", "independent", "activity"]
430
+ : ["all", "duplicates", "shared", "independent", "reclaimable", "activity"]
431
+ el("vault-views").innerHTML = views.map((view) => `
432
+ <button class="vault-nav-row ${state.view === view ? "selected" : ""}" type="button" data-view="${view}" ${state.view === view ? 'aria-current="page"' : ""}>
433
+ <i class="${viewIcon[view]}"></i>
434
+ <span class="vault-nav-copy"><span class="vault-nav-name">${esc(viewLabel[view])}</span></span>
435
+ <span class="vault-nav-count ${view === "duplicates" && counts[view] ? "attention" : ""}">${counts[view]}</span>
436
+ </button>`).join("")
437
+ }
438
+
439
+ const renderSourceNode = (source, depth, counts) => {
440
+ const children = sourceChildren(source.id).filter((child) => {
441
+ const count = state.view === "duplicates" ? counts.duplicates.get(child.id) : counts.tracked.get(child.id)
442
+ return child.kind === "virtual" || count > 0 || child.kind === "external"
443
+ })
444
+ const duplicateCount = counts.duplicates.get(source.id) || 0
445
+ const trackedCount = counts.tracked.get(source.id) || 0
446
+ if (!IS_APP_MODE && state.view === "duplicates" && duplicateCount === 0) return ""
447
+ const collapsed = state.collapsedSources.has(source.id)
448
+ const pathText = source.kind === "virtual" ? (source.id === "apps" ? "api" : "") : sourcePath(source)
449
+ const count = state.view === "duplicates" ? duplicateCount : trackedCount
450
+ const icon = source.kind === "app" ? "fa-regular fa-folder-open" : "fa-regular fa-folder"
451
+ const removable = !IS_APP_MODE && source.kind === "external"
452
+ let html = `<div class="vault-source-line depth-${Math.min(depth, 2)} ${pathText ? "has-path" : ""}${removable ? " removable" : ""}">`
453
+ if (children.length) {
454
+ html += `<button class="vault-source-toggle" type="button" data-toggle-source="${attr(source.id)}" aria-label="${collapsed ? COPY.expand : COPY.collapse}" aria-expanded="${!collapsed}"><i class="fa-solid fa-chevron-${collapsed ? "right" : "down"}"></i></button>`
455
+ } else {
456
+ html += `<span class="vault-source-toggle placeholder"></span>`
457
+ }
458
+ html += `<button class="vault-nav-row ${state.sourceId === source.id ? "selected" : ""}" type="button" data-source="${attr(source.id)}" ${state.sourceId === source.id ? 'aria-current="page"' : ""}>
459
+ <i class="${icon}"></i>
460
+ <span class="vault-nav-copy"><span class="vault-nav-name">${esc(source.label)}</span>${pathText ? `<span class="vault-nav-path" title="${attr(pathText)}">${esc(pathText)}</span>` : ""}</span>
461
+ <span class="vault-nav-count ${duplicateCount ? "attention" : ""}">${count || ""}</span>
462
+ </button>${removable ? `<button class="vault-source-remove" type="button" data-remove-source="${attr(source.id)}" aria-label="${attr(`${COPY.remove_external_folder}: ${source.label}`)}" title="${attr(COPY.remove_external_folder)}"><i class="fa-solid fa-xmark"></i></button>` : ""}</div>`
463
+ if (!collapsed) html += children.map((child) => renderSourceNode(child, depth + 1, counts)).join("")
464
+ return html
465
+ }
466
+
467
+ const renderLocations = (items) => {
468
+ el("locations-label").textContent = COPY.locations
469
+ const pinokio = sourceById("pinokio")
470
+ const external = sourceById("external")
471
+ const counts = {
472
+ duplicates: sourceCounts(state.data.duplicates.filter((item) => item.shareable !== false)),
473
+ tracked: sourceCounts(items)
474
+ }
475
+ let html
476
+ if (IS_APP_MODE) {
477
+ const source = sourceById(SCOPE_ID)
478
+ html = source ? renderSourceNode(source, 0, counts) : ""
479
+ } else {
480
+ html = pinokio ? renderSourceNode(pinokio, 0, counts) : ""
481
+ if (external && sourceChildren("external").length) html += renderSourceNode(external, 0, counts)
482
+ }
483
+ el("vault-locations").innerHTML = html
484
+ el("vault-rail-footer").innerHTML = !IS_APP_MODE && state.view === "duplicates"
485
+ ? `<button class="vault-text-button" type="button" data-view="all">${esc(COPY.show_all_locations)}</button>`
486
+ : ""
487
+ }
488
+
489
+ const selectedSource = () => state.sourceId ? sourceById(state.sourceId) : null
490
+ const toolbarSummary = (visibleItems) => {
491
+ if (state.view === "duplicates") {
492
+ const locations = new Set(visibleItems.map((item) => item.source_id).filter(Boolean)).size
493
+ const bytes = visibleItems.filter((item) => item.shareable).reduce((sum, item) => sum + item.size, 0)
494
+ return `${countLabel(visibleItems.length)} · ${countLabel(locations, COPY.location, COPY.locations_lower)} · ${fmt(bytes)} ${COPY.can_save_suffix}`
495
+ } else if (state.view === "activity") {
496
+ return countLabel(visibleItems.length, COPY.event, COPY.events)
497
+ } else if (state.view === "reclaimable") {
498
+ return `${countLabel(visibleItems.length)} · ${fmt(state.data.reclaimable)}`
499
+ }
500
+ const duplicateCount = visibleItems.filter((item) => item.status === "duplicate").length
501
+ const saveable = visibleItems.filter((item) => item.status === "duplicate" && item.shareable).reduce((sum, item) => sum + item.size, 0)
502
+ return `${countLabel(visibleItems.length)}${duplicateCount ? ` · ${countLabel(duplicateCount, COPY.duplicate.toLowerCase(), COPY.duplicates.toLowerCase())} · ${fmt(saveable)} ${COPY.can_save_suffix}` : ""}`
503
+ }
504
+
505
+ const updateToolbarSummary = (visibleItems) => {
506
+ const summary = el("vault-toolbar-summary")
507
+ if (summary) summary.textContent = toolbarSummary(visibleItems)
508
+ }
509
+
510
+ const searchPlaceholder = () => {
511
+ if (state.view === "duplicates") return COPY.search_duplicates
512
+ if (state.view === "shared") return COPY.search_shared
513
+ if (state.view === "independent") return COPY.search_skipped
514
+ if (state.view === "activity") return COPY.search_activity
515
+ const source = selectedSource()
516
+ return source && source.kind === "app" ? COPY.search_in.replace("{location}", source.label) : COPY.search_all
517
+ }
518
+ const supportsDisplayMode = () => state.view === "all" || state.view === "shared" || state.view === "independent"
519
+ const displayModeControl = () => supportsDisplayMode() ? `<div class="vault-display-mode" role="group" aria-label="${attr(COPY.display_mode)}">
520
+ <button type="button" data-display-mode="folders" aria-pressed="${state.displayMode === "folders"}" class="${state.displayMode === "folders" ? "selected" : ""}">${esc(COPY.folders)}</button>
521
+ <button type="button" data-display-mode="files" aria-pressed="${state.displayMode === "files"}" class="${state.displayMode === "files" ? "selected" : ""}">${esc(COPY.files_mode)}</button>
522
+ </div>` : ""
523
+ const deduplicateAllAction = () => {
524
+ const serverActions = state.data && Array.isArray(state.data.file_actions)
525
+ ? state.data.file_actions
526
+ : []
527
+ return serverActions.find((action) => action.kind === "deduplicate_all") ||
528
+ state.deduplicateActions.get(null) ||
529
+ null
530
+ }
531
+ const deduplicateProgressMarkup = (action, count) => {
532
+ if (action.phase === "queued") {
533
+ return `<button class="vault-button primary vault-deduplicate-button" type="button" disabled aria-busy="true">${esc(COPY.deduplicate_queued)}</button>`
534
+ }
535
+ const total = Math.max(0, Number(action.files_total) || count)
536
+ const completed = Math.min(total, Math.max(0, Number(action.files_completed) || 0))
537
+ const percent = total ? (completed / total) * 100 : 0
538
+ const label = COPY.deduplicating_files
539
+ .replace("{done}", completed)
540
+ .replace("{total}", total)
541
+ return `<button class="vault-button primary vault-deduplicate-button" type="button" disabled aria-busy="true">
542
+ <span>${esc(label)}</span>
543
+ <span class="vault-deduplicate-progress" role="progressbar" aria-label="${attr(label)}" aria-valuemin="0" aria-valuemax="${total}" aria-valuenow="${completed}"><span style="--vault-deduplicate-progress:${percent.toFixed(2)}%"></span></span>
544
+ </button>`
545
+ }
546
+ const deduplicateAllActionMarkup = (count) => {
547
+ const action = deduplicateAllAction()
548
+ if (action) return deduplicateProgressMarkup(action, count)
549
+ const busy = [...state.deduplicateActions.values()].some((item) => item.kind === "deduplicate") ||
550
+ !!(state.data && Array.isArray(state.data.file_actions) &&
551
+ state.data.file_actions.some((item) => item.kind === "deduplicate"))
552
+ return `<button class="vault-button primary" type="button" id="btn-deduplicate-all"${busy ? " disabled" : ""}>${esc(COPY.deduplicate_all)} ${countLabel(count)}</button>`
553
+ }
554
+ const deduplicateAllCount = (visibleItems) => !IS_APP_MODE && state.view === "duplicates"
555
+ ? visibleItems.filter((item) => item.status === "duplicate" && item.shareable).length
556
+ : 0
557
+ const deduplicateAllContainerMarkup = (count) => count
558
+ ? `<span class="vault-toolbar-action" data-deduplicate-all-action data-deduplicate-count="${count}">${deduplicateAllActionMarkup(count)}</span>`
559
+ : ""
560
+ const updateDeduplicateAllAction = (visibleItems) => {
561
+ const toolbar = el("vault-toolbar")
562
+ const current = toolbar.querySelector("[data-deduplicate-all-action]")
563
+ const count = deduplicateAllCount(visibleItems)
564
+ if (!count) {
565
+ if (current) current.remove()
566
+ return
567
+ }
568
+ if (!current) {
569
+ toolbar.insertAdjacentHTML("beforeend", deduplicateAllContainerMarkup(count))
570
+ return
571
+ }
572
+ current.dataset.deduplicateCount = count
573
+ current.innerHTML = deduplicateAllActionMarkup(count)
574
+ }
575
+ const renderToolbar = (visibleItems) => {
576
+ if (state.view === "reclaimable") {
577
+ const count = state.data.blobs.filter((blob) => blob.orphan).length
578
+ el("vault-toolbar").innerHTML = count
579
+ ? `<span class="vault-toolbar-count" id="vault-toolbar-summary">${esc(toolbarSummary(visibleItems))}</span><button class="vault-button" type="button" id="btn-reclaim-all">${esc(COPY.reclaim_all)}</button>`
580
+ : ""
581
+ return
582
+ }
583
+ const deduplicateAll = deduplicateAllContainerMarkup(deduplicateAllCount(visibleItems))
584
+ el("vault-toolbar").innerHTML = `<label class="vault-search"><i class="fa-solid fa-magnifying-glass"></i><input id="vault-search" value="${attr(state.query)}" placeholder="${attr(searchPlaceholder())}" aria-label="${attr(searchPlaceholder())}" /></label>
585
+ ${state.view === "all" ? `<select class="vault-select" id="vault-status-filter" aria-label="${attr(COPY.all_statuses)}">
586
+ <option value="all" ${state.statusFilter === "all" ? "selected" : ""}>${esc(COPY.all_statuses)}</option>
587
+ <option value="duplicate" ${state.statusFilter === "duplicate" ? "selected" : ""}>${esc(COPY.duplicates)}</option>
588
+ <option value="shared" ${state.statusFilter === "shared" ? "selected" : ""}>${esc(COPY.shared)}</option>
589
+ <option value="tracked" ${state.statusFilter === "tracked" ? "selected" : ""}>${esc(COPY.tracked)}</option>
590
+ <option value="independent" ${state.statusFilter === "independent" ? "selected" : ""}>${esc(COPY.skipped)}</option>
591
+ </select>` : state.view === "duplicates" ? `<span class="vault-select">${esc(COPY.by_location)}</span>` : ""}
592
+ ${displayModeControl()}
593
+ <span class="vault-toolbar-count" id="vault-toolbar-summary">${esc(toolbarSummary(visibleItems))}</span>
594
+ ${deduplicateAll}`
595
+ }
596
+
597
+ const statusMarkup = (item) => {
598
+ if (item.unverified || item.status === "unavailable") {
599
+ return `<span class="vault-status"><i class="fa-regular fa-circle-xmark"></i>${esc(COPY.unavailable)}</span>`
600
+ }
601
+ if (item.status === "duplicate") {
602
+ const unavailable = item.unavailable_reason === "metadata"
603
+ ? COPY.permissions_differ
604
+ : item.unavailable_reason === "different_disk" ? COPY.different_disk : COPY.sharing_unavailable
605
+ return item.shareable
606
+ ? `<span class="vault-status"><span class="vault-status-dot warning"></span>${esc(COPY.duplicate)}</span>`
607
+ : `<span class="vault-status"><i class="fa-regular fa-circle-xmark"></i>${esc(unavailable)}</span>`
608
+ }
609
+ if (item.status === "shared") return `<span class="vault-status"><i class="fa-solid fa-link"></i>${esc(COPY.shared)} · ${item.locations.length} ${esc(COPY.locations_lower)}</span>`
610
+ if (item.status === "independent") return `<span class="vault-status"><i class="fa-solid fa-circle-minus"></i>${esc(COPY.kept_separate)}</span>`
611
+ return `<span class="vault-status"><span class="vault-status-dot"></span>${esc(COPY.tracked)}</span>`
612
+ }
613
+ const spaceMarkup = (item) => {
614
+ if (item.status === "duplicate") return item.shareable ? `${fmt(item.size)} ${COPY.can_save_suffix}` : COPY.unavailable
615
+ return "—"
616
+ }
617
+ const duplicateAction = (item) => {
618
+ if (item.status === "duplicate") return `<button class="vault-text-button" type="button" data-skip="${attr(item.path)}">${esc(COPY.skip)}</button>`
619
+ return ""
620
+ }
621
+ const sharingControl = (item) => {
622
+ const serverActions = state.data && Array.isArray(state.data.file_actions)
623
+ ? state.data.file_actions
624
+ : []
625
+ const serverAction = serverActions.find((action) => action.path === item.path) ||
626
+ (state.data && state.data.file_action && state.data.file_action.path === item.path
627
+ ? state.data.file_action
628
+ : null)
629
+ const action = serverAction || state.fileActions.get(item.path)
630
+ if (action && action.kind === "detach" && action.path === item.path) {
631
+ if (action.phase === "queued") {
632
+ return `<span class="vault-status-cell">
633
+ ${statusMarkup(item)}
634
+ <span class="vault-file-action-queued"><i class="fa-regular fa-clock" aria-hidden="true"></i>${esc(COPY.queued_to_undo)}</span>
635
+ </span>`
636
+ }
637
+ const total = Math.max(0, Number(action.bytes_total) || item.size || 0)
638
+ const copied = Math.min(total, Math.max(0, Number(action.bytes_copied) || 0))
639
+ const percent = total ? (copied / total) * 100 : 0
640
+ const progress = COPY.copy_progress
641
+ .replace("{copied}", fmt(copied))
642
+ .replace("{total}", fmt(total))
643
+ return `<span class="vault-status-cell busy">
644
+ <span class="vault-file-action-progress">
645
+ <span class="vault-file-action-head"><strong>${esc(COPY.undoing_sharing)}</strong><span>${esc(progress)}</span></span>
646
+ <span class="vault-file-action-track" role="progressbar" aria-label="${attr(COPY.undo_storage_sharing.replace("{file}", basename(item.relative_path)))}" aria-valuemin="0" aria-valuemax="${total}" aria-valuenow="${copied}"><span style="--vault-file-action-progress:${percent.toFixed(2)}%"></span></span>
647
+ </span>
648
+ </span>`
649
+ }
650
+ let control = ""
651
+ if (item.unverified) {
652
+ return `<span class="vault-status-cell">${statusMarkup(item)}</span>`
653
+ }
654
+ if (item.status === "shared") {
655
+ control = `<button class="vault-sharing-switch on" type="button" role="switch" aria-checked="true" aria-label="${attr(COPY.undo_storage_sharing.replace("{file}", basename(item.relative_path)))}" data-detach="${attr(item.path)}"><span class="vault-sharing-thumb"></span></button>`
656
+ } else if (item.status === "independent") {
657
+ control = `<button class="vault-sharing-switch" type="button" role="switch" aria-checked="false" aria-label="${attr(COPY.allow_storage_sharing.replace("{file}", basename(item.relative_path)))}" data-reshare="${attr(item.path)}"><span class="vault-sharing-thumb"></span></button>`
658
+ } else if (item.status === "duplicate") {
659
+ control = duplicateAction(item)
660
+ }
661
+ return `<span class="vault-status-cell">${statusMarkup(item)}${control}</span>`
662
+ }
663
+
664
+ const fileDetail = (item) => {
665
+ if (!state.expandedFiles.has(item.path) || !item.locations || item.locations.length < 2) return ""
666
+ return `<div class="vault-detail"><div class="vault-detail-label">${esc(COPY.identical_contents_at)} ${countLabel(item.locations.length, COPY.location, COPY.locations_lower)}</div>${item.locations.map((location) => `
667
+ <div class="vault-location-detail"><i class="fa-regular fa-file"></i><span>${esc(location.source_label || "")}${location.relative_path ? ` / ${esc(location.relative_path)}` : ""}</span></div>`).join("")}</div>`
668
+ }
669
+
670
+ const renderFileRow = (item, depth = 0, showMatch = false) => {
671
+ const directoryPath = dirname(item.relative_path)
672
+ const match = item.match
673
+ const expandable = item.locations && item.locations.length > 1
674
+ const rowTail = showMatch
675
+ ? `<span>${match ? `<span class="vault-match-path">${esc(match.path)}</span>` : "—"}</span>
676
+ <span class="vault-space">${esc(spaceMarkup(item))}</span>
677
+ <span class="vault-row-action">${duplicateAction(item)}</span>`
678
+ : sharingControl(item)
679
+ return `<div class="vault-file-row" data-file-path="${attr(item.path)}">
680
+ <div class="vault-name-cell indent-${Math.min(depth, 2)}">
681
+ ${expandable ? `<button class="vault-disclosure" type="button" data-expand-file="${attr(item.path)}" aria-label="${state.expandedFiles.has(item.path) ? COPY.collapse : COPY.expand}" aria-expanded="${state.expandedFiles.has(item.path)}"><i class="fa-solid fa-chevron-${state.expandedFiles.has(item.path) ? "down" : "right"}"></i></button>` : `<span class="vault-disclosure"></span>`}
682
+ <i class="fa-regular fa-file vault-name-icon"></i>
683
+ <span class="vault-name-copy"><span class="vault-file-name">${esc(basename(item.relative_path))}</span>${directoryPath && depth === 0 ? `<span class="vault-file-path">${esc(directoryPath)}</span>` : ""}</span>
684
+ </div>
685
+ <span class="vault-size">${item.size ? fmt(item.size) : "—"}</span>
686
+ ${rowTail}
687
+ </div>${fileDetail(item)}`
688
+ }
689
+
690
+ const sizeOf = (item) => Math.max(0, Number(item && item.size) || 0)
691
+ const compareRows = (left, right, nameOf) => {
692
+ const byName = () => nameOf(left).localeCompare(nameOf(right))
693
+ if (!state.sizeSort) return byName()
694
+ const sizeDifference = sizeOf(left) - sizeOf(right)
695
+ return (state.sizeSort === "asc" ? sizeDifference : -sizeDifference) || byName()
696
+ }
697
+ const makeTree = (items) => {
698
+ const root = { dirs: new Map(), files: [] }
699
+ for (const item of items) {
700
+ const parts = String(item.relative_path || basename(item.path)).split("/").filter(Boolean)
701
+ const fileName = parts.pop() || basename(item.path)
702
+ item._treeName = fileName
703
+ let node = root
704
+ for (const part of parts) {
705
+ if (!node.dirs.has(part)) node.dirs.set(part, { name: part, dirs: new Map(), files: [] })
706
+ node = node.dirs.get(part)
707
+ }
708
+ node.files.push(item)
709
+ }
710
+ const setTreeSize = (node) => {
711
+ node.size = node.files.reduce((sum, item) => sum + sizeOf(item), 0)
712
+ for (const child of node.dirs.values()) node.size += setTreeSize(child)
713
+ return node.size
714
+ }
715
+ setTreeSize(root)
716
+ return root
717
+ }
718
+ const nodeItems = (node) => [...node.files, ...[...node.dirs.values()].flatMap((child) => nodeItems(child))]
719
+ const renderTreeNode = (node, prefix, depth) => {
720
+ let current = node
721
+ const labels = [node.name]
722
+ while (current.files.length === 0 && current.dirs.size === 1) {
723
+ current = [...current.dirs.values()][0]
724
+ labels.push(current.name)
725
+ }
726
+ const key = [...prefix, ...labels].join("/")
727
+ const collapsed = state.collapsedDirs.has(key)
728
+ const items = nodeItems(current)
729
+ const duplicateCount = items.filter((item) => item.status === "duplicate").length
730
+ const shared = items.filter((item) => item.status === "shared").length
731
+ const summary = duplicateCount ? countLabel(duplicateCount, COPY.duplicate.toLowerCase(), COPY.duplicates.toLowerCase()) : shared ? COPY.shared : COPY.tracked
732
+ let html = `<div class="vault-file-row directory">
733
+ <div class="vault-name-cell indent-${Math.min(depth, 2)}"><button class="vault-disclosure" type="button" data-toggle-dir="${attr(key)}" aria-label="${collapsed ? COPY.expand : COPY.collapse}" aria-expanded="${!collapsed}"><i class="fa-solid fa-chevron-${collapsed ? "right" : "down"}"></i></button><i class="fa-regular fa-folder vault-name-icon"></i><span class="vault-file-name">${esc(labels.join(" / "))}</span></div>
734
+ <span class="vault-size">${fmt(current.size)}</span><span>${esc(summary)}</span>
735
+ </div>`
736
+ if (!collapsed) {
737
+ html += [...current.files].sort((a, b) => compareRows(a, b, (item) => item._treeName)).map((item) => renderFileRow(item, depth + 1)).join("")
738
+ html += [...current.dirs.values()].sort((a, b) => compareRows(a, b, (node) => node.name)).map((child) => renderTreeNode(child, [...prefix, ...labels], depth + 1)).join("")
739
+ }
740
+ return html
741
+ }
742
+ const renderTree = (items) => {
743
+ const tree = makeTree(items)
744
+ return [...tree.files].sort((a, b) => compareRows(a, b, (item) => item._treeName)).map((item) => renderFileRow(item)).join("") +
745
+ [...tree.dirs.values()].sort((a, b) => compareRows(a, b, (node) => node.name)).map((node) => renderTreeNode(node, [], 0)).join("")
746
+ }
747
+
748
+ const groupTitle = (source) => {
749
+ if (!source) return COPY.unknown_location
750
+ const parts = [source.label]
751
+ let current = source
752
+ while (current && current.parent_id) {
753
+ current = sourceById(current.parent_id)
754
+ if (current) parts.unshift(current.label)
755
+ }
756
+ return parts.join(" / ")
757
+ }
758
+ const compareSources = (first, second) => {
759
+ const rank = (source) => source && source.kind === "external" ? 1 : source ? 0 : 2
760
+ return rank(first) - rank(second) || groupTitle(first).localeCompare(groupTitle(second))
761
+ }
762
+ const sourceSort = (a, b) => compareSources(sourceById(a[0]), sourceById(b[0]))
763
+ const flatLocation = (item) => [groupTitle(sourceById(item.source_id)), item.relative_path].filter(Boolean).join(" / ")
764
+ const renderFlatFiles = (items) => [...items]
765
+ .sort((a, b) => compareRows(a, b, flatLocation))
766
+ .map((item) => {
767
+ const expandable = item.locations && item.locations.length > 1
768
+ return `<div class="vault-file-row" data-file-path="${attr(item.path)}">
769
+ <div class="vault-name-cell">
770
+ ${expandable ? `<button class="vault-disclosure" type="button" data-expand-file="${attr(item.path)}" aria-label="${state.expandedFiles.has(item.path) ? COPY.collapse : COPY.expand}" aria-expanded="${state.expandedFiles.has(item.path)}"><i class="fa-solid fa-chevron-${state.expandedFiles.has(item.path) ? "down" : "right"}"></i></button>` : `<span class="vault-disclosure"></span>`}
771
+ <i class="fa-regular fa-file vault-name-icon"></i>
772
+ <span class="vault-file-name">${esc(basename(item.relative_path))}</span>
773
+ </div>
774
+ <span class="vault-flat-location">${esc(flatLocation(item))}</span>
775
+ <span class="vault-size">${item.size ? fmt(item.size) : "—"}</span>
776
+ ${sharingControl(item)}
777
+ </div>${fileDetail(item)}`
778
+ }).join("")
779
+ const deduplicateActionForScope = (sourceId) => {
780
+ const serverActions = state.data && Array.isArray(state.data.file_actions)
781
+ ? state.data.file_actions
782
+ : []
783
+ return serverActions.find((action) =>
784
+ action.kind === "deduplicate" && action.source_id === sourceId) ||
785
+ state.deduplicateActions.get(sourceId) ||
786
+ null
787
+ }
788
+ const deduplicateActionMarkup = (sourceId, count) => {
789
+ const action = deduplicateActionForScope(sourceId)
790
+ if (action) return deduplicateProgressMarkup(action, count)
791
+ return `<button class="vault-button primary" type="button" data-deduplicate-scope="${attr(sourceId)}"${deduplicateAllAction() ? " disabled" : ""}>${esc(COPY.deduplicate)} ${countLabel(count)}</button>`
792
+ }
793
+ const renderDuplicateGroups = (items) => {
794
+ const groups = new Map()
795
+ for (const item of items) {
796
+ const key = item.source_id || "unknown"
797
+ if (!groups.has(key)) groups.set(key, [])
798
+ groups.get(key).push(item)
799
+ }
800
+ const orderedGroups = [...groups.entries()]
801
+ .map(([sourceId, group]) => ({
802
+ sourceId,
803
+ source: sourceById(sourceId),
804
+ group,
805
+ bytes: group.reduce((sum, item) => sum + sizeOf(item), 0)
806
+ }))
807
+ .sort((left, right) => {
808
+ const sizeDifference = left.bytes - right.bytes
809
+ return (state.sizeSort === "asc" ? sizeDifference : -sizeDifference) ||
810
+ compareSources(left.source, right.source)
811
+ })
812
+ return orderedGroups.map(({ sourceId, source, group, bytes }) => {
813
+ const action = source && source.shareable
814
+ ? deduplicateActionMarkup(sourceId, group.length)
815
+ : `<span class="vault-unavailable">${esc(COPY.sharing_unavailable)}</span>`
816
+ return `<div class="vault-group-row"><div class="vault-group-main"><div class="vault-group-title"><i class="fa-regular fa-folder"></i><span>${esc(groupTitle(source))}</span></div><div class="vault-group-meta">${countLabel(group.length, COPY.duplicate.toLowerCase(), COPY.duplicates.toLowerCase())} · ${bytes ? fmt(bytes) : COPY.unavailable}</div></div><div class="vault-group-action" data-deduplicate-action-scope="${attr(sourceId)}" data-deduplicate-count="${group.length}">${action}</div></div>${[...group].sort((a, b) => compareRows(a, b, (item) => item.relative_path)).map((item) => renderFileRow(item, 0, true)).join("")}`
817
+ }).join("")
818
+ }
819
+
820
+ const renderInventoryGroups = (items) => {
821
+ const groups = new Map()
822
+ for (const item of items) {
823
+ const key = item.source_id || "unknown"
824
+ if (!groups.has(key)) groups.set(key, [])
825
+ groups.get(key).push(item)
826
+ }
827
+ return [...groups.entries()].sort(sourceSort).map(([sourceId, group]) => {
828
+ const source = sourceById(sourceId)
829
+ const duplicates = group.filter((item) => item.status === "duplicate")
830
+ const saveable = duplicates.filter((item) => item.shareable).reduce((sum, item) => sum + item.size, 0)
831
+ const shared = group.filter((item) => item.status === "shared").length
832
+ const meta = [countLabel(group.length)]
833
+ if (duplicates.length) meta.push(countLabel(duplicates.length, COPY.duplicate.toLowerCase(), COPY.duplicates.toLowerCase()))
834
+ else if (shared) meta.push(`${shared} ${COPY.shared.toLowerCase()}`)
835
+ if (saveable) meta.push(`${fmt(saveable)} ${COPY.can_save_suffix}`)
836
+ return `<div class="vault-group-row"><div class="vault-group-main"><div class="vault-group-title"><i class="fa-regular fa-folder"></i><span>${esc(groupTitle(source))}</span></div><div class="vault-group-meta">${esc(meta.join(" · "))}</div></div></div>${renderTree(group)}`
837
+ }).join("")
838
+ }
839
+
840
+ const emptyState = (view) => {
841
+ const activeScan = scanActive(state.data && state.data.scan)
842
+ const scanning = scanMatchesContext(state.data && state.data.scan)
843
+ const content = {
844
+ all: scanning
845
+ ? [COPY.scan_waiting, COPY.scan_waiting_hint, "fa-solid fa-circle-notch fa-spin"]
846
+ : [COPY.no_files, COPY.no_files_hint, "fa-regular fa-folder-open"],
847
+ duplicates: [COPY.no_duplicates, COPY.no_duplicates_hint, "fa-regular fa-circle-check"],
848
+ shared: [COPY.no_shared, COPY.no_shared_hint, "fa-solid fa-link"],
849
+ independent: [COPY.no_skipped, COPY.no_skipped_hint, "fa-solid fa-circle-minus"],
850
+ reclaimable: [COPY.no_reclaimable, COPY.no_reclaimable_hint, "fa-regular fa-circle-check"],
851
+ activity: [COPY.no_activity, COPY.no_activity_hint, "fa-solid fa-wave-square"]
852
+ }[view]
853
+ if (view === "all") content[1] = content[1].replace("{size}", fmt(candidateMinBytes()))
854
+ const scanLabel = IS_APP_MODE ? COPY.scan_app : COPY.scan
855
+ return `<div class="vault-empty"><div class="vault-empty-inner"><i class="${content[2]}"></i><h3>${esc(content[0])}</h3><p>${esc(content[1])}</p>${view === "duplicates" ? `<button class="vault-button" type="button" data-view="all">${esc(COPY.view_all)}</button>` : view === "all" && !activeScan ? `<button class="vault-button" type="button" id="btn-empty-scan">${esc(scanLabel)}</button>` : ""}</div></div>`
856
+ }
857
+
858
+ const renderReclaimable = () => {
859
+ const blobs = state.data.blobs.filter((blob) => blob.orphan)
860
+ if (!blobs.length) return emptyState("reclaimable")
861
+ return [...blobs].sort((a, b) => compareRows(a, b, (blob) => blob.names[0] ? blob.names[0].path : blob.hash)).map((blob) => `<div class="vault-file-row"><div class="vault-name-cell"><span class="vault-disclosure"></span><i class="fa-regular fa-file vault-name-icon"></i><span class="vault-name-copy"><span class="vault-file-name">${esc(blob.names[0] ? basename(blob.names[0].path) : `${blob.hash.slice(0, 12)}…`)}</span></span></div><span class="vault-size">${fmt(blob.size)}</span><span class="vault-space">${fmt(blob.size)}</span><span class="vault-row-action"><button class="vault-text-button" type="button" data-reclaim="${attr(blob.hash)}">${esc(COPY.reclaim)}</button></span></div>`).join("")
862
+ }
863
+
864
+ const renderActivity = () => {
865
+ const events = activityItems()
866
+ const query = state.query.trim().toLowerCase()
867
+ const undoBatches = Array.isArray(state.data.undo_batches)
868
+ ? state.data.undo_batches.filter((batch) => !query ||
869
+ `${COPY.event_convert} ${batch.files || 0} ${COPY.files}`.toLowerCase().includes(query))
870
+ : []
871
+ if (!events.length && !undoBatches.length) return emptyState("activity")
872
+ const seenBatches = new Set()
873
+ const eventRows = events.map((event) => {
874
+ let undo = ""
875
+ if (event.kind === "convert" && event.batch_id && event.undoable !== false && !seenBatches.has(event.batch_id)) {
876
+ seenBatches.add(event.batch_id)
877
+ undo = `<button class="vault-text-button" type="button" data-undo="${attr(event.batch_id)}">${esc(COPY.undo)}</button>`
878
+ }
879
+ const eventPath = event.path
880
+ ? `${event.source_label ? `${event.source_label} / ` : ""}${event.relative_path || event.path}`
881
+ : (event.hash || "").slice(0, 12)
882
+ return `<div class="vault-file-row"><div class="vault-name-cell"><span class="vault-disclosure"></span><i class="fa-solid fa-wave-square vault-name-icon"></i><span class="vault-name-copy"><span class="vault-file-name vault-event-kind">${esc(eventLabels[event.kind] || event.kind)}</span><span class="vault-file-path">${esc(eventPath)}</span></span></div><span class="vault-size">${event.bytes_saved ? fmt(event.bytes_saved) : event.size ? fmt(event.size) : "—"}</span><span class="vault-event-time">${esc(new Date(event.ts).toLocaleString())}</span><span class="vault-row-action">${undo}</span></div>`
883
+ }).join("")
884
+ const batchRows = undoBatches.filter((batch) => !seenBatches.has(batch.batch_id)).map((batch) =>
885
+ `<div class="vault-file-row"><div class="vault-name-cell"><span class="vault-disclosure"></span><i class="fa-solid fa-wave-square vault-name-icon"></i><span class="vault-name-copy"><span class="vault-file-name vault-event-kind">${esc(COPY.event_convert)}</span><span class="vault-file-path">${esc(countLabel(batch.files || 0))}</span></span></div><span class="vault-size">${fmt(batch.bytes || 0)}</span><span class="vault-event-time">${batch.ts ? esc(new Date(batch.ts).toLocaleString()) : "—"}</span><span class="vault-row-action"><button class="vault-text-button" type="button" data-undo="${attr(batch.batch_id)}">${esc(COPY.undo)}</button></span></div>`
886
+ ).join("")
887
+ return batchRows + eventRows
888
+ }
889
+
890
+ const renderTable = (items) => {
891
+ let body = ""
892
+ let tableClass = "inventory"
893
+ let headers = [COPY.name, COPY.size, COPY.status]
894
+ if (state.view === "duplicates") {
895
+ tableClass = "matches"
896
+ headers = [COPY.name, COPY.size, COPY.matches, COPY.can_save, ""]
897
+ body = items.length ? renderDuplicateGroups(items) : emptyState("duplicates")
898
+ } else if (state.view === "reclaimable") {
899
+ tableClass = "reclaimable"
900
+ headers = [COPY.name, COPY.size, COPY.can_free, ""]
901
+ body = renderReclaimable()
902
+ } else if (state.view === "activity") {
903
+ tableClass = "activity"
904
+ headers = [COPY.name, COPY.size, COPY.last_scanned, ""]
905
+ body = renderActivity()
906
+ } else if (state.displayMode === "files" && supportsDisplayMode()) {
907
+ tableClass = "flat"
908
+ headers = [COPY.name, COPY.location_column, COPY.size, COPY.status]
909
+ body = items.length ? renderFlatFiles(items) : emptyState(state.view)
910
+ } else {
911
+ body = items.length ? (state.sourceId ? renderTree(items) : renderInventoryGroups(items)) : emptyState(state.view)
912
+ }
913
+ const sortableSize = tableClass === "flat" || state.view === "duplicates" || state.view === "reclaimable"
914
+ const sizeSortLabel = state.sizeSort === "desc" ? COPY.sort_smallest : COPY.sort_largest
915
+ const sizeSortIcon = state.sizeSort === "desc" ? "fa-arrow-down-wide-short" : state.sizeSort === "asc" ? "fa-arrow-up-short-wide" : "fa-sort"
916
+ const headerMarkup = headers.map((header) => header === COPY.size && sortableSize
917
+ ? `<span class="vault-sort-column" role="columnheader" aria-sort="${state.sizeSort === "desc" ? "descending" : state.sizeSort === "asc" ? "ascending" : "none"}"><button class="vault-sort-button ${state.sizeSort ? "active" : ""}" type="button" data-sort-size aria-label="${attr(sizeSortLabel)}">${esc(header)}<i class="fa-solid ${sizeSortIcon}" aria-hidden="true"></i></button></span>`
918
+ : `<span>${esc(header)}</span>`).join("")
919
+ el("vault-table-wrap").innerHTML = `<div class="vault-table ${tableClass}"><div class="vault-columns">${headerMarkup}</div>${body}</div>`
920
+ }
921
+
922
+ const renderOverview = () => {
923
+ const data = state.data
924
+ const last = data.last_scan
925
+ const lastAttempt = data.last_attempt || last
926
+ const incomplete = !!data.incomplete
927
+ const metricsExact = !!last && !incomplete && data.metrics_exact !== false
928
+ const activeScan = scanActive(data.scan)
929
+ const scanning = scanMatchesContext(data.scan)
930
+ const busyElsewhere = activeScan && !scanning
931
+ const hasReviewableFiles = !activeScan && Math.max(0, Number(data.pending_bytes) || 0) > 0
932
+ const metrics = el("vault-metrics")
933
+ metrics.classList.add("summary")
934
+ {
935
+ const beforeBytes = Math.max(0, Number.isFinite(data.bytes_without_sharing)
936
+ ? data.bytes_without_sharing
937
+ : (data.last_scan && Number.isFinite(data.last_scan.bytes_total)
938
+ ? data.last_scan.bytes_total
939
+ : 0))
940
+ const afterBytes = Math.max(0, Number.isFinite(data.bytes_on_disk)
941
+ ? data.bytes_on_disk
942
+ : beforeBytes)
943
+ const afterRatio = beforeBytes ? Math.min(100, (afterBytes / beforeBytes) * 100) : 0
944
+ const pendingBytes = Math.max(0, Number(data.pending_bytes) || 0)
945
+ const savedBytes = Number.isFinite(data.saved_by_sharing)
946
+ ? Math.max(0, data.saved_by_sharing)
947
+ : (IS_APP_MODE ? Math.max(0, beforeBytes - afterBytes) : 0)
948
+ const headline = incomplete
949
+ ? (savedBytes
950
+ ? COPY.at_least_saved.replace("{size}", fmt(savedBytes))
951
+ : COPY.no_verified_savings)
952
+ : last
953
+ ? (savedBytes
954
+ ? (IS_APP_MODE
955
+ ? COPY.saved_for_app.replace("{size}", fmt(savedBytes))
956
+ : `${fmt(savedBytes)} ${COPY.disk_space_saved}`)
957
+ : COPY.no_space_saved)
958
+ : (IS_APP_MODE ? COPY.find_app_savings : COPY.find_savings)
959
+ const help = IS_APP_MODE ? COPY.effective_help : COPY.before_help
960
+ const helpId = IS_APP_MODE ? "vault-after-help" : "vault-before-help"
961
+ const helpMarkup = `<button class="vault-compare-info" type="button" aria-describedby="${helpId}" aria-label="${attr(help)}"><i class="fa-regular fa-circle-question" aria-hidden="true"></i></button>`
962
+ const comparison = metricsExact ? `
963
+ <div class="vault-comparison" aria-label="${attr(`${COPY.before}: ${fmt(beforeBytes)}. ${COPY.after}: ${fmt(afterBytes)}.`)}">
964
+ <div class="vault-compare-row">
965
+ <span class="vault-compare-label">${esc(COPY.before)}${IS_APP_MODE ? "" : helpMarkup}</span>
966
+ <span class="vault-compare-track"><span class="vault-compare-fill before"></span></span>
967
+ <span class="vault-compare-value">${fmt(beforeBytes)}</span>
968
+ </div>
969
+ <div class="vault-compare-row">
970
+ <span class="vault-compare-label">${esc(COPY.after)}${IS_APP_MODE ? helpMarkup : ""}</span>
971
+ <span class="vault-compare-track"><span class="vault-compare-fill after" style="--vault-after-ratio:${afterRatio.toFixed(2)}%"></span></span>
972
+ <span class="vault-compare-value">${fmt(afterBytes)}</span>
973
+ </div>
974
+ </div>` : ""
975
+ const opportunity = activeScan
976
+ ? `<span class="vault-summary-state"><i class="fa-solid fa-circle-notch fa-spin"></i>${esc(COPY.scanning)}</span>`
977
+ : pendingBytes
978
+ ? `<span class="vault-summary-state attention"><i class="fa-regular fa-copy"></i><strong>${esc((incomplete
979
+ ? COPY.at_least_can_be_saved
980
+ : savedBytes ? COPY.more_can_be_saved : COPY.available_to_save).replace("{size}", fmt(pendingBytes)))}</strong></span><button class="vault-button primary review-primary" type="button" id="btn-review-metric">${esc(COPY.review_files)}</button>`
981
+ : lastAttempt
982
+ ? `<span class="vault-summary-state"><i class="fa-regular fa-circle-check"></i>${esc(incomplete
983
+ ? COPY.no_matches_in_scanned_locations
984
+ : COPY.no_more_matches.replace("{size}", fmt(scannedCandidateMinBytes())))}</span>`
985
+ : ""
986
+ const freshness = lastAttempt ? `${COPY.scanned} ${timeAgo(lastAttempt.ts)}` : COPY.not_scanned
987
+ metrics.innerHTML = `
988
+ <div class="vault-summary-main">
989
+ <div class="vault-summary-label"><i class="fa-solid fa-hard-drive"></i>${esc(COPY.save_space)}</div>
990
+ <div class="vault-summary-value">${esc(headline)}</div>
991
+ ${comparison}
992
+ </div>
993
+ <div class="vault-summary-side">${opportunity}${opportunity ? `<span class="vault-summary-divider" aria-hidden="true"></span>` : ""}<span class="vault-summary-freshness">${esc(freshness)}</span></div>`
994
+ document.querySelectorAll("body > .vault-compare-tooltip").forEach((tooltip) => tooltip.remove())
995
+ if (metricsExact) {
996
+ const tooltip = document.createElement("span")
997
+ tooltip.id = helpId
998
+ tooltip.className = "vault-compare-tooltip"
999
+ tooltip.setAttribute("role", "tooltip")
1000
+ tooltip.textContent = help
1001
+ tooltip.hidden = true
1002
+ document.body.appendChild(tooltip)
1003
+ }
1004
+ const storageDetails = !IS_APP_MODE && el("vault-storage-details")
1005
+ if (storageDetails) {
1006
+ const homeBytes = metricsExact
1007
+ ? (last.home_bytes_total == null ? last.bytes_total : last.home_bytes_total)
1008
+ : null
1009
+ storageDetails.innerHTML = `<div class="vault-advanced-title">${esc(COPY.storage_details)}</div>
1010
+ <dl class="vault-storage-list">
1011
+ <div><dt>${esc(COPY.pinokio_folder)}</dt><dd>${homeBytes == null ? "—" : fmt(homeBytes)}</dd></div>
1012
+ </dl>`
1013
+ }
1014
+ }
1015
+ const scanButton = el("btn-scan")
1016
+ scanButton.classList.toggle("primary", !hasReviewableFiles)
1017
+ scanButton.innerHTML = activeScan
1018
+ ? `<i class="fa-solid fa-circle-notch fa-spin"></i>${esc(COPY.scanning)}`
1019
+ : `<i class="fa-solid fa-rotate"></i>${esc(state.candidateMinBytesDraft === null
1020
+ ? (last ? COPY.scan_again : (IS_APP_MODE ? COPY.scan_app : COPY.scan))
1021
+ : COPY.apply_and_scan)}`
1022
+ scanButton.disabled = activeScan
1023
+ const sizeSelect = el("vault-candidate-size")
1024
+ const selectedSize = state.candidateMinBytesDraft === null
1025
+ ? candidateMinBytes()
1026
+ : state.candidateMinBytesDraft
1027
+ sizeSelect.innerHTML = candidateSizeOptions().map((bytes) =>
1028
+ `<option value="${bytes}"${bytes === selectedSize ? " selected" : ""}>${esc(`${fmt(bytes)} ${COPY.and_larger}`)}</option>`
1029
+ ).join("")
1030
+ sizeSelect.disabled = activeScan
1031
+ const optionsButton = el("btn-vault-options")
1032
+ const repairTitle = el("vault-repair-title")
1033
+ const repairDescription = el("vault-repair-description")
1034
+ const repairButton = el("btn-repair")
1035
+ if (optionsButton) optionsButton.setAttribute("aria-label", COPY.vault_options)
1036
+ if (repairTitle) repairTitle.textContent = COPY.repair_index
1037
+ if (repairDescription) repairDescription.textContent = COPY.repair_description
1038
+ if (repairButton) {
1039
+ repairButton.textContent = COPY.repair_action
1040
+ repairButton.disabled = activeScan
1041
+ }
1042
+ const scanState = el("vault-scan-state")
1043
+ if (busyElsewhere) {
1044
+ scanState.classList.add("show")
1045
+ scanState.innerHTML = `<i class="fa-solid fa-circle-notch fa-spin"></i><strong>${esc(COPY.scanning_elsewhere)}</strong><span class="vault-scan-detail">${esc(COPY.scanning_elsewhere_hint)}</span>`
1046
+ return
1047
+ }
1048
+ if (scanning) {
1049
+ const scan = data.scan
1050
+ const scanPhase = scan.phase || "counting"
1051
+ const queued = scanPhase === "queued"
1052
+ const counting = scanPhase === "counting"
1053
+ const walking = scanPhase === "discovering"
1054
+ const verifying = scanPhase === "verifying"
1055
+ const hashTotal = scan.hash_total || 0
1056
+ const hashDone = Math.min(Math.max(0, hashTotal - (scan.queued || 0)), hashTotal)
1057
+ const hashRatio = hashTotal ? Math.min(1, hashDone / hashTotal) : 1
1058
+ const scanSource = scan.scope_id ? sourceById(scan.scope_id) : null
1059
+ const scanProgressLabel = scanSource
1060
+ ? COPY.scan_location.replace("{location}", scanSource.label)
1061
+ : COPY.scan_progress
1062
+ const phase = queued ? COPY.scan_queued : counting ? COPY.scan_counting : walking ? scanProgressLabel : verifying ? COPY.scan_finishing : COPY.scan_analyzing
1063
+ const rawCountWeight = Number.isFinite(scan.estimated_count_weight) ? scan.estimated_count_weight : 0.4
1064
+ const countWeight = Math.max(0, Math.min(0.98, rawCountWeight))
1065
+ const rawWalkWeight = Number.isFinite(scan.estimated_walk_weight) ? scan.estimated_walk_weight : 0.5
1066
+ const walkWeight = Math.max(0, Math.min(0.98 - countWeight, rawWalkWeight))
1067
+ const analysisWeight = Math.max(0, 1 - countWeight - walkWeight)
1068
+ const countEstimate = Number.isFinite(scan.count_estimate_files) && scan.count_estimate_files > 0
1069
+ ? scan.count_estimate_files
1070
+ : null
1071
+ const countRatio = countEstimate === null ? null : Math.min(1, (scan.counted_files || 0) / countEstimate)
1072
+ const totalFiles = Number.isFinite(scan.total_files) ? scan.total_files : null
1073
+ const details = counting
1074
+ ? [`${scan.counted_dirs || 0} ${COPY.scan_folders_found}`, `${scan.counted_files || 0} ${COPY.scan_files_found}`]
1075
+ : [`${scan.dirs || 0} ${COPY.scan_folders}`, totalFiles === null
1076
+ ? `${scan.files || 0} ${COPY.scan_files}`
1077
+ : COPY.scan_files_checked.replace("{done}", scan.files || 0).replace("{total}", totalFiles), fmt(scan.bytes_total || 0)]
1078
+ if (scan.current_file) details.push(`${COPY.analyzing} ${scan.current_file}`)
1079
+ if (walking && scan.queued > 1) details.push(`${scan.queued} ${COPY.waiting}`)
1080
+ if (!walking && hashTotal) {
1081
+ details.push(COPY.scan_checked.replace("{done}", hashDone).replace("{total}", hashTotal))
1082
+ }
1083
+ let percent = ""
1084
+ let progress
1085
+ if (queued || (counting && countRatio === null) || (walking && totalFiles === null)) {
1086
+ const ariaText = `${details.join(" · ")}. ${COPY.scan_counting_help}.`
1087
+ progress = { determinate: false, ariaText }
1088
+ } else {
1089
+ const progressRatio = counting
1090
+ ? countRatio * countWeight
1091
+ : verifying
1092
+ ? 0.99
1093
+ : walking
1094
+ ? countWeight + ((totalFiles ? Math.min(1, (scan.files || 0) / totalFiles) : 1) * walkWeight)
1095
+ : Math.min(0.98, countWeight + walkWeight + (analysisWeight * hashRatio))
1096
+ const boundedProgress = Math.max(0, Math.min(1, progressRatio))
1097
+ const progressValue = Math.round(boundedProgress * 1000) / 10
1098
+ const progressHelp = counting ? COPY.scan_count_estimate_help : verifying ? COPY.scan_finishing_help : walking ? COPY.scan_file_progress_help : COPY.scan_estimate_help
1099
+ const progressText = COPY.about_percent.replace("{percent}", progressValue)
1100
+ percent = `~${progressValue}%`
1101
+ progress = { determinate: true, value: progressValue, ratio: boundedProgress, help: progressHelp, text: progressText }
1102
+ }
1103
+ if (!scanState.querySelector(".vault-progress-track")) {
1104
+ scanState.innerHTML = `<i class="fa-solid fa-circle-notch fa-spin"></i><strong></strong><span class="vault-scan-detail"></span><span class="vault-scan-percent" hidden></span><span class="vault-progress-track" role="progressbar"><span class="vault-progress-bar"></span></span>`
1105
+ }
1106
+ scanState.classList.add("show")
1107
+ scanState.querySelector("strong").textContent = phase
1108
+ scanState.querySelector(".vault-scan-detail").textContent = details.join(" · ")
1109
+ const percentNode = scanState.querySelector(".vault-scan-percent")
1110
+ const track = scanState.querySelector(".vault-progress-track")
1111
+ const bar = scanState.querySelector(".vault-progress-bar")
1112
+ track.setAttribute("aria-label", phase)
1113
+ if (progress.determinate) {
1114
+ percentNode.hidden = false
1115
+ percentNode.textContent = percent
1116
+ percentNode.title = progress.help
1117
+ track.setAttribute("aria-valuemin", "0")
1118
+ track.setAttribute("aria-valuemax", "100")
1119
+ track.setAttribute("aria-valuenow", progress.value)
1120
+ track.setAttribute("aria-valuetext", `${progress.text} ${progress.help}.`)
1121
+ bar.className = "vault-progress-bar determinate"
1122
+ bar.style.setProperty("--vault-progress", progress.ratio)
1123
+ } else {
1124
+ percentNode.hidden = true
1125
+ track.removeAttribute("aria-valuemin")
1126
+ track.removeAttribute("aria-valuemax")
1127
+ track.removeAttribute("aria-valuenow")
1128
+ track.setAttribute("aria-valuetext", progress.ariaText)
1129
+ bar.className = "vault-progress-bar indeterminate"
1130
+ bar.style.removeProperty("--vault-progress")
1131
+ }
1132
+ } else {
1133
+ scanState.classList.remove("show")
1134
+ scanState.innerHTML = ""
1135
+ }
1136
+ }
1137
+
1138
+ const renderResult = () => {
1139
+ const result = el("vault-result")
1140
+ if (!state.scanResult) {
1141
+ result.className = "vault-result"
1142
+ result.innerHTML = ""
1143
+ return
1144
+ }
1145
+ const info = state.scanResult
1146
+ const duplicateLabel = countLabel(info.count, COPY.duplicate.toLowerCase(), COPY.duplicates.toLowerCase())
1147
+ const review = info.count ? `<button class="vault-button primary review-primary" type="button" id="btn-review-result">${esc(COPY.review)} ${duplicateLabel}<i class="fa-solid fa-chevron-right"></i></button>` : ""
1148
+ if (info.incomplete) {
1149
+ result.className = `vault-result show incomplete${state.scanProblemsOpen ? " expanded" : ""}`
1150
+ const unreadableLabel = `${info.inaccessible} ${info.inaccessible === 1 ? COPY.scan_unreadable_path : COPY.scan_unreadable_paths}`
1151
+ const viewLabel = info.inaccessible === 1 ? COPY.view_unreadable_path : COPY.view_unreadable_paths
1152
+ const paths = info.inaccessiblePaths.map((filePath) =>
1153
+ `<div class="vault-result-path"><span class="vault-result-path-dot" aria-hidden="true"></span><span>${esc(filePath)}</span></div>`
1154
+ ).join("")
1155
+ const toggle = `<button class="vault-result-toggle" type="button" id="btn-scan-problems" aria-expanded="${state.scanProblemsOpen}" aria-controls="vault-result-paths">${esc(viewLabel)}<i class="fa-solid fa-chevron-down"></i></button>`
1156
+ result.innerHTML = `<div class="vault-result-message"><i class="fa-solid fa-triangle-exclamation"></i><span class="vault-result-heading"><strong>${esc(COPY.scan_incomplete)}</strong><span>${esc(`${unreadableLabel}. ${COPY.scan_partial_rest}`)}</span></span><span class="vault-result-actions">${toggle}${review}</span></div><div class="vault-result-paths" id="vault-result-paths"${state.scanProblemsOpen ? "" : " hidden"}>${paths}</div>`
1157
+ return
1158
+ }
1159
+ result.className = "vault-result show"
1160
+ const skipped = info.skipped ? ` · ${countLabel(info.skipped)} ${esc(COPY.scan_not_analyzed)}` : ""
1161
+ const resultIcon = info.skipped ? "fa-solid fa-triangle-exclamation" : "fa-regular fa-circle-check"
1162
+ result.innerHTML = `<i class="${resultIcon}"></i><strong>${esc(COPY.scan_complete)}</strong><span class="vault-result-detail">${duplicateLabel} ${esc(COPY.found_in)} ${countLabel(info.locations, COPY.location, COPY.locations_lower)} · ${fmt(info.bytes)} ${esc(COPY.can_be_saved)}${skipped}</span>${review}`
1163
+ }
1164
+ const renderFeedback = () => {
1165
+ const feedback = el("vault-feedback")
1166
+ if (!state.feedback) {
1167
+ feedback.className = "vault-feedback"
1168
+ feedback.innerHTML = ""
1169
+ return
1170
+ }
1171
+ feedback.className = `vault-feedback show ${state.feedback.error ? "error" : ""}`
1172
+ feedback.innerHTML = `<i class="fa-solid fa-${state.feedback.error ? "triangle-exclamation" : "circle-check"}"></i><span>${esc(state.feedback.message)}</span>`
1173
+ }
1174
+ const renderCloudWarning = () => {
1175
+ const warning = el("vault-cloud-warning")
1176
+ const provider = state.data && state.data.cloud_sync_warning
1177
+ const key = provider ? `pinokio:vault:cloud-warning:${provider}` : null
1178
+ let dismissed = false
1179
+ try { dismissed = !!(key && localStorage.getItem(key)) } catch (error) {}
1180
+ if (!provider || dismissed) {
1181
+ warning.classList.remove("show")
1182
+ warning.innerHTML = ""
1183
+ return
1184
+ }
1185
+ const message = COPY.cloud_sync_warning.replace("{provider}", provider)
1186
+ warning.classList.add("show")
1187
+ warning.innerHTML = `<i class="fa-solid fa-triangle-exclamation"></i><span>${esc(message)}</span><button class="vault-text-button" id="btn-dismiss-cloud" type="button" data-warning-key="${attr(key)}">${esc(COPY.dismiss)}</button>`
1188
+ }
1189
+
1190
+ const render = () => {
1191
+ if (!state.data) return
1192
+ renderOverview()
1193
+ renderResult()
1194
+ renderFeedback()
1195
+ renderCloudWarning()
1196
+ if (!state.data.enabled) {
1197
+ el("vault-explorer").style.display = "none"
1198
+ return
1199
+ }
1200
+ el("vault-explorer").style.display = "grid"
1201
+ const items = buildItems()
1202
+ renderViews(items)
1203
+ renderLocations(items)
1204
+ const visible = activeItems(items)
1205
+ renderToolbar(visible)
1206
+ renderTable(visible)
1207
+ if (state.displayMode === "files" && supportsDisplayMode()) {
1208
+ const count = state.view === "shared"
1209
+ ? countLabel(visible.length, "shared file", "shared files")
1210
+ : state.view === "independent"
1211
+ ? countLabel(visible.length, "file kept separate", "files kept separate")
1212
+ : countLabel(visible.length, "tracked file", "tracked files")
1213
+ const order = state.sizeSort === "desc" ? COPY.sorted_largest : state.sizeSort === "asc" ? COPY.sorted_smallest : ""
1214
+ el("vault-pane-footer").textContent = `${count}${order ? ` · ${order}` : ""}`
1215
+ } else {
1216
+ el("vault-pane-footer").textContent = state.view === "duplicates"
1217
+ ? COPY.duplicate_note
1218
+ : state.view === "reclaimable"
1219
+ ? COPY.reclaimable_note
1220
+ : COPY.tracked_note.replace("{size}", fmt(candidateMinBytes()))
1221
+ }
1222
+ }
1223
+
1224
+ const scanActive = (scan) => !!(scan && (scan.pending || scan.active || scan.queued > 0))
1225
+ const scanMatchesContext = (scan) => scanActive(scan) && (!IS_APP_MODE || scan.scope_id === SCOPE_ID)
1226
+ const acceptActionState = (data) => {
1227
+ const sequence = data && data.action_sequence
1228
+ if (!Number.isFinite(sequence)) return true
1229
+ if (sequence < state.actionSequence) return false
1230
+ state.actionSequence = sequence
1231
+ return true
1232
+ }
1233
+ const compareTooltip = (trigger) => el(trigger && trigger.getAttribute("aria-describedby"))
1234
+ const showCompareTooltip = (trigger) => {
1235
+ const tooltip = compareTooltip(trigger)
1236
+ if (!tooltip) return
1237
+ tooltip.hidden = false
1238
+ const anchor = trigger.getBoundingClientRect()
1239
+ const width = tooltip.offsetWidth
1240
+ const height = tooltip.offsetHeight
1241
+ const margin = 8
1242
+ const center = anchor.left + (anchor.width / 2)
1243
+ tooltip.style.left = `${Math.max(margin + width / 2, Math.min(window.innerWidth - margin - width / 2, center))}px`
1244
+ const below = anchor.bottom + 6
1245
+ tooltip.style.top = `${below + height <= window.innerHeight - margin
1246
+ ? below
1247
+ : Math.max(margin, anchor.top - height - 6)}px`
1248
+ }
1249
+ const hideCompareTooltip = (trigger) => {
1250
+ const tooltip = compareTooltip(trigger)
1251
+ if (tooltip) tooltip.hidden = true
1252
+ }
1253
+ const hideCompareTooltips = () => {
1254
+ document.querySelectorAll("body > .vault-compare-tooltip").forEach((tooltip) => {
1255
+ tooltip.hidden = true
1256
+ })
1257
+ }
1258
+ const reviewedScan = () => {
1259
+ try { return localStorage.getItem(reviewedScanKey) }
1260
+ catch (error) { return null }
1261
+ }
1262
+ const markScanReviewed = () => {
1263
+ const last = state.data && (state.data.last_attempt || state.data.last_scan)
1264
+ if (!last || !last.ts) return
1265
+ try { localStorage.setItem(reviewedScanKey, String(last.ts)) } catch (error) {}
1266
+ }
1267
+ const fetchJson = async (url) => {
1268
+ const response = await fetch(url)
1269
+ if (!response.ok) throw new Error(COPY.status_request_failed.replace("{status}", response.status))
1270
+ return response.json()
1271
+ }
1272
+ const notifyNavigationStatus = () => {
1273
+ document.dispatchEvent(new CustomEvent("pinokio:vault-status-changed"))
1274
+ if (window.parent !== window) {
1275
+ window.parent.postMessage({ type: "pinokio:vault-status-changed" }, window.location.origin)
1276
+ }
1277
+ }
1278
+ const applyFullData = (data) => {
1279
+ if (!acceptActionState(data)) return scanActive(state.data && state.data.scan)
1280
+ const scanning = scanActive(data.scan)
1281
+ const contextualScan = !IS_APP_MODE || !data.scan || data.scan.scope_id === SCOPE_ID
1282
+ const lastAttempt = data.last_attempt || data.last_scan
1283
+ const completed = state.scanRequested && !scanning && lastAttempt &&
1284
+ lastAttempt.ts !== state.scanBaseline
1285
+ const incomplete = contextualScan && !scanning && !!data.incomplete
1286
+ const failed = contextualScan && state.scanRequested && !scanning && data.scan && data.scan.error
1287
+ const unreviewed = !scanning && lastAttempt &&
1288
+ reviewedScan() !== String(lastAttempt.ts) &&
1289
+ (data.duplicates.some((item) => item.shareable !== false) || lastAttempt.hash_failures > 0)
1290
+ state.data = data
1291
+ notifyNavigationStatus()
1292
+ if (failed) {
1293
+ state.scanRequested = false
1294
+ state.feedback = { error: true, message: data.scan.error }
1295
+ } else if (completed || incomplete || (!state.scanResult && unreviewed)) {
1296
+ state.scanRequested = false
1297
+ const shareable = data.duplicates.filter((item) => item.shareable !== false)
1298
+ state.scanResult = {
1299
+ count: shareable.length,
1300
+ locations: new Set(shareable.map((item) => item.source_id).filter(Boolean)).size,
1301
+ bytes: shareable.reduce((sum, item) => sum + item.size, 0),
1302
+ skipped: Number(lastAttempt && lastAttempt.hash_failures) || 0,
1303
+ incomplete,
1304
+ inaccessible: incomplete && Array.isArray(lastAttempt && lastAttempt.unreadable)
1305
+ ? lastAttempt.unreadable.length
1306
+ : 0,
1307
+ inaccessiblePaths: incomplete && Array.isArray(lastAttempt && lastAttempt.unreadable)
1308
+ ? lastAttempt.unreadable.map((entry) => entry.path)
1309
+ : []
1310
+ }
1311
+ }
1312
+ if (data.activity_error) {
1313
+ state.feedback = { error: true, message: COPY.activity_write_failed }
1314
+ }
1315
+ if (data.scan && data.scan.persistence_warning) {
1316
+ state.feedback = { error: true, message: COPY.scan_persistence_write_failed }
1317
+ }
1318
+ render()
1319
+ if (actionPollingNeeded()) pollFileActions().catch(() => {})
1320
+ return scanning
1321
+ }
1322
+ const refresh = async () => {
1323
+ const requestId = ++state.refreshRequestId
1324
+ let delay = null
1325
+ try {
1326
+ const progressOnly = !!(state.data && scanActive(state.data.scan))
1327
+ if (progressOnly) {
1328
+ const progress = await fetchJson(statusUrl(true))
1329
+ if (requestId !== state.refreshRequestId) return
1330
+ state.data.scan = progress.scan
1331
+ state.data.last_scan = progress.last_scan
1332
+ if (scanActive(progress.scan)) {
1333
+ delay = 1500
1334
+ renderOverview()
1335
+ renderFeedback()
1336
+ } else {
1337
+ const data = await fetchJson(statusUrl())
1338
+ if (requestId !== state.refreshRequestId) return
1339
+ if (applyFullData(data) || state.scanRequested) delay = 1500
1340
+ }
1341
+ } else {
1342
+ const data = await fetchJson(statusUrl())
1343
+ if (requestId !== state.refreshRequestId) return
1344
+ if (applyFullData(data) || state.scanRequested) delay = 1500
1345
+ }
1346
+ } catch (error) {
1347
+ if (requestId !== state.refreshRequestId) return
1348
+ state.feedback = { error: true, message: error && error.message ? error.message : String(error) }
1349
+ renderFeedback()
1350
+ delay = 5000
1351
+ } finally {
1352
+ if (requestId === state.refreshRequestId) {
1353
+ clearTimeout(window.__vaultRefresh)
1354
+ window.__vaultRefresh = delay == null ? null : setTimeout(refresh, delay)
1355
+ }
1356
+ }
1357
+ }
1358
+
1359
+ const wait = (delay) => new Promise((resolve) => setTimeout(resolve, delay))
1360
+ const actionPollingNeeded = () => state.fileActions.size ||
1361
+ state.deduplicateActions.size ||
1362
+ !!(state.data && Array.isArray(state.data.file_actions) && state.data.file_actions.length)
1363
+ const renderFileActionRows = () => {
1364
+ const actionPaths = new Set([
1365
+ ...state.fileActions.keys(),
1366
+ ...((state.data && Array.isArray(state.data.file_actions))
1367
+ ? state.data.file_actions.map((action) => action.path)
1368
+ : [])
1369
+ ].filter(Boolean))
1370
+ if (!actionPaths.size) return
1371
+ const items = new Map(buildItems()
1372
+ .filter((item) => actionPaths.has(item.path))
1373
+ .map((item) => [item.path, item]))
1374
+ for (const row of document.querySelectorAll(".vault-file-row[data-file-path]")) {
1375
+ const item = items.get(row.dataset.filePath)
1376
+ if (!item) continue
1377
+ const current = row.querySelector(".vault-status-cell")
1378
+ if (!current) continue
1379
+ const container = document.createElement("div")
1380
+ container.innerHTML = sharingControl(item)
1381
+ const replacement = container.firstElementChild
1382
+ if (replacement) current.replaceWith(replacement)
1383
+ }
1384
+ }
1385
+ const renderDeduplicateActions = () => {
1386
+ for (const container of document.querySelectorAll("[data-deduplicate-action-scope]")) {
1387
+ const sourceId = container.dataset.deduplicateActionScope
1388
+ const count = Math.max(0, Number(container.dataset.deduplicateCount) || 0)
1389
+ container.innerHTML = deduplicateActionMarkup(sourceId, count)
1390
+ }
1391
+ const all = document.querySelector("[data-deduplicate-all-action]")
1392
+ if (all) {
1393
+ const count = Math.max(0, Number(all.dataset.deduplicateCount) || 0)
1394
+ all.innerHTML = deduplicateAllActionMarkup(count)
1395
+ }
1396
+ }
1397
+ const pollFileActions = async () => {
1398
+ if (state.fileActionPollRunning) return
1399
+ state.fileActionPollRunning = true
1400
+ try {
1401
+ while (actionPollingNeeded()) {
1402
+ try {
1403
+ const progress = await fetchJson(statusUrl(true))
1404
+ if (acceptActionState(progress)) {
1405
+ const active = progress.file_action || null
1406
+ if (state.data) state.data.file_action = active
1407
+ if (state.data) {
1408
+ state.data.file_actions = Array.isArray(progress.file_actions)
1409
+ ? progress.file_actions
1410
+ : active ? [active] : []
1411
+ state.data.action_sequence = progress.action_sequence
1412
+ }
1413
+ for (const [filePath, action] of state.fileActions) {
1414
+ const serverAction = state.data && state.data.file_actions
1415
+ ? state.data.file_actions.find((item) => item.path === filePath)
1416
+ : null
1417
+ state.fileActions.set(filePath, serverAction || Object.assign({}, action, { phase: "queued" }))
1418
+ }
1419
+ for (const [sourceId, action] of state.deduplicateActions) {
1420
+ const serverAction = state.data && state.data.file_actions
1421
+ ? state.data.file_actions.find((item) => sourceId === null
1422
+ ? item.kind === "deduplicate_all"
1423
+ : item.kind === "deduplicate" && item.source_id === sourceId)
1424
+ : null
1425
+ state.deduplicateActions.set(sourceId, serverAction || action)
1426
+ }
1427
+ renderFileActionRows()
1428
+ renderDeduplicateActions()
1429
+ }
1430
+ } catch (error) {}
1431
+ if (actionPollingNeeded()) await wait(200)
1432
+ }
1433
+ } finally {
1434
+ state.fileActionPollRunning = false
1435
+ if (actionPollingNeeded()) pollFileActions().catch(() => {})
1436
+ }
1437
+ }
1438
+ const runAction = async (payload, success, options = {}) => {
1439
+ state.feedback = null
1440
+ renderFeedback()
1441
+ if (options.filePath && state.data) {
1442
+ const item = buildItems().find((candidate) => candidate.path === options.filePath)
1443
+ state.fileActions.set(options.filePath, {
1444
+ kind: "detach",
1445
+ path: options.filePath,
1446
+ source_id: item ? item.source_id : null,
1447
+ bytes_copied: 0,
1448
+ bytes_total: item ? item.size : 0,
1449
+ phase: "queued"
1450
+ })
1451
+ render()
1452
+ pollFileActions().catch(() => {})
1453
+ }
1454
+ if (options.deduplicateAction && state.data) {
1455
+ const sourceId = options.deduplicateAction.sourceId
1456
+ state.deduplicateActions.set(sourceId, {
1457
+ kind: options.deduplicateAction.kind,
1458
+ source_id: sourceId,
1459
+ files_completed: 0,
1460
+ files_total: options.deduplicateAction.total || 0,
1461
+ phase: "queued"
1462
+ })
1463
+ renderDeduplicateActions()
1464
+ pollFileActions().catch(() => {})
1465
+ }
1466
+ try {
1467
+ const result = await post(payload)
1468
+ if (result.error) state.feedback = { error: true, message: result.error }
1469
+ else {
1470
+ const outcome = typeof success === "function" ? success(result) : success
1471
+ state.feedback = outcome && typeof outcome === "object"
1472
+ ? outcome
1473
+ : { error: false, message: outcome }
1474
+ if (result.persistence_warning) {
1475
+ state.feedback = {
1476
+ error: true,
1477
+ message: [state.feedback && state.feedback.message, COPY.persistence_write_failed]
1478
+ .filter(Boolean).join(" ")
1479
+ }
1480
+ }
1481
+ }
1482
+ } catch (error) {
1483
+ state.feedback = { error: true, message: error && error.message ? error.message : String(error) }
1484
+ }
1485
+ await refresh()
1486
+ if (options.filePath) {
1487
+ state.fileActions.delete(options.filePath)
1488
+ if (state.data && state.data.file_action &&
1489
+ state.data.file_action.path === options.filePath) {
1490
+ state.data.file_action = null
1491
+ }
1492
+ if (state.data && Array.isArray(state.data.file_actions)) {
1493
+ state.data.file_actions = state.data.file_actions
1494
+ .filter((action) => action.path !== options.filePath)
1495
+ }
1496
+ render()
1497
+ }
1498
+ if (options.deduplicateAction) {
1499
+ const sourceId = options.deduplicateAction.sourceId
1500
+ state.deduplicateActions.delete(sourceId)
1501
+ if (state.data && Array.isArray(state.data.file_actions)) {
1502
+ state.data.file_actions = state.data.file_actions.filter((action) =>
1503
+ action.kind !== options.deduplicateAction.kind || action.source_id !== sourceId)
1504
+ }
1505
+ render()
1506
+ }
1507
+ }
1508
+
1509
+ const deduplicateFeedback = (result) => {
1510
+ const waiting = (result.locked || 0) + (result.incompatible || 0) + (result.unavailable || 0) + (result.failed || 0)
1511
+ const skippedLocations = Math.max(0, Number(result.locations_skipped) || 0)
1512
+ const messages = []
1513
+ if (result.converted || result.bytes_saved) messages.push(`${COPY.converted}: ${fmt(result.bytes_saved || 0)}.`)
1514
+ if (result.stale) messages.push(COPY.changed_since_scan)
1515
+ if (waiting) messages.push(`${COPY.files_still_waiting.replace("{count}", countLabel(waiting))}.`)
1516
+ if (skippedLocations) {
1517
+ messages.push(`${skippedLocations} ${skippedLocations === 1 ? COPY.location_not_processed : COPY.locations_not_processed}.`)
1518
+ }
1519
+ if (!result.stale && !waiting) return messages.join(" ") || `${COPY.converted}: ${fmt(0)}`
1520
+ return {
1521
+ error: true,
1522
+ message: messages.join(" ")
1523
+ }
1524
+ }
1525
+
1526
+ const detachFeedback = (result) => {
1527
+ if (result.status === "detached") return COPY.sharing_undone
1528
+ const messages = {
1529
+ locked: COPY.separate_locked,
1530
+ stale: COPY.separate_changed,
1531
+ conflict: COPY.separate_conflict,
1532
+ "insufficient-space": COPY.insufficient_space,
1533
+ "capacity-unavailable": COPY.capacity_unavailable,
1534
+ "not-found": COPY.separate_not_found
1535
+ }
1536
+ return { error: true, message: messages[result.status] || COPY.action_not_completed }
1537
+ }
1538
+ const skipFeedback = (result) => result.status === "ignored"
1539
+ ? COPY.skipped_action
1540
+ : { error: true, message: result.status === "unavailable" ? COPY.unavailable : COPY.action_not_completed }
1541
+
1542
+ const chooseExternalFolder = () => new Promise((resolve, reject) => {
1543
+ const picker = new Socket()
1544
+ let settled = false
1545
+ const finish = (value) => {
1546
+ if (settled) return
1547
+ settled = true
1548
+ resolve(value)
1549
+ }
1550
+ const fail = (error) => {
1551
+ if (settled) return
1552
+ settled = true
1553
+ reject(error)
1554
+ }
1555
+ picker.run({
1556
+ method: "kernel.bin.filepicker",
1557
+ params: { title: COPY.add_external_folder, type: "folder" }
1558
+ }, (packet) => {
1559
+ if (packet.type === "result") {
1560
+ const paths = packet.data && Array.isArray(packet.data.paths) ? packet.data.paths : []
1561
+ finish(paths[0] || null)
1562
+ picker.close()
1563
+ } else if (packet.type === "error") {
1564
+ const message = packet.data && packet.data.message ? packet.data.message : COPY.folder_picker_error
1565
+ fail(new Error(message))
1566
+ picker.close()
1567
+ }
1568
+ }).then(() => finish(null)).catch(fail)
1569
+ })
1570
+
1571
+ document.addEventListener("click", async (event) => {
1572
+ const advanced = el("vault-advanced")
1573
+ if (advanced && advanced.open && !advanced.contains(event.target)) advanced.open = false
1574
+ const target = event.target.closest("button")
1575
+ if (!target) return
1576
+ if (target.id === "btn-dismiss-cloud") {
1577
+ try { localStorage.setItem(target.dataset.warningKey, "1") } catch (error) {}
1578
+ renderCloudWarning()
1579
+ } else if (target.hasAttribute("data-sort-size")) {
1580
+ state.sizeSort = state.sizeSort === "desc" ? "asc" : "desc"
1581
+ render()
1582
+ } else if (target.dataset.displayMode) {
1583
+ state.displayMode = target.dataset.displayMode === "files" ? "files" : "folders"
1584
+ state.sizeSort = state.displayMode === "files" ? "desc" : null
1585
+ render()
1586
+ } else if (target.dataset.view) {
1587
+ state.view = target.dataset.view
1588
+ state.sourceId = SCOPE_ID
1589
+ state.query = ""
1590
+ state.statusFilter = "all"
1591
+ state.displayMode = state.view === "shared" ? "files" : "folders"
1592
+ state.sizeSort = state.view === "shared" || state.view === "duplicates" ? "desc" : null
1593
+ if (state.view === "duplicates") {
1594
+ markScanReviewed()
1595
+ state.scanResult = null
1596
+ }
1597
+ render()
1598
+ } else if (target.dataset.source) {
1599
+ state.sourceId = IS_APP_MODE
1600
+ ? SCOPE_ID
1601
+ : (state.sourceId === target.dataset.source ? null : target.dataset.source)
1602
+ render()
1603
+ } else if (target.dataset.toggleSource) {
1604
+ const id = target.dataset.toggleSource
1605
+ if (state.collapsedSources.has(id)) state.collapsedSources.delete(id)
1606
+ else state.collapsedSources.add(id)
1607
+ renderLocations(buildItems())
1608
+ } else if (target.dataset.toggleDir) {
1609
+ const key = target.dataset.toggleDir
1610
+ if (state.collapsedDirs.has(key)) state.collapsedDirs.delete(key)
1611
+ else state.collapsedDirs.add(key)
1612
+ render()
1613
+ } else if (target.dataset.expandFile) {
1614
+ const file = target.dataset.expandFile
1615
+ if (state.expandedFiles.has(file)) state.expandedFiles.delete(file)
1616
+ else state.expandedFiles.add(file)
1617
+ render()
1618
+ } else if (target.id === "btn-add-source") {
1619
+ target.disabled = true
1620
+ try {
1621
+ const folderPath = await chooseExternalFolder()
1622
+ if (!folderPath) return
1623
+ const result = await post({ action: "add_source", path: folderPath })
1624
+ if (result.error) {
1625
+ state.feedback = { error: true, message: result.error }
1626
+ } else {
1627
+ state.view = "all"
1628
+ state.displayMode = "folders"
1629
+ state.sizeSort = null
1630
+ state.sourceId = result.source && result.source.id ? result.source.id : null
1631
+ state.query = ""
1632
+ state.feedback = {
1633
+ error: false,
1634
+ message: result.created === false
1635
+ ? COPY.external_exists
1636
+ : result.source && result.source.shareable === false
1637
+ ? COPY.external_other_disk
1638
+ : COPY.external_added
1639
+ }
1640
+ }
1641
+ await refresh()
1642
+ } catch (error) {
1643
+ state.feedback = { error: true, message: error && error.message ? error.message : String(error) }
1644
+ renderFeedback()
1645
+ } finally {
1646
+ target.disabled = false
1647
+ }
1648
+ } else if (target.dataset.removeSource) {
1649
+ target.disabled = true
1650
+ try {
1651
+ const result = await post({ action: "remove_source", source_id: target.dataset.removeSource })
1652
+ if (result.error) {
1653
+ state.feedback = { error: true, message: result.error }
1654
+ } else {
1655
+ if (state.sourceId === target.dataset.removeSource) state.sourceId = null
1656
+ state.feedback = { error: false, message: COPY.external_removed }
1657
+ }
1658
+ await refresh()
1659
+ } catch (error) {
1660
+ state.feedback = { error: true, message: error && error.message ? error.message : String(error) }
1661
+ renderFeedback()
1662
+ } finally {
1663
+ target.disabled = false
1664
+ }
1665
+ } else if (target.id === "btn-scan" || target.id === "btn-empty-scan") {
1666
+ state.scanRequested = true
1667
+ const previousAttempt = state.data.last_attempt || state.data.last_scan
1668
+ state.scanBaseline = previousAttempt ? previousAttempt.ts : null
1669
+ state.scanResult = null
1670
+ state.scanProblemsOpen = false
1671
+ state.feedback = null
1672
+ try {
1673
+ const payload = { action: "scan", scope_id: SCOPE_ID }
1674
+ if (state.candidateMinBytesDraft !== null) {
1675
+ payload.candidate_min_bytes = state.candidateMinBytesDraft
1676
+ }
1677
+ const result = await post(payload)
1678
+ if (result.error) throw new Error(result.error)
1679
+ state.candidateMinBytesDraft = null
1680
+ await refresh()
1681
+ } catch (error) {
1682
+ state.scanRequested = false
1683
+ state.feedback = { error: true, message: error && error.message ? error.message : String(error) }
1684
+ renderFeedback()
1685
+ }
1686
+ } else if (target.id === "btn-scan-problems") {
1687
+ state.scanProblemsOpen = !state.scanProblemsOpen
1688
+ const paths = el("vault-result-paths")
1689
+ target.setAttribute("aria-expanded", String(state.scanProblemsOpen))
1690
+ target.closest(".vault-result").classList.toggle("expanded", state.scanProblemsOpen)
1691
+ if (paths) paths.hidden = !state.scanProblemsOpen
1692
+ } else if (target.id === "btn-repair") {
1693
+ target.disabled = true
1694
+ target.textContent = COPY.repairing
1695
+ await runAction({ action: "repair" }, COPY.repair_done)
1696
+ if (advanced) advanced.open = false
1697
+ } else if (target.id === "btn-review-result" || target.id === "btn-review-metric") {
1698
+ if (!state.scanResult || !state.scanResult.incomplete) markScanReviewed()
1699
+ state.view = "duplicates"
1700
+ state.displayMode = "folders"
1701
+ state.sizeSort = "desc"
1702
+ state.sourceId = SCOPE_ID
1703
+ state.query = ""
1704
+ state.scanResult = null
1705
+ render()
1706
+ } else if (target.id === "btn-deduplicate-all") {
1707
+ const groups = new Map()
1708
+ for (const item of activeItems(buildItems())) {
1709
+ if (item.status !== "duplicate" || !item.shareable || !item.source_id) continue
1710
+ if (!groups.has(item.source_id)) groups.set(item.source_id, [])
1711
+ groups.get(item.source_id).push(item.path)
1712
+ }
1713
+ const payloadGroups = [...groups].map(([scopeId, paths]) => ({
1714
+ scope_id: scopeId,
1715
+ paths
1716
+ }))
1717
+ const total = payloadGroups.reduce((sum, group) => sum + group.paths.length, 0)
1718
+ await runAction({
1719
+ action: "deduplicate_all",
1720
+ groups: payloadGroups
1721
+ }, deduplicateFeedback, {
1722
+ deduplicateAction: { kind: "deduplicate_all", sourceId: null, total }
1723
+ })
1724
+ } else if (target.dataset.deduplicateScope) {
1725
+ const paths = activeItems(buildItems())
1726
+ .filter((item) =>
1727
+ item.status === "duplicate" &&
1728
+ item.shareable &&
1729
+ item.source_id === target.dataset.deduplicateScope)
1730
+ .map((item) => item.path)
1731
+ await runAction({
1732
+ action: "deduplicate",
1733
+ scope_id: target.dataset.deduplicateScope,
1734
+ paths
1735
+ }, deduplicateFeedback, {
1736
+ deduplicateAction: {
1737
+ kind: "deduplicate",
1738
+ sourceId: target.dataset.deduplicateScope,
1739
+ total: paths.length
1740
+ }
1741
+ })
1742
+ } else if (target.dataset.detach) {
1743
+ if (state.fileActions.has(target.dataset.detach)) return
1744
+ const sharingSwitch = target.classList.contains("vault-sharing-switch")
1745
+ if (sharingSwitch) {
1746
+ target.disabled = true
1747
+ target.setAttribute("aria-busy", "true")
1748
+ }
1749
+ await runAction(
1750
+ { action: "detach", path: target.dataset.detach },
1751
+ detachFeedback,
1752
+ { filePath: sharingSwitch ? target.dataset.detach : null }
1753
+ )
1754
+ } else if (target.dataset.skip) {
1755
+ await runAction({ action: "skip", path: target.dataset.skip }, skipFeedback)
1756
+ } else if (target.dataset.reshare) {
1757
+ if (target.classList.contains("vault-sharing-switch")) {
1758
+ target.disabled = true
1759
+ target.setAttribute("aria-busy", "true")
1760
+ }
1761
+ await runAction({ action: "reshare", path: target.dataset.reshare }, (result) => result.status === "resharable"
1762
+ ? COPY.included_in_scans
1763
+ : { error: true, message: COPY.action_not_completed })
1764
+ } else if (target.dataset.reclaim) {
1765
+ await runAction({ action: "reclaim", hash: target.dataset.reclaim }, (result) => result.status === "reclaimed"
1766
+ ? `${COPY.reclaimed}: ${fmt(result.bytes_freed || 0)}`
1767
+ : { error: true, message: COPY.unavailable })
1768
+ } else if (target.id === "btn-reclaim-all") {
1769
+ await runAction({ action: "reclaim_all" }, (result) => result.failed
1770
+ ? { error: true, message: `${COPY.reclaimed}: ${fmt(result.bytes_freed || 0)}. ${COPY.action_not_completed}` }
1771
+ : `${COPY.reclaimed}: ${fmt(result.bytes_freed || 0)}`)
1772
+ } else if (target.dataset.undo) {
1773
+ await runAction({ action: "undo", batch_id: target.dataset.undo }, (result) =>
1774
+ result.status === "insufficient-space"
1775
+ ? { error: true, message: COPY.insufficient_space }
1776
+ : result.status === "capacity-unavailable"
1777
+ ? { error: true, message: COPY.capacity_unavailable }
1778
+ : result.failed
1779
+ ? { error: true, message: `${COPY.event_undo} (${result.undone || 0}). ${COPY.undo_incomplete}` }
1780
+ : `${COPY.event_undo} (${result.undone || 0})`)
1781
+ }
1782
+ })
1783
+
1784
+ document.addEventListener("input", (event) => {
1785
+ if (event.target.id !== "vault-search") return
1786
+ state.query = event.target.value
1787
+ const items = activeItems(buildItems())
1788
+ updateToolbarSummary(items)
1789
+ updateDeduplicateAllAction(items)
1790
+ renderTable(items)
1791
+ })
1792
+ document.addEventListener("mouseover", (event) => {
1793
+ const trigger = event.target.closest && event.target.closest(".vault-compare-info")
1794
+ if (trigger) showCompareTooltip(trigger)
1795
+ })
1796
+ document.addEventListener("mouseout", (event) => {
1797
+ const trigger = event.target.closest && event.target.closest(".vault-compare-info")
1798
+ if (trigger && !trigger.contains(event.relatedTarget)) hideCompareTooltip(trigger)
1799
+ })
1800
+ document.addEventListener("focusin", (event) => {
1801
+ if (event.target.classList && event.target.classList.contains("vault-compare-info")) {
1802
+ showCompareTooltip(event.target)
1803
+ }
1804
+ })
1805
+ document.addEventListener("focusout", (event) => {
1806
+ if (event.target.classList && event.target.classList.contains("vault-compare-info")) {
1807
+ hideCompareTooltip(event.target)
1808
+ }
1809
+ })
1810
+ window.addEventListener("resize", hideCompareTooltips)
1811
+ window.addEventListener("scroll", hideCompareTooltips, true)
1812
+ document.addEventListener("change", (event) => {
1813
+ if (event.target.id === "vault-candidate-size") {
1814
+ const selected = Number(event.target.value)
1815
+ state.candidateMinBytesDraft = selected === candidateMinBytes() ? null : selected
1816
+ renderOverview()
1817
+ } else if (event.target.id === "vault-status-filter") {
1818
+ state.statusFilter = event.target.value
1819
+ render()
1820
+ }
1821
+ })
1822
+
1823
+ el("btn-scan").textContent = COPY.scan
1824
+ el("vault-candidate-size-label").textContent = COPY.check_files
1825
+ const addSourceButton = el("btn-add-source")
1826
+ if (addSourceButton) {
1827
+ addSourceButton.setAttribute("aria-label", COPY.add_external_folder)
1828
+ addSourceButton.setAttribute("title", COPY.add_external_folder)
1829
+ }
1830
+ el("vault-pane").setAttribute("aria-label", COPY.files_region)
1831
+ if (el("btn-vault-options")) el("btn-vault-options").setAttribute("aria-label", COPY.vault_options)
1832
+ if (el("vault-repair-title")) el("vault-repair-title").textContent = COPY.repair_index
1833
+ if (el("vault-repair-description")) el("vault-repair-description").textContent = COPY.repair_description
1834
+ if (el("btn-repair")) el("btn-repair").textContent = COPY.repair_action
1835
+ refresh()