pinokiod 8.0.102 → 8.0.105
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/vault/index.js +65 -0
- package/kernel/vault/registry.js +1 -0
- package/kernel/vault/registry_core.js +71 -0
- package/package.json +1 -1
- package/server/index.js +11 -0
- package/server/public/vault.js +79 -40
- package/test/vault-engine.test.js +153 -0
- package/test/vault-ui.test.js +104 -0
package/kernel/vault/index.js
CHANGED
|
@@ -2854,6 +2854,9 @@ class Vault {
|
|
|
2854
2854
|
))
|
|
2855
2855
|
await this.refreshInodeSnapshots(entry.hash, entry.dev, entry.ino)
|
|
2856
2856
|
if (options.reclassify !== false) {
|
|
2857
|
+
// Free before reclassifying: classification must see the final state,
|
|
2858
|
+
// or it records a reference against an anchor about to be deleted.
|
|
2859
|
+
await this.freeUnusedAnchors([entry.hash])
|
|
2857
2860
|
await this.reclassifyHashes([entry.hash])
|
|
2858
2861
|
}
|
|
2859
2862
|
if (options.changedHashes) options.changedHashes.add(entry.hash)
|
|
@@ -2915,6 +2918,7 @@ class Vault {
|
|
|
2915
2918
|
}
|
|
2916
2919
|
}
|
|
2917
2920
|
if (affectedHashes.size) {
|
|
2921
|
+
await this.freeUnusedAnchors(affectedHashes)
|
|
2918
2922
|
await this.reclassifyHashes(affectedHashes)
|
|
2919
2923
|
}
|
|
2920
2924
|
const event = {
|
|
@@ -2998,6 +3002,7 @@ class Vault {
|
|
|
2998
3002
|
}
|
|
2999
3003
|
}
|
|
3000
3004
|
if (affectedHashes.size) {
|
|
3005
|
+
await this.freeUnusedAnchors(affectedHashes)
|
|
3001
3006
|
await this.reclassifyHashes(affectedHashes)
|
|
3002
3007
|
}
|
|
3003
3008
|
if (changedHashes instanceof FileActionChanges) {
|
|
@@ -3074,6 +3079,27 @@ class Vault {
|
|
|
3074
3079
|
return result
|
|
3075
3080
|
}
|
|
3076
3081
|
|
|
3082
|
+
// Separating the last path to shared content leaves an anchor nothing
|
|
3083
|
+
// references. Freeing it here keeps that cleanup out of the user's hands.
|
|
3084
|
+
// reclaim() revalidates and refuses while any path still links, so a
|
|
3085
|
+
// partial separation frees nothing and a failure is never fatal.
|
|
3086
|
+
async freeUnusedAnchors(hashes) {
|
|
3087
|
+
for (const hash of hashes) {
|
|
3088
|
+
let anchors = []
|
|
3089
|
+
try {
|
|
3090
|
+
anchors = await this.registry.anchorsForHash(hash)
|
|
3091
|
+
} catch (error) {
|
|
3092
|
+
continue
|
|
3093
|
+
}
|
|
3094
|
+
for (const anchor of anchors) {
|
|
3095
|
+
if (anchor.nlink !== 1) continue
|
|
3096
|
+
try {
|
|
3097
|
+
await this.reclaim(hash, anchor.store_id)
|
|
3098
|
+
} catch (error) {}
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3077
3103
|
async reclaimAll() {
|
|
3078
3104
|
const summary = { reclaimed: 0, bytes_freed: 0, failed: 0 }
|
|
3079
3105
|
let cursor = { store_id: "", hash: "" }
|
|
@@ -3330,6 +3356,45 @@ class Vault {
|
|
|
3330
3356
|
}
|
|
3331
3357
|
}
|
|
3332
3358
|
|
|
3359
|
+
async fileLocations(scopeId, filePath, options = {}) {
|
|
3360
|
+
if (typeof filePath !== "string" || !filePath) {
|
|
3361
|
+
throw new TypeError("Invalid vault file path.")
|
|
3362
|
+
}
|
|
3363
|
+
const target = path.resolve(filePath)
|
|
3364
|
+
const entry = await this.registry.getFile(target)
|
|
3365
|
+
const sourceIds = this.scopeSourceIds(scopeId)
|
|
3366
|
+
if (!entry ||
|
|
3367
|
+
(scopeId && !sourceIds.includes(entry.source_id))) {
|
|
3368
|
+
return { path: target, items: [], total: 0, next_cursor: null }
|
|
3369
|
+
}
|
|
3370
|
+
const cursor = typeof options.cursor === "string"
|
|
3371
|
+
? options.cursor.slice(0, 2048)
|
|
3372
|
+
: ""
|
|
3373
|
+
const pageSize = boundedInteger(
|
|
3374
|
+
options.page_size, 100, 1, STATUS_PAGE_SIZE)
|
|
3375
|
+
const identity = entry.status === "linked"
|
|
3376
|
+
? { dev: entry.dev, ino: entry.ino }
|
|
3377
|
+
: { hash: entry.hash }
|
|
3378
|
+
if (!identity.hash && !Number.isFinite(identity.dev)) {
|
|
3379
|
+
return { path: target, items: [], total: 0, next_cursor: null }
|
|
3380
|
+
}
|
|
3381
|
+
// Every authorized location is listed whatever the scope. A scope decides
|
|
3382
|
+
// what can be selected and acted on, not what the user is told exists.
|
|
3383
|
+
const result = await this.registry.fileLocationChildren(Object.assign({
|
|
3384
|
+
externalSourceIds: this.configuredExternalSourceIds(),
|
|
3385
|
+
cursor,
|
|
3386
|
+
pageSize
|
|
3387
|
+
}, identity))
|
|
3388
|
+
return {
|
|
3389
|
+
path: target,
|
|
3390
|
+
items: (result.rows || []).map((row) => Object.assign({
|
|
3391
|
+
path: row.path
|
|
3392
|
+
}, this.locationForPath(row.path, row.source_id))),
|
|
3393
|
+
total: Number(result.total) || 0,
|
|
3394
|
+
next_cursor: result.nextCursor || null
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3397
|
+
|
|
3333
3398
|
async duplicateGroupSelection(scopeId, hash, options = {}) {
|
|
3334
3399
|
if (typeof hash !== "string" || !SHA256_RE.test(hash)) {
|
|
3335
3400
|
throw new TypeError("Invalid vault content identifier.")
|
package/kernel/vault/registry.js
CHANGED
|
@@ -5633,6 +5633,77 @@ class RegistryCore {
|
|
|
5633
5633
|
}
|
|
5634
5634
|
}
|
|
5635
5635
|
|
|
5636
|
+
fileLocationChildren(options = {}) {
|
|
5637
|
+
const externalSourceIds = [...new Set(
|
|
5638
|
+
(options.externalSourceIds || []).filter(Boolean))]
|
|
5639
|
+
const pageSize = Math.max(
|
|
5640
|
+
1, Math.min(500, Number(options.pageSize) || 100))
|
|
5641
|
+
const inode = Number.isFinite(options.dev) && Number.isFinite(options.ino)
|
|
5642
|
+
const identity = inode
|
|
5643
|
+
? `${options.dev}:${options.ino}`
|
|
5644
|
+
: String(options.hash || "")
|
|
5645
|
+
const authorized = this.duplicateGroupFilter(
|
|
5646
|
+
[],
|
|
5647
|
+
"",
|
|
5648
|
+
true,
|
|
5649
|
+
"child",
|
|
5650
|
+
inode ? ["linked"] : [],
|
|
5651
|
+
externalSourceIds
|
|
5652
|
+
)
|
|
5653
|
+
const identityWhere = inode
|
|
5654
|
+
? "child.dev = ? AND child.ino = ?"
|
|
5655
|
+
: "child.hash = ?"
|
|
5656
|
+
const identityValues = inode
|
|
5657
|
+
? [options.dev, options.ino]
|
|
5658
|
+
: [options.hash]
|
|
5659
|
+
const decoded = this.decodeCursor(options.cursor)
|
|
5660
|
+
const cursorWhere = []
|
|
5661
|
+
const cursorValues = []
|
|
5662
|
+
if (decoded &&
|
|
5663
|
+
decoded.sort === "file-locations" &&
|
|
5664
|
+
decoded.identity === identity &&
|
|
5665
|
+
typeof decoded.path === "string") {
|
|
5666
|
+
cursorWhere.push("child.path > ?")
|
|
5667
|
+
cursorValues.push(decoded.path)
|
|
5668
|
+
}
|
|
5669
|
+
const rows = this.database.prepare(`
|
|
5670
|
+
SELECT child.*
|
|
5671
|
+
FROM files child
|
|
5672
|
+
WHERE ${identityWhere}
|
|
5673
|
+
AND ${authorized.where.join(" AND ")}
|
|
5674
|
+
${cursorWhere.length
|
|
5675
|
+
? `AND ${cursorWhere.join(" AND ")}`
|
|
5676
|
+
: ""}
|
|
5677
|
+
ORDER BY child.path
|
|
5678
|
+
LIMIT ?
|
|
5679
|
+
`).all(
|
|
5680
|
+
...identityValues,
|
|
5681
|
+
...authorized.values,
|
|
5682
|
+
...cursorValues,
|
|
5683
|
+
pageSize + 1
|
|
5684
|
+
)
|
|
5685
|
+
const hasMore = rows.length > pageSize
|
|
5686
|
+
if (hasMore) rows.pop()
|
|
5687
|
+
const last = rows[rows.length - 1]
|
|
5688
|
+
const total = Number(this.database.prepare(`
|
|
5689
|
+
SELECT COUNT(*) AS count
|
|
5690
|
+
FROM files child
|
|
5691
|
+
WHERE ${identityWhere}
|
|
5692
|
+
AND ${authorized.where.join(" AND ")}
|
|
5693
|
+
`).get(...identityValues, ...authorized.values).count) || 0
|
|
5694
|
+
return {
|
|
5695
|
+
rows,
|
|
5696
|
+
total,
|
|
5697
|
+
nextCursor: hasMore && last
|
|
5698
|
+
? this.encodeCursor({
|
|
5699
|
+
sort: "file-locations",
|
|
5700
|
+
identity,
|
|
5701
|
+
path: last.path
|
|
5702
|
+
})
|
|
5703
|
+
: null
|
|
5704
|
+
}
|
|
5705
|
+
}
|
|
5706
|
+
|
|
5636
5707
|
duplicateGroupSelection(options = {}) {
|
|
5637
5708
|
const sourceIds = [...new Set(
|
|
5638
5709
|
(options.sourceIds || []).filter(Boolean))]
|
package/package.json
CHANGED
package/server/index.js
CHANGED
|
@@ -15883,6 +15883,17 @@ class Server {
|
|
|
15883
15883
|
req.query.folder_discovery_child_page))
|
|
15884
15884
|
return
|
|
15885
15885
|
}
|
|
15886
|
+
const locationsPath = req.query &&
|
|
15887
|
+
typeof req.query.locations_path === "string"
|
|
15888
|
+
? req.query.locations_path
|
|
15889
|
+
: null
|
|
15890
|
+
if (locationsPath) {
|
|
15891
|
+
res.json(await vault.fileLocations(scopeId, locationsPath, {
|
|
15892
|
+
cursor: req.query && req.query.cursor,
|
|
15893
|
+
page_size: req.query && req.query.page_size
|
|
15894
|
+
}))
|
|
15895
|
+
return
|
|
15896
|
+
}
|
|
15886
15897
|
const groupHash = req.query &&
|
|
15887
15898
|
typeof req.query.group_hash === "string"
|
|
15888
15899
|
? req.query.group_hash
|
package/server/public/vault.js
CHANGED
|
@@ -5,14 +5,14 @@ const COPY = {
|
|
|
5
5
|
duplicates: "Duplicates",
|
|
6
6
|
cannot_deduplicate: "Cannot deduplicate",
|
|
7
7
|
shared: "Deduplicated",
|
|
8
|
-
reclaimable: "
|
|
8
|
+
reclaimable: "Trash",
|
|
9
9
|
activity: "Activity",
|
|
10
10
|
all_description: "Every scanned file and its current deduplication status.",
|
|
11
11
|
duplicates_description: "Identical files waiting to be deduplicated.",
|
|
12
12
|
unavailable_description: "Identical files that cannot share storage.",
|
|
13
13
|
shared_description: "Files currently sharing disk storage through hardlinks.",
|
|
14
14
|
tracked_description: "Files with no duplicate action required.",
|
|
15
|
-
reclaimable_description: "
|
|
15
|
+
reclaimable_description: "Spare copies no file is using.",
|
|
16
16
|
activity_description: "A history of changes made by Disk Saver.",
|
|
17
17
|
add_external_folder: "Add external folder",
|
|
18
18
|
find_folders: "Find more savings",
|
|
@@ -236,16 +236,15 @@ const COPY = {
|
|
|
236
236
|
selected_copy: "Selected",
|
|
237
237
|
loading_copies: "Loading copies…",
|
|
238
238
|
show_more_copies: "Show {count} more copies",
|
|
239
|
+
loading_locations: "Loading locations…",
|
|
240
|
+
show_more_locations: "Show {count} more locations",
|
|
239
241
|
making_separate: "Making file separate",
|
|
240
242
|
make_separate: "Make separate",
|
|
241
243
|
try_again: "Try again",
|
|
242
|
-
reclaim: "
|
|
243
|
-
reclaim_all: "
|
|
244
|
-
cleanup_ready: "{size}
|
|
245
|
-
|
|
246
|
-
review_cleanup: "Review cleanup",
|
|
247
|
-
private_link: "private link",
|
|
248
|
-
private_links: "private links",
|
|
244
|
+
reclaim: "Delete",
|
|
245
|
+
reclaim_all: "Empty Trash",
|
|
246
|
+
cleanup_ready: "{size} in the Trash",
|
|
247
|
+
review_cleanup: "Open Trash",
|
|
249
248
|
make_file_separate: "Make file separate",
|
|
250
249
|
make_files_separate: "Make {count} files separate",
|
|
251
250
|
making_separate_selected: "Making files separate",
|
|
@@ -260,7 +259,6 @@ const COPY = {
|
|
|
260
259
|
select_for_separation: "Select to make separate",
|
|
261
260
|
select_all_on_page: "Select all on this page",
|
|
262
261
|
identical_contents_at: "Identical contents at",
|
|
263
|
-
locations_shown: "{shown} of {total} locations shown",
|
|
264
262
|
no_files: "No files found",
|
|
265
263
|
no_files_hint: "Run a scan to find files that can be deduplicated. Scanning never links files together or replaces them.",
|
|
266
264
|
scan_waiting: "Waiting for scan results",
|
|
@@ -273,21 +271,20 @@ const COPY = {
|
|
|
273
271
|
no_shared_hint: "Deduplicated files will appear here after you review duplicates.",
|
|
274
272
|
no_tracked: "No files with no action needed",
|
|
275
273
|
no_tracked_hint: "Files without a duplicate action will appear here after a scan.",
|
|
276
|
-
no_reclaimable: "
|
|
277
|
-
no_reclaimable_hint: "
|
|
274
|
+
no_reclaimable: "Trash is empty",
|
|
275
|
+
no_reclaimable_hint: "Spare copies Pinokio no longer needs appear here.",
|
|
278
276
|
no_activity: "No activity yet",
|
|
279
277
|
no_activity_hint: "Changes made by Disk Saver will appear here.",
|
|
280
278
|
view_all: "View all files",
|
|
281
279
|
tracked_note: "Only files {size} and larger appear here. Files keep their current locations.",
|
|
282
280
|
tracked_note_all: "All non-empty files appear here. Files keep their current locations.",
|
|
283
281
|
duplicate_note: "Only files waiting for review are shown.",
|
|
284
|
-
reclaimable_note: "These private links have no remaining linked files. Cleaning them up frees disk space.",
|
|
285
282
|
activity_note: "Recent changes made by Disk Saver.",
|
|
286
283
|
converted: "Deduplicated",
|
|
287
284
|
separated: "Separated",
|
|
288
285
|
reclaimed: "Cleaned up",
|
|
289
286
|
event_convert: "Deduplicated",
|
|
290
|
-
event_reclaim: "
|
|
287
|
+
event_reclaim: "Emptied from Trash",
|
|
291
288
|
event_detach: "Separated",
|
|
292
289
|
event_change: "File state changed"
|
|
293
290
|
}
|
|
@@ -330,6 +327,14 @@ const folderDiscoveryChildrenUrl = (folder, page = 0) => {
|
|
|
330
327
|
})
|
|
331
328
|
return `/info/dedup?${query.toString()}`
|
|
332
329
|
}
|
|
330
|
+
const fileLocationsUrl = (filePath, options = {}) => {
|
|
331
|
+
const query = new URLSearchParams()
|
|
332
|
+
if (SCOPE_ID) query.set("scope_id", SCOPE_ID)
|
|
333
|
+
query.set("locations_path", filePath)
|
|
334
|
+
if (options.cursor) query.set("cursor", options.cursor)
|
|
335
|
+
query.set("page_size", String(DUPLICATE_CHILD_PAGE_SIZE))
|
|
336
|
+
return `/info/dedup?${query.toString()}`
|
|
337
|
+
}
|
|
333
338
|
const duplicateGroupUrl = (hash, options = {}) => {
|
|
334
339
|
const query = new URLSearchParams()
|
|
335
340
|
if (SCOPE_ID) query.set("scope_id", SCOPE_ID)
|
|
@@ -416,6 +421,8 @@ const state = {
|
|
|
416
421
|
expandedFiles: new Set(),
|
|
417
422
|
expandedDuplicateGroups: new Set(),
|
|
418
423
|
duplicateGroupChildren: new Map(),
|
|
424
|
+
fileLocations: new Map(),
|
|
425
|
+
locationsScanTs: undefined,
|
|
419
426
|
duplicateGroupGeneration: 0,
|
|
420
427
|
selectedDuplicateFiles: new Map(),
|
|
421
428
|
selectedSeparateFiles: new Set(),
|
|
@@ -454,11 +461,19 @@ const state = {
|
|
|
454
461
|
pageCursors: [""]
|
|
455
462
|
}
|
|
456
463
|
|
|
464
|
+
// Expanded location lists describe one grouping of one file. Anything that
|
|
465
|
+
// can change that grouping closes them instead of leaving stale paths open.
|
|
466
|
+
const closeFileLocations = () => {
|
|
467
|
+
state.expandedFiles.clear()
|
|
468
|
+
state.fileLocations.clear()
|
|
469
|
+
}
|
|
470
|
+
|
|
457
471
|
const resetPage = () => {
|
|
458
472
|
state.page = 0
|
|
459
473
|
state.pageCursors = [""]
|
|
460
474
|
state.expandedDuplicateGroups.clear()
|
|
461
475
|
state.duplicateGroupChildren.clear()
|
|
476
|
+
closeFileLocations()
|
|
462
477
|
state.duplicateGroupGeneration += 1
|
|
463
478
|
}
|
|
464
479
|
|
|
@@ -1696,15 +1711,22 @@ const sharingControl = (item) => {
|
|
|
1696
1711
|
}
|
|
1697
1712
|
|
|
1698
1713
|
const fileDetail = (item) => {
|
|
1699
|
-
if (!state.expandedFiles.has(item.path)
|
|
1700
|
-
const
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1714
|
+
if (!state.expandedFiles.has(item.path)) return ""
|
|
1715
|
+
const locations = state.fileLocations.get(item.path)
|
|
1716
|
+
if (!locations || (locations.loading && !locations.loaded)) {
|
|
1717
|
+
return `<div class="vault-detail"><div class="vault-detail-label"><i class="fa-solid fa-circle-notch fa-spin"></i> ${esc(COPY.loading_locations)}</div></div>`
|
|
1718
|
+
}
|
|
1719
|
+
if (locations.error) {
|
|
1720
|
+
return `<div class="vault-detail"><div class="vault-detail-label error">${esc(locations.error)}</div></div>`
|
|
1721
|
+
}
|
|
1722
|
+
const total = Math.max(locations.items.length, Number(locations.total) || 0)
|
|
1723
|
+
const label = `${COPY.identical_contents_at} ${countLabel(total, COPY.location, COPY.locations_lower)}`
|
|
1724
|
+
const remaining = Math.max(0, total - locations.items.length)
|
|
1725
|
+
const more = locations.nextCursor
|
|
1726
|
+
? `<div class="vault-location-detail"><button class="vault-text-button" type="button" data-more-file-locations="${attr(item.path)}" ${locations.loading ? "disabled" : ""}>${esc(COPY.show_more_locations.replace("{count}", Math.min(remaining, DUPLICATE_CHILD_PAGE_SIZE)))}</button></div>`
|
|
1727
|
+
: ""
|
|
1728
|
+
return `<div class="vault-detail"><div class="vault-detail-label">${esc(label)}</div>${locations.items.map((location) => `
|
|
1729
|
+
<div class="vault-location-detail"><i class="fa-regular fa-file"></i><span>${esc(externalLocation(location) || [location.source_label, location.relative_path].filter(Boolean).join(" / "))}</span>${revealButton(location.path, location.source_id, basename(location.relative_path))}</div>`).join("")}${more}</div>`
|
|
1708
1730
|
}
|
|
1709
1731
|
const separateCheckbox = (item) => item.status === "shared"
|
|
1710
1732
|
? `<input class="vault-row-checkbox" type="checkbox" data-select-separate="${attr(item.path)}" aria-label="${attr(`${COPY.select_for_separation}: ${basename(item.relative_path)}`)}" ${state.separateAllMatching || state.selectedSeparateFiles.has(item.path) ? "checked" : ""} />`
|
|
@@ -1721,7 +1743,7 @@ const rowSelectionCheckbox = (item) =>
|
|
|
1721
1743
|
const renderFileRow = (item, depth = 0, showMatch = false) => {
|
|
1722
1744
|
const directoryPath = dirname(item.relative_path)
|
|
1723
1745
|
const match = item.match
|
|
1724
|
-
const expandable = item.
|
|
1746
|
+
const expandable = Number(item.location_count) > 1
|
|
1725
1747
|
const rowTail = showMatch
|
|
1726
1748
|
? `<span>${match ? `<span class="vault-match-path">${esc(match.path)}</span>` : "—"}</span>
|
|
1727
1749
|
<span class="vault-space">${esc(spaceMarkup(item))}</span>
|
|
@@ -1826,7 +1848,7 @@ const flatLocation = (item) => externalLocation(item) ||
|
|
|
1826
1848
|
const renderFlatFiles = (items) => [...items]
|
|
1827
1849
|
.sort((a, b) => compareRows(a, b, flatLocation))
|
|
1828
1850
|
.map((item) => {
|
|
1829
|
-
const expandable = item.
|
|
1851
|
+
const expandable = Number(item.location_count) > 1
|
|
1830
1852
|
return `<div class="vault-file-row">
|
|
1831
1853
|
<div class="vault-name-cell">
|
|
1832
1854
|
${separateCheckbox(item)}
|
|
@@ -2230,7 +2252,7 @@ const paneFooterText = () => {
|
|
|
2230
2252
|
return `${count}${order ? ` · ${order}` : ""}`
|
|
2231
2253
|
}
|
|
2232
2254
|
if (state.view === "duplicates") return COPY.duplicate_note
|
|
2233
|
-
if (state.view === "reclaimable") return
|
|
2255
|
+
if (state.view === "reclaimable") return ""
|
|
2234
2256
|
if (state.view === "activity") return COPY.activity_note
|
|
2235
2257
|
const minimumSize = state.data.last_scan &&
|
|
2236
2258
|
Number.isFinite(state.data.last_scan.candidate_min_bytes)
|
|
@@ -2754,12 +2776,8 @@ const renderCleanupNotice = () => {
|
|
|
2754
2776
|
}
|
|
2755
2777
|
const bytes = Number(state.data.reclaimable) || 0
|
|
2756
2778
|
const title = COPY.cleanup_ready.replace("{size}", fmt(bytes))
|
|
2757
|
-
const detail = COPY.cleanup_ready_detail.replace(
|
|
2758
|
-
"{count}",
|
|
2759
|
-
countLabel(unusedCount, COPY.private_link, COPY.private_links)
|
|
2760
|
-
)
|
|
2761
2779
|
notice.className = "vault-cleanup-notice show"
|
|
2762
|
-
notice.innerHTML = `<i class="fa-
|
|
2780
|
+
notice.innerHTML = `<i class="fa-regular fa-trash-can" aria-hidden="true"></i><strong>${esc(title)}</strong><button class="vault-button" id="btn-review-cleanup" type="button">${esc(COPY.review_cleanup)}<i class="fa-solid fa-chevron-right" aria-hidden="true"></i></button>`
|
|
2763
2781
|
}
|
|
2764
2782
|
|
|
2765
2783
|
const clearPanel = (id, className) => {
|
|
@@ -2846,9 +2864,9 @@ const fetchJson = async (url) => {
|
|
|
2846
2864
|
if (!response.ok) throw new Error(COPY.status_request_failed.replace("{status}", response.status))
|
|
2847
2865
|
return response.json()
|
|
2848
2866
|
}
|
|
2849
|
-
const
|
|
2867
|
+
const loadPagedChildren = async (cache, key, buildUrl, append) => {
|
|
2850
2868
|
const generation = state.duplicateGroupGeneration
|
|
2851
|
-
const current =
|
|
2869
|
+
const current = cache.get(key) || {
|
|
2852
2870
|
items: [],
|
|
2853
2871
|
total: 0,
|
|
2854
2872
|
nextCursor: null,
|
|
@@ -2859,12 +2877,11 @@ const loadDuplicateGroupChildren = async (hash, append = false) => {
|
|
|
2859
2877
|
if (current.loading) return
|
|
2860
2878
|
current.loading = true
|
|
2861
2879
|
current.error = null
|
|
2862
|
-
|
|
2880
|
+
cache.set(key, current)
|
|
2863
2881
|
render()
|
|
2864
2882
|
try {
|
|
2865
|
-
const result = await fetchJson(
|
|
2866
|
-
|
|
2867
|
-
}))
|
|
2883
|
+
const result = await fetchJson(
|
|
2884
|
+
buildUrl(append ? current.nextCursor : null))
|
|
2868
2885
|
if (generation !== state.duplicateGroupGeneration) return
|
|
2869
2886
|
const items = Array.isArray(result.items) ? result.items : []
|
|
2870
2887
|
current.items = append ? current.items.concat(items) : items
|
|
@@ -2883,6 +2900,12 @@ const loadDuplicateGroupChildren = async (hash, append = false) => {
|
|
|
2883
2900
|
}
|
|
2884
2901
|
}
|
|
2885
2902
|
}
|
|
2903
|
+
const loadDuplicateGroupChildren = (hash, append = false) =>
|
|
2904
|
+
loadPagedChildren(state.duplicateGroupChildren, hash, (cursor) =>
|
|
2905
|
+
duplicateGroupUrl(hash, { cursor }), append)
|
|
2906
|
+
const loadFileLocations = (filePath, append = false) =>
|
|
2907
|
+
loadPagedChildren(state.fileLocations, filePath, (cursor) =>
|
|
2908
|
+
fileLocationsUrl(filePath, { cursor }), append)
|
|
2886
2909
|
const duplicateGroupSelectionPaths = async (hash) =>
|
|
2887
2910
|
fetchJson(duplicateGroupUrl(hash, { select: true }))
|
|
2888
2911
|
const duplicateGroupPageSelectionItems = async () =>
|
|
@@ -2919,6 +2942,11 @@ const applyFullData = (data) => {
|
|
|
2919
2942
|
reviewedScan() !== String(data.last_scan.ts) &&
|
|
2920
2943
|
(shareableDuplicateCount > 0 || data.last_scan.partial)
|
|
2921
2944
|
settleFolderDiscoveryStart()
|
|
2945
|
+
const publishedScan = data.last_scan ? data.last_scan.ts : null
|
|
2946
|
+
if (publishedScan !== state.locationsScanTs) {
|
|
2947
|
+
state.locationsScanTs = publishedScan
|
|
2948
|
+
closeFileLocations()
|
|
2949
|
+
}
|
|
2922
2950
|
state.data = data
|
|
2923
2951
|
const fileAction = serverFileAction(data.file_action)
|
|
2924
2952
|
if (fileAction) state.actionProgress = fileAction
|
|
@@ -3034,6 +3062,7 @@ const runAction = async (payload, success) => {
|
|
|
3034
3062
|
} catch (error) {
|
|
3035
3063
|
state.feedback = { error: true, message: error && error.message ? error.message : String(error) }
|
|
3036
3064
|
}
|
|
3065
|
+
closeFileLocations()
|
|
3037
3066
|
await refresh(true)
|
|
3038
3067
|
}
|
|
3039
3068
|
|
|
@@ -3741,11 +3770,21 @@ document.addEventListener("click", async (event) => {
|
|
|
3741
3770
|
target.dataset.moreDuplicateGroup,
|
|
3742
3771
|
true
|
|
3743
3772
|
)
|
|
3773
|
+
} else if (target.dataset.moreFileLocations) {
|
|
3774
|
+
await loadFileLocations(target.dataset.moreFileLocations, true)
|
|
3744
3775
|
} else if (target.dataset.expandFile) {
|
|
3745
3776
|
const file = target.dataset.expandFile
|
|
3746
|
-
if (state.expandedFiles.has(file))
|
|
3747
|
-
|
|
3748
|
-
|
|
3777
|
+
if (state.expandedFiles.has(file)) {
|
|
3778
|
+
state.expandedFiles.delete(file)
|
|
3779
|
+
render()
|
|
3780
|
+
} else {
|
|
3781
|
+
state.expandedFiles.add(file)
|
|
3782
|
+
render()
|
|
3783
|
+
const loaded = state.fileLocations.get(file)
|
|
3784
|
+
if (!loaded || (!loaded.loaded && !loaded.loading)) {
|
|
3785
|
+
await loadFileLocations(file)
|
|
3786
|
+
}
|
|
3787
|
+
}
|
|
3749
3788
|
} else if (target.dataset.removeSource) {
|
|
3750
3789
|
const source = sourceById(target.dataset.removeSource)
|
|
3751
3790
|
if (!source) return
|
|
@@ -2447,6 +2447,159 @@ describe("Save Space engine", () => {
|
|
|
2447
2447
|
await close(vault)
|
|
2448
2448
|
})
|
|
2449
2449
|
|
|
2450
|
+
test("Make separate frees the anchor once nothing links to it", async () => {
|
|
2451
|
+
const { home, vault } = await makeVault()
|
|
2452
|
+
const pair = await duplicatePair(home)
|
|
2453
|
+
await vault.sweeper.scan()
|
|
2454
|
+
const duplicate = [...await vault.registry.files({
|
|
2455
|
+
statuses: ["duplicate"]
|
|
2456
|
+
})][0]
|
|
2457
|
+
await vault.perform("deduplicate", { path: duplicate.path })
|
|
2458
|
+
const hash = duplicate.hash
|
|
2459
|
+
const storePath = vault.storePathFor(hash)
|
|
2460
|
+
const linked = [...await vault.registry.files({ statuses: ["linked"] })]
|
|
2461
|
+
assert.equal(linked.length, 2)
|
|
2462
|
+
|
|
2463
|
+
assert.equal((await vault.perform("detach", {
|
|
2464
|
+
path: linked[0].path
|
|
2465
|
+
})).status, "detached")
|
|
2466
|
+
assert.equal(fs.existsSync(storePath), true)
|
|
2467
|
+
assert.equal((await fs.promises.stat(storePath)).nlink, 2)
|
|
2468
|
+
assert.equal((await vault.registry.anchorsForHash(hash)).length, 1)
|
|
2469
|
+
|
|
2470
|
+
assert.equal((await vault.perform("detach", {
|
|
2471
|
+
path: linked[1].path
|
|
2472
|
+
})).status, "detached")
|
|
2473
|
+
assert.equal(fs.existsSync(storePath), false)
|
|
2474
|
+
assert.equal((await vault.registry.anchorsForHash(hash)).length, 0)
|
|
2475
|
+
|
|
2476
|
+
for (const entry of linked) {
|
|
2477
|
+
await assertCandidateContents(entry.path, pair.contents, pair.size)
|
|
2478
|
+
}
|
|
2479
|
+
const activity = await vault.status(null, {
|
|
2480
|
+
view: "activity",
|
|
2481
|
+
page_size: 500
|
|
2482
|
+
})
|
|
2483
|
+
assert.equal(activity.items.some((item) => item.kind === "reclaim"), true)
|
|
2484
|
+
await close(vault)
|
|
2485
|
+
})
|
|
2486
|
+
|
|
2487
|
+
test("freeing the anchor leaves the registry ready to deduplicate again", async () => {
|
|
2488
|
+
const { home, vault } = await makeVault()
|
|
2489
|
+
await duplicatePair(home)
|
|
2490
|
+
await vault.sweeper.scan()
|
|
2491
|
+
const duplicate = [...await vault.registry.files({
|
|
2492
|
+
statuses: ["duplicate"]
|
|
2493
|
+
})][0]
|
|
2494
|
+
await vault.perform("deduplicate", { path: duplicate.path })
|
|
2495
|
+
for (const row of [...await vault.registry.files({
|
|
2496
|
+
statuses: ["linked"]
|
|
2497
|
+
})]) {
|
|
2498
|
+
assert.equal((await vault.perform("detach", {
|
|
2499
|
+
path: row.path
|
|
2500
|
+
})).status, "detached")
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
const rows = [...await vault.registry.files({})]
|
|
2504
|
+
assert.equal(rows.filter((row) => row.status === "reference").length, 1)
|
|
2505
|
+
assert.equal(rows.filter((row) => row.status === "duplicate").length, 1)
|
|
2506
|
+
const target = rows.find((row) => row.status === "duplicate")
|
|
2507
|
+
assert.equal((await vault.perform("deduplicate", {
|
|
2508
|
+
path: target.path
|
|
2509
|
+
})).status, "converted")
|
|
2510
|
+
await close(vault)
|
|
2511
|
+
})
|
|
2512
|
+
|
|
2513
|
+
test("bulk Make separate frees only the anchors nothing links to", async () => {
|
|
2514
|
+
const { home, vault } = await makeVault()
|
|
2515
|
+
await duplicatePair(home, "kept.bin")
|
|
2516
|
+
const trio = crypto.randomBytes(4096)
|
|
2517
|
+
for (const name of ["one", "two", "three"]) {
|
|
2518
|
+
await writeCandidate(path.join(home, "api", name, "freed.bin"), trio)
|
|
2519
|
+
}
|
|
2520
|
+
await vault.sweeper.scan()
|
|
2521
|
+
const result = await vault.perform("deduplicate_files", {
|
|
2522
|
+
paths: [...await vault.registry.files({
|
|
2523
|
+
statuses: ["duplicate"]
|
|
2524
|
+
})].map((item) => item.path)
|
|
2525
|
+
})
|
|
2526
|
+
assert.equal(result.converted, 3)
|
|
2527
|
+
|
|
2528
|
+
const linked = [...await vault.registry.files({ statuses: ["linked"] })]
|
|
2529
|
+
const freedHash = linked.find((item) =>
|
|
2530
|
+
path.basename(item.path) === "freed.bin").hash
|
|
2531
|
+
const keptHash = linked.find((item) =>
|
|
2532
|
+
path.basename(item.path) === "kept.bin").hash
|
|
2533
|
+
const freedStore = vault.storePathFor(freedHash)
|
|
2534
|
+
const keptStore = vault.storePathFor(keptHash)
|
|
2535
|
+
|
|
2536
|
+
const separated = await vault.perform("separate_files", {
|
|
2537
|
+
paths: linked
|
|
2538
|
+
.filter((item) => path.basename(item.path) === "freed.bin")
|
|
2539
|
+
.map((item) => item.path)
|
|
2540
|
+
.concat(linked.find((item) =>
|
|
2541
|
+
path.basename(item.path) === "kept.bin").path)
|
|
2542
|
+
})
|
|
2543
|
+
assert.equal(separated.separated, 4)
|
|
2544
|
+
|
|
2545
|
+
assert.equal(fs.existsSync(freedStore), false)
|
|
2546
|
+
assert.equal((await vault.registry.anchorsForHash(freedHash)).length, 0)
|
|
2547
|
+
assert.equal(fs.existsSync(keptStore), true)
|
|
2548
|
+
assert.equal((await vault.registry.anchorsForHash(keptHash)).length, 1)
|
|
2549
|
+
await close(vault)
|
|
2550
|
+
})
|
|
2551
|
+
|
|
2552
|
+
test("Expanding a file lists every location for its own identity", async () => {
|
|
2553
|
+
const { home, vault } = await makeVault()
|
|
2554
|
+
const contents = crypto.randomBytes(4096)
|
|
2555
|
+
for (const name of ["one", "two", "three"]) {
|
|
2556
|
+
await writeCandidate(path.join(home, "api", name, "shared.bin"), contents)
|
|
2557
|
+
}
|
|
2558
|
+
await vault.sweeper.scan()
|
|
2559
|
+
const duplicate = [...await vault.registry.files({
|
|
2560
|
+
statuses: ["duplicate"]
|
|
2561
|
+
})][0]
|
|
2562
|
+
|
|
2563
|
+
const byHash = await vault.fileLocations(null, duplicate.path)
|
|
2564
|
+
assert.equal(byHash.total, 3)
|
|
2565
|
+
assert.equal(byHash.items.length, 3)
|
|
2566
|
+
assert.equal(byHash.items.some((entry) =>
|
|
2567
|
+
entry.path === duplicate.path), true)
|
|
2568
|
+
|
|
2569
|
+
await vault.perform("deduplicate", { path: duplicate.path })
|
|
2570
|
+
const byInode = await vault.fileLocations(null, duplicate.path)
|
|
2571
|
+
assert.equal(byInode.total, 2)
|
|
2572
|
+
assert.equal(byInode.items.length, 2)
|
|
2573
|
+
const untouched = [...await vault.registry.files({
|
|
2574
|
+
statuses: ["duplicate"]
|
|
2575
|
+
})][0]
|
|
2576
|
+
assert.equal(byInode.items.some((entry) =>
|
|
2577
|
+
entry.path === untouched.path), false)
|
|
2578
|
+
await close(vault)
|
|
2579
|
+
})
|
|
2580
|
+
|
|
2581
|
+
test("an app workspace lists every location but acts only on its own", async () => {
|
|
2582
|
+
const { home, vault } = await makeVault()
|
|
2583
|
+
const contents = crypto.randomBytes(4096)
|
|
2584
|
+
for (const app of ["one", "two", "three"]) {
|
|
2585
|
+
await writeCandidate(path.join(home, "api", app, "shared.bin"), contents)
|
|
2586
|
+
}
|
|
2587
|
+
await vault.sweeper.scan()
|
|
2588
|
+
|
|
2589
|
+
const scope = "app:two"
|
|
2590
|
+
const status = await vault.status(scope, { view: "all", page_size: 100 })
|
|
2591
|
+
assert.equal(status.items.length, 1)
|
|
2592
|
+
const row = status.items[0]
|
|
2593
|
+
assert.equal(row.location_count, 3)
|
|
2594
|
+
|
|
2595
|
+
const locations = await vault.fileLocations(scope, row.path)
|
|
2596
|
+
assert.equal(locations.total, 3)
|
|
2597
|
+
assert.equal(locations.items.length, 3)
|
|
2598
|
+
assert.equal(new Set(locations.items.map((item) =>
|
|
2599
|
+
item.source_id)).size, 3)
|
|
2600
|
+
await close(vault)
|
|
2601
|
+
})
|
|
2602
|
+
|
|
2450
2603
|
test("invalid scoped actions cannot expand into a global mutation", async () => {
|
|
2451
2604
|
const { home, vault } = await makeVault()
|
|
2452
2605
|
await duplicatePair(home)
|
package/test/vault-ui.test.js
CHANGED
|
@@ -784,6 +784,110 @@ describe("Save Space interface", () => {
|
|
|
784
784
|
dom.window.close()
|
|
785
785
|
})
|
|
786
786
|
|
|
787
|
+
test("expanding a file loads its real locations instead of row samples", async () => {
|
|
788
|
+
const duplicate = item({
|
|
789
|
+
path: "/pinokio/api/app/models/duplicate.bin",
|
|
790
|
+
relative_path: "models/duplicate.bin",
|
|
791
|
+
status: "duplicate",
|
|
792
|
+
shareable: true,
|
|
793
|
+
location_count: 3,
|
|
794
|
+
locations: [{
|
|
795
|
+
path: "/pinokio/api/app/models/duplicate.bin",
|
|
796
|
+
source_id: "app:app",
|
|
797
|
+
source_label: "app",
|
|
798
|
+
relative_path: "models/duplicate.bin"
|
|
799
|
+
}, {
|
|
800
|
+
path: "/pinokio/api/app/models/sample.bin",
|
|
801
|
+
source_id: "app:app",
|
|
802
|
+
source_label: "app",
|
|
803
|
+
relative_path: "models/sample.bin"
|
|
804
|
+
}]
|
|
805
|
+
})
|
|
806
|
+
const base = fixture([duplicate])
|
|
807
|
+
base.inventory.source_counts.duplicates["app:app"] = 1
|
|
808
|
+
base.inventory.shareable_by_source["app:app"] = 1
|
|
809
|
+
const response = (url) => {
|
|
810
|
+
const parsed = new URL(url, "http://localhost")
|
|
811
|
+
if (parsed.searchParams.get("locations_path")) {
|
|
812
|
+
return {
|
|
813
|
+
path: parsed.searchParams.get("locations_path"),
|
|
814
|
+
items: ["first", "second", "third"].map((name) => ({
|
|
815
|
+
path: `/pinokio/api/app/models/${name}.bin`,
|
|
816
|
+
source_id: "app:app",
|
|
817
|
+
source_label: "app",
|
|
818
|
+
relative_path: `models/${name}.bin`
|
|
819
|
+
})),
|
|
820
|
+
total: 3,
|
|
821
|
+
next_cursor: null
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
const result = JSON.parse(JSON.stringify(base))
|
|
825
|
+
result.items = [duplicate]
|
|
826
|
+
return result
|
|
827
|
+
}
|
|
828
|
+
const { dom, getRequests } = await makePage(response)
|
|
829
|
+
const document = dom.window.document
|
|
830
|
+
|
|
831
|
+
document.querySelector('[data-view="duplicates"]').click()
|
|
832
|
+
await waitFor(() => document.querySelector(
|
|
833
|
+
'[data-view="duplicates"].selected'))
|
|
834
|
+
const disclosure = document.querySelector(
|
|
835
|
+
'[data-expand-file="/pinokio/api/app/models/duplicate.bin"]')
|
|
836
|
+
assert.ok(disclosure)
|
|
837
|
+
disclosure.click()
|
|
838
|
+
|
|
839
|
+
await waitFor(() => getRequests.some((url) =>
|
|
840
|
+
new URL(url, "http://localhost").searchParams
|
|
841
|
+
.get("locations_path") === "/pinokio/api/app/models/duplicate.bin"))
|
|
842
|
+
await waitFor(() => document.querySelector(".vault-detail") &&
|
|
843
|
+
document.querySelector(".vault-detail").textContent.includes("third.bin"))
|
|
844
|
+
const detail = document.querySelector(".vault-detail")
|
|
845
|
+
assert.match(detail.textContent, /Identical contents at 3 locations/)
|
|
846
|
+
assert.match(detail.textContent, /first\.bin/)
|
|
847
|
+
assert.doesNotMatch(detail.textContent, /sample\.bin/)
|
|
848
|
+
assert.doesNotMatch(detail.textContent, /of 3 locations shown/)
|
|
849
|
+
await settle()
|
|
850
|
+
dom.window.close()
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
test("leftover storage is presented as a Trash the user can empty", async () => {
|
|
854
|
+
const blob = {
|
|
855
|
+
store_id: "home",
|
|
856
|
+
hash: "b".repeat(64),
|
|
857
|
+
size: 11500000,
|
|
858
|
+
nlink: 1,
|
|
859
|
+
orphan: 1
|
|
860
|
+
}
|
|
861
|
+
const base = fixture([])
|
|
862
|
+
base.inventory.counts.reclaimable = 1
|
|
863
|
+
base.reclaimable = blob.size
|
|
864
|
+
base.enabled = true
|
|
865
|
+
const response = (url) => {
|
|
866
|
+
const view = new URL(url, "http://localhost")
|
|
867
|
+
.searchParams.get("view") || "all"
|
|
868
|
+
const result = JSON.parse(JSON.stringify(base))
|
|
869
|
+
result.inventory.view = view
|
|
870
|
+
result.items = view === "reclaimable" ? [blob] : []
|
|
871
|
+
return result
|
|
872
|
+
}
|
|
873
|
+
const { dom } = await makePage(response)
|
|
874
|
+
const document = dom.window.document
|
|
875
|
+
|
|
876
|
+
assert.match(document.querySelector("#vault-views").textContent, /Trash/)
|
|
877
|
+
document.querySelector('[data-view="reclaimable"]').click()
|
|
878
|
+
await waitFor(() => document.querySelector(
|
|
879
|
+
'[data-view="reclaimable"].selected'))
|
|
880
|
+
await waitFor(() => document.querySelector("[data-reclaim]"))
|
|
881
|
+
|
|
882
|
+
const body = document.body.textContent
|
|
883
|
+
assert.match(body, /Empty Trash/)
|
|
884
|
+
assert.doesNotMatch(body, /private link/i)
|
|
885
|
+
assert.doesNotMatch(body, /ready to clean up/i)
|
|
886
|
+
assert.doesNotMatch(body, /their linked files were deleted/i)
|
|
887
|
+
await settle()
|
|
888
|
+
dom.window.close()
|
|
889
|
+
})
|
|
890
|
+
|
|
787
891
|
test("Cannot deduplicate is separate from actionable duplicates", async () => {
|
|
788
892
|
const duplicate = item({
|
|
789
893
|
path: "/pinokio/api/app/models/duplicate.bin",
|