pinokiod 8.0.49 → 8.0.51
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 +84 -74
- package/package.json +1 -1
- package/server/public/vault.css +14 -4
- package/server/public/vault.js +47 -14
- package/test/vault-sweep.test.js +36 -25
- package/test/vault-ui.test.js +29 -32
package/kernel/vault/index.js
CHANGED
|
@@ -103,8 +103,8 @@ class Vault {
|
|
|
103
103
|
get blobRoot() {
|
|
104
104
|
return path.resolve(this.root, "sha256")
|
|
105
105
|
}
|
|
106
|
-
get
|
|
107
|
-
return path.resolve(this.root, "sources")
|
|
106
|
+
get externalSourcesPath() {
|
|
107
|
+
return path.resolve(this.root, "external-sources.json")
|
|
108
108
|
}
|
|
109
109
|
storePathFor(hash) {
|
|
110
110
|
if (typeof hash !== "string" || !SHA256_RE.test(hash)) {
|
|
@@ -129,6 +129,43 @@ class Vault {
|
|
|
129
129
|
if (!st) throw unsafeStoragePath(directory)
|
|
130
130
|
return st
|
|
131
131
|
}
|
|
132
|
+
async readExternalSourcePaths() {
|
|
133
|
+
let raw
|
|
134
|
+
try {
|
|
135
|
+
raw = await this.registry.readFile(this.externalSourcesPath)
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (isMissingError(error)) return []
|
|
138
|
+
throw error
|
|
139
|
+
}
|
|
140
|
+
let parsed
|
|
141
|
+
try {
|
|
142
|
+
parsed = JSON.parse(raw)
|
|
143
|
+
} catch (error) {
|
|
144
|
+
throw new Error("External folder settings are invalid.")
|
|
145
|
+
}
|
|
146
|
+
if (!parsed || !Array.isArray(parsed.paths)) {
|
|
147
|
+
throw new Error("External folder settings are invalid.")
|
|
148
|
+
}
|
|
149
|
+
const paths = []
|
|
150
|
+
for (const value of parsed.paths) {
|
|
151
|
+
if (typeof value !== "string" || !path.isAbsolute(value.trim())) continue
|
|
152
|
+
const resolved = path.resolve(value.trim())
|
|
153
|
+
if (!paths.some((existing) => samePath(existing, resolved))) paths.push(resolved)
|
|
154
|
+
}
|
|
155
|
+
return paths
|
|
156
|
+
}
|
|
157
|
+
async writeExternalSourcePaths(folderPaths) {
|
|
158
|
+
const paths = []
|
|
159
|
+
for (const value of folderPaths) {
|
|
160
|
+
if (typeof value !== "string" || !path.isAbsolute(value.trim())) continue
|
|
161
|
+
const resolved = path.resolve(value.trim())
|
|
162
|
+
if (!paths.some((existing) => samePath(existing, resolved))) paths.push(resolved)
|
|
163
|
+
}
|
|
164
|
+
await this.registry.atomicWrite(
|
|
165
|
+
this.externalSourcesPath,
|
|
166
|
+
JSON.stringify({ paths }, null, 2)
|
|
167
|
+
)
|
|
168
|
+
}
|
|
132
169
|
async storeStatIfPresent(storePath, options = {}) {
|
|
133
170
|
if (!isPathWithin(this.blobRoot, storePath)) throw unsafeStoragePath(storePath)
|
|
134
171
|
if (!await this.directoryIfSafe(this.root) || !await this.directoryIfSafe(this.blobRoot)) {
|
|
@@ -205,6 +242,11 @@ class Vault {
|
|
|
205
242
|
}
|
|
206
243
|
}
|
|
207
244
|
}
|
|
245
|
+
case "remove_source":
|
|
246
|
+
if (typeof payload.source_id !== "string" || !payload.source_id) {
|
|
247
|
+
return { error: "Choose an external folder to remove." }
|
|
248
|
+
}
|
|
249
|
+
return this.runMutation(() => this.removeExternalSource(payload.source_id))
|
|
208
250
|
case "scan": {
|
|
209
251
|
if (payload.scope_id && !this.scanSource(payload.scope_id)) {
|
|
210
252
|
return { error: "That scan location is no longer available." }
|
|
@@ -313,34 +355,6 @@ class Vault {
|
|
|
313
355
|
return source
|
|
314
356
|
}
|
|
315
357
|
|
|
316
|
-
const seenExternalRoots = new Set()
|
|
317
|
-
const addExternalEntries = async (importRoot, idPrefix = "") => {
|
|
318
|
-
let entries = []
|
|
319
|
-
try {
|
|
320
|
-
entries = await fs.promises.readdir(importRoot, { withFileTypes: true })
|
|
321
|
-
} catch (error) {
|
|
322
|
-
if (!isMissingError(error)) throw error
|
|
323
|
-
}
|
|
324
|
-
for (const entry of entries) {
|
|
325
|
-
if (!entry.isSymbolicLink()) continue
|
|
326
|
-
const mountPath = path.resolve(importRoot, entry.name)
|
|
327
|
-
try {
|
|
328
|
-
const root = await fs.promises.realpath(mountPath)
|
|
329
|
-
const st = await fs.promises.stat(root)
|
|
330
|
-
if (!st.isDirectory()) continue
|
|
331
|
-
const canonical = path.resolve(root)
|
|
332
|
-
if (seenExternalRoots.has(canonical)) continue
|
|
333
|
-
seenExternalRoots.add(canonical)
|
|
334
|
-
sources.push(await decorate({
|
|
335
|
-
id: sourceId("external", `${idPrefix}${entry.name}`), kind: "external", label: entry.name,
|
|
336
|
-
root: canonical, mount_path: mountPath, parent_id: "external"
|
|
337
|
-
}))
|
|
338
|
-
} catch (error) {
|
|
339
|
-
if (!isMissingError(error)) throw error
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
|
|
344
358
|
let apiEntries = []
|
|
345
359
|
try {
|
|
346
360
|
apiEntries = await fs.promises.readdir(apiRoot, { withFileTypes: true })
|
|
@@ -356,9 +370,15 @@ class Vault {
|
|
|
356
370
|
}))
|
|
357
371
|
}
|
|
358
372
|
}
|
|
359
|
-
await
|
|
360
|
-
|
|
361
|
-
|
|
373
|
+
for (const configuredPath of await this.readExternalSourcePaths()) {
|
|
374
|
+
sources.push(await decorate({
|
|
375
|
+
id: sourceId("external", configuredPath),
|
|
376
|
+
kind: "external",
|
|
377
|
+
label: path.basename(configuredPath) || configuredPath,
|
|
378
|
+
root: configuredPath,
|
|
379
|
+
parent_id: "external",
|
|
380
|
+
configured: true
|
|
381
|
+
}))
|
|
362
382
|
}
|
|
363
383
|
|
|
364
384
|
let homeEntries = []
|
|
@@ -421,49 +441,38 @@ class Vault {
|
|
|
421
441
|
return { created: false, source: existing }
|
|
422
442
|
}
|
|
423
443
|
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
try
|
|
432
|
-
await fs.promises.symlink(canonical, candidate, this.kernel.platform === "win32" ? "junction" : "dir")
|
|
433
|
-
mountPath = candidate
|
|
434
|
-
break
|
|
435
|
-
} catch (error) {
|
|
436
|
-
if (error && error.code === "EEXIST") continue
|
|
437
|
-
if (error && (error.code === "EACCES" || error.code === "EPERM")) {
|
|
438
|
-
throw new Error("Pinokio does not have permission to add that folder.")
|
|
439
|
-
}
|
|
440
|
-
throw new Error("Pinokio could not add that folder.")
|
|
441
|
-
}
|
|
444
|
+
const configuredPaths = await this.readExternalSourcePaths()
|
|
445
|
+
configuredPaths.push(canonical)
|
|
446
|
+
await this.writeExternalSourcePaths(configuredPaths)
|
|
447
|
+
await this.refreshSources()
|
|
448
|
+
const source = this._sources.find((item) =>
|
|
449
|
+
item.kind === "external" && item.configured && samePath(item.root, canonical))
|
|
450
|
+
if (!source) {
|
|
451
|
+
throw new Error("The folder could not be added. Restart Pinokio and try again.")
|
|
442
452
|
}
|
|
443
|
-
|
|
453
|
+
return { created: true, source }
|
|
454
|
+
}
|
|
455
|
+
async removeExternalSource(sourceIdToRemove) {
|
|
456
|
+
await this.refreshSources()
|
|
457
|
+
const source = this._sources.find((item) =>
|
|
458
|
+
item.id === sourceIdToRemove && item.kind === "external" && item.configured)
|
|
459
|
+
if (!source) return { error: "That external folder is no longer configured." }
|
|
444
460
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
return { created: true, source }
|
|
450
|
-
} catch (error) {
|
|
451
|
-
// Adding a source is transactional. Roll back only the exact directory
|
|
452
|
-
// link created above; preserve any path an external writer replaced.
|
|
453
|
-
try {
|
|
454
|
-
const [mounted, target] = await Promise.all([
|
|
455
|
-
fs.promises.lstat(mountPath),
|
|
456
|
-
fs.promises.realpath(mountPath)
|
|
457
|
-
])
|
|
458
|
-
if (mounted.isSymbolicLink() && samePath(target, canonical)) {
|
|
459
|
-
await fs.promises.unlink(mountPath)
|
|
460
|
-
}
|
|
461
|
-
} catch {
|
|
462
|
-
// The original error remains authoritative; an unverified path is
|
|
463
|
-
// deliberately preserved rather than deleted.
|
|
464
|
-
}
|
|
465
|
-
throw error
|
|
461
|
+
const configuredPaths = await this.readExternalSourcePaths()
|
|
462
|
+
const remaining = configuredPaths.filter((folderPath) => !samePath(folderPath, source.root))
|
|
463
|
+
if (remaining.length === configuredPaths.length) {
|
|
464
|
+
return { error: "That external folder is no longer configured." }
|
|
466
465
|
}
|
|
466
|
+
await this.writeExternalSourcePaths(remaining)
|
|
467
|
+
await this.refreshSources()
|
|
468
|
+
this.reconcileConfiguredSources()
|
|
469
|
+
for (const [filePath, entry] of [...this.registry.excluded]) {
|
|
470
|
+
if (entry.source_id === source.id) this.registry.excluded.delete(filePath)
|
|
471
|
+
}
|
|
472
|
+
this.registry.sourceScans.delete(source.id)
|
|
473
|
+
this.registry.lastScan = null
|
|
474
|
+
this.registry.schedulePersist()
|
|
475
|
+
return { removed: true, source_id: source.id, label: source.label }
|
|
467
476
|
}
|
|
468
477
|
sourceForPath(filePath, preferredId) {
|
|
469
478
|
let cursor = path.resolve(filePath)
|
|
@@ -1811,7 +1820,8 @@ class Vault {
|
|
|
1811
1820
|
display_path: source.kind === "pinokio" ? source.root : (source.mount_path || source.root),
|
|
1812
1821
|
target_path: source.kind === "external" ? source.root : null,
|
|
1813
1822
|
parent_id: scopeId ? null : source.parent_id, app: source.app || null,
|
|
1814
|
-
available: source.available !== false, shareable: source.kind === "virtual" ? null : !!source.shareable
|
|
1823
|
+
available: source.available !== false, shareable: source.kind === "virtual" ? null : !!source.shareable,
|
|
1824
|
+
removable: source.kind === "external" && source.configured === true
|
|
1815
1825
|
}))
|
|
1816
1826
|
const result = {
|
|
1817
1827
|
enabled: true,
|
package/package.json
CHANGED
package/server/public/vault.css
CHANGED
|
@@ -473,16 +473,26 @@ body[data-vault-mode="app"] .vault-overview {
|
|
|
473
473
|
.vault-cloud-warning .vault-text-button { margin-left: auto; }
|
|
474
474
|
.vault-cleanup-notice {
|
|
475
475
|
flex: 0 0 auto;
|
|
476
|
-
|
|
476
|
+
border-bottom-color: color-mix(in srgb, var(--vault-warning) 45%, var(--task-border));
|
|
477
|
+
background: color-mix(in srgb, var(--vault-warning) 16%, var(--task-panel));
|
|
477
478
|
}
|
|
478
|
-
.vault-cleanup-notice > i { color: var(--
|
|
479
|
+
.vault-cleanup-notice > i { color: var(--vault-warning); }
|
|
479
480
|
.vault-cleanup-notice strong { flex: 0 0 auto; font-weight: 650; }
|
|
480
481
|
.vault-cleanup-notice-detail {
|
|
481
482
|
min-width: 0;
|
|
482
483
|
flex: 1 1 auto;
|
|
483
|
-
color: var(--task-
|
|
484
|
+
color: color-mix(in srgb, var(--task-text) 78%, var(--vault-warning));
|
|
485
|
+
}
|
|
486
|
+
.vault-cleanup-notice .vault-button {
|
|
487
|
+
flex: 0 0 auto;
|
|
488
|
+
margin-left: auto;
|
|
489
|
+
border-color: color-mix(in srgb, var(--vault-warning) 70%, var(--task-border-strong));
|
|
490
|
+
background: var(--task-panel);
|
|
491
|
+
font-weight: 650;
|
|
492
|
+
}
|
|
493
|
+
.vault-cleanup-notice .vault-button:hover {
|
|
494
|
+
background: color-mix(in srgb, var(--vault-warning) 12%, var(--task-panel));
|
|
484
495
|
}
|
|
485
|
-
.vault-cleanup-notice .vault-button { flex: 0 0 auto; margin-left: auto; }
|
|
486
496
|
.vault-explorer {
|
|
487
497
|
display: grid;
|
|
488
498
|
min-height: 0;
|
package/server/public/vault.js
CHANGED
|
@@ -20,13 +20,17 @@ const COPY = {
|
|
|
20
20
|
external_added: "Added to Locations. Run a scan when you’re ready.",
|
|
21
21
|
external_exists: "That folder is already in Locations.",
|
|
22
22
|
external_other_disk: "Added to Locations. It can be scanned, but files on this disk cannot be deduplicated with files in your Pinokio folder.",
|
|
23
|
+
remove_external_folder: "Remove from Locations",
|
|
24
|
+
remove_external_confirm: "Remove “{name}” from Locations? This does not delete or modify any files.",
|
|
25
|
+
external_removed: "Removed from Locations. No files were changed.",
|
|
23
26
|
pinokio_folder: "Pinokio folder",
|
|
24
27
|
save_space: "Save space",
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
disk_space_freed: "of disk space freed",
|
|
29
|
+
sharing_saves_for_app: "Sharing saves {size} for this app",
|
|
30
|
+
without_sharing: "Without sharing",
|
|
31
|
+
without_sharing_help: "Estimated size of every scanned location if each app stored its own copy. File Explorer may count deduplicated files differently.",
|
|
32
|
+
on_disk: "On disk",
|
|
33
|
+
app_share: "This app’s share",
|
|
30
34
|
effective_help: "For deduplicated files, disk usage is divided evenly among every location using them.",
|
|
31
35
|
nothing_more_to_save: "Nothing else to save",
|
|
32
36
|
more_can_be_saved: "{size} more can be saved",
|
|
@@ -36,7 +40,8 @@ const COPY = {
|
|
|
36
40
|
find_savings: "Scan to find duplicate files and save disk space",
|
|
37
41
|
find_app_savings: "Scan this app to find duplicate files and save disk space",
|
|
38
42
|
storage_details: "Storage details",
|
|
39
|
-
|
|
43
|
+
kept_deduplicated: "Kept deduplicated",
|
|
44
|
+
already_shared: "Already shared before Save space",
|
|
40
45
|
last_scanned: "Last scanned",
|
|
41
46
|
never: "Never",
|
|
42
47
|
scan: "Scan now",
|
|
@@ -528,6 +533,9 @@ const batchAction = (source) => {
|
|
|
528
533
|
if (!source.shareable || !shareable.length) return `<div class="vault-pane-action-note">${esc(COPY.sharing_unavailable)}</div>`
|
|
529
534
|
return `<button class="vault-button" type="button" data-deduplicate-scope="${attr(source.id)}">${esc(COPY.deduplicate)} ${countLabel(shareable.length)}</button>`
|
|
530
535
|
}
|
|
536
|
+
const removeSourceAction = (source) => source && source.removable
|
|
537
|
+
? `<button class="vault-button" type="button" data-remove-source="${attr(source.id)}">${esc(COPY.remove_external_folder)}</button>`
|
|
538
|
+
: ""
|
|
531
539
|
|
|
532
540
|
const toolbarSummary = (visibleItems) => {
|
|
533
541
|
if (state.view === "duplicates") {
|
|
@@ -586,7 +594,8 @@ const renderToolbar = (visibleItems, allItems) => {
|
|
|
586
594
|
${displayModeControl()}
|
|
587
595
|
${descriptionMarkup}
|
|
588
596
|
<span class="vault-toolbar-count" id="vault-toolbar-summary">${esc(toolbarSummary(visibleItems))}</span>
|
|
589
|
-
${state.view === "all" ? batchAction(source) : bulkDeduplicationAction(allItems)}
|
|
597
|
+
${state.view === "all" ? batchAction(source) : bulkDeduplicationAction(allItems)}
|
|
598
|
+
${removeSourceAction(source)}`
|
|
590
599
|
}
|
|
591
600
|
|
|
592
601
|
const statusMarkup = (item) => {
|
|
@@ -939,23 +948,31 @@ const renderOverview = () => {
|
|
|
939
948
|
(!!last || beforeBytes > 0 || afterBytes > 0 || Number(data.pending_bytes) > 0)
|
|
940
949
|
const afterRatio = beforeBytes ? Math.min(100, (afterBytes / beforeBytes) * 100) : 0
|
|
941
950
|
const pendingBytes = Math.max(0, Number(data.pending_bytes) || 0)
|
|
951
|
+
// The headline reports bytes actually freed by user actions, so it always
|
|
952
|
+
// survives a comparison against the OS file manager. Sharing that already
|
|
953
|
+
// existed when a scan adopted it (package-manager hardlinks, earlier runs)
|
|
954
|
+
// never changed free space and stays out of this number.
|
|
955
|
+
const freedBytes = Math.max(0, Number(data.lifetime_bytes_saved) || 0)
|
|
956
|
+
const sharedNow = Math.max(0, Number(data.saved_by_sharing) || 0)
|
|
942
957
|
const headline = hasComparison
|
|
943
958
|
? (IS_APP_MODE
|
|
944
|
-
? COPY.
|
|
945
|
-
: `${fmt(
|
|
959
|
+
? COPY.sharing_saves_for_app.replace("{size}", fmt(Math.max(0, beforeBytes - afterBytes)))
|
|
960
|
+
: `${fmt(freedBytes)} ${COPY.disk_space_freed}`)
|
|
946
961
|
: (IS_APP_MODE ? COPY.find_app_savings : COPY.find_savings)
|
|
947
|
-
const
|
|
962
|
+
const beforeLabel = COPY.without_sharing
|
|
963
|
+
const afterLabel = IS_APP_MODE ? COPY.app_share : COPY.on_disk
|
|
964
|
+
const help = IS_APP_MODE ? COPY.effective_help : COPY.without_sharing_help
|
|
948
965
|
const helpId = IS_APP_MODE ? "vault-after-help" : "vault-before-help"
|
|
949
966
|
const helpMarkup = `<span class="vault-compare-info" tabindex="0" aria-describedby="${helpId}"><i class="fa-regular fa-circle-question" aria-hidden="true"></i><span class="vault-compare-tooltip" id="${helpId}" role="tooltip">${esc(help)}</span></span>`
|
|
950
967
|
const comparison = hasComparison ? `
|
|
951
|
-
<div class="vault-comparison" aria-label="${attr(`${
|
|
968
|
+
<div class="vault-comparison" aria-label="${attr(`${beforeLabel}: ${fmt(beforeBytes)}. ${afterLabel}: ${fmt(afterBytes)}.`)}">
|
|
952
969
|
<div class="vault-compare-row">
|
|
953
|
-
<span class="vault-compare-label">${esc(
|
|
970
|
+
<span class="vault-compare-label">${esc(beforeLabel)}${IS_APP_MODE ? "" : helpMarkup}</span>
|
|
954
971
|
<span class="vault-compare-track"><span class="vault-compare-fill before"></span></span>
|
|
955
972
|
<span class="vault-compare-value">${fmt(beforeBytes)}</span>
|
|
956
973
|
</div>
|
|
957
974
|
<div class="vault-compare-row">
|
|
958
|
-
<span class="vault-compare-label">${esc(
|
|
975
|
+
<span class="vault-compare-label">${esc(afterLabel)}${IS_APP_MODE ? helpMarkup : ""}</span>
|
|
959
976
|
<span class="vault-compare-track"><span class="vault-compare-fill after" style="--vault-after-ratio:${afterRatio.toFixed(2)}%"></span></span>
|
|
960
977
|
<span class="vault-compare-value">${fmt(afterBytes)}</span>
|
|
961
978
|
</div>
|
|
@@ -976,10 +993,14 @@ const renderOverview = () => {
|
|
|
976
993
|
const storageDetails = !IS_APP_MODE && el("vault-storage-details")
|
|
977
994
|
if (storageDetails) {
|
|
978
995
|
const homeBytes = last ? (last.home_bytes_total == null ? last.bytes_total : last.home_bytes_total) : null
|
|
996
|
+
// Clamped because deleting linked files later shrinks the live sharing
|
|
997
|
+
// total without touching the lifetime counter.
|
|
998
|
+
const alreadyShared = Math.max(0, sharedNow - freedBytes)
|
|
979
999
|
storageDetails.innerHTML = `<div class="vault-advanced-title">${esc(COPY.storage_details)}</div>
|
|
980
1000
|
<dl class="vault-storage-list">
|
|
981
1001
|
<div><dt>${esc(COPY.pinokio_folder)}</dt><dd>${homeBytes == null ? "—" : fmt(homeBytes)}</dd></div>
|
|
982
|
-
<div><dt>${esc(COPY.
|
|
1002
|
+
<div><dt>${esc(COPY.kept_deduplicated)}</dt><dd>${fmt(sharedNow)}</dd></div>
|
|
1003
|
+
<div><dt>${esc(COPY.already_shared)}</dt><dd>${fmt(alreadyShared)}</dd></div>
|
|
983
1004
|
</dl>`
|
|
984
1005
|
}
|
|
985
1006
|
}
|
|
@@ -1568,6 +1589,18 @@ document.addEventListener("click", async (event) => {
|
|
|
1568
1589
|
if (state.expandedFiles.has(file)) state.expandedFiles.delete(file)
|
|
1569
1590
|
else state.expandedFiles.add(file)
|
|
1570
1591
|
render()
|
|
1592
|
+
} else if (target.dataset.removeSource) {
|
|
1593
|
+
const source = sourceById(target.dataset.removeSource)
|
|
1594
|
+
if (!source) return
|
|
1595
|
+
const message = COPY.remove_external_confirm.replace("{name}", source.label)
|
|
1596
|
+
if (!window.confirm(message)) return
|
|
1597
|
+
await runAction({
|
|
1598
|
+
action: "remove_source",
|
|
1599
|
+
source_id: source.id
|
|
1600
|
+
}, () => {
|
|
1601
|
+
state.sourceId = null
|
|
1602
|
+
return COPY.external_removed
|
|
1603
|
+
})
|
|
1571
1604
|
} else if (target.id === "btn-add-source") {
|
|
1572
1605
|
target.disabled = true
|
|
1573
1606
|
try {
|
package/test/vault-sweep.test.js
CHANGED
|
@@ -265,7 +265,7 @@ describe('vault manual scan (phase 3)', () => {
|
|
|
265
265
|
assert.strictEqual((await fs.promises.stat(outside)).nlink, 1)
|
|
266
266
|
})
|
|
267
267
|
|
|
268
|
-
test('top-level
|
|
268
|
+
test('top-level api links are not adopted as external sources', async (t) => {
|
|
269
269
|
const { home, vault } = await makeEnv()
|
|
270
270
|
const external = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-external-'))
|
|
271
271
|
const nested = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-external-nested-'))
|
|
@@ -286,22 +286,20 @@ describe('vault manual scan (phase 3)', () => {
|
|
|
286
286
|
const source = vault.sources().find((item) => item.kind === 'external' && item.label === 'linked-models')
|
|
287
287
|
const canonicalImported = path.resolve(await fs.promises.realpath(external), 'models', 'm.bin')
|
|
288
288
|
const canonicalNestedFile = path.resolve(await fs.promises.realpath(nested), 'should-not-scan.bin')
|
|
289
|
-
assert.
|
|
290
|
-
assert.strictEqual(
|
|
291
|
-
assert.ok(vault.registry.scanIndex.has(canonicalImported), 'external root was scanned')
|
|
289
|
+
assert.strictEqual(source, undefined, 'api links are not Vault configuration')
|
|
290
|
+
assert.strictEqual(vault.registry.scanIndex.has(canonicalImported), false)
|
|
292
291
|
assert.strictEqual(vault.registry.scanIndex.has(canonicalNestedFile), false, 'nested directory link was not followed')
|
|
293
|
-
assert.strictEqual(vault.registry.duplicates.get(canonicalImported).source_id, source.id)
|
|
294
292
|
assert.strictEqual((await fs.promises.stat(local)).nlink, 2)
|
|
295
293
|
|
|
296
294
|
const status = await vault.status()
|
|
297
|
-
assert.
|
|
298
|
-
assert.strictEqual(status.duplicates.
|
|
295
|
+
assert.strictEqual(status.sources.some((item) => item.kind === 'external'), false)
|
|
296
|
+
assert.strictEqual(status.duplicates.some((item) => item.path === canonicalImported), false)
|
|
299
297
|
assert.strictEqual(status.last_scan.home_bytes_total, content.length, 'Pinokio metric excludes external files')
|
|
300
|
-
assert.strictEqual(status.last_scan.
|
|
298
|
+
assert.strictEqual(status.last_scan.bytes_total, content.length)
|
|
301
299
|
})
|
|
302
300
|
|
|
303
|
-
test('adding an external source
|
|
304
|
-
const { home, vault } = await makeEnv()
|
|
301
|
+
test('adding and removing an external source persists only its path and never creates a link', async () => {
|
|
302
|
+
const { home, vault, kernel } = await makeEnv()
|
|
305
303
|
const external = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-added-external-'))
|
|
306
304
|
homes.push(external)
|
|
307
305
|
const content = crypto.randomBytes(4096)
|
|
@@ -312,27 +310,42 @@ describe('vault manual scan (phase 3)', () => {
|
|
|
312
310
|
assert.strictEqual(added.source.kind, 'external')
|
|
313
311
|
assert.strictEqual(added.source.parent_id, 'external')
|
|
314
312
|
assert.strictEqual(vault.registry.scanIndex.size, 0, 'adding a source never scans it')
|
|
315
|
-
assert.strictEqual((await fs.promises.
|
|
316
|
-
assert.strictEqual(
|
|
313
|
+
assert.strictEqual(added.source.root, path.resolve(await fs.promises.realpath(external)))
|
|
314
|
+
assert.strictEqual(added.source.mount_path, undefined)
|
|
315
|
+
assert.deepStrictEqual(
|
|
316
|
+
JSON.parse(await fs.promises.readFile(vault.externalSourcesPath, 'utf8')).paths,
|
|
317
|
+
[added.source.root]
|
|
318
|
+
)
|
|
319
|
+
assert.strictEqual(fs.existsSync(path.resolve(home, 'vault', 'sources')), false)
|
|
317
320
|
assert.deepStrictEqual(await fs.promises.readdir(path.resolve(home, 'api')), [],
|
|
318
|
-
'a
|
|
321
|
+
'adding a Vault source never creates an api link')
|
|
319
322
|
|
|
320
323
|
const repeated = await vault.addExternalSource(external)
|
|
321
324
|
assert.strictEqual(repeated.created, false, 'the same physical folder is not imported twice')
|
|
322
325
|
assert.strictEqual(vault.sources().filter((source) => source.kind === 'external').length, 1)
|
|
323
326
|
|
|
327
|
+
const fresh = new Vault(kernel)
|
|
328
|
+
await fresh.init()
|
|
329
|
+
assert.ok(fresh.sources().some((source) =>
|
|
330
|
+
source.kind === 'external' && source.root === added.source.root))
|
|
331
|
+
|
|
324
332
|
await vault.sweeper.scan()
|
|
325
333
|
const canonicalImported = path.resolve(await fs.promises.realpath(imported))
|
|
326
334
|
assert.ok(vault.registry.scanIndex.has(canonicalImported))
|
|
327
335
|
|
|
328
|
-
await
|
|
329
|
-
|
|
336
|
+
const removed = await vault.perform('remove_source', { source_id: added.source.id })
|
|
337
|
+
assert.strictEqual(removed.removed, true)
|
|
338
|
+
assert.deepStrictEqual(
|
|
339
|
+
JSON.parse(await fs.promises.readFile(vault.externalSourcesPath, 'utf8')).paths,
|
|
340
|
+
[]
|
|
341
|
+
)
|
|
330
342
|
const status = await vault.status()
|
|
331
343
|
assert.strictEqual(vault.registry.links.has(canonicalImported), false)
|
|
332
344
|
assert.strictEqual(vault.registry.duplicates.has(canonicalImported), false)
|
|
333
345
|
assert.strictEqual(vault.registry.scanIndex.has(canonicalImported), false)
|
|
334
346
|
assert.strictEqual(status.sources.some((source) => source.kind === 'external'), false)
|
|
335
347
|
assert.strictEqual(status.duplicates.some((item) => item.path === canonicalImported), false)
|
|
348
|
+
assert.deepStrictEqual(await fs.promises.readFile(imported), content)
|
|
336
349
|
})
|
|
337
350
|
|
|
338
351
|
test('nested external sources are walked once and attributed to the deepest source', async () => {
|
|
@@ -393,7 +406,7 @@ describe('vault manual scan (phase 3)', () => {
|
|
|
393
406
|
const realStat = fs.promises.stat
|
|
394
407
|
let externalStats = 0
|
|
395
408
|
fs.promises.stat = async (target, options) => {
|
|
396
|
-
if (path.resolve(String(target)) === path.resolve(canonicalExternal) && ++externalStats ===
|
|
409
|
+
if (path.resolve(String(target)) === path.resolve(canonicalExternal) && ++externalStats === 1) {
|
|
397
410
|
const error = new Error('temporary external metadata failure')
|
|
398
411
|
error.code = 'EIO'
|
|
399
412
|
throw error
|
|
@@ -452,28 +465,26 @@ describe('vault manual scan (phase 3)', () => {
|
|
|
452
465
|
assert.strictEqual(vault.registry.lastScan.files, 2)
|
|
453
466
|
})
|
|
454
467
|
|
|
455
|
-
test('a failed source
|
|
468
|
+
test('a failed external-source settings write creates no configuration', async () => {
|
|
456
469
|
const { vault } = await makeEnv()
|
|
457
470
|
const external = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-add-rollback-'))
|
|
458
471
|
homes.push(external)
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
fs.promises.readdir = async (target, options) => {
|
|
463
|
-
if (path.resolve(target) === importRoot && ++importReads === 1) {
|
|
472
|
+
const realAtomicWrite = vault.registry.atomicWrite
|
|
473
|
+
vault.registry.atomicWrite = async (target, contents) => {
|
|
474
|
+
if (path.resolve(target) === vault.externalSourcesPath) {
|
|
464
475
|
const error = new Error('transient read failure')
|
|
465
476
|
error.code = 'EIO'
|
|
466
477
|
throw error
|
|
467
478
|
}
|
|
468
|
-
return
|
|
479
|
+
return realAtomicWrite.call(vault.registry, target, contents)
|
|
469
480
|
}
|
|
470
481
|
try {
|
|
471
482
|
await assert.rejects(vault.addExternalSource(external), (error) => error.code === 'EIO')
|
|
472
483
|
} finally {
|
|
473
|
-
|
|
484
|
+
vault.registry.atomicWrite = realAtomicWrite
|
|
474
485
|
}
|
|
475
486
|
|
|
476
|
-
assert.
|
|
487
|
+
assert.strictEqual(fs.existsSync(vault.externalSourcesPath), false)
|
|
477
488
|
assert.strictEqual(vault.sources().some((source) => source.kind === 'external'), false)
|
|
478
489
|
})
|
|
479
490
|
|
package/test/vault-ui.test.js
CHANGED
|
@@ -1260,11 +1260,11 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
1260
1260
|
|
|
1261
1261
|
test('repair is advanced, metrics distinguish detected and explicit savings, and refresh retries', async () => {
|
|
1262
1262
|
const vaultPage = await vaultPageSource()
|
|
1263
|
-
assert.match(vaultPage, /
|
|
1264
|
-
assert.match(vaultPage, /
|
|
1263
|
+
assert.match(vaultPage, /disk_space_freed:\s*"of disk space freed"/)
|
|
1264
|
+
assert.match(vaultPage, /without_sharing_help:\s*"Estimated size of every scanned location if each app stored its own copy\. File Explorer may count deduplicated files differently\."/)
|
|
1265
1265
|
assert.match(vaultPage, /nothing_more_to_save:\s*"Nothing else to save"/)
|
|
1266
1266
|
assert.match(vaultPage, /more_can_be_saved:\s*"\{size\} more can be saved"/)
|
|
1267
|
-
assert.match(vaultPage, /
|
|
1267
|
+
assert.match(vaultPage, /Number\(data\.saved_by_sharing\)/)
|
|
1268
1268
|
assert.match(vaultPage, /Number\(data\.bytes_without_sharing\)/)
|
|
1269
1269
|
assert.match(vaultPage, /Number\(data\.bytes_on_disk\)/)
|
|
1270
1270
|
assert.match(vaultPage, /Number\(data\.pending_bytes\)/)
|
|
@@ -1276,7 +1276,8 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
1276
1276
|
assert.match(vaultPage, /candidate_size:\s*candidateSize\(\)/)
|
|
1277
1277
|
assert.match(vaultPage, /localStorage\.setItem\(candidateSizeKey/)
|
|
1278
1278
|
assert.match(vaultPage, /id='vault-storage-details'/)
|
|
1279
|
-
assert.match(vaultPage, /
|
|
1279
|
+
assert.match(vaultPage, /Number\(data\.lifetime_bytes_saved\)/)
|
|
1280
|
+
assert.match(vaultPage, /Math\.max\(0, sharedNow - freedBytes\)/)
|
|
1280
1281
|
assert.match(vaultPage, /repair_index:\s*"Repair index"/)
|
|
1281
1282
|
assert.match(vaultPage, /<details class='vault-advanced'/)
|
|
1282
1283
|
assert.doesNotMatch(vaultPage, /id='btn-rebuild'/)
|
|
@@ -1333,6 +1334,8 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
1333
1334
|
assert.match(vaultPage, /id='btn-add-source'/)
|
|
1334
1335
|
assert.match(vaultPage, /<script src="\/Socket\.js"><\/script>/)
|
|
1335
1336
|
assert.match(vaultPage, /action:\s*"add_source"/)
|
|
1337
|
+
assert.match(vaultPage, /action:\s*"remove_source"/)
|
|
1338
|
+
assert.match(vaultPage, /remove_external_folder:\s*"Remove from Locations"/)
|
|
1336
1339
|
assert.match(vaultPage, /identical_contents_at:\s*"Identical contents at"/)
|
|
1337
1340
|
assert.doesNotMatch(vaultPage, /Matching locations/)
|
|
1338
1341
|
assert.match(vaultPage, /scan_waiting:\s*"Waiting for scan results"/)
|
|
@@ -1525,7 +1528,7 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
1525
1528
|
await runVaultScript(dom)
|
|
1526
1529
|
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
1527
1530
|
|
|
1528
|
-
assert.strictEqual(dom.window.document.querySelector('.vault-summary-value').textContent, '4.51 GB
|
|
1531
|
+
assert.strictEqual(dom.window.document.querySelector('.vault-summary-value').textContent, 'Sharing saves 4.51 GB for this app')
|
|
1529
1532
|
assert.deepStrictEqual([...dom.window.document.querySelectorAll('.vault-compare-value')].map((node) => node.textContent), ['7.09 GB', '2.58 GB'])
|
|
1530
1533
|
assert.match(dom.window.document.querySelector('.vault-compare-fill.after').getAttribute('style'), /--vault-after-ratio:36\.36%/)
|
|
1531
1534
|
assert.strictEqual(dom.window.document.getElementById('vault-after-help').textContent,
|
|
@@ -1601,8 +1604,8 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
1601
1604
|
await runVaultScript(dom)
|
|
1602
1605
|
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
1603
1606
|
|
|
1604
|
-
assert.strictEqual(dom.window.document.querySelector('.vault-summary-value').textContent, '
|
|
1605
|
-
assert.deepStrictEqual([...dom.window.document.querySelectorAll('.vault-compare-label')].map((node) => node.firstChild.textContent.trim()), ['
|
|
1607
|
+
assert.strictEqual(dom.window.document.querySelector('.vault-summary-value').textContent, '258.77 GB of disk space freed')
|
|
1608
|
+
assert.deepStrictEqual([...dom.window.document.querySelectorAll('.vault-compare-label')].map((node) => node.firstChild.textContent.trim()), ['Without sharing', 'On disk'])
|
|
1606
1609
|
assert.deepStrictEqual([...dom.window.document.querySelectorAll('.vault-compare-value')].map((node) => node.textContent), ['661.75 GB', '404.8 GB'])
|
|
1607
1610
|
assert.match(dom.window.document.querySelector('.vault-compare-fill.after').getAttribute('style'), /--vault-after-ratio:61\.17%/)
|
|
1608
1611
|
assert.strictEqual(dom.window.document.getElementById('vault-before-help').textContent,
|
|
@@ -1613,7 +1616,10 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
1613
1616
|
assert.strictEqual(dom.window.document.getElementById('btn-review-metric').classList.contains('primary'), true)
|
|
1614
1617
|
assert.strictEqual(dom.window.document.getElementById('btn-scan').classList.contains('primary'), false)
|
|
1615
1618
|
assert.match(dom.window.document.getElementById('vault-storage-details').textContent, /Pinokio folder\s*166\.22 GB/)
|
|
1616
|
-
assert.match(dom.window.document.getElementById('vault-storage-details').textContent, /
|
|
1619
|
+
assert.match(dom.window.document.getElementById('vault-storage-details').textContent, /Kept deduplicated\s*256\.95 GB/)
|
|
1620
|
+
// lifetime (241) exceeds live sharing (239.3) here, so the clamp keeps
|
|
1621
|
+
// the pre-existing figure at zero instead of going negative.
|
|
1622
|
+
assert.match(dom.window.document.getElementById('vault-storage-details').textContent, /Already shared before Save space\s*0 B/)
|
|
1617
1623
|
const cleanupNotice = dom.window.document.getElementById('vault-cleanup-notice')
|
|
1618
1624
|
assert.strictEqual(cleanupNotice.classList.contains('show'), true)
|
|
1619
1625
|
assert.match(cleanupNotice.textContent, /ready to clean up/)
|
|
@@ -1684,7 +1690,7 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
1684
1690
|
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
1685
1691
|
|
|
1686
1692
|
assert.strictEqual(dom.window.document.querySelector('.vault-summary-value').textContent,
|
|
1687
|
-
'3 GB of disk space
|
|
1693
|
+
'3 GB of disk space freed')
|
|
1688
1694
|
assert.deepStrictEqual(
|
|
1689
1695
|
[...dom.window.document.querySelectorAll('.vault-compare-value')].map((node) => node.textContent),
|
|
1690
1696
|
['10 GB', '7 GB'])
|
|
@@ -2588,7 +2594,7 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
2588
2594
|
assert.doesNotMatch(actionRoute, /convertPending|runExclusive/)
|
|
2589
2595
|
})
|
|
2590
2596
|
|
|
2591
|
-
test('a stale external source id cannot authorize files outside its current target', async (
|
|
2597
|
+
test('a stale external source id cannot authorize files outside its current target', async () => {
|
|
2592
2598
|
const { home, vault } = await makeEnv()
|
|
2593
2599
|
const firstTarget = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-scope-old-'))
|
|
2594
2600
|
const secondTarget = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-scope-new-'))
|
|
@@ -2597,16 +2603,8 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
2597
2603
|
const hash = sha256(content)
|
|
2598
2604
|
const original = await writeFile(path.resolve(home, 'api', 'appA', 'model.bin'), content)
|
|
2599
2605
|
const outside = await writeFile(path.resolve(firstTarget, 'model.bin'), content)
|
|
2600
|
-
const
|
|
2601
|
-
try {
|
|
2602
|
-
await fs.promises.symlink(firstTarget, mount, process.platform === 'win32' ? 'junction' : 'dir')
|
|
2603
|
-
} catch (error) {
|
|
2604
|
-
t.skip(`directory links unavailable: ${error.message}`)
|
|
2605
|
-
return
|
|
2606
|
-
}
|
|
2607
|
-
await vault.refreshSources()
|
|
2606
|
+
const externalSource = (await vault.addExternalSource(firstTarget)).source
|
|
2608
2607
|
const originalSource = vault.sources().find((source) => source.kind === 'app' && source.app === 'appA')
|
|
2609
|
-
const externalSource = vault.sources().find((source) => source.kind === 'external' && source.label === 'external-models')
|
|
2610
2608
|
await vault.adopt(original, hash, { app: 'appA', source_id: originalSource.id })
|
|
2611
2609
|
const outsideStat = await fs.promises.stat(outside)
|
|
2612
2610
|
vault.registry.duplicates.set(outside, {
|
|
@@ -2614,12 +2612,12 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
2614
2612
|
dev: outsideStat.dev, ino: outsideStat.ino,
|
|
2615
2613
|
mtime: outsideStat.mtimeMs, ctime: outsideStat.ctimeMs
|
|
2616
2614
|
})
|
|
2617
|
-
await
|
|
2618
|
-
await
|
|
2615
|
+
await vault.writeExternalSourcePaths([secondTarget])
|
|
2616
|
+
await vault.refreshSources()
|
|
2619
2617
|
|
|
2620
2618
|
const result = await vault.perform('deduplicate', { scope_id: externalSource.id })
|
|
2621
2619
|
|
|
2622
|
-
assert.
|
|
2620
|
+
assert.match(result.error, /no longer available/i)
|
|
2623
2621
|
assert.strictEqual((await fs.promises.stat(outside)).nlink, 1)
|
|
2624
2622
|
assert.ok(vault.registry.duplicates.has(outside))
|
|
2625
2623
|
})
|
|
@@ -2768,16 +2766,13 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
2768
2766
|
assert.strictEqual(vault.registry.links.has(pending), false)
|
|
2769
2767
|
})
|
|
2770
2768
|
|
|
2771
|
-
test('status separates app, Pinokio folder, and external source metadata', async (
|
|
2769
|
+
test('status separates app, Pinokio folder, and external source metadata', async () => {
|
|
2772
2770
|
const { home, vault } = await makeEnv()
|
|
2773
|
-
const
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
t.skip(`directory links unavailable: ${error.message}`)
|
|
2779
|
-
return
|
|
2780
|
-
}
|
|
2771
|
+
const externalParent = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-ui-external-'))
|
|
2772
|
+
const external = path.resolve(externalParent, 'linked-models')
|
|
2773
|
+
homes.push(externalParent)
|
|
2774
|
+
await fs.promises.mkdir(external)
|
|
2775
|
+
await vault.addExternalSource(external)
|
|
2781
2776
|
await fs.promises.mkdir(path.resolve(home, 'api', 'local-app'), { recursive: true })
|
|
2782
2777
|
await fs.promises.mkdir(path.resolve(home, 'cache'), { recursive: true })
|
|
2783
2778
|
await vault.refreshSources()
|
|
@@ -2785,6 +2780,8 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
2785
2780
|
const byKind = new Map(status.sources.map((source) => [source.id, source]))
|
|
2786
2781
|
assert.ok([...byKind.values()].some((source) => source.kind === 'app' && source.label === 'local-app' && source.parent_id === 'apps'))
|
|
2787
2782
|
assert.ok([...byKind.values()].some((source) => source.kind === 'folder' && source.label === 'cache' && source.parent_id === 'pinokio'))
|
|
2788
|
-
assert.ok([...byKind.values()].some((source) =>
|
|
2783
|
+
assert.ok([...byKind.values()].some((source) =>
|
|
2784
|
+
source.kind === 'external' && source.label === 'linked-models' &&
|
|
2785
|
+
source.parent_id === 'external' && source.removable === true))
|
|
2789
2786
|
})
|
|
2790
2787
|
})
|