pinokiod 8.0.110 → 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 +219 -6
- 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 +100 -0
- package/test/vault-ui.test.js +197 -21
package/kernel/vault/index.js
CHANGED
|
@@ -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
|
|
@@ -1227,6 +1236,19 @@ class Vault {
|
|
|
1227
1236
|
}
|
|
1228
1237
|
}
|
|
1229
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
|
+
|
|
1230
1252
|
sourceIsWithinScope(source, scopeId) {
|
|
1231
1253
|
if (!scopeId) return true
|
|
1232
1254
|
const seen = new Set()
|
|
@@ -2025,7 +2047,10 @@ class Vault {
|
|
|
2025
2047
|
const selection = this.separateSelection(payload)
|
|
2026
2048
|
if (selection.error) return { error: selection.error }
|
|
2027
2049
|
const total = await this.registry.matchingFileSummary(
|
|
2028
|
-
"linked",
|
|
2050
|
+
"linked",
|
|
2051
|
+
selection.sourceIds,
|
|
2052
|
+
selection.query,
|
|
2053
|
+
selection.nameOnly)
|
|
2029
2054
|
if (!total.count) {
|
|
2030
2055
|
return { error: "No matching deduplicated files remain." }
|
|
2031
2056
|
}
|
|
@@ -2586,7 +2611,10 @@ class Vault {
|
|
|
2586
2611
|
}
|
|
2587
2612
|
return {
|
|
2588
2613
|
sourceIds,
|
|
2589
|
-
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"
|
|
2590
2618
|
}
|
|
2591
2619
|
}
|
|
2592
2620
|
|
|
@@ -2954,7 +2982,8 @@ class Vault {
|
|
|
2954
2982
|
selection.sourceIds,
|
|
2955
2983
|
cursor,
|
|
2956
2984
|
100,
|
|
2957
|
-
selection.query
|
|
2985
|
+
selection.query,
|
|
2986
|
+
selection.nameOnly
|
|
2958
2987
|
)
|
|
2959
2988
|
if (!rows.length) break
|
|
2960
2989
|
const affectedHashes = new Set()
|
|
@@ -3303,6 +3332,181 @@ class Vault {
|
|
|
3303
3332
|
}, this.locationForPath(row.path, row.source_id)))
|
|
3304
3333
|
}
|
|
3305
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
|
+
|
|
3306
3510
|
async duplicateGroupChildren(scopeId, hash, options = {}) {
|
|
3307
3511
|
if (typeof hash !== "string" || !SHA256_RE.test(hash)) {
|
|
3308
3512
|
throw new TypeError("Invalid vault content identifier.")
|
|
@@ -3326,7 +3530,9 @@ class Vault {
|
|
|
3326
3530
|
cursor,
|
|
3327
3531
|
pageSize,
|
|
3328
3532
|
unrestricted: !scopeId,
|
|
3329
|
-
activeUnrestricted: !scopeId && !locationId
|
|
3533
|
+
activeUnrestricted: !scopeId && !locationId,
|
|
3534
|
+
// Content groups are only ever listed by the flat file view.
|
|
3535
|
+
nameOnly: true
|
|
3330
3536
|
})
|
|
3331
3537
|
return {
|
|
3332
3538
|
hash,
|
|
@@ -3389,7 +3595,8 @@ class Vault {
|
|
|
3389
3595
|
sourceIds: this.scopeSourceIds(scopeId, locationId),
|
|
3390
3596
|
externalSourceIds: this.configuredExternalSourceIds(),
|
|
3391
3597
|
query: String(options.query || "").slice(0, 500).trim(),
|
|
3392
|
-
unrestricted: !scopeId && !locationId
|
|
3598
|
+
unrestricted: !scopeId && !locationId,
|
|
3599
|
+
nameOnly: true
|
|
3393
3600
|
})
|
|
3394
3601
|
return {
|
|
3395
3602
|
hash,
|
|
@@ -3416,7 +3623,8 @@ class Vault {
|
|
|
3416
3623
|
options.page_size, STATUS_PAGE_SIZE, 1, STATUS_PAGE_SIZE),
|
|
3417
3624
|
sizeSort: options.size_sort || "desc",
|
|
3418
3625
|
unrestricted: !scopeId && !locationId,
|
|
3419
|
-
authorizedUnrestricted: !scopeId
|
|
3626
|
+
authorizedUnrestricted: !scopeId,
|
|
3627
|
+
nameOnly: true
|
|
3420
3628
|
})
|
|
3421
3629
|
return {
|
|
3422
3630
|
items: result.items || [],
|
|
@@ -3429,6 +3637,9 @@ class Vault {
|
|
|
3429
3637
|
const view = STATUS_VIEWS.has(options.view) ? options.view : "all"
|
|
3430
3638
|
const groupDuplicates =
|
|
3431
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"
|
|
3432
3643
|
const statusFilter = STATUS_FILTERS.has(options.status_filter)
|
|
3433
3644
|
? options.status_filter
|
|
3434
3645
|
: "all"
|
|
@@ -3464,6 +3675,8 @@ class Vault {
|
|
|
3464
3675
|
? options.size_sort || "desc"
|
|
3465
3676
|
: options.size_sort,
|
|
3466
3677
|
groupDuplicates,
|
|
3678
|
+
nameOnly: filesMode,
|
|
3679
|
+
nameSort: options.name_sort === "desc" ? "desc" : "asc",
|
|
3467
3680
|
cursor
|
|
3468
3681
|
})
|
|
3469
3682
|
let items
|
package/kernel/vault/registry.js
CHANGED