pinokiod 8.0.108 → 8.0.111
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 +222 -44
- package/kernel/vault/registry.js +2 -0
- package/kernel/vault/registry_core.js +314 -46
- package/package.json +1 -1
- package/server/index.js +21 -8
- package/server/public/vault.css +17 -4
- package/server/public/vault.js +351 -126
- package/server/views/app.ejs +3 -3
- package/test/vault-engine.test.js +123 -0
- package/test/vault-ui.test.js +197 -21
package/kernel/vault/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
const fs = require("fs")
|
|
2
2
|
const path = require("path")
|
|
3
3
|
const crypto = require("crypto")
|
|
4
|
-
const { execFile } = require("child_process")
|
|
5
4
|
const { Worker } = require("worker_threads")
|
|
5
|
+
const Util = require("../util")
|
|
6
6
|
const Registry = require("./registry")
|
|
7
7
|
const Scanner = require("./scanner")
|
|
8
8
|
const Sweeper = require("./sweeper")
|
|
@@ -150,6 +150,15 @@ const sameFileMetadata = (left, right) => {
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
const sourceId = (kind, name) => `${kind}:${encodeURIComponent(name)}`
|
|
153
|
+
const commonDirectory = (left, right) => {
|
|
154
|
+
let index = 0
|
|
155
|
+
while (index < left.length &&
|
|
156
|
+
index < right.length &&
|
|
157
|
+
left[index] === right[index]) index += 1
|
|
158
|
+
const shared = left.slice(0, index)
|
|
159
|
+
const cut = shared.lastIndexOf("/")
|
|
160
|
+
return cut < 0 ? "" : shared.slice(0, cut)
|
|
161
|
+
}
|
|
153
162
|
const boundedInteger = (value, fallback, minimum, maximum) => {
|
|
154
163
|
const parsed = Number(value)
|
|
155
164
|
if (!Number.isSafeInteger(parsed)) return fallback
|
|
@@ -188,41 +197,11 @@ class FileActionChanges {
|
|
|
188
197
|
}
|
|
189
198
|
}
|
|
190
199
|
|
|
191
|
-
const revealInFileManager = (filePath, platform = process.platform) =>
|
|
192
|
-
new Promise((resolve, reject) => {
|
|
193
|
-
const command = platform === "darwin"
|
|
194
|
-
? "open"
|
|
195
|
-
: platform === "win32"
|
|
196
|
-
? "explorer.exe"
|
|
197
|
-
: "xdg-open"
|
|
198
|
-
const args = platform === "darwin"
|
|
199
|
-
? ["-R", filePath]
|
|
200
|
-
: platform === "win32"
|
|
201
|
-
? [`/select,${filePath}`]
|
|
202
|
-
: [path.dirname(filePath)]
|
|
203
|
-
execFile(command, args, {
|
|
204
|
-
timeout: 10000,
|
|
205
|
-
windowsHide: true
|
|
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
|
-
})
|
|
216
|
-
})
|
|
217
|
-
|
|
218
200
|
class Vault {
|
|
219
201
|
constructor(kernel) {
|
|
220
202
|
this.kernel = kernel
|
|
221
203
|
this.fileManagerLauncher = (filePath) =>
|
|
222
|
-
|
|
223
|
-
filePath,
|
|
224
|
-
this.kernel.platform || process.platform
|
|
225
|
-
)
|
|
204
|
+
Util.openfs(filePath, { action: "view" }, this.kernel)
|
|
226
205
|
this.enabled = false
|
|
227
206
|
this.initialized = false
|
|
228
207
|
this.mode = null
|
|
@@ -1257,6 +1236,19 @@ class Vault {
|
|
|
1257
1236
|
}
|
|
1258
1237
|
}
|
|
1259
1238
|
|
|
1239
|
+
sourceLabelChain(source) {
|
|
1240
|
+
if (!source) return ""
|
|
1241
|
+
const parts = [source.label]
|
|
1242
|
+
const seen = new Set([source.id])
|
|
1243
|
+
let current = source
|
|
1244
|
+
while (current && current.parent_id && !seen.has(current.parent_id)) {
|
|
1245
|
+
seen.add(current.parent_id)
|
|
1246
|
+
current = this._sourcesById.get(current.parent_id)
|
|
1247
|
+
if (current) parts.unshift(current.label)
|
|
1248
|
+
}
|
|
1249
|
+
return parts.join(" / ")
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1260
1252
|
sourceIsWithinScope(source, scopeId) {
|
|
1261
1253
|
if (!scopeId) return true
|
|
1262
1254
|
const seen = new Set()
|
|
@@ -2055,7 +2047,10 @@ class Vault {
|
|
|
2055
2047
|
const selection = this.separateSelection(payload)
|
|
2056
2048
|
if (selection.error) return { error: selection.error }
|
|
2057
2049
|
const total = await this.registry.matchingFileSummary(
|
|
2058
|
-
"linked",
|
|
2050
|
+
"linked",
|
|
2051
|
+
selection.sourceIds,
|
|
2052
|
+
selection.query,
|
|
2053
|
+
selection.nameOnly)
|
|
2059
2054
|
if (!total.count) {
|
|
2060
2055
|
return { error: "No matching deduplicated files remain." }
|
|
2061
2056
|
}
|
|
@@ -2616,7 +2611,10 @@ class Vault {
|
|
|
2616
2611
|
}
|
|
2617
2612
|
return {
|
|
2618
2613
|
sourceIds,
|
|
2619
|
-
query: String(payload.query || "").slice(0, 500).trim()
|
|
2614
|
+
query: String(payload.query || "").slice(0, 500).trim(),
|
|
2615
|
+
// Separating "all matching" has to mean the rows the search listed, so
|
|
2616
|
+
// it repeats the filter the view was using.
|
|
2617
|
+
nameOnly: payload.display_mode === "files"
|
|
2620
2618
|
}
|
|
2621
2619
|
}
|
|
2622
2620
|
|
|
@@ -2984,7 +2982,8 @@ class Vault {
|
|
|
2984
2982
|
selection.sourceIds,
|
|
2985
2983
|
cursor,
|
|
2986
2984
|
100,
|
|
2987
|
-
selection.query
|
|
2985
|
+
selection.query,
|
|
2986
|
+
selection.nameOnly
|
|
2988
2987
|
)
|
|
2989
2988
|
if (!rows.length) break
|
|
2990
2989
|
const affectedHashes = new Set()
|
|
@@ -3320,24 +3319,194 @@ class Vault {
|
|
|
3320
3319
|
}
|
|
3321
3320
|
|
|
3322
3321
|
publicDuplicateChildItems(rows) {
|
|
3323
|
-
const publicStatus = {
|
|
3324
|
-
reference: "tracked",
|
|
3325
|
-
duplicate: "duplicate",
|
|
3326
|
-
linked: "shared"
|
|
3327
|
-
}
|
|
3328
3322
|
return (rows || []).map((row) => Object.assign({
|
|
3329
3323
|
kind: "duplicate_path",
|
|
3330
3324
|
path: row.path,
|
|
3331
3325
|
hash: row.hash,
|
|
3332
3326
|
size: Number(row.size) || 0,
|
|
3333
3327
|
app: row.app || null,
|
|
3334
|
-
status:
|
|
3328
|
+
status: PUBLIC_FILE_STATUS[row.status],
|
|
3335
3329
|
registry_status: row.status,
|
|
3336
3330
|
shareable: row.status === "duplicate",
|
|
3337
3331
|
selectable: !!row.selectable
|
|
3338
3332
|
}, this.locationForPath(row.path, row.source_id)))
|
|
3339
3333
|
}
|
|
3340
3334
|
|
|
3335
|
+
// One level of the folder tree. Subfolders come back with the totals for
|
|
3336
|
+
// everything inside them, which is what makes sorting a level by size mean
|
|
3337
|
+
// anything; files sitting directly in the folder come back as normal rows.
|
|
3338
|
+
async treeEntries(scopeId, options = {}) {
|
|
3339
|
+
if (!this.enabled || !this.registry) return { enabled: false }
|
|
3340
|
+
const locationId = typeof options.location_id === "string" &&
|
|
3341
|
+
options.location_id
|
|
3342
|
+
? options.location_id
|
|
3343
|
+
: null
|
|
3344
|
+
const view = STATUS_VIEWS.has(options.view) ? options.view : "all"
|
|
3345
|
+
const statusFilter = STATUS_FILTERS.has(options.status_filter)
|
|
3346
|
+
? options.status_filter
|
|
3347
|
+
: "all"
|
|
3348
|
+
const query = String(options.query || "").slice(0, 500).trim()
|
|
3349
|
+
const sizeSort = options.size_sort === "asc" || options.size_sort === "desc"
|
|
3350
|
+
? options.size_sort
|
|
3351
|
+
: null
|
|
3352
|
+
const nameSort = options.name_sort === "desc" ? "desc" : "asc"
|
|
3353
|
+
// The authorised set, not the requested id: a scoped page must not be able
|
|
3354
|
+
// to read another location by naming it, and a rail entry standing for a
|
|
3355
|
+
// group of locations -- Pinokio, Apps -- owns no files of its own, so it
|
|
3356
|
+
// resolves to its descendants rather than to an empty tree.
|
|
3357
|
+
const sourceIds = this.scopeSourceIds(scopeId, locationId)
|
|
3358
|
+
const source = sourceIds.length === 1
|
|
3359
|
+
? this._sourcesById.get(sourceIds[0])
|
|
3360
|
+
: null
|
|
3361
|
+
// A group of locations, or none chosen at all, is a tree of locations.
|
|
3362
|
+
if (!source || !source.root) {
|
|
3363
|
+
const rows = await this.registry.treeLocationRows({
|
|
3364
|
+
sourceIds,
|
|
3365
|
+
unrestricted: !scopeId && !locationId,
|
|
3366
|
+
externalSourceIds: this.configuredExternalSourceIds(),
|
|
3367
|
+
view,
|
|
3368
|
+
statusFilter,
|
|
3369
|
+
query,
|
|
3370
|
+
nameOnly: false
|
|
3371
|
+
})
|
|
3372
|
+
const items = rows
|
|
3373
|
+
.map((row) => {
|
|
3374
|
+
const entry = this._sourcesById.get(row.source_id)
|
|
3375
|
+
if (!entry) return null
|
|
3376
|
+
return {
|
|
3377
|
+
kind: "location",
|
|
3378
|
+
name: entry.label,
|
|
3379
|
+
label: this.sourceLabelChain(entry),
|
|
3380
|
+
relative_path: "",
|
|
3381
|
+
location_id: entry.id,
|
|
3382
|
+
source_id: entry.id,
|
|
3383
|
+
size: Number(row.bytes) || 0,
|
|
3384
|
+
file_count: Number(row.file_count) || 0,
|
|
3385
|
+
duplicate_count: Number(row.duplicate_count) || 0,
|
|
3386
|
+
unavailable_count: Number(row.unavailable_count) || 0,
|
|
3387
|
+
linked_count: Number(row.linked_count) || 0
|
|
3388
|
+
}
|
|
3389
|
+
})
|
|
3390
|
+
.filter(Boolean)
|
|
3391
|
+
items.sort((left, right) => {
|
|
3392
|
+
if (sizeSort === "asc" && left.size !== right.size) return left.size - right.size
|
|
3393
|
+
if (sizeSort === "desc" && left.size !== right.size) return right.size - left.size
|
|
3394
|
+
const order = left.label.localeCompare(right.label)
|
|
3395
|
+
return nameSort === "desc" ? -order : order
|
|
3396
|
+
})
|
|
3397
|
+
return {
|
|
3398
|
+
enabled: true,
|
|
3399
|
+
parent: "",
|
|
3400
|
+
source_id: null,
|
|
3401
|
+
items,
|
|
3402
|
+
has_next: false,
|
|
3403
|
+
next_cursor: null
|
|
3404
|
+
}
|
|
3405
|
+
}
|
|
3406
|
+
const parent = String(options.parent || "")
|
|
3407
|
+
.replace(/^[\\/]+/, "")
|
|
3408
|
+
.replace(/[\\/]+$/, "")
|
|
3409
|
+
.slice(0, 4096)
|
|
3410
|
+
const root = source.root.replace(/[\\/]+$/, "")
|
|
3411
|
+
const separator = root.includes("\\") && !root.includes("/") ? "\\" : "/"
|
|
3412
|
+
const prefix = parent
|
|
3413
|
+
? `${root}${separator}${parent.split("/").join(separator)}${separator}`
|
|
3414
|
+
: `${root}${separator}`
|
|
3415
|
+
const pageSize = boundedInteger(
|
|
3416
|
+
options.page_size, STATUS_PAGE_SIZE, 1, STATUS_PAGE_SIZE)
|
|
3417
|
+
const directoryOffset = boundedInteger(
|
|
3418
|
+
options.directory_offset, 0, 0, Number.MAX_SAFE_INTEGER)
|
|
3419
|
+
const level = await this.registry.treeLevel({
|
|
3420
|
+
sourceId: source.id,
|
|
3421
|
+
prefix,
|
|
3422
|
+
view,
|
|
3423
|
+
statusFilter,
|
|
3424
|
+
query,
|
|
3425
|
+
nameOnly: false,
|
|
3426
|
+
sizeSort,
|
|
3427
|
+
nameSort,
|
|
3428
|
+
cursor: typeof options.cursor === "string"
|
|
3429
|
+
? options.cursor.slice(0, 2048)
|
|
3430
|
+
: "",
|
|
3431
|
+
pageSize,
|
|
3432
|
+
// One extra of each so the merge can tell whether a side has more.
|
|
3433
|
+
directoryLimit: pageSize + 1,
|
|
3434
|
+
directoryOffset,
|
|
3435
|
+
externalSourceIds: this.configuredExternalSourceIds()
|
|
3436
|
+
})
|
|
3437
|
+
const directories = (level.directories || []).map((row) => {
|
|
3438
|
+
// A run of folders holding nothing but one another reads as one row.
|
|
3439
|
+
// The shared prefix of the first and last path underneath is exactly
|
|
3440
|
+
// that run, because the two are the extremes of a sorted list.
|
|
3441
|
+
const shared = commonDirectory(row.lo || "", row.hi || "")
|
|
3442
|
+
const label = shared && shared.startsWith(`${row.name}/`)
|
|
3443
|
+
? shared
|
|
3444
|
+
: row.name
|
|
3445
|
+
return {
|
|
3446
|
+
kind: "directory",
|
|
3447
|
+
name: row.name,
|
|
3448
|
+
label,
|
|
3449
|
+
relative_path: parent ? `${parent}/${label}` : label,
|
|
3450
|
+
source_id: source.id,
|
|
3451
|
+
size: Number(row.bytes) || 0,
|
|
3452
|
+
file_count: Number(row.file_count) || 0,
|
|
3453
|
+
duplicate_count: Number(row.duplicate_count) || 0,
|
|
3454
|
+
unavailable_count: Number(row.unavailable_count) || 0,
|
|
3455
|
+
linked_count: Number(row.linked_count) || 0
|
|
3456
|
+
}
|
|
3457
|
+
})
|
|
3458
|
+
const files = this.publicFileItems(level.files)
|
|
3459
|
+
.map((item) => Object.assign({ kind: "file" }, item))
|
|
3460
|
+
const fileCursors = (level.files && level.files.rowCursors) || []
|
|
3461
|
+
// Both sides arrive sorted the same way, so walking them together gives the
|
|
3462
|
+
// page its true order. Sorting by size interleaves folders and files: the
|
|
3463
|
+
// question on screen is "what is biggest", and a large loose file buried
|
|
3464
|
+
// under small folders is the wrong answer to it. Sorting by name keeps the
|
|
3465
|
+
// familiar folders-first grouping.
|
|
3466
|
+
const ahead = (left, right) => {
|
|
3467
|
+
if (sizeSort) {
|
|
3468
|
+
if (left.size !== right.size) {
|
|
3469
|
+
return sizeSort === "asc"
|
|
3470
|
+
? left.size < right.size
|
|
3471
|
+
: left.size > right.size
|
|
3472
|
+
}
|
|
3473
|
+
} else if ((left.kind === "directory") !== (right.kind === "directory")) {
|
|
3474
|
+
return left.kind === "directory"
|
|
3475
|
+
}
|
|
3476
|
+
const order = String(left.label || left.relative_path || "")
|
|
3477
|
+
.localeCompare(String(right.label || right.relative_path || ""))
|
|
3478
|
+
return nameSort === "desc" ? order > 0 : order < 0
|
|
3479
|
+
}
|
|
3480
|
+
const items = []
|
|
3481
|
+
let directoryIndex = 0
|
|
3482
|
+
let fileIndex = 0
|
|
3483
|
+
while (items.length < pageSize &&
|
|
3484
|
+
(directoryIndex < directories.length || fileIndex < files.length)) {
|
|
3485
|
+
const directory = directories[directoryIndex]
|
|
3486
|
+
const file = files[fileIndex]
|
|
3487
|
+
if (directory && (!file || ahead(directory, file))) {
|
|
3488
|
+
items.push(directory)
|
|
3489
|
+
directoryIndex += 1
|
|
3490
|
+
} else if (file) {
|
|
3491
|
+
items.push(file)
|
|
3492
|
+
fileIndex += 1
|
|
3493
|
+
} else break
|
|
3494
|
+
}
|
|
3495
|
+
return {
|
|
3496
|
+
enabled: true,
|
|
3497
|
+
parent,
|
|
3498
|
+
source_id: source.id,
|
|
3499
|
+
items,
|
|
3500
|
+
has_next: directoryIndex < directories.length ||
|
|
3501
|
+
fileIndex < files.length ||
|
|
3502
|
+
!!(level.files && level.files.nextCursor),
|
|
3503
|
+
next_directory_offset: directoryOffset + directoryIndex,
|
|
3504
|
+
next_cursor: fileIndex > 0
|
|
3505
|
+
? fileCursors[fileIndex - 1] || null
|
|
3506
|
+
: (typeof options.cursor === "string" ? options.cursor : null)
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
|
|
3341
3510
|
async duplicateGroupChildren(scopeId, hash, options = {}) {
|
|
3342
3511
|
if (typeof hash !== "string" || !SHA256_RE.test(hash)) {
|
|
3343
3512
|
throw new TypeError("Invalid vault content identifier.")
|
|
@@ -3361,7 +3530,9 @@ class Vault {
|
|
|
3361
3530
|
cursor,
|
|
3362
3531
|
pageSize,
|
|
3363
3532
|
unrestricted: !scopeId,
|
|
3364
|
-
activeUnrestricted: !scopeId && !locationId
|
|
3533
|
+
activeUnrestricted: !scopeId && !locationId,
|
|
3534
|
+
// Content groups are only ever listed by the flat file view.
|
|
3535
|
+
nameOnly: true
|
|
3365
3536
|
})
|
|
3366
3537
|
return {
|
|
3367
3538
|
hash,
|
|
@@ -3424,7 +3595,8 @@ class Vault {
|
|
|
3424
3595
|
sourceIds: this.scopeSourceIds(scopeId, locationId),
|
|
3425
3596
|
externalSourceIds: this.configuredExternalSourceIds(),
|
|
3426
3597
|
query: String(options.query || "").slice(0, 500).trim(),
|
|
3427
|
-
unrestricted: !scopeId && !locationId
|
|
3598
|
+
unrestricted: !scopeId && !locationId,
|
|
3599
|
+
nameOnly: true
|
|
3428
3600
|
})
|
|
3429
3601
|
return {
|
|
3430
3602
|
hash,
|
|
@@ -3451,7 +3623,8 @@ class Vault {
|
|
|
3451
3623
|
options.page_size, STATUS_PAGE_SIZE, 1, STATUS_PAGE_SIZE),
|
|
3452
3624
|
sizeSort: options.size_sort || "desc",
|
|
3453
3625
|
unrestricted: !scopeId && !locationId,
|
|
3454
|
-
authorizedUnrestricted: !scopeId
|
|
3626
|
+
authorizedUnrestricted: !scopeId,
|
|
3627
|
+
nameOnly: true
|
|
3455
3628
|
})
|
|
3456
3629
|
return {
|
|
3457
3630
|
items: result.items || [],
|
|
@@ -3464,6 +3637,9 @@ class Vault {
|
|
|
3464
3637
|
const view = STATUS_VIEWS.has(options.view) ? options.view : "all"
|
|
3465
3638
|
const groupDuplicates =
|
|
3466
3639
|
view === "duplicates" && options.display_mode === "files"
|
|
3640
|
+
// The flat list shows a file name where the tree shows a folder path, so
|
|
3641
|
+
// the search has to match what is on screen. See queryClause().
|
|
3642
|
+
const filesMode = options.display_mode === "files"
|
|
3467
3643
|
const statusFilter = STATUS_FILTERS.has(options.status_filter)
|
|
3468
3644
|
? options.status_filter
|
|
3469
3645
|
: "all"
|
|
@@ -3499,6 +3675,8 @@ class Vault {
|
|
|
3499
3675
|
? options.size_sort || "desc"
|
|
3500
3676
|
: options.size_sort,
|
|
3501
3677
|
groupDuplicates,
|
|
3678
|
+
nameOnly: filesMode,
|
|
3679
|
+
nameSort: options.name_sort === "desc" ? "desc" : "asc",
|
|
3502
3680
|
cursor
|
|
3503
3681
|
})
|
|
3504
3682
|
let items
|
package/kernel/vault/registry.js
CHANGED