pinokiod 8.0.110 → 8.0.112
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 +226 -6
- package/kernel/vault/registry.js +2 -0
- package/kernel/vault/registry_core.js +329 -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 +151 -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,188 @@ 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
|
+
// Every source root is produced by path.resolve, so it is already native
|
|
3412
|
+
// and the platform separator is the right one -- no need to infer it from
|
|
3413
|
+
// the string, which a directory named with a backslash would fool.
|
|
3414
|
+
const separator = path.sep
|
|
3415
|
+
const prefix = parent
|
|
3416
|
+
? `${root}${separator}${parent.split("/").join(separator)}${separator}`
|
|
3417
|
+
: `${root}${separator}`
|
|
3418
|
+
const pageSize = boundedInteger(
|
|
3419
|
+
options.page_size, STATUS_PAGE_SIZE, 1, STATUS_PAGE_SIZE)
|
|
3420
|
+
const directoryOffset = boundedInteger(
|
|
3421
|
+
options.directory_offset, 0, 0, Number.MAX_SAFE_INTEGER)
|
|
3422
|
+
const level = await this.registry.treeLevel({
|
|
3423
|
+
sourceId: source.id,
|
|
3424
|
+
prefix,
|
|
3425
|
+
separator,
|
|
3426
|
+
view,
|
|
3427
|
+
statusFilter,
|
|
3428
|
+
query,
|
|
3429
|
+
nameOnly: false,
|
|
3430
|
+
sizeSort,
|
|
3431
|
+
nameSort,
|
|
3432
|
+
cursor: typeof options.cursor === "string"
|
|
3433
|
+
? options.cursor.slice(0, 2048)
|
|
3434
|
+
: "",
|
|
3435
|
+
pageSize,
|
|
3436
|
+
// One extra of each so the merge can tell whether a side has more.
|
|
3437
|
+
directoryLimit: pageSize + 1,
|
|
3438
|
+
directoryOffset,
|
|
3439
|
+
externalSourceIds: this.configuredExternalSourceIds()
|
|
3440
|
+
})
|
|
3441
|
+
const directories = (level.directories || []).map((row) => {
|
|
3442
|
+
// A run of folders holding nothing but one another reads as one row.
|
|
3443
|
+
// The shared prefix of the first and last path underneath is exactly
|
|
3444
|
+
// that run, because the two are the extremes of a sorted list.
|
|
3445
|
+
const native = (value) => separator === "/"
|
|
3446
|
+
? String(value || "")
|
|
3447
|
+
: String(value || "").split(separator).join("/")
|
|
3448
|
+
const shared = commonDirectory(native(row.lo), native(row.hi))
|
|
3449
|
+
const label = shared && shared.startsWith(`${row.name}/`)
|
|
3450
|
+
? shared
|
|
3451
|
+
: row.name
|
|
3452
|
+
return {
|
|
3453
|
+
kind: "directory",
|
|
3454
|
+
name: row.name,
|
|
3455
|
+
label,
|
|
3456
|
+
relative_path: parent ? `${parent}/${label}` : label,
|
|
3457
|
+
source_id: source.id,
|
|
3458
|
+
size: Number(row.bytes) || 0,
|
|
3459
|
+
file_count: Number(row.file_count) || 0,
|
|
3460
|
+
duplicate_count: Number(row.duplicate_count) || 0,
|
|
3461
|
+
unavailable_count: Number(row.unavailable_count) || 0,
|
|
3462
|
+
linked_count: Number(row.linked_count) || 0
|
|
3463
|
+
}
|
|
3464
|
+
})
|
|
3465
|
+
const files = this.publicFileItems(level.files)
|
|
3466
|
+
.map((item) => Object.assign({ kind: "file" }, item))
|
|
3467
|
+
const fileCursors = (level.files && level.files.rowCursors) || []
|
|
3468
|
+
// Both sides arrive sorted the same way, so walking them together gives the
|
|
3469
|
+
// page its true order. Sorting by size interleaves folders and files: the
|
|
3470
|
+
// question on screen is "what is biggest", and a large loose file buried
|
|
3471
|
+
// under small folders is the wrong answer to it. Sorting by name keeps the
|
|
3472
|
+
// familiar folders-first grouping.
|
|
3473
|
+
const ahead = (left, right) => {
|
|
3474
|
+
if (sizeSort) {
|
|
3475
|
+
if (left.size !== right.size) {
|
|
3476
|
+
return sizeSort === "asc"
|
|
3477
|
+
? left.size < right.size
|
|
3478
|
+
: left.size > right.size
|
|
3479
|
+
}
|
|
3480
|
+
} else if ((left.kind === "directory") !== (right.kind === "directory")) {
|
|
3481
|
+
return left.kind === "directory"
|
|
3482
|
+
}
|
|
3483
|
+
const order = String(left.label || left.relative_path || "")
|
|
3484
|
+
.localeCompare(String(right.label || right.relative_path || ""))
|
|
3485
|
+
return nameSort === "desc" ? order > 0 : order < 0
|
|
3486
|
+
}
|
|
3487
|
+
const items = []
|
|
3488
|
+
let directoryIndex = 0
|
|
3489
|
+
let fileIndex = 0
|
|
3490
|
+
while (items.length < pageSize &&
|
|
3491
|
+
(directoryIndex < directories.length || fileIndex < files.length)) {
|
|
3492
|
+
const directory = directories[directoryIndex]
|
|
3493
|
+
const file = files[fileIndex]
|
|
3494
|
+
if (directory && (!file || ahead(directory, file))) {
|
|
3495
|
+
items.push(directory)
|
|
3496
|
+
directoryIndex += 1
|
|
3497
|
+
} else if (file) {
|
|
3498
|
+
items.push(file)
|
|
3499
|
+
fileIndex += 1
|
|
3500
|
+
} else break
|
|
3501
|
+
}
|
|
3502
|
+
return {
|
|
3503
|
+
enabled: true,
|
|
3504
|
+
parent,
|
|
3505
|
+
source_id: source.id,
|
|
3506
|
+
items,
|
|
3507
|
+
has_next: directoryIndex < directories.length ||
|
|
3508
|
+
fileIndex < files.length ||
|
|
3509
|
+
!!(level.files && level.files.nextCursor),
|
|
3510
|
+
next_directory_offset: directoryOffset + directoryIndex,
|
|
3511
|
+
next_cursor: fileIndex > 0
|
|
3512
|
+
? fileCursors[fileIndex - 1] || null
|
|
3513
|
+
: (typeof options.cursor === "string" ? options.cursor : null)
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3306
3517
|
async duplicateGroupChildren(scopeId, hash, options = {}) {
|
|
3307
3518
|
if (typeof hash !== "string" || !SHA256_RE.test(hash)) {
|
|
3308
3519
|
throw new TypeError("Invalid vault content identifier.")
|
|
@@ -3326,7 +3537,9 @@ class Vault {
|
|
|
3326
3537
|
cursor,
|
|
3327
3538
|
pageSize,
|
|
3328
3539
|
unrestricted: !scopeId,
|
|
3329
|
-
activeUnrestricted: !scopeId && !locationId
|
|
3540
|
+
activeUnrestricted: !scopeId && !locationId,
|
|
3541
|
+
// Content groups are only ever listed by the flat file view.
|
|
3542
|
+
nameOnly: true
|
|
3330
3543
|
})
|
|
3331
3544
|
return {
|
|
3332
3545
|
hash,
|
|
@@ -3389,7 +3602,8 @@ class Vault {
|
|
|
3389
3602
|
sourceIds: this.scopeSourceIds(scopeId, locationId),
|
|
3390
3603
|
externalSourceIds: this.configuredExternalSourceIds(),
|
|
3391
3604
|
query: String(options.query || "").slice(0, 500).trim(),
|
|
3392
|
-
unrestricted: !scopeId && !locationId
|
|
3605
|
+
unrestricted: !scopeId && !locationId,
|
|
3606
|
+
nameOnly: true
|
|
3393
3607
|
})
|
|
3394
3608
|
return {
|
|
3395
3609
|
hash,
|
|
@@ -3416,7 +3630,8 @@ class Vault {
|
|
|
3416
3630
|
options.page_size, STATUS_PAGE_SIZE, 1, STATUS_PAGE_SIZE),
|
|
3417
3631
|
sizeSort: options.size_sort || "desc",
|
|
3418
3632
|
unrestricted: !scopeId && !locationId,
|
|
3419
|
-
authorizedUnrestricted: !scopeId
|
|
3633
|
+
authorizedUnrestricted: !scopeId,
|
|
3634
|
+
nameOnly: true
|
|
3420
3635
|
})
|
|
3421
3636
|
return {
|
|
3422
3637
|
items: result.items || [],
|
|
@@ -3429,6 +3644,9 @@ class Vault {
|
|
|
3429
3644
|
const view = STATUS_VIEWS.has(options.view) ? options.view : "all"
|
|
3430
3645
|
const groupDuplicates =
|
|
3431
3646
|
view === "duplicates" && options.display_mode === "files"
|
|
3647
|
+
// The flat list shows a file name where the tree shows a folder path, so
|
|
3648
|
+
// the search has to match what is on screen. See queryClause().
|
|
3649
|
+
const filesMode = options.display_mode === "files"
|
|
3432
3650
|
const statusFilter = STATUS_FILTERS.has(options.status_filter)
|
|
3433
3651
|
? options.status_filter
|
|
3434
3652
|
: "all"
|
|
@@ -3464,6 +3682,8 @@ class Vault {
|
|
|
3464
3682
|
? options.size_sort || "desc"
|
|
3465
3683
|
: options.size_sort,
|
|
3466
3684
|
groupDuplicates,
|
|
3685
|
+
nameOnly: filesMode,
|
|
3686
|
+
nameSort: options.name_sort === "desc" ? "desc" : "asc",
|
|
3467
3687
|
cursor
|
|
3468
3688
|
})
|
|
3469
3689
|
let items
|
package/kernel/vault/registry.js
CHANGED