pinokiod 8.0.102 → 8.0.106
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 +91 -9
- package/kernel/vault/registry.js +1 -0
- package/kernel/vault/registry_core.js +80 -0
- package/package.json +1 -1
- package/server/index.js +11 -0
- package/server/public/vault.css +58 -1
- package/server/public/vault.js +193 -56
- package/test/vault-engine.test.js +159 -7
- package/test/vault-ui.test.js +156 -1
package/kernel/vault/index.js
CHANGED
|
@@ -44,6 +44,12 @@ const AUTOMATIC_ACTIONS = new Set([
|
|
|
44
44
|
])
|
|
45
45
|
const PERMISSION_DENIED_CODES = new Set(["EACCES", "EPERM"])
|
|
46
46
|
const BUSY_CODES = new Set(["EBUSY"])
|
|
47
|
+
const PUBLIC_FILE_STATUS = {
|
|
48
|
+
reference: "tracked",
|
|
49
|
+
duplicate: "duplicate",
|
|
50
|
+
unavailable: "unavailable",
|
|
51
|
+
linked: "shared"
|
|
52
|
+
}
|
|
47
53
|
const hardlinkUnavailableCode = (code) =>
|
|
48
54
|
HARDLINK_UNSUPPORTED_CODES.has(code) || PERMISSION_DENIED_CODES.has(code)
|
|
49
55
|
const replacementLockedCode = (code) =>
|
|
@@ -197,7 +203,16 @@ const revealInFileManager = (filePath, platform = process.platform) =>
|
|
|
197
203
|
execFile(command, args, {
|
|
198
204
|
timeout: 10000,
|
|
199
205
|
windowsHide: true
|
|
200
|
-
}, (error) =>
|
|
206
|
+
}, (error) => {
|
|
207
|
+
// Explorer reports a non-zero exit code even when it opens the folder,
|
|
208
|
+
// so an exit status alone is not a failure on Windows. A missing binary
|
|
209
|
+
// or a refused spawn still is.
|
|
210
|
+
if (error && platform === "win32" && Number.isInteger(error.code)) {
|
|
211
|
+
resolve()
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
error ? reject(error) : resolve()
|
|
215
|
+
})
|
|
201
216
|
})
|
|
202
217
|
|
|
203
218
|
class Vault {
|
|
@@ -1359,8 +1374,10 @@ class Vault {
|
|
|
1359
1374
|
if (!entry || entry.unavailable_reason === "stale") {
|
|
1360
1375
|
return { error: "This file is no longer tracked. Scan again to refresh this view." }
|
|
1361
1376
|
}
|
|
1377
|
+
// Opening a file manager mutates nothing, so it is allowed for any tracked
|
|
1378
|
+
// path regardless of the scope the request came from.
|
|
1362
1379
|
const source = this.sourceForPath(entry.path, entry.source_id)
|
|
1363
|
-
if (!source
|
|
1380
|
+
if (!source) {
|
|
1364
1381
|
return { error: "This file is outside the current location." }
|
|
1365
1382
|
}
|
|
1366
1383
|
if (!await this.canonicalPathIsWithinSource(entry.path, source)) {
|
|
@@ -1373,7 +1390,10 @@ class Vault {
|
|
|
1373
1390
|
try {
|
|
1374
1391
|
await this.fileManagerLauncher(entry.path)
|
|
1375
1392
|
} catch (error) {
|
|
1376
|
-
|
|
1393
|
+
// Naming the underlying failure is what makes a report actionable; the
|
|
1394
|
+
// generic sentence hid whether the spawn, the binary, or the path failed.
|
|
1395
|
+
const detail = error && error.message ? ` (${error.message})` : ""
|
|
1396
|
+
return { error: `The file manager could not open this file.${detail}` }
|
|
1377
1397
|
}
|
|
1378
1398
|
return { revealed: true }
|
|
1379
1399
|
}
|
|
@@ -2854,6 +2874,9 @@ class Vault {
|
|
|
2854
2874
|
))
|
|
2855
2875
|
await this.refreshInodeSnapshots(entry.hash, entry.dev, entry.ino)
|
|
2856
2876
|
if (options.reclassify !== false) {
|
|
2877
|
+
// Free before reclassifying: classification must see the final state,
|
|
2878
|
+
// or it records a reference against an anchor about to be deleted.
|
|
2879
|
+
await this.freeUnusedAnchors([entry.hash])
|
|
2857
2880
|
await this.reclassifyHashes([entry.hash])
|
|
2858
2881
|
}
|
|
2859
2882
|
if (options.changedHashes) options.changedHashes.add(entry.hash)
|
|
@@ -2915,6 +2938,7 @@ class Vault {
|
|
|
2915
2938
|
}
|
|
2916
2939
|
}
|
|
2917
2940
|
if (affectedHashes.size) {
|
|
2941
|
+
await this.freeUnusedAnchors(affectedHashes)
|
|
2918
2942
|
await this.reclassifyHashes(affectedHashes)
|
|
2919
2943
|
}
|
|
2920
2944
|
const event = {
|
|
@@ -2998,6 +3022,7 @@ class Vault {
|
|
|
2998
3022
|
}
|
|
2999
3023
|
}
|
|
3000
3024
|
if (affectedHashes.size) {
|
|
3025
|
+
await this.freeUnusedAnchors(affectedHashes)
|
|
3001
3026
|
await this.reclassifyHashes(affectedHashes)
|
|
3002
3027
|
}
|
|
3003
3028
|
if (changedHashes instanceof FileActionChanges) {
|
|
@@ -3074,6 +3099,27 @@ class Vault {
|
|
|
3074
3099
|
return result
|
|
3075
3100
|
}
|
|
3076
3101
|
|
|
3102
|
+
// Separating the last path to shared content leaves an anchor nothing
|
|
3103
|
+
// references. Freeing it here keeps that cleanup out of the user's hands.
|
|
3104
|
+
// reclaim() revalidates and refuses while any path still links, so a
|
|
3105
|
+
// partial separation frees nothing and a failure is never fatal.
|
|
3106
|
+
async freeUnusedAnchors(hashes) {
|
|
3107
|
+
for (const hash of hashes) {
|
|
3108
|
+
let anchors = []
|
|
3109
|
+
try {
|
|
3110
|
+
anchors = await this.registry.anchorsForHash(hash)
|
|
3111
|
+
} catch (error) {
|
|
3112
|
+
continue
|
|
3113
|
+
}
|
|
3114
|
+
for (const anchor of anchors) {
|
|
3115
|
+
if (anchor.nlink !== 1) continue
|
|
3116
|
+
try {
|
|
3117
|
+
await this.reclaim(hash, anchor.store_id)
|
|
3118
|
+
} catch (error) {}
|
|
3119
|
+
}
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
|
|
3077
3123
|
async reclaimAll() {
|
|
3078
3124
|
const summary = { reclaimed: 0, bytes_freed: 0, failed: 0 }
|
|
3079
3125
|
let cursor = { store_id: "", hash: "" }
|
|
@@ -3227,12 +3273,7 @@ class Vault {
|
|
|
3227
3273
|
dev: match.dev,
|
|
3228
3274
|
ino: match.ino
|
|
3229
3275
|
}, this.locationForPath(match.path, match.source_id)))
|
|
3230
|
-
const publicStatus =
|
|
3231
|
-
reference: "tracked",
|
|
3232
|
-
duplicate: "duplicate",
|
|
3233
|
-
unavailable: "unavailable",
|
|
3234
|
-
linked: "shared"
|
|
3235
|
-
}[row.status]
|
|
3276
|
+
const publicStatus = PUBLIC_FILE_STATUS[row.status]
|
|
3236
3277
|
const result = Object.assign({
|
|
3237
3278
|
path: row.path,
|
|
3238
3279
|
hash: row.hash,
|
|
@@ -3330,6 +3371,47 @@ class Vault {
|
|
|
3330
3371
|
}
|
|
3331
3372
|
}
|
|
3332
3373
|
|
|
3374
|
+
async fileLocations(scopeId, filePath, options = {}) {
|
|
3375
|
+
if (typeof filePath !== "string" || !filePath) {
|
|
3376
|
+
throw new TypeError("Invalid vault file path.")
|
|
3377
|
+
}
|
|
3378
|
+
const target = path.resolve(filePath)
|
|
3379
|
+
const entry = await this.registry.getFile(target)
|
|
3380
|
+
const sourceIds = this.scopeSourceIds(scopeId)
|
|
3381
|
+
if (!entry ||
|
|
3382
|
+
(scopeId && !sourceIds.includes(entry.source_id))) {
|
|
3383
|
+
return { path: target, items: [], total: 0, next_cursor: null }
|
|
3384
|
+
}
|
|
3385
|
+
const cursor = typeof options.cursor === "string"
|
|
3386
|
+
? options.cursor.slice(0, 2048)
|
|
3387
|
+
: ""
|
|
3388
|
+
const pageSize = boundedInteger(
|
|
3389
|
+
options.page_size, 100, 1, STATUS_PAGE_SIZE)
|
|
3390
|
+
const identity = entry.status === "linked"
|
|
3391
|
+
? { dev: entry.dev, ino: entry.ino }
|
|
3392
|
+
: { hash: entry.hash }
|
|
3393
|
+
if (!identity.hash && !Number.isFinite(identity.dev)) {
|
|
3394
|
+
return { path: target, items: [], total: 0, next_cursor: null }
|
|
3395
|
+
}
|
|
3396
|
+
// Every authorized location is listed whatever the scope. A scope decides
|
|
3397
|
+
// what can be selected and acted on, not what the user is told exists.
|
|
3398
|
+
const result = await this.registry.fileLocationChildren(Object.assign({
|
|
3399
|
+
externalSourceIds: this.configuredExternalSourceIds(),
|
|
3400
|
+
cursor,
|
|
3401
|
+
pageSize
|
|
3402
|
+
}, identity))
|
|
3403
|
+
return {
|
|
3404
|
+
path: target,
|
|
3405
|
+
items: (result.rows || []).map((row) => Object.assign({
|
|
3406
|
+
path: row.path,
|
|
3407
|
+
status: PUBLIC_FILE_STATUS[row.status] || null
|
|
3408
|
+
}, this.locationForPath(row.path, row.source_id))),
|
|
3409
|
+
total: Number(result.total) || 0,
|
|
3410
|
+
deduplicatable: Number(result.duplicateTotal) || 0,
|
|
3411
|
+
next_cursor: result.nextCursor || null
|
|
3412
|
+
}
|
|
3413
|
+
}
|
|
3414
|
+
|
|
3333
3415
|
async duplicateGroupSelection(scopeId, hash, options = {}) {
|
|
3334
3416
|
if (typeof hash !== "string" || !SHA256_RE.test(hash)) {
|
|
3335
3417
|
throw new TypeError("Invalid vault content identifier.")
|
package/kernel/vault/registry.js
CHANGED
|
@@ -5633,6 +5633,86 @@ 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
|
+
// Only paths still storing their own copy can free anything.
|
|
5695
|
+
const duplicateTotal = Number(this.database.prepare(`
|
|
5696
|
+
SELECT COUNT(*) AS count
|
|
5697
|
+
FROM files child
|
|
5698
|
+
WHERE ${identityWhere}
|
|
5699
|
+
AND child.status = 'duplicate'
|
|
5700
|
+
AND ${authorized.where.join(" AND ")}
|
|
5701
|
+
`).get(...identityValues, ...authorized.values).count) || 0
|
|
5702
|
+
return {
|
|
5703
|
+
rows,
|
|
5704
|
+
total,
|
|
5705
|
+
duplicateTotal,
|
|
5706
|
+
nextCursor: hasMore && last
|
|
5707
|
+
? this.encodeCursor({
|
|
5708
|
+
sort: "file-locations",
|
|
5709
|
+
identity,
|
|
5710
|
+
path: last.path
|
|
5711
|
+
})
|
|
5712
|
+
: null
|
|
5713
|
+
}
|
|
5714
|
+
}
|
|
5715
|
+
|
|
5636
5716
|
duplicateGroupSelection(options = {}) {
|
|
5637
5717
|
const sourceIds = [...new Set(
|
|
5638
5718
|
(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.css
CHANGED
|
@@ -964,6 +964,32 @@ body[data-vault-mode="global"] .vault-explorer {
|
|
|
964
964
|
outline: 2px solid color-mix(in srgb, var(--task-accent) 55%, transparent);
|
|
965
965
|
outline-offset: 1px;
|
|
966
966
|
}
|
|
967
|
+
.vault-confirm-overlay {
|
|
968
|
+
position: fixed;
|
|
969
|
+
z-index: 140;
|
|
970
|
+
inset: 0;
|
|
971
|
+
display: grid;
|
|
972
|
+
place-items: center;
|
|
973
|
+
padding: 24px;
|
|
974
|
+
background: color-mix(in srgb, var(--task-text) 34%, transparent);
|
|
975
|
+
}
|
|
976
|
+
.vault-confirm-overlay[hidden] { display: none; }
|
|
977
|
+
.vault-confirm-dialog {
|
|
978
|
+
width: min(420px, 100%);
|
|
979
|
+
padding: 18px 20px 16px;
|
|
980
|
+
border: 1px solid var(--task-border-strong);
|
|
981
|
+
border-radius: 10px;
|
|
982
|
+
background: var(--task-panel);
|
|
983
|
+
box-shadow: 0 22px 70px color-mix(in srgb, var(--task-text) 24%, transparent);
|
|
984
|
+
}
|
|
985
|
+
.vault-confirm-message {
|
|
986
|
+
margin: 0 0 16px;
|
|
987
|
+
color: var(--task-text);
|
|
988
|
+
font-size: 12.5px;
|
|
989
|
+
line-height: 1.5;
|
|
990
|
+
}
|
|
991
|
+
.vault-confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
|
992
|
+
|
|
967
993
|
.vault-find-overlay {
|
|
968
994
|
position: fixed;
|
|
969
995
|
z-index: 120;
|
|
@@ -2043,9 +2069,40 @@ body.dark .vault-row-action .vault-text-button:hover:not(:disabled) {
|
|
|
2043
2069
|
background: color-mix(in srgb, var(--task-soft) 48%, transparent);
|
|
2044
2070
|
}
|
|
2045
2071
|
.vault-detail-label { margin-bottom: 5px; color: var(--task-muted); font-size: 10px; font-weight: 650; }
|
|
2046
|
-
.vault-location-detail { display: flex; align-items: center; gap: 7px;
|
|
2072
|
+
.vault-location-detail { display: flex; align-items: center; gap: 7px; min-height: 26px; color: var(--task-text); font-size: 10.5px; }
|
|
2047
2073
|
.vault-location-detail i { color: var(--task-muted); }
|
|
2048
2074
|
.vault-location-detail > span { min-width: 0; flex: 1; overflow-wrap: anywhere; }
|
|
2075
|
+
.vault-location-detail > .vault-location-path {
|
|
2076
|
+
min-width: 0;
|
|
2077
|
+
flex: 1;
|
|
2078
|
+
white-space: nowrap;
|
|
2079
|
+
overflow: hidden;
|
|
2080
|
+
text-overflow: ellipsis;
|
|
2081
|
+
}
|
|
2082
|
+
.vault-location-detail > .vault-location-note {
|
|
2083
|
+
flex: 0 0 auto;
|
|
2084
|
+
margin-left: auto;
|
|
2085
|
+
padding-left: 12px;
|
|
2086
|
+
color: var(--task-muted);
|
|
2087
|
+
white-space: nowrap;
|
|
2088
|
+
}
|
|
2089
|
+
.vault-location-detail > .vault-location-reveal-placeholder { flex: 0 0 26px; }
|
|
2090
|
+
.vault-location-link {
|
|
2091
|
+
appearance: none;
|
|
2092
|
+
padding: 0;
|
|
2093
|
+
border: 0;
|
|
2094
|
+
background: none;
|
|
2095
|
+
color: var(--task-accent);
|
|
2096
|
+
font: inherit;
|
|
2097
|
+
text-align: left;
|
|
2098
|
+
cursor: pointer;
|
|
2099
|
+
overflow: hidden;
|
|
2100
|
+
text-overflow: ellipsis;
|
|
2101
|
+
white-space: nowrap;
|
|
2102
|
+
max-width: 100%;
|
|
2103
|
+
}
|
|
2104
|
+
.vault-location-link:hover { text-decoration: underline; }
|
|
2105
|
+
.vault-location-link i { color: inherit; font-size: 9px; margin-left: 5px; }
|
|
2049
2106
|
.vault-duplicate-content-group {
|
|
2050
2107
|
min-height: 52px;
|
|
2051
2108
|
background: color-mix(in srgb, var(--task-soft) 28%, transparent);
|
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,18 @@ 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
|
+
open_in_disk_saver: "Open this file in Disk Saver",
|
|
241
|
+
open_location_confirm: "Open Disk Saver for {location}?",
|
|
242
|
+
open_location_accept: "Open",
|
|
243
|
+
show_more_locations: "Show {count} more locations",
|
|
239
244
|
making_separate: "Making file separate",
|
|
240
245
|
make_separate: "Make separate",
|
|
241
246
|
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",
|
|
247
|
+
reclaim: "Delete",
|
|
248
|
+
reclaim_all: "Empty Trash",
|
|
249
|
+
cleanup_ready: "{size} in the Trash",
|
|
250
|
+
review_cleanup: "Open Trash",
|
|
249
251
|
make_file_separate: "Make file separate",
|
|
250
252
|
make_files_separate: "Make {count} files separate",
|
|
251
253
|
making_separate_selected: "Making files separate",
|
|
@@ -259,8 +261,12 @@ const COPY = {
|
|
|
259
261
|
separation_cancelled: "Separation cancelled after {count}.",
|
|
260
262
|
select_for_separation: "Select to make separate",
|
|
261
263
|
select_all_on_page: "Select all on this page",
|
|
262
|
-
|
|
263
|
-
|
|
264
|
+
stored_times: "Stored {count} times · {size} each",
|
|
265
|
+
this_file: "this file",
|
|
266
|
+
copy_that_stays: "the copy that stays",
|
|
267
|
+
already_deduplicated: "already deduplicated",
|
|
268
|
+
not_deduplicated_yet: "not deduplicated yet",
|
|
269
|
+
cannot_be_deduplicated: "cannot be deduplicated",
|
|
264
270
|
no_files: "No files found",
|
|
265
271
|
no_files_hint: "Run a scan to find files that can be deduplicated. Scanning never links files together or replaces them.",
|
|
266
272
|
scan_waiting: "Waiting for scan results",
|
|
@@ -273,26 +279,34 @@ const COPY = {
|
|
|
273
279
|
no_shared_hint: "Deduplicated files will appear here after you review duplicates.",
|
|
274
280
|
no_tracked: "No files with no action needed",
|
|
275
281
|
no_tracked_hint: "Files without a duplicate action will appear here after a scan.",
|
|
276
|
-
no_reclaimable: "
|
|
277
|
-
no_reclaimable_hint: "
|
|
282
|
+
no_reclaimable: "Trash is empty",
|
|
283
|
+
no_reclaimable_hint: "Spare copies Pinokio no longer needs appear here.",
|
|
278
284
|
no_activity: "No activity yet",
|
|
279
285
|
no_activity_hint: "Changes made by Disk Saver will appear here.",
|
|
280
286
|
view_all: "View all files",
|
|
281
287
|
tracked_note: "Only files {size} and larger appear here. Files keep their current locations.",
|
|
282
288
|
tracked_note_all: "All non-empty files appear here. Files keep their current locations.",
|
|
283
289
|
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
290
|
activity_note: "Recent changes made by Disk Saver.",
|
|
286
291
|
converted: "Deduplicated",
|
|
287
292
|
separated: "Separated",
|
|
288
293
|
reclaimed: "Cleaned up",
|
|
289
294
|
event_convert: "Deduplicated",
|
|
290
|
-
event_reclaim: "
|
|
295
|
+
event_reclaim: "Emptied from Trash",
|
|
291
296
|
event_detach: "Separated",
|
|
292
297
|
event_change: "File state changed"
|
|
293
298
|
}
|
|
294
299
|
|
|
295
300
|
const SCOPE_ID = document.body.dataset.vaultScope || null
|
|
301
|
+
const FOCUS_GROUP = (() => {
|
|
302
|
+
const value = new URLSearchParams(window.location.search).get("group")
|
|
303
|
+
return typeof value === "string" && /^[a-f0-9]{64}$/.test(value.toLowerCase())
|
|
304
|
+
? value.toLowerCase()
|
|
305
|
+
: null
|
|
306
|
+
})()
|
|
307
|
+
// A scoped page hands one content group off to the global page, which opens
|
|
308
|
+
// on it once its first page of duplicate results arrives.
|
|
309
|
+
let focusGroupPending = !!FOCUS_GROUP
|
|
296
310
|
const IS_APP_MODE = document.body.dataset.vaultMode === "app" && !!SCOPE_ID
|
|
297
311
|
const APP_NAME = IS_APP_MODE ? document.body.dataset.vaultApp || "" : ""
|
|
298
312
|
const HOME_PATH = document.body.dataset.vaultHome || ""
|
|
@@ -330,6 +344,14 @@ const folderDiscoveryChildrenUrl = (folder, page = 0) => {
|
|
|
330
344
|
})
|
|
331
345
|
return `/info/dedup?${query.toString()}`
|
|
332
346
|
}
|
|
347
|
+
const fileLocationsUrl = (filePath, options = {}) => {
|
|
348
|
+
const query = new URLSearchParams()
|
|
349
|
+
if (SCOPE_ID) query.set("scope_id", SCOPE_ID)
|
|
350
|
+
query.set("locations_path", filePath)
|
|
351
|
+
if (options.cursor) query.set("cursor", options.cursor)
|
|
352
|
+
query.set("page_size", String(DUPLICATE_CHILD_PAGE_SIZE))
|
|
353
|
+
return `/info/dedup?${query.toString()}`
|
|
354
|
+
}
|
|
333
355
|
const duplicateGroupUrl = (hash, options = {}) => {
|
|
334
356
|
const query = new URLSearchParams()
|
|
335
357
|
if (SCOPE_ID) query.set("scope_id", SCOPE_ID)
|
|
@@ -405,17 +427,19 @@ const state = {
|
|
|
405
427
|
candidateSize: defaultCandidateSize,
|
|
406
428
|
persistedCandidateSize: defaultCandidateSize,
|
|
407
429
|
candidateSizeInitialized: false,
|
|
408
|
-
view: "all",
|
|
430
|
+
view: FOCUS_GROUP ? "duplicates" : "all",
|
|
409
431
|
sourceId: SCOPE_ID,
|
|
410
432
|
query: "",
|
|
411
433
|
statusFilter: "all",
|
|
412
|
-
displayMode: "folders",
|
|
413
|
-
sizeSort: null,
|
|
434
|
+
displayMode: FOCUS_GROUP ? "files" : "folders",
|
|
435
|
+
sizeSort: FOCUS_GROUP ? "desc" : null,
|
|
414
436
|
collapsedSources: new Set(),
|
|
415
437
|
collapsedDirs: new Set(),
|
|
416
438
|
expandedFiles: new Set(),
|
|
417
439
|
expandedDuplicateGroups: new Set(),
|
|
418
440
|
duplicateGroupChildren: new Map(),
|
|
441
|
+
fileLocations: new Map(),
|
|
442
|
+
locationsScanTs: undefined,
|
|
419
443
|
duplicateGroupGeneration: 0,
|
|
420
444
|
selectedDuplicateFiles: new Map(),
|
|
421
445
|
selectedSeparateFiles: new Set(),
|
|
@@ -454,11 +478,19 @@ const state = {
|
|
|
454
478
|
pageCursors: [""]
|
|
455
479
|
}
|
|
456
480
|
|
|
481
|
+
// Expanded location lists describe one grouping of one file. Anything that
|
|
482
|
+
// can change that grouping closes them instead of leaving stale paths open.
|
|
483
|
+
const closeFileLocations = () => {
|
|
484
|
+
state.expandedFiles.clear()
|
|
485
|
+
state.fileLocations.clear()
|
|
486
|
+
}
|
|
487
|
+
|
|
457
488
|
const resetPage = () => {
|
|
458
489
|
state.page = 0
|
|
459
490
|
state.pageCursors = [""]
|
|
460
491
|
state.expandedDuplicateGroups.clear()
|
|
461
492
|
state.duplicateGroupChildren.clear()
|
|
493
|
+
closeFileLocations()
|
|
462
494
|
state.duplicateGroupGeneration += 1
|
|
463
495
|
}
|
|
464
496
|
|
|
@@ -594,9 +626,59 @@ const saveCandidateSize = (size) => {
|
|
|
594
626
|
})
|
|
595
627
|
return candidateSizeSaveTail
|
|
596
628
|
}
|
|
629
|
+
// Small confirm dialog in the workspace's own visual language, so leaving the
|
|
630
|
+
// page never happens through a native browser prompt.
|
|
631
|
+
const confirmLeave = (message, confirmLabel) => new Promise((resolve) => {
|
|
632
|
+
const overlay = document.createElement("div")
|
|
633
|
+
overlay.className = "vault-confirm-overlay"
|
|
634
|
+
overlay.innerHTML = `<section class="vault-confirm-dialog" role="dialog" aria-modal="true"><p class="vault-confirm-message"></p><div class="vault-confirm-actions"><button class="vault-button" type="button" data-confirm-cancel>${esc(COPY.cancel)}</button><button class="vault-button primary" type="button" data-confirm-accept>${esc(confirmLabel)}</button></div></section>`
|
|
635
|
+
overlay.querySelector(".vault-confirm-message").textContent = message
|
|
636
|
+
const settle = (value) => {
|
|
637
|
+
document.removeEventListener("keydown", onKey, true)
|
|
638
|
+
overlay.remove()
|
|
639
|
+
resolve(value)
|
|
640
|
+
}
|
|
641
|
+
const onKey = (event) => {
|
|
642
|
+
if (event.key !== "Escape") return
|
|
643
|
+
event.preventDefault()
|
|
644
|
+
event.stopPropagation()
|
|
645
|
+
settle(false)
|
|
646
|
+
}
|
|
647
|
+
overlay.addEventListener("click", (event) => {
|
|
648
|
+
if (event.target === overlay) settle(false)
|
|
649
|
+
if (event.target.closest("[data-confirm-cancel]")) settle(false)
|
|
650
|
+
if (event.target.closest("[data-confirm-accept]")) settle(true)
|
|
651
|
+
})
|
|
652
|
+
document.addEventListener("keydown", onKey, true)
|
|
653
|
+
document.body.appendChild(overlay)
|
|
654
|
+
const accept = overlay.querySelector("[data-confirm-accept]")
|
|
655
|
+
if (accept) accept.focus()
|
|
656
|
+
})
|
|
657
|
+
|
|
597
658
|
const openGlobalWorkspace = () => {
|
|
598
659
|
window.parent.location.assign("/vault")
|
|
599
660
|
}
|
|
661
|
+
// Opens a new tab rather than navigating: this workspace can be embedded in an
|
|
662
|
+
// app page, where navigating would either nest a second copy of the interface
|
|
663
|
+
// or drop the user out of the app they were looking at.
|
|
664
|
+
// A copy that belongs to another app opens that app's own workspace, where it
|
|
665
|
+
// can actually be acted on. Anything else — caches, external folders — has no
|
|
666
|
+
// app page, so it opens the global one. Either way the user is leaving this
|
|
667
|
+
// page, so it is confirmed first and the parent window is replaced rather than
|
|
668
|
+
// nesting a second copy of the interface inside this frame.
|
|
669
|
+
const openContentGroup = async (group, kind, label) => {
|
|
670
|
+
// The label and kind travel with the row: an app workspace cannot look up
|
|
671
|
+
// sources belonging to other apps, so a lookup here would name every one of
|
|
672
|
+
// them "unknown" and send them all to the global page.
|
|
673
|
+
const app = kind === "app" && label ? label : null
|
|
674
|
+
const name = label || COPY.unknown_location
|
|
675
|
+
const accepted = await confirmLeave(
|
|
676
|
+
COPY.open_location_confirm.replace("{location}", name),
|
|
677
|
+
COPY.open_location_accept)
|
|
678
|
+
if (!accepted) return
|
|
679
|
+
const base = app ? `/vault/app/${encodeURIComponent(app)}` : "/vault"
|
|
680
|
+
window.parent.location.assign(`${base}?group=${encodeURIComponent(group)}`)
|
|
681
|
+
}
|
|
600
682
|
const applyAutomaticScanSnapshot = (snapshot) => {
|
|
601
683
|
if (!IS_APP_MODE || !AUTOMATIC_SUPPORTED) return
|
|
602
684
|
const settings = snapshot && Array.isArray(snapshot.settings)
|
|
@@ -688,24 +770,15 @@ const onAutomaticScanMessage = (event) => {
|
|
|
688
770
|
}
|
|
689
771
|
const sourceById = (id) => (state.data.sources || []).find((source) => source.id === id)
|
|
690
772
|
const sourceChildren = (id) => (state.data.sources || []).filter((source) => source.parent_id === id)
|
|
691
|
-
const sourceIsWithinScope = (sourceId) => {
|
|
692
|
-
if (!SCOPE_ID) return true
|
|
693
|
-
const seen = new Set()
|
|
694
|
-
let source = sourceById(sourceId)
|
|
695
|
-
while (source && !seen.has(source.id)) {
|
|
696
|
-
if (source.id === SCOPE_ID) return true
|
|
697
|
-
seen.add(source.id)
|
|
698
|
-
source = sourceById(source.parent_id)
|
|
699
|
-
}
|
|
700
|
-
return false
|
|
701
|
-
}
|
|
702
773
|
const revealLabel = document.body.dataset.platform === "darwin"
|
|
703
774
|
? COPY.show_in_finder
|
|
704
775
|
: document.body.dataset.platform === "win32"
|
|
705
776
|
? COPY.show_in_file_explorer
|
|
706
777
|
: COPY.open_containing_folder
|
|
778
|
+
// Revealing changes nothing, so it is offered for every tracked path, not
|
|
779
|
+
// only the ones the current scope can act on.
|
|
707
780
|
const revealButton = (filePath, sourceId, name) => {
|
|
708
|
-
if (!filePath
|
|
781
|
+
if (!filePath) return ""
|
|
709
782
|
const label = `${revealLabel}: ${name || basename(filePath)}`
|
|
710
783
|
return `<button class="vault-reveal-button" type="button" data-reveal-file="${attr(filePath)}" aria-label="${attr(label)}" title="${attr(revealLabel)}"><i class="fa-regular fa-folder-open" aria-hidden="true"></i></button>`
|
|
711
784
|
}
|
|
@@ -1696,15 +1769,46 @@ const sharingControl = (item) => {
|
|
|
1696
1769
|
}
|
|
1697
1770
|
|
|
1698
1771
|
const fileDetail = (item) => {
|
|
1699
|
-
if (!state.expandedFiles.has(item.path)
|
|
1700
|
-
const
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1772
|
+
if (!state.expandedFiles.has(item.path)) return ""
|
|
1773
|
+
const locations = state.fileLocations.get(item.path)
|
|
1774
|
+
if (!locations || (locations.loading && !locations.loaded)) {
|
|
1775
|
+
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>`
|
|
1776
|
+
}
|
|
1777
|
+
if (locations.error) {
|
|
1778
|
+
return `<div class="vault-detail"><div class="vault-detail-label error">${esc(locations.error)}</div></div>`
|
|
1779
|
+
}
|
|
1780
|
+
const total = Math.max(locations.items.length, Number(locations.total) || 0)
|
|
1781
|
+
// Copies are byte-identical, so the size is stated once for the group.
|
|
1782
|
+
const label = COPY.stored_times
|
|
1783
|
+
.replace("{count}", total)
|
|
1784
|
+
.replace("{size}", fmt(item.size))
|
|
1785
|
+
const remaining = Math.max(0, total - locations.items.length)
|
|
1786
|
+
const more = locations.nextCursor
|
|
1787
|
+
? `<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>`
|
|
1788
|
+
: ""
|
|
1789
|
+
const body = locations.items.map((location) => {
|
|
1790
|
+
const where = externalLocation(location) ||
|
|
1791
|
+
[location.source_label, location.relative_path].filter(Boolean).join(" / ")
|
|
1792
|
+
const note = location.path === item.path
|
|
1793
|
+
? COPY.this_file
|
|
1794
|
+
: location.status === "tracked"
|
|
1795
|
+
? COPY.copy_that_stays
|
|
1796
|
+
: location.status === "shared"
|
|
1797
|
+
? COPY.already_deduplicated
|
|
1798
|
+
: location.status === "unavailable"
|
|
1799
|
+
? COPY.cannot_be_deduplicated
|
|
1800
|
+
: COPY.not_deduplicated_yet
|
|
1801
|
+
const outside = SCOPE_ID && item.hash && location.source_id !== SCOPE_ID
|
|
1802
|
+
const text = outside
|
|
1803
|
+
? `<button class="vault-location-link" type="button" data-open-group="${attr(item.hash)}" data-open-kind="${attr(location.source_kind || "")}" data-open-label="${attr(location.source_label || "")}" title="${attr(COPY.open_in_disk_saver)}">${esc(where)}</button>`
|
|
1804
|
+
: esc(where)
|
|
1805
|
+
// Rows keep identical slots whether or not a reveal button exists, so the
|
|
1806
|
+
// notes form a column and every row is the same height.
|
|
1807
|
+
const reveal = revealButton(
|
|
1808
|
+
location.path, location.source_id, basename(location.relative_path))
|
|
1809
|
+
return `<div class="vault-location-detail"><i class="fa-regular fa-file"></i><span class="vault-location-path" title="${attr(where)}">${text}</span><span class="vault-location-note">${esc(note)}</span>${reveal || `<span class="vault-location-reveal-placeholder"></span>`}</div>`
|
|
1810
|
+
}).join("")
|
|
1811
|
+
return `<div class="vault-detail"><div class="vault-detail-label">${esc(label)}</div>${body}${more}</div>`
|
|
1708
1812
|
}
|
|
1709
1813
|
const separateCheckbox = (item) => item.status === "shared"
|
|
1710
1814
|
? `<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 +1825,7 @@ const rowSelectionCheckbox = (item) =>
|
|
|
1721
1825
|
const renderFileRow = (item, depth = 0, showMatch = false) => {
|
|
1722
1826
|
const directoryPath = dirname(item.relative_path)
|
|
1723
1827
|
const match = item.match
|
|
1724
|
-
const expandable = item.
|
|
1828
|
+
const expandable = Number(item.location_count) > 1
|
|
1725
1829
|
const rowTail = showMatch
|
|
1726
1830
|
? `<span>${match ? `<span class="vault-match-path">${esc(match.path)}</span>` : "—"}</span>
|
|
1727
1831
|
<span class="vault-space">${esc(spaceMarkup(item))}</span>
|
|
@@ -1826,7 +1930,7 @@ const flatLocation = (item) => externalLocation(item) ||
|
|
|
1826
1930
|
const renderFlatFiles = (items) => [...items]
|
|
1827
1931
|
.sort((a, b) => compareRows(a, b, flatLocation))
|
|
1828
1932
|
.map((item) => {
|
|
1829
|
-
const expandable = item.
|
|
1933
|
+
const expandable = Number(item.location_count) > 1
|
|
1830
1934
|
return `<div class="vault-file-row">
|
|
1831
1935
|
<div class="vault-name-cell">
|
|
1832
1936
|
${separateCheckbox(item)}
|
|
@@ -2230,7 +2334,7 @@ const paneFooterText = () => {
|
|
|
2230
2334
|
return `${count}${order ? ` · ${order}` : ""}`
|
|
2231
2335
|
}
|
|
2232
2336
|
if (state.view === "duplicates") return COPY.duplicate_note
|
|
2233
|
-
if (state.view === "reclaimable") return
|
|
2337
|
+
if (state.view === "reclaimable") return ""
|
|
2234
2338
|
if (state.view === "activity") return COPY.activity_note
|
|
2235
2339
|
const minimumSize = state.data.last_scan &&
|
|
2236
2340
|
Number.isFinite(state.data.last_scan.candidate_min_bytes)
|
|
@@ -2754,12 +2858,8 @@ const renderCleanupNotice = () => {
|
|
|
2754
2858
|
}
|
|
2755
2859
|
const bytes = Number(state.data.reclaimable) || 0
|
|
2756
2860
|
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
2861
|
notice.className = "vault-cleanup-notice show"
|
|
2762
|
-
notice.innerHTML = `<i class="fa-
|
|
2862
|
+
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
2863
|
}
|
|
2764
2864
|
|
|
2765
2865
|
const clearPanel = (id, className) => {
|
|
@@ -2846,9 +2946,9 @@ const fetchJson = async (url) => {
|
|
|
2846
2946
|
if (!response.ok) throw new Error(COPY.status_request_failed.replace("{status}", response.status))
|
|
2847
2947
|
return response.json()
|
|
2848
2948
|
}
|
|
2849
|
-
const
|
|
2949
|
+
const loadPagedChildren = async (cache, key, buildUrl, append) => {
|
|
2850
2950
|
const generation = state.duplicateGroupGeneration
|
|
2851
|
-
const current =
|
|
2951
|
+
const current = cache.get(key) || {
|
|
2852
2952
|
items: [],
|
|
2853
2953
|
total: 0,
|
|
2854
2954
|
nextCursor: null,
|
|
@@ -2859,16 +2959,18 @@ const loadDuplicateGroupChildren = async (hash, append = false) => {
|
|
|
2859
2959
|
if (current.loading) return
|
|
2860
2960
|
current.loading = true
|
|
2861
2961
|
current.error = null
|
|
2862
|
-
|
|
2962
|
+
cache.set(key, current)
|
|
2863
2963
|
render()
|
|
2864
2964
|
try {
|
|
2865
|
-
const result = await fetchJson(
|
|
2866
|
-
|
|
2867
|
-
}))
|
|
2965
|
+
const result = await fetchJson(
|
|
2966
|
+
buildUrl(append ? current.nextCursor : null))
|
|
2868
2967
|
if (generation !== state.duplicateGroupGeneration) return
|
|
2869
2968
|
const items = Array.isArray(result.items) ? result.items : []
|
|
2870
2969
|
current.items = append ? current.items.concat(items) : items
|
|
2871
2970
|
current.total = Math.max(0, Number(result.total) || 0)
|
|
2971
|
+
// Location lists carry how many copies still cost space; group children
|
|
2972
|
+
// do not, and read as zero.
|
|
2973
|
+
current.deduplicatable = Math.max(0, Number(result.deduplicatable) || 0)
|
|
2872
2974
|
current.nextCursor = result.next_cursor || null
|
|
2873
2975
|
current.loaded = true
|
|
2874
2976
|
} catch (error) {
|
|
@@ -2883,6 +2985,12 @@ const loadDuplicateGroupChildren = async (hash, append = false) => {
|
|
|
2883
2985
|
}
|
|
2884
2986
|
}
|
|
2885
2987
|
}
|
|
2988
|
+
const loadDuplicateGroupChildren = (hash, append = false) =>
|
|
2989
|
+
loadPagedChildren(state.duplicateGroupChildren, hash, (cursor) =>
|
|
2990
|
+
duplicateGroupUrl(hash, { cursor }), append)
|
|
2991
|
+
const loadFileLocations = (filePath, append = false) =>
|
|
2992
|
+
loadPagedChildren(state.fileLocations, filePath, (cursor) =>
|
|
2993
|
+
fileLocationsUrl(filePath, { cursor }), append)
|
|
2886
2994
|
const duplicateGroupSelectionPaths = async (hash) =>
|
|
2887
2995
|
fetchJson(duplicateGroupUrl(hash, { select: true }))
|
|
2888
2996
|
const duplicateGroupPageSelectionItems = async () =>
|
|
@@ -2919,6 +3027,17 @@ const applyFullData = (data) => {
|
|
|
2919
3027
|
reviewedScan() !== String(data.last_scan.ts) &&
|
|
2920
3028
|
(shareableDuplicateCount > 0 || data.last_scan.partial)
|
|
2921
3029
|
settleFolderDiscoveryStart()
|
|
3030
|
+
if (focusGroupPending &&
|
|
3031
|
+
data.inventory && data.inventory.view === "duplicates") {
|
|
3032
|
+
focusGroupPending = false
|
|
3033
|
+
state.expandedDuplicateGroups.add(FOCUS_GROUP)
|
|
3034
|
+
loadDuplicateGroupChildren(FOCUS_GROUP)
|
|
3035
|
+
}
|
|
3036
|
+
const publishedScan = data.last_scan ? data.last_scan.ts : null
|
|
3037
|
+
if (publishedScan !== state.locationsScanTs) {
|
|
3038
|
+
state.locationsScanTs = publishedScan
|
|
3039
|
+
closeFileLocations()
|
|
3040
|
+
}
|
|
2922
3041
|
state.data = data
|
|
2923
3042
|
const fileAction = serverFileAction(data.file_action)
|
|
2924
3043
|
if (fileAction) state.actionProgress = fileAction
|
|
@@ -3034,6 +3153,7 @@ const runAction = async (payload, success) => {
|
|
|
3034
3153
|
} catch (error) {
|
|
3035
3154
|
state.feedback = { error: true, message: error && error.message ? error.message : String(error) }
|
|
3036
3155
|
}
|
|
3156
|
+
closeFileLocations()
|
|
3037
3157
|
await refresh(true)
|
|
3038
3158
|
}
|
|
3039
3159
|
|
|
@@ -3371,6 +3491,13 @@ const closeScanSizeMenu = () => {
|
|
|
3371
3491
|
document.addEventListener("click", async (event) => {
|
|
3372
3492
|
const target = event.target.closest("button")
|
|
3373
3493
|
if (!target) return
|
|
3494
|
+
if (target.dataset.openGroup) {
|
|
3495
|
+
await openContentGroup(
|
|
3496
|
+
target.dataset.openGroup,
|
|
3497
|
+
target.dataset.openKind,
|
|
3498
|
+
target.dataset.openLabel)
|
|
3499
|
+
return
|
|
3500
|
+
}
|
|
3374
3501
|
if (target.hasAttribute("data-dismiss-automatic-scan-coachmark")) {
|
|
3375
3502
|
dismissAutomaticScanCoachmark(true)
|
|
3376
3503
|
return
|
|
@@ -3741,11 +3868,21 @@ document.addEventListener("click", async (event) => {
|
|
|
3741
3868
|
target.dataset.moreDuplicateGroup,
|
|
3742
3869
|
true
|
|
3743
3870
|
)
|
|
3871
|
+
} else if (target.dataset.moreFileLocations) {
|
|
3872
|
+
await loadFileLocations(target.dataset.moreFileLocations, true)
|
|
3744
3873
|
} else if (target.dataset.expandFile) {
|
|
3745
3874
|
const file = target.dataset.expandFile
|
|
3746
|
-
if (state.expandedFiles.has(file))
|
|
3747
|
-
|
|
3748
|
-
|
|
3875
|
+
if (state.expandedFiles.has(file)) {
|
|
3876
|
+
state.expandedFiles.delete(file)
|
|
3877
|
+
render()
|
|
3878
|
+
} else {
|
|
3879
|
+
state.expandedFiles.add(file)
|
|
3880
|
+
render()
|
|
3881
|
+
const loaded = state.fileLocations.get(file)
|
|
3882
|
+
if (!loaded || (!loaded.loaded && !loaded.loading)) {
|
|
3883
|
+
await loadFileLocations(file)
|
|
3884
|
+
}
|
|
3885
|
+
}
|
|
3749
3886
|
} else if (target.dataset.removeSource) {
|
|
3750
3887
|
const source = sourceById(target.dataset.removeSource)
|
|
3751
3888
|
if (!source) return
|
|
@@ -1553,7 +1553,7 @@ describe("Save Space engine", () => {
|
|
|
1553
1553
|
await close(vault)
|
|
1554
1554
|
})
|
|
1555
1555
|
|
|
1556
|
-
test("revealing
|
|
1556
|
+
test("revealing works for any tracked path, whatever the scope", async () => {
|
|
1557
1557
|
const { home, vault } = await makeVault()
|
|
1558
1558
|
const pair = await duplicatePair(home, 'model "quoted".bin')
|
|
1559
1559
|
await vault.sweeper.scan()
|
|
@@ -1571,19 +1571,18 @@ describe("Save Space engine", () => {
|
|
|
1571
1571
|
}), { revealed: true })
|
|
1572
1572
|
assert.deepEqual(launched, [pair.first])
|
|
1573
1573
|
|
|
1574
|
-
|
|
1574
|
+
assert.notEqual(first.source_id, second.source_id)
|
|
1575
|
+
assert.deepEqual(await vault.perform("reveal", {
|
|
1575
1576
|
scope_id: first.source_id,
|
|
1576
1577
|
path: pair.second
|
|
1577
|
-
})
|
|
1578
|
-
assert.
|
|
1579
|
-
assert.notEqual(first.source_id, second.source_id)
|
|
1580
|
-
assert.deepEqual(launched, [pair.first])
|
|
1578
|
+
}), { revealed: true })
|
|
1579
|
+
assert.deepEqual(launched, [pair.first, pair.second])
|
|
1581
1580
|
|
|
1582
1581
|
const untracked = await vault.perform("reveal", {
|
|
1583
1582
|
path: path.join(home, "api", "first", "missing.bin")
|
|
1584
1583
|
})
|
|
1585
1584
|
assert.match(untracked.error, /no longer tracked/i)
|
|
1586
|
-
assert.deepEqual(launched, [pair.first])
|
|
1585
|
+
assert.deepEqual(launched, [pair.first, pair.second])
|
|
1587
1586
|
|
|
1588
1587
|
await close(vault)
|
|
1589
1588
|
})
|
|
@@ -2447,6 +2446,159 @@ describe("Save Space engine", () => {
|
|
|
2447
2446
|
await close(vault)
|
|
2448
2447
|
})
|
|
2449
2448
|
|
|
2449
|
+
test("Make separate frees the anchor once nothing links to it", async () => {
|
|
2450
|
+
const { home, vault } = await makeVault()
|
|
2451
|
+
const pair = await duplicatePair(home)
|
|
2452
|
+
await vault.sweeper.scan()
|
|
2453
|
+
const duplicate = [...await vault.registry.files({
|
|
2454
|
+
statuses: ["duplicate"]
|
|
2455
|
+
})][0]
|
|
2456
|
+
await vault.perform("deduplicate", { path: duplicate.path })
|
|
2457
|
+
const hash = duplicate.hash
|
|
2458
|
+
const storePath = vault.storePathFor(hash)
|
|
2459
|
+
const linked = [...await vault.registry.files({ statuses: ["linked"] })]
|
|
2460
|
+
assert.equal(linked.length, 2)
|
|
2461
|
+
|
|
2462
|
+
assert.equal((await vault.perform("detach", {
|
|
2463
|
+
path: linked[0].path
|
|
2464
|
+
})).status, "detached")
|
|
2465
|
+
assert.equal(fs.existsSync(storePath), true)
|
|
2466
|
+
assert.equal((await fs.promises.stat(storePath)).nlink, 2)
|
|
2467
|
+
assert.equal((await vault.registry.anchorsForHash(hash)).length, 1)
|
|
2468
|
+
|
|
2469
|
+
assert.equal((await vault.perform("detach", {
|
|
2470
|
+
path: linked[1].path
|
|
2471
|
+
})).status, "detached")
|
|
2472
|
+
assert.equal(fs.existsSync(storePath), false)
|
|
2473
|
+
assert.equal((await vault.registry.anchorsForHash(hash)).length, 0)
|
|
2474
|
+
|
|
2475
|
+
for (const entry of linked) {
|
|
2476
|
+
await assertCandidateContents(entry.path, pair.contents, pair.size)
|
|
2477
|
+
}
|
|
2478
|
+
const activity = await vault.status(null, {
|
|
2479
|
+
view: "activity",
|
|
2480
|
+
page_size: 500
|
|
2481
|
+
})
|
|
2482
|
+
assert.equal(activity.items.some((item) => item.kind === "reclaim"), true)
|
|
2483
|
+
await close(vault)
|
|
2484
|
+
})
|
|
2485
|
+
|
|
2486
|
+
test("freeing the anchor leaves the registry ready to deduplicate again", async () => {
|
|
2487
|
+
const { home, vault } = await makeVault()
|
|
2488
|
+
await duplicatePair(home)
|
|
2489
|
+
await vault.sweeper.scan()
|
|
2490
|
+
const duplicate = [...await vault.registry.files({
|
|
2491
|
+
statuses: ["duplicate"]
|
|
2492
|
+
})][0]
|
|
2493
|
+
await vault.perform("deduplicate", { path: duplicate.path })
|
|
2494
|
+
for (const row of [...await vault.registry.files({
|
|
2495
|
+
statuses: ["linked"]
|
|
2496
|
+
})]) {
|
|
2497
|
+
assert.equal((await vault.perform("detach", {
|
|
2498
|
+
path: row.path
|
|
2499
|
+
})).status, "detached")
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
const rows = [...await vault.registry.files({})]
|
|
2503
|
+
assert.equal(rows.filter((row) => row.status === "reference").length, 1)
|
|
2504
|
+
assert.equal(rows.filter((row) => row.status === "duplicate").length, 1)
|
|
2505
|
+
const target = rows.find((row) => row.status === "duplicate")
|
|
2506
|
+
assert.equal((await vault.perform("deduplicate", {
|
|
2507
|
+
path: target.path
|
|
2508
|
+
})).status, "converted")
|
|
2509
|
+
await close(vault)
|
|
2510
|
+
})
|
|
2511
|
+
|
|
2512
|
+
test("bulk Make separate frees only the anchors nothing links to", async () => {
|
|
2513
|
+
const { home, vault } = await makeVault()
|
|
2514
|
+
await duplicatePair(home, "kept.bin")
|
|
2515
|
+
const trio = crypto.randomBytes(4096)
|
|
2516
|
+
for (const name of ["one", "two", "three"]) {
|
|
2517
|
+
await writeCandidate(path.join(home, "api", name, "freed.bin"), trio)
|
|
2518
|
+
}
|
|
2519
|
+
await vault.sweeper.scan()
|
|
2520
|
+
const result = await vault.perform("deduplicate_files", {
|
|
2521
|
+
paths: [...await vault.registry.files({
|
|
2522
|
+
statuses: ["duplicate"]
|
|
2523
|
+
})].map((item) => item.path)
|
|
2524
|
+
})
|
|
2525
|
+
assert.equal(result.converted, 3)
|
|
2526
|
+
|
|
2527
|
+
const linked = [...await vault.registry.files({ statuses: ["linked"] })]
|
|
2528
|
+
const freedHash = linked.find((item) =>
|
|
2529
|
+
path.basename(item.path) === "freed.bin").hash
|
|
2530
|
+
const keptHash = linked.find((item) =>
|
|
2531
|
+
path.basename(item.path) === "kept.bin").hash
|
|
2532
|
+
const freedStore = vault.storePathFor(freedHash)
|
|
2533
|
+
const keptStore = vault.storePathFor(keptHash)
|
|
2534
|
+
|
|
2535
|
+
const separated = await vault.perform("separate_files", {
|
|
2536
|
+
paths: linked
|
|
2537
|
+
.filter((item) => path.basename(item.path) === "freed.bin")
|
|
2538
|
+
.map((item) => item.path)
|
|
2539
|
+
.concat(linked.find((item) =>
|
|
2540
|
+
path.basename(item.path) === "kept.bin").path)
|
|
2541
|
+
})
|
|
2542
|
+
assert.equal(separated.separated, 4)
|
|
2543
|
+
|
|
2544
|
+
assert.equal(fs.existsSync(freedStore), false)
|
|
2545
|
+
assert.equal((await vault.registry.anchorsForHash(freedHash)).length, 0)
|
|
2546
|
+
assert.equal(fs.existsSync(keptStore), true)
|
|
2547
|
+
assert.equal((await vault.registry.anchorsForHash(keptHash)).length, 1)
|
|
2548
|
+
await close(vault)
|
|
2549
|
+
})
|
|
2550
|
+
|
|
2551
|
+
test("Expanding a file lists every location for its own identity", async () => {
|
|
2552
|
+
const { home, vault } = await makeVault()
|
|
2553
|
+
const contents = crypto.randomBytes(4096)
|
|
2554
|
+
for (const name of ["one", "two", "three"]) {
|
|
2555
|
+
await writeCandidate(path.join(home, "api", name, "shared.bin"), contents)
|
|
2556
|
+
}
|
|
2557
|
+
await vault.sweeper.scan()
|
|
2558
|
+
const duplicate = [...await vault.registry.files({
|
|
2559
|
+
statuses: ["duplicate"]
|
|
2560
|
+
})][0]
|
|
2561
|
+
|
|
2562
|
+
const byHash = await vault.fileLocations(null, duplicate.path)
|
|
2563
|
+
assert.equal(byHash.total, 3)
|
|
2564
|
+
assert.equal(byHash.items.length, 3)
|
|
2565
|
+
assert.equal(byHash.items.some((entry) =>
|
|
2566
|
+
entry.path === duplicate.path), true)
|
|
2567
|
+
|
|
2568
|
+
await vault.perform("deduplicate", { path: duplicate.path })
|
|
2569
|
+
const byInode = await vault.fileLocations(null, duplicate.path)
|
|
2570
|
+
assert.equal(byInode.total, 2)
|
|
2571
|
+
assert.equal(byInode.items.length, 2)
|
|
2572
|
+
const untouched = [...await vault.registry.files({
|
|
2573
|
+
statuses: ["duplicate"]
|
|
2574
|
+
})][0]
|
|
2575
|
+
assert.equal(byInode.items.some((entry) =>
|
|
2576
|
+
entry.path === untouched.path), false)
|
|
2577
|
+
await close(vault)
|
|
2578
|
+
})
|
|
2579
|
+
|
|
2580
|
+
test("an app workspace lists every location but acts only on its own", async () => {
|
|
2581
|
+
const { home, vault } = await makeVault()
|
|
2582
|
+
const contents = crypto.randomBytes(4096)
|
|
2583
|
+
for (const app of ["one", "two", "three"]) {
|
|
2584
|
+
await writeCandidate(path.join(home, "api", app, "shared.bin"), contents)
|
|
2585
|
+
}
|
|
2586
|
+
await vault.sweeper.scan()
|
|
2587
|
+
|
|
2588
|
+
const scope = "app:two"
|
|
2589
|
+
const status = await vault.status(scope, { view: "all", page_size: 100 })
|
|
2590
|
+
assert.equal(status.items.length, 1)
|
|
2591
|
+
const row = status.items[0]
|
|
2592
|
+
assert.equal(row.location_count, 3)
|
|
2593
|
+
|
|
2594
|
+
const locations = await vault.fileLocations(scope, row.path)
|
|
2595
|
+
assert.equal(locations.total, 3)
|
|
2596
|
+
assert.equal(locations.items.length, 3)
|
|
2597
|
+
assert.equal(new Set(locations.items.map((item) =>
|
|
2598
|
+
item.source_id)).size, 3)
|
|
2599
|
+
await close(vault)
|
|
2600
|
+
})
|
|
2601
|
+
|
|
2450
2602
|
test("invalid scoped actions cannot expand into a global mutation", async () => {
|
|
2451
2603
|
const { home, vault } = await makeVault()
|
|
2452
2604
|
await duplicatePair(home)
|
package/test/vault-ui.test.js
CHANGED
|
@@ -160,7 +160,9 @@ const makePage = async (status, options = {}) => {
|
|
|
160
160
|
runScripts: "outside-only",
|
|
161
161
|
url: appMode
|
|
162
162
|
? "http://localhost/vault/app/app"
|
|
163
|
-
:
|
|
163
|
+
: `http://localhost/vault${options.focusGroup
|
|
164
|
+
? `?group=${options.focusGroup}`
|
|
165
|
+
: ""}`
|
|
164
166
|
}
|
|
165
167
|
)
|
|
166
168
|
const requests = []
|
|
@@ -784,6 +786,159 @@ describe("Save Space interface", () => {
|
|
|
784
786
|
dom.window.close()
|
|
785
787
|
})
|
|
786
788
|
|
|
789
|
+
test("expanding a file loads its real locations instead of row samples", async () => {
|
|
790
|
+
const duplicate = item({
|
|
791
|
+
path: "/pinokio/api/app/models/duplicate.bin",
|
|
792
|
+
relative_path: "models/duplicate.bin",
|
|
793
|
+
status: "duplicate",
|
|
794
|
+
shareable: true,
|
|
795
|
+
location_count: 3,
|
|
796
|
+
locations: [{
|
|
797
|
+
path: "/pinokio/api/app/models/duplicate.bin",
|
|
798
|
+
source_id: "app:app",
|
|
799
|
+
source_label: "app",
|
|
800
|
+
relative_path: "models/duplicate.bin"
|
|
801
|
+
}, {
|
|
802
|
+
path: "/pinokio/api/app/models/sample.bin",
|
|
803
|
+
source_id: "app:app",
|
|
804
|
+
source_label: "app",
|
|
805
|
+
relative_path: "models/sample.bin"
|
|
806
|
+
}]
|
|
807
|
+
})
|
|
808
|
+
const base = fixture([duplicate])
|
|
809
|
+
base.inventory.source_counts.duplicates["app:app"] = 1
|
|
810
|
+
base.inventory.shareable_by_source["app:app"] = 1
|
|
811
|
+
const response = (url) => {
|
|
812
|
+
const parsed = new URL(url, "http://localhost")
|
|
813
|
+
if (parsed.searchParams.get("locations_path")) {
|
|
814
|
+
return {
|
|
815
|
+
path: parsed.searchParams.get("locations_path"),
|
|
816
|
+
items: [
|
|
817
|
+
["duplicate", "duplicate"],
|
|
818
|
+
["first", "tracked"],
|
|
819
|
+
["third", "shared"]
|
|
820
|
+
].map(([name, status]) => ({
|
|
821
|
+
path: `/pinokio/api/app/models/${name}.bin`,
|
|
822
|
+
status,
|
|
823
|
+
source_id: "app:app",
|
|
824
|
+
source_label: "app",
|
|
825
|
+
relative_path: `models/${name}.bin`
|
|
826
|
+
})),
|
|
827
|
+
total: 3,
|
|
828
|
+
deduplicatable: 1,
|
|
829
|
+
next_cursor: null
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
const result = JSON.parse(JSON.stringify(base))
|
|
833
|
+
result.items = [duplicate]
|
|
834
|
+
return result
|
|
835
|
+
}
|
|
836
|
+
const { dom, getRequests } = await makePage(response)
|
|
837
|
+
const document = dom.window.document
|
|
838
|
+
|
|
839
|
+
document.querySelector('[data-view="duplicates"]').click()
|
|
840
|
+
await waitFor(() => document.querySelector(
|
|
841
|
+
'[data-view="duplicates"].selected'))
|
|
842
|
+
const disclosure = document.querySelector(
|
|
843
|
+
'[data-expand-file="/pinokio/api/app/models/duplicate.bin"]')
|
|
844
|
+
assert.ok(disclosure)
|
|
845
|
+
disclosure.click()
|
|
846
|
+
|
|
847
|
+
await waitFor(() => getRequests.some((url) =>
|
|
848
|
+
new URL(url, "http://localhost").searchParams
|
|
849
|
+
.get("locations_path") === "/pinokio/api/app/models/duplicate.bin"))
|
|
850
|
+
await waitFor(() => document.querySelector(".vault-location-path"))
|
|
851
|
+
const detail = document.querySelector(".vault-detail")
|
|
852
|
+
assert.match(detail.textContent, /Stored 3 times · 4\.1 KB each/)
|
|
853
|
+
assert.doesNotMatch(detail.textContent, /sample\.bin/)
|
|
854
|
+
assert.doesNotMatch(detail.textContent, /of 3 locations shown/)
|
|
855
|
+
const notes = [...detail.querySelectorAll(".vault-location-note")]
|
|
856
|
+
.map((node) => node.textContent)
|
|
857
|
+
assert.deepEqual(notes, [
|
|
858
|
+
"this file",
|
|
859
|
+
"the copy that stays",
|
|
860
|
+
"already deduplicated"
|
|
861
|
+
])
|
|
862
|
+
await settle()
|
|
863
|
+
dom.window.close()
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
test("leftover storage is presented as a Trash the user can empty", async () => {
|
|
867
|
+
const blob = {
|
|
868
|
+
store_id: "home",
|
|
869
|
+
hash: "b".repeat(64),
|
|
870
|
+
size: 11500000,
|
|
871
|
+
nlink: 1,
|
|
872
|
+
orphan: 1
|
|
873
|
+
}
|
|
874
|
+
const base = fixture([])
|
|
875
|
+
base.inventory.counts.reclaimable = 1
|
|
876
|
+
base.reclaimable = blob.size
|
|
877
|
+
base.enabled = true
|
|
878
|
+
const response = (url) => {
|
|
879
|
+
const view = new URL(url, "http://localhost")
|
|
880
|
+
.searchParams.get("view") || "all"
|
|
881
|
+
const result = JSON.parse(JSON.stringify(base))
|
|
882
|
+
result.inventory.view = view
|
|
883
|
+
result.items = view === "reclaimable" ? [blob] : []
|
|
884
|
+
return result
|
|
885
|
+
}
|
|
886
|
+
const { dom } = await makePage(response)
|
|
887
|
+
const document = dom.window.document
|
|
888
|
+
|
|
889
|
+
assert.match(document.querySelector("#vault-views").textContent, /Trash/)
|
|
890
|
+
document.querySelector('[data-view="reclaimable"]').click()
|
|
891
|
+
await waitFor(() => document.querySelector(
|
|
892
|
+
'[data-view="reclaimable"].selected'))
|
|
893
|
+
await waitFor(() => document.querySelector("[data-reclaim]"))
|
|
894
|
+
|
|
895
|
+
const body = document.body.textContent
|
|
896
|
+
assert.match(body, /Empty Trash/)
|
|
897
|
+
assert.doesNotMatch(body, /private link/i)
|
|
898
|
+
assert.doesNotMatch(body, /ready to clean up/i)
|
|
899
|
+
assert.doesNotMatch(body, /their linked files were deleted/i)
|
|
900
|
+
await settle()
|
|
901
|
+
dom.window.close()
|
|
902
|
+
})
|
|
903
|
+
|
|
904
|
+
test("a scoped page hands one content group to the global page", async () => {
|
|
905
|
+
const hash = "c".repeat(64)
|
|
906
|
+
const duplicate = item({
|
|
907
|
+
path: "/pinokio/api/app/models/duplicate.bin",
|
|
908
|
+
relative_path: "models/duplicate.bin",
|
|
909
|
+
status: "duplicate",
|
|
910
|
+
shareable: true,
|
|
911
|
+
hash,
|
|
912
|
+
location_count: 3
|
|
913
|
+
})
|
|
914
|
+
const base = fixture([duplicate])
|
|
915
|
+
base.inventory.source_counts.duplicates["app:app"] = 1
|
|
916
|
+
base.inventory.shareable_by_source["app:app"] = 1
|
|
917
|
+
const response = (url) => {
|
|
918
|
+
const parsed = new URL(url, "http://localhost")
|
|
919
|
+
if (parsed.searchParams.get("group_hash")) {
|
|
920
|
+
return { hash, items: [], total: 3, next_cursor: null }
|
|
921
|
+
}
|
|
922
|
+
const result = JSON.parse(JSON.stringify(base))
|
|
923
|
+
result.inventory.view = parsed.searchParams.get("view") || "all"
|
|
924
|
+
result.items = [duplicate]
|
|
925
|
+
return result
|
|
926
|
+
}
|
|
927
|
+
const { dom, getRequests } = await makePage(response, {
|
|
928
|
+
focusGroup: hash
|
|
929
|
+
})
|
|
930
|
+
const document = dom.window.document
|
|
931
|
+
|
|
932
|
+
await waitFor(() => document.querySelector(
|
|
933
|
+
'[data-view="duplicates"].selected'))
|
|
934
|
+
assert.ok(document.querySelector('[data-display-mode="files"].selected'))
|
|
935
|
+
await waitFor(() => getRequests.some((url) =>
|
|
936
|
+
new URL(url, "http://localhost").searchParams
|
|
937
|
+
.get("group_hash") === hash))
|
|
938
|
+
await settle()
|
|
939
|
+
dom.window.close()
|
|
940
|
+
})
|
|
941
|
+
|
|
787
942
|
test("Cannot deduplicate is separate from actionable duplicates", async () => {
|
|
788
943
|
const duplicate = item({
|
|
789
944
|
path: "/pinokio/api/app/models/duplicate.bin",
|