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.
- package/kernel/api/index.js +12 -3
- package/kernel/index.js +24 -0
- package/kernel/shell.js +2 -1
- package/kernel/vault/constants.js +13 -0
- package/kernel/vault/hash_worker.js +31 -0
- package/kernel/vault/index.js +2360 -0
- package/kernel/vault/registry.js +643 -0
- package/kernel/vault/snapshot.js +29 -0
- package/kernel/vault/sweeper.js +740 -0
- package/kernel/vault/walker.js +142 -0
- package/package.json +1 -1
- package/server/index.js +109 -0
- package/server/lib/privacy_filter_cache.js +4 -1
- package/server/public/storage-size.js +12 -0
- package/server/public/style.css +13 -0
- package/server/public/tab-link-popover.js +3 -0
- package/server/public/vault-nav.js +96 -0
- package/server/public/vault.css +1079 -0
- package/server/public/vault.js +1835 -0
- package/server/views/app.ejs +93 -16
- package/server/views/partials/main_sidebar.ejs +6 -0
- package/server/views/partials/vault_workspace.ejs +55 -0
- package/server/views/vault.ejs +29 -0
- package/server/views/vault_app.ejs +22 -0
- package/test/plugin-action-functions.test.js +19 -0
- package/test/privacy-filter-cache.test.js +1 -0
- package/test/vault-engine.test.js +1610 -0
- package/test/vault-sweep.test.js +1344 -0
- package/test/vault-ui.test.js +2443 -0
|
@@ -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
|
+
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
|
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()
|