cursedbelt-core 1.0.0
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/LICENSE +15 -0
- package/dist/client/wsWire.d.ts +49 -0
- package/dist/client/wsWire.js +57 -0
- package/dist/config/appConfig.d.ts +361 -0
- package/dist/config/appConfig.js +308 -0
- package/dist/core/activity/index.d.ts +8 -0
- package/dist/core/activity/index.js +7 -0
- package/dist/core/activity/model.d.ts +115 -0
- package/dist/core/activity/model.js +63 -0
- package/dist/core/analytics/index.d.ts +18 -0
- package/dist/core/analytics/index.js +18 -0
- package/dist/core/analytics/range.d.ts +186 -0
- package/dist/core/analytics/range.js +271 -0
- package/dist/core/analytics/schedule.d.ts +301 -0
- package/dist/core/analytics/schedule.js +372 -0
- package/dist/core/category/index.d.ts +3 -0
- package/dist/core/category/index.js +6 -0
- package/dist/core/category/memoryStore.d.ts +21 -0
- package/dist/core/category/memoryStore.js +97 -0
- package/dist/core/category/tree.d.ts +26 -0
- package/dist/core/category/tree.js +113 -0
- package/dist/core/category/types.d.ts +48 -0
- package/dist/core/category/types.js +8 -0
- package/dist/core/color.d.ts +14 -0
- package/dist/core/color.js +32 -0
- package/dist/core/colorScheme/colorSchemeStore.d.ts +70 -0
- package/dist/core/colorScheme/colorSchemeStore.js +123 -0
- package/dist/core/colorScheme/index.d.ts +7 -0
- package/dist/core/colorScheme/index.js +7 -0
- package/dist/core/domainOwners.d.ts +19 -0
- package/dist/core/domainOwners.js +15 -0
- package/dist/core/download.d.ts +14 -0
- package/dist/core/download.js +37 -0
- package/dist/core/events/ccEventBus.d.ts +44 -0
- package/dist/core/events/ccEventBus.js +70 -0
- package/dist/core/file-tree/fileTreeModel.d.ts +276 -0
- package/dist/core/file-tree/fileTreeModel.js +312 -0
- package/dist/core/folder-tree/actions.d.ts +84 -0
- package/dist/core/folder-tree/actions.js +119 -0
- package/dist/core/folder-tree/index.d.ts +3 -0
- package/dist/core/folder-tree/index.js +20 -0
- package/dist/core/folder-tree/path.d.ts +76 -0
- package/dist/core/folder-tree/path.js +146 -0
- package/dist/core/folder-tree/tree.d.ts +86 -0
- package/dist/core/folder-tree/tree.js +184 -0
- package/dist/core/layout/index.d.ts +1 -0
- package/dist/core/layout/index.js +7 -0
- package/dist/core/master-lock/index.d.ts +14 -0
- package/dist/core/master-lock/index.js +14 -0
- package/dist/core/master-lock/kdf.d.ts +63 -0
- package/dist/core/master-lock/kdf.js +117 -0
- package/dist/core/master-lock/policy.d.ts +32 -0
- package/dist/core/master-lock/policy.js +52 -0
- package/dist/core/master-lock/presence.d.ts +52 -0
- package/dist/core/master-lock/presence.js +61 -0
- package/dist/core/master-lock/wire.d.ts +139 -0
- package/dist/core/master-lock/wire.js +81 -0
- package/dist/core/media/bgRemoval.d.ts +27 -0
- package/dist/core/media/bgRemoval.js +101 -0
- package/dist/core/media/ffmpeg.d.ts +57 -0
- package/dist/core/media/ffmpeg.js +126 -0
- package/dist/core/media/format.d.ts +20 -0
- package/dist/core/media/format.js +54 -0
- package/dist/core/media/index.d.ts +12 -0
- package/dist/core/media/index.js +18 -0
- package/dist/core/media/mediaItem.d.ts +15 -0
- package/dist/core/media/mediaItem.js +34 -0
- package/dist/core/media/renderPlan.d.ts +63 -0
- package/dist/core/media/renderPlan.js +232 -0
- package/dist/core/media/segment.d.ts +79 -0
- package/dist/core/media/segment.js +247 -0
- package/dist/core/media/size.d.ts +1 -0
- package/dist/core/media/size.js +3 -0
- package/dist/core/media/trim.d.ts +21 -0
- package/dist/core/media/trim.js +40 -0
- package/dist/core/media/types.d.ts +148 -0
- package/dist/core/media/types.js +11 -0
- package/dist/core/media/uploadLimits.d.ts +7 -0
- package/dist/core/media/uploadLimits.js +16 -0
- package/dist/core/media/validate.d.ts +9 -0
- package/dist/core/media/validate.js +121 -0
- package/dist/core/media/videoCuts.d.ts +152 -0
- package/dist/core/media/videoCuts.js +284 -0
- package/dist/core/navigation/breadcrumbCompat.d.ts +24 -0
- package/dist/core/navigation/breadcrumbCompat.js +21 -0
- package/dist/core/navigation/coverageTest.d.ts +24 -0
- package/dist/core/navigation/coverageTest.js +48 -0
- package/dist/core/navigation/index.d.ts +6 -0
- package/dist/core/navigation/index.js +10 -0
- package/dist/core/navigation/migrateNavPreferences.d.ts +55 -0
- package/dist/core/navigation/migrateNavPreferences.js +63 -0
- package/dist/core/navigation/navigationModel.d.ts +165 -0
- package/dist/core/navigation/navigationModel.js +14 -0
- package/dist/core/navigation/navigationSelectors.d.ts +63 -0
- package/dist/core/navigation/navigationSelectors.js +167 -0
- package/dist/core/navigation/searchCatalogue.d.ts +23 -0
- package/dist/core/navigation/searchCatalogue.js +76 -0
- package/dist/core/net/reconnectSchedule.d.ts +46 -0
- package/dist/core/net/reconnectSchedule.js +38 -0
- package/dist/core/palette/ShortcutRegistry.d.ts +33 -0
- package/dist/core/palette/ShortcutRegistry.js +52 -0
- package/dist/core/palette/fuzzyRank.d.ts +18 -0
- package/dist/core/palette/fuzzyRank.js +73 -0
- package/dist/core/palette/hotkeys.d.ts +28 -0
- package/dist/core/palette/hotkeys.js +118 -0
- package/dist/core/palette/index.d.ts +3 -0
- package/dist/core/palette/index.js +8 -0
- package/dist/core/schema/projection.d.ts +84 -0
- package/dist/core/schema/projection.js +184 -0
- package/dist/core/session/claims.d.ts +145 -0
- package/dist/core/session/claims.js +157 -0
- package/dist/core/sharing/index.d.ts +5 -0
- package/dist/core/sharing/index.js +4 -0
- package/dist/core/sharing/model.d.ts +195 -0
- package/dist/core/sharing/model.js +150 -0
- package/dist/core/site/siteCopy.d.ts +92 -0
- package/dist/core/site/siteCopy.js +91 -0
- package/dist/core/slots.d.ts +22 -0
- package/dist/core/slots.js +31 -0
- package/dist/core/styles/constructableSheet.d.ts +51 -0
- package/dist/core/styles/constructableSheet.js +80 -0
- package/dist/core/tokens/registry.d.ts +119 -0
- package/dist/core/tokens/registry.js +447 -0
- package/dist/core/uiScale/index.d.ts +8 -0
- package/dist/core/uiScale/index.js +8 -0
- package/dist/core/uiScale/scale.d.ts +36 -0
- package/dist/core/uiScale/scale.js +47 -0
- package/dist/core/uiScale/uiScaleStore.d.ts +47 -0
- package/dist/core/uiScale/uiScaleStore.js +63 -0
- package/dist/core/wire/__fixtures__/rowsMarshalWorker.d.ts +1 -0
- package/dist/core/wire/__fixtures__/rowsMarshalWorker.js +23 -0
- package/dist/core/wire/index.d.ts +17 -0
- package/dist/core/wire/index.js +17 -0
- package/dist/core/wire/ingress.d.ts +44 -0
- package/dist/core/wire/ingress.js +134 -0
- package/dist/core/wire/marshalWorker.d.ts +70 -0
- package/dist/core/wire/marshalWorker.js +273 -0
- package/dist/core/wire/marshalling.d.ts +122 -0
- package/dist/core/wire/marshalling.js +126 -0
- package/dist/core/wire/wireHub.d.ts +80 -0
- package/dist/core/wire/wireHub.js +116 -0
- package/dist/ctgr/base64url.d.ts +8 -0
- package/dist/ctgr/base64url.js +39 -0
- package/dist/ctgr/chunkAccumulator.d.ts +214 -0
- package/dist/ctgr/chunkAccumulator.js +307 -0
- package/dist/ctgr/chunkStore.d.ts +24 -0
- package/dist/ctgr/chunkStore.js +125 -0
- package/dist/ctgr/codec.d.ts +44 -0
- package/dist/ctgr/codec.js +153 -0
- package/dist/ctgr/compress.d.ts +2 -0
- package/dist/ctgr/compress.js +20 -0
- package/dist/ctgr/crc32.d.ts +2 -0
- package/dist/ctgr/crc32.js +23 -0
- package/dist/ctgr/digest.d.ts +1 -0
- package/dist/ctgr/digest.js +15 -0
- package/dist/ctgr/index.d.ts +9 -0
- package/dist/ctgr/index.js +17 -0
- package/dist/ctgr/types.d.ts +64 -0
- package/dist/ctgr/types.js +21 -0
- package/dist/ctgr/v0compat.d.ts +27 -0
- package/dist/ctgr/v0compat.js +128 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +17 -0
- package/dist/shared/errors.d.ts +101 -0
- package/dist/shared/errors.js +99 -0
- package/dist/testing/bytes.d.ts +32 -0
- package/dist/testing/bytes.js +33 -0
- package/dist/testing/domInspect.d.ts +59 -0
- package/dist/testing/domInspect.js +115 -0
- package/dist/testing/fakeFetch.d.ts +23 -0
- package/dist/testing/fakeFetch.js +21 -0
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.js +11 -0
- package/package.json +373 -0
- package/src/client/wsWire.ts +83 -0
- package/src/config/appConfig.spec.ts +158 -0
- package/src/config/appConfig.ts +372 -0
- package/src/core/activity/index.ts +21 -0
- package/src/core/activity/model.ts +160 -0
- package/src/core/analytics/index.ts +18 -0
- package/src/core/analytics/range.spec.ts +280 -0
- package/src/core/analytics/range.ts +383 -0
- package/src/core/analytics/schedule.spec.ts +311 -0
- package/src/core/analytics/schedule.ts +624 -0
- package/src/core/category/category.spec.ts +144 -0
- package/src/core/category/index.ts +19 -0
- package/src/core/category/memoryStore.ts +104 -0
- package/src/core/category/tree.ts +118 -0
- package/src/core/category/types.ts +59 -0
- package/src/core/color.spec.ts +35 -0
- package/src/core/color.ts +35 -0
- package/src/core/colorScheme/colorScheme.spec.ts +127 -0
- package/src/core/colorScheme/colorSchemeStore.ts +184 -0
- package/src/core/colorScheme/index.ts +23 -0
- package/src/core/domainOwners.ts +32 -0
- package/src/core/download.spec.ts +38 -0
- package/src/core/download.ts +35 -0
- package/src/core/events/ccEventBus.spec.ts +90 -0
- package/src/core/events/ccEventBus.ts +120 -0
- package/src/core/file-tree/fileTreeModel.spec.ts +462 -0
- package/src/core/file-tree/fileTreeModel.ts +511 -0
- package/src/core/folder-tree/actions.spec.ts +103 -0
- package/src/core/folder-tree/actions.ts +153 -0
- package/src/core/folder-tree/index.ts +46 -0
- package/src/core/folder-tree/path.spec.ts +113 -0
- package/src/core/folder-tree/path.ts +148 -0
- package/src/core/folder-tree/tree.spec.ts +157 -0
- package/src/core/folder-tree/tree.ts +244 -0
- package/src/core/layout/index.ts +7 -0
- package/src/core/master-lock/index.ts +50 -0
- package/src/core/master-lock/kdf.spec.ts +95 -0
- package/src/core/master-lock/kdf.ts +145 -0
- package/src/core/master-lock/policy.ts +55 -0
- package/src/core/master-lock/presence.spec.ts +73 -0
- package/src/core/master-lock/presence.ts +71 -0
- package/src/core/master-lock/wire.ts +155 -0
- package/src/core/media/bgRemoval.spec.ts +120 -0
- package/src/core/media/bgRemoval.ts +123 -0
- package/src/core/media/ffmpeg.spec.ts +135 -0
- package/src/core/media/ffmpeg.ts +162 -0
- package/src/core/media/format.spec.ts +41 -0
- package/src/core/media/format.ts +58 -0
- package/src/core/media/index.ts +105 -0
- package/src/core/media/mediaItem.spec.ts +46 -0
- package/src/core/media/mediaItem.ts +40 -0
- package/src/core/media/renderPlan.spec.ts +140 -0
- package/src/core/media/renderPlan.ts +318 -0
- package/src/core/media/segment.spec.ts +159 -0
- package/src/core/media/segment.ts +352 -0
- package/src/core/media/size.spec.ts +19 -0
- package/src/core/media/size.ts +3 -0
- package/src/core/media/trim.spec.ts +75 -0
- package/src/core/media/trim.ts +60 -0
- package/src/core/media/types.ts +147 -0
- package/src/core/media/uploadLimits.ts +18 -0
- package/src/core/media/validate.spec.ts +106 -0
- package/src/core/media/validate.ts +131 -0
- package/src/core/media/videoCuts.spec.ts +327 -0
- package/src/core/media/videoCuts.ts +355 -0
- package/src/core/navigation/breadcrumbCompat.ts +44 -0
- package/src/core/navigation/coverageTest.ts +54 -0
- package/src/core/navigation/index.ts +50 -0
- package/src/core/navigation/migrateNavPreferences.ts +119 -0
- package/src/core/navigation/navigation.spec.ts +388 -0
- package/src/core/navigation/navigationModel.ts +185 -0
- package/src/core/navigation/navigationSelectors.ts +206 -0
- package/src/core/navigation/searchCatalogue.ts +89 -0
- package/src/core/net/reconnectSchedule.spec.ts +49 -0
- package/src/core/net/reconnectSchedule.ts +78 -0
- package/src/core/palette/ShortcutRegistry.ts +69 -0
- package/src/core/palette/fuzzyRank.ts +78 -0
- package/src/core/palette/hotkeys.ts +132 -0
- package/src/core/palette/index.ts +22 -0
- package/src/core/palette/palette.spec.ts +160 -0
- package/src/core/schema/projection.spec.ts +122 -0
- package/src/core/schema/projection.ts +225 -0
- package/src/core/session/claims.ts +181 -0
- package/src/core/sharing/index.ts +33 -0
- package/src/core/sharing/model.spec.ts +104 -0
- package/src/core/sharing/model.ts +296 -0
- package/src/core/site/siteCopy.ts +115 -0
- package/src/core/slots.ts +38 -0
- package/src/core/styles/constructableSheet.ts +86 -0
- package/src/core/tokens/registry.spec.ts +160 -0
- package/src/core/tokens/registry.ts +536 -0
- package/src/core/uiScale/index.ts +32 -0
- package/src/core/uiScale/scale.ts +63 -0
- package/src/core/uiScale/uiScale.spec.ts +77 -0
- package/src/core/uiScale/uiScaleStore.ts +100 -0
- package/src/core/wire/__fixtures__/rowsMarshalWorker.ts +24 -0
- package/src/core/wire/index.ts +56 -0
- package/src/core/wire/ingress.spec.ts +276 -0
- package/src/core/wire/ingress.ts +182 -0
- package/src/core/wire/marshalWorker.spec.ts +196 -0
- package/src/core/wire/marshalWorker.ts +361 -0
- package/src/core/wire/marshalling.spec.ts +139 -0
- package/src/core/wire/marshalling.ts +238 -0
- package/src/core/wire/wireHub.spec.ts +137 -0
- package/src/core/wire/wireHub.ts +213 -0
- package/src/ctgr/base64url.ts +44 -0
- package/src/ctgr/chunkAccumulator.ts +403 -0
- package/src/ctgr/chunkStore.spec.ts +101 -0
- package/src/ctgr/chunkStore.ts +168 -0
- package/src/ctgr/codec.spec.ts +208 -0
- package/src/ctgr/codec.ts +202 -0
- package/src/ctgr/compress.ts +21 -0
- package/src/ctgr/crc32.ts +23 -0
- package/src/ctgr/digest.ts +14 -0
- package/src/ctgr/index.ts +39 -0
- package/src/ctgr/types.ts +80 -0
- package/src/ctgr/v0compat.spec.ts +73 -0
- package/src/ctgr/v0compat.ts +156 -0
- package/src/declaredDepsAreImported.spec.ts +87 -0
- package/src/index.ts +25 -0
- package/src/leafSubpathsImportNothing.spec.ts +287 -0
- package/src/lockfileIsTracked.spec.ts +50 -0
- package/src/namedSubpathsResolve.spec.ts +122 -0
- package/src/noPathDeps.spec.ts +24 -0
- package/src/publishShape.spec.ts +89 -0
- package/src/shared/errors.ts +153 -0
- package/src/skippedTestsAreAnswered.spec.ts +221 -0
- package/src/testTmpRoot.spec.ts +102 -0
- package/src/testing/bytes.ts +38 -0
- package/src/testing/domInspect.spec.ts +79 -0
- package/src/testing/domInspect.ts +127 -0
- package/src/testing/fakeFetch.ts +26 -0
- package/src/testing/index.ts +11 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The FOLDER OPERATIONS every app with folders offers — one model, so "move it into
|
|
3
|
+
* that one" means the same thing in music as it does in collections.
|
|
4
|
+
*
|
|
5
|
+
* ── The owner's ask, verbatim (draft, 2026-08-25) ───────────────────────────────────────
|
|
6
|
+
* *"in cb make or ensure we have a standardized folder list view. I want it applied to
|
|
7
|
+
* music app and collections app and other apps with folders like that which should get the
|
|
8
|
+
* same features if any. The features should include a 3 dot menu for choosing to move all
|
|
9
|
+
* contents to a different folder or move a folder into another folder. For instance I could
|
|
10
|
+
* move rock songs from a folder 'extra-rock-songs' to a 'rock' folder with it. Also allow
|
|
11
|
+
* deleting a folder which deletes all the folder contents or allows deleting the folder and
|
|
12
|
+
* having the contents not grouped in a folder anymore. Allow renaming folders."*
|
|
13
|
+
*
|
|
14
|
+
* ── 🔴 Four verbs, and three of them already existed ────────────────────────────────────
|
|
15
|
+
* `planFolderRename`, `planFolderMove` and `planFolderDelete` in `tree.ts` are exactly
|
|
16
|
+
* rename, "move a folder into another folder", and "delete the folder and leave the
|
|
17
|
+
* contents ungrouped" — built for the vault and notes, and reachable from no menu anywhere.
|
|
18
|
+
* The gap the owner is describing is not model, it is SURFACE: every app grew its own
|
|
19
|
+
* subset of folder tools, so the operations that were never wired are the ones nobody has.
|
|
20
|
+
* This module adds the ONE missing verb and the shared descriptions; the menu that renders
|
|
21
|
+
* them is `react/folder-tree/FolderActionsMenu`.
|
|
22
|
+
*
|
|
23
|
+
* ── 🔴 Why "delete the contents too" is not a plan ──────────────────────────────────────
|
|
24
|
+
* The other three verbs are pure path rewrites — a {@link FolderRemap} list the consumer
|
|
25
|
+
* applies, with nothing destroyed. Deleting the CONTENTS destroys items, and this module
|
|
26
|
+
* has never seen an item; it only ever sees the paths they carry. So `purge` is a mode the
|
|
27
|
+
* menu offers and the CONSUMER implements against its own store, which is also the only
|
|
28
|
+
* place that can count what is about to be lost or refuse the operation. Modelling it here
|
|
29
|
+
* would mean inventing an item, and an item this model cannot see is one it cannot protect.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
folderPathKey,
|
|
34
|
+
isFolderPathUnder,
|
|
35
|
+
parseFolderPath,
|
|
36
|
+
serializeFolderPath,
|
|
37
|
+
} from "./path";
|
|
38
|
+
import type { FolderNode, FolderRemap } from "./tree";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Move everything at or under `source` INTO `destination`, and stop `source` existing.
|
|
42
|
+
*
|
|
43
|
+
* The owner's own example: *"I could move rock songs from a folder 'extra-rock-songs' to a
|
|
44
|
+
* 'rock' folder with it."* Items directly in `extra-rock-songs` land directly in `rock`;
|
|
45
|
+
* `extra-rock-songs/live` becomes `rock/live`.
|
|
46
|
+
*
|
|
47
|
+
* 🔴 This is NOT `planFolderMove`, and the difference is the whole reason it exists.
|
|
48
|
+
* `planFolderMove(source, newParent)` makes `source` a CHILD — `rock/extra-rock-songs` —
|
|
49
|
+
* which keeps the folder the owner is trying to get rid of. Both are real operations a
|
|
50
|
+
* person wants ("put this inside that" vs "empty this into that"), they read almost
|
|
51
|
+
* identically in a menu, and getting them confused is silent: the files are all present and
|
|
52
|
+
* the folder you meant to dissolve is still there one level down.
|
|
53
|
+
*
|
|
54
|
+
* @param destination an empty array means the ROOT — "take these out of any folder".
|
|
55
|
+
*/
|
|
56
|
+
export function planFolderMergeInto(
|
|
57
|
+
serializedPaths: Iterable<string>,
|
|
58
|
+
source: readonly string[],
|
|
59
|
+
destination: readonly string[],
|
|
60
|
+
): FolderRemap[] {
|
|
61
|
+
if (source.length === 0) throw new Error("cannot move the root's contents");
|
|
62
|
+
if (isFolderPathUnder(destination, source)) {
|
|
63
|
+
throw new Error("cannot move a folder's contents into itself or one of its descendants");
|
|
64
|
+
}
|
|
65
|
+
const out: FolderRemap[] = [];
|
|
66
|
+
const seen = new Set<string>();
|
|
67
|
+
for (const raw of serializedPaths) {
|
|
68
|
+
const path = parseFolderPath(raw);
|
|
69
|
+
if (path.length === 0 || !isFolderPathUnder(path, source)) continue;
|
|
70
|
+
const key = folderPathKey(path);
|
|
71
|
+
if (seen.has(key)) continue;
|
|
72
|
+
seen.add(key);
|
|
73
|
+
// Drop the whole SOURCE prefix and re-root what is left under `destination`.
|
|
74
|
+
const moved = [...destination, ...path.slice(source.length)];
|
|
75
|
+
const from = serializeFolderPath(path);
|
|
76
|
+
const to = serializeFolderPath(moved);
|
|
77
|
+
if (from !== to) out.push({ from, to });
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One place a folder may be moved or merged to. `path` empty = the root. */
|
|
83
|
+
export interface FolderTarget {
|
|
84
|
+
path: string[];
|
|
85
|
+
serialized: string;
|
|
86
|
+
/** `Home › Phones`, for a menu label. The root's label is the caller's word for it. */
|
|
87
|
+
label: string;
|
|
88
|
+
key: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Every folder `source` may legally be moved into, deepest-first within each branch.
|
|
93
|
+
*
|
|
94
|
+
* 🔴 A folder can never be moved into itself or into one of its own descendants — that
|
|
95
|
+
* orphans the subtree, and `planFolderMove` throws on it. Filtering the DESTINATIONS rather
|
|
96
|
+
* than catching the throw is what makes the menu honest: the offer a person is shown is the
|
|
97
|
+
* set of offers that work, so there is no way to pick one and be told no.
|
|
98
|
+
*
|
|
99
|
+
* The current parent is filtered out too. "Move it to where it already is" is not a
|
|
100
|
+
* no-op a person meant to ask for; it is a menu entry that does nothing, which reads as a
|
|
101
|
+
* broken control.
|
|
102
|
+
*/
|
|
103
|
+
export function folderMoveTargets(
|
|
104
|
+
nodes: readonly FolderNode[],
|
|
105
|
+
source: readonly string[],
|
|
106
|
+
options: { rootLabel?: string; separator?: string } = {},
|
|
107
|
+
): FolderTarget[] {
|
|
108
|
+
const separator = options.separator ?? " › ";
|
|
109
|
+
const currentParent = folderPathKey(source.slice(0, -1));
|
|
110
|
+
const targets: FolderTarget[] = [];
|
|
111
|
+
// The root, unless the folder is already at the top level.
|
|
112
|
+
if (source.length > 1) {
|
|
113
|
+
targets.push({
|
|
114
|
+
path: [],
|
|
115
|
+
serialized: "",
|
|
116
|
+
label: options.rootLabel ?? "Top level",
|
|
117
|
+
key: folderPathKey([]),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const walk = (list: readonly FolderNode[]): void => {
|
|
121
|
+
for (const node of list) {
|
|
122
|
+
const key = folderPathKey(node.path);
|
|
123
|
+
const isSelfOrDescendant = isFolderPathUnder(node.path, source);
|
|
124
|
+
if (!isSelfOrDescendant && key !== currentParent) {
|
|
125
|
+
targets.push({
|
|
126
|
+
path: [...node.path],
|
|
127
|
+
serialized: node.serialized,
|
|
128
|
+
label: node.path.join(separator),
|
|
129
|
+
key,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
// 🔴 Descend even into a branch that was itself refused: only the SUBTREE of
|
|
133
|
+
// `source` is unreachable. The current parent is skipped as a target and its
|
|
134
|
+
// children are perfectly good ones.
|
|
135
|
+
if (!isSelfOrDescendant) walk(node.children);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
walk(nodes);
|
|
139
|
+
return targets;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* What "delete this folder" is allowed to mean.
|
|
144
|
+
*
|
|
145
|
+
* Two modes rather than one, because the owner asked for both in one sentence and they are
|
|
146
|
+
* opposites: one is a filing change and the other destroys files. They are never the same
|
|
147
|
+
* button — see {@link FolderActionsMenuProps} for how the menu keeps them apart.
|
|
148
|
+
*/
|
|
149
|
+
export type FolderDeleteMode =
|
|
150
|
+
/** Splice the folder out; every item keeps existing, one path segment shorter. */
|
|
151
|
+
| "ungroup"
|
|
152
|
+
/** Delete the folder AND everything in it. The consumer's store does this, not us. */
|
|
153
|
+
| "purge";
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// cursedbelt-core/folder-tree — the PURE nested-folder model: path encoding, the derived
|
|
2
|
+
// tree, per-folder counts, and the structural-edit plans (rename/move/delete). No
|
|
3
|
+
// React, no DOM, node-builtin-free — so a SERVER can hold the same folder semantics
|
|
4
|
+
// its UI does.
|
|
5
|
+
//
|
|
6
|
+
// import { buildFolderTree, planFolderRename, serializeFolderPath } from 'cursedbelt-core/folder-tree';
|
|
7
|
+
//
|
|
8
|
+
// The three UI pieces (rail, picker, breadcrumb) live at `cursedbelt/react/folder-tree`,
|
|
9
|
+
// which re-exports everything here — so a browser-only consumer keeps one import.
|
|
10
|
+
//
|
|
11
|
+
// ── Why the model is its own export ─────────────────────────────────────────────
|
|
12
|
+
// The vault (the first consumer) is zero-knowledge: folders are derived entirely in
|
|
13
|
+
// the browser from encrypted meta, so a React subpath was the whole world. `apps/notes`
|
|
14
|
+
// is the second, and its folders are SERVER state — filed items are searched, filtered
|
|
15
|
+
// and bulk-moved by SQL. Importing the React barrel into a Bun server to reach
|
|
16
|
+
// `isFolderPathUnder` would drag `Button`/`Input` and the whole component graph into a
|
|
17
|
+
// process that renders nothing. Same law as `cursedbelt-core/sharing` vs `cursedbelt/react/sharing`.
|
|
18
|
+
export {
|
|
19
|
+
FOLDER_PATH_DELIMITER,
|
|
20
|
+
FOLDER_PATH_ESCAPE,
|
|
21
|
+
folderDescendantPrefix,
|
|
22
|
+
folderPathKey,
|
|
23
|
+
formatFolderPath,
|
|
24
|
+
isFolderPathUnder,
|
|
25
|
+
isValidFolderName,
|
|
26
|
+
normalizeFolderName,
|
|
27
|
+
parseFolderPath,
|
|
28
|
+
serializeFolderPath,
|
|
29
|
+
} from "./path";
|
|
30
|
+
export {
|
|
31
|
+
buildFolderIndex,
|
|
32
|
+
buildFolderTree,
|
|
33
|
+
flattenFolderTree,
|
|
34
|
+
type FolderIndex,
|
|
35
|
+
type FolderNode,
|
|
36
|
+
type FolderRemap,
|
|
37
|
+
planFolderDelete,
|
|
38
|
+
planFolderMove,
|
|
39
|
+
planFolderRename,
|
|
40
|
+
} from "./tree";
|
|
41
|
+
export {
|
|
42
|
+
type FolderDeleteMode,
|
|
43
|
+
type FolderTarget,
|
|
44
|
+
folderMoveTargets,
|
|
45
|
+
planFolderMergeInto,
|
|
46
|
+
} from "./actions";
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
folderDescendantPrefix,
|
|
4
|
+
folderPathKey,
|
|
5
|
+
formatFolderPath,
|
|
6
|
+
isFolderPathUnder,
|
|
7
|
+
isValidFolderName,
|
|
8
|
+
parseFolderPath,
|
|
9
|
+
serializeFolderPath,
|
|
10
|
+
} from "./path";
|
|
11
|
+
|
|
12
|
+
describe("folder path serialization", () => {
|
|
13
|
+
test("round-trips a simple nested path", () => {
|
|
14
|
+
const path = ["Home", "Phones", "Noah"];
|
|
15
|
+
const s = serializeFolderPath(path);
|
|
16
|
+
expect(s).toBe("Home/Phones/Noah");
|
|
17
|
+
expect(parseFolderPath(s)).toEqual(path);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("escapes a literal delimiter inside a name so it stays ONE segment", () => {
|
|
21
|
+
const path = ["TV/Movies"];
|
|
22
|
+
const s = serializeFolderPath(path);
|
|
23
|
+
expect(s).toBe("TV\\/Movies");
|
|
24
|
+
expect(parseFolderPath(s)).toEqual(["TV/Movies"]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("escapes a literal backslash", () => {
|
|
28
|
+
const path = ["a\\b", "c"];
|
|
29
|
+
const s = serializeFolderPath(path);
|
|
30
|
+
expect(s).toBe("a\\\\b/c");
|
|
31
|
+
expect(parseFolderPath(s)).toEqual(["a\\b", "c"]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("a name containing an escaped-delimiter sequence round-trips", () => {
|
|
35
|
+
const path = ["a\\/b"]; // literally: a, backslash, slash, b
|
|
36
|
+
expect(parseFolderPath(serializeFolderPath(path))).toEqual(path);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("trims segments and drops empty ones", () => {
|
|
40
|
+
expect(serializeFolderPath([" Home ", "", " ", "Phones"])).toBe("Home/Phones");
|
|
41
|
+
expect(parseFolderPath("/Home//Phones/")).toEqual(["Home", "Phones"]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("empty path is the empty string and back", () => {
|
|
45
|
+
expect(serializeFolderPath([])).toBe("");
|
|
46
|
+
expect(parseFolderPath("")).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("folder name validity", () => {
|
|
51
|
+
test("accepts ordinary names, including ones with a slash", () => {
|
|
52
|
+
expect(isValidFolderName("Work")).toBe(true);
|
|
53
|
+
expect(isValidFolderName("TV/Movies")).toBe(true);
|
|
54
|
+
expect(isValidFolderName(" padded ")).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("rejects blank and whitespace-only names", () => {
|
|
58
|
+
expect(isValidFolderName("")).toBe(false);
|
|
59
|
+
expect(isValidFolderName(" ")).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("rejects control characters", () => {
|
|
63
|
+
expect(isValidFolderName("a\tb")).toBe(false);
|
|
64
|
+
expect(isValidFolderName("a\nb")).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("path identity + containment", () => {
|
|
69
|
+
test("keys dedupe case-insensitively per segment", () => {
|
|
70
|
+
expect(folderPathKey(["Work"])).toBe(folderPathKey(["work"]));
|
|
71
|
+
expect(folderPathKey(["Home", "Phones"])).toBe(folderPathKey(["home", "PHONES"]));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("distinct nested paths never collide on the key (no naive string prefix)", () => {
|
|
75
|
+
expect(folderPathKey(["Home", "Phones"])).not.toBe(folderPathKey(["Homework", "hones"]));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("containment is segment-aligned", () => {
|
|
79
|
+
expect(isFolderPathUnder(["Home", "Phones"], ["Home"])).toBe(true);
|
|
80
|
+
expect(isFolderPathUnder(["Home"], ["Home"])).toBe(true);
|
|
81
|
+
expect(isFolderPathUnder(["Homework"], ["Home"])).toBe(false);
|
|
82
|
+
expect(isFolderPathUnder(["Home"], [])).toBe(true); // root contains all
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("formats a breadcrumb", () => {
|
|
86
|
+
expect(formatFolderPath(["Home", "Phones", "Noah"])).toBe("Home › Phones › Noah");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("folderDescendantPrefix", () => {
|
|
91
|
+
test("is the prefix every descendant's stored path starts with", () => {
|
|
92
|
+
expect(folderDescendantPrefix(["Home"])).toBe("Home/");
|
|
93
|
+
expect(folderDescendantPrefix(["Home", "Phones"])).toBe("Home/Phones/");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a sibling whose NAME starts the same is not matched by it", () => {
|
|
97
|
+
const prefix = folderDescendantPrefix(["Home"]);
|
|
98
|
+
expect(serializeFolderPath(["Home", "Phones"]).startsWith(prefix)).toBe(true);
|
|
99
|
+
expect(serializeFolderPath(["Homework"]).startsWith(prefix)).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("an escaped delimiter inside a name cannot be read as a child", () => {
|
|
103
|
+
// "TV/Film" is ONE folder; its stored form is `TV\/Film`, so a prefix search for
|
|
104
|
+
// the folder "TV" must not claim it. This is the migration hazard path.ts calls out.
|
|
105
|
+
const prefix = folderDescendantPrefix(["TV"]);
|
|
106
|
+
expect(serializeFolderPath(["TV/Film"]).startsWith(prefix)).toBe(false);
|
|
107
|
+
expect(serializeFolderPath(["TV", "Film"]).startsWith(prefix)).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("the root has no prefix — the caller skips the filter instead", () => {
|
|
111
|
+
expect(folderDescendantPrefix([])).toBe("");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Folder PATH encoding — the storage contract every consumer of the folder tree
|
|
3
|
+
* shares. A folder path is an ordered array of segment names (`["Home","Phones",
|
|
4
|
+
* "Noah"]`); on disk it is a single delimiter-joined STRING so it can ride in one
|
|
5
|
+
* field (an entry's `meta.folder`, a note's group tag) without a separate registry.
|
|
6
|
+
*
|
|
7
|
+
* ── Why a delimited string and not id references ─────────────────────────────
|
|
8
|
+
* The first consumer (the vault) is a zero-knowledge, op-log-synced store: a folder
|
|
9
|
+
* lives inside an entry's ENCRYPTED meta, and there is no server-side place to keep
|
|
10
|
+
* an id→name map that every device would have to converge on. Deriving the whole
|
|
11
|
+
* tree from the set of path strings on the items keeps folders a pure function of
|
|
12
|
+
* the items — nothing extra to sync, nothing to version-skew. The cost is that a
|
|
13
|
+
* folder exists only while it (or a descendant) holds an item; that is the same
|
|
14
|
+
* "derived, not declared" model the vault already used for flat folders, extended
|
|
15
|
+
* to nesting.
|
|
16
|
+
*
|
|
17
|
+
* ── The delimiter, and why it is ESCAPED rather than forbidden ───────────────
|
|
18
|
+
* `/` joins segments. A `/` is common enough inside a real folder name ("TV/Film",
|
|
19
|
+
* "and/or") that forbidding it would be a footgun, and — critically — a MIGRATION
|
|
20
|
+
* hazard: a legacy flat folder literally named "TV/Movies" must stay ONE folder,
|
|
21
|
+
* not silently split into two. So the delimiter is escaped, not banned: a literal
|
|
22
|
+
* `/` in a name becomes `\/` and a literal `\` becomes `\\`. Serialize→parse is a
|
|
23
|
+
* lossless round trip for any name that is non-empty and free of control chars.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The character that joins path segments in the stored string. */
|
|
27
|
+
export const FOLDER_PATH_DELIMITER = "/";
|
|
28
|
+
/** The escape character. A literal delimiter or escape inside a name is prefixed. */
|
|
29
|
+
export const FOLDER_PATH_ESCAPE = "\\";
|
|
30
|
+
|
|
31
|
+
/** Trim a raw name for use as a segment. Internal whitespace is preserved; only the
|
|
32
|
+
* ends are trimmed, so " My Bank " and "My Bank" are the same folder. */
|
|
33
|
+
export function normalizeFolderName(name: string): string {
|
|
34
|
+
return name.trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A folder name is valid iff it is non-empty after trimming and carries no control
|
|
38
|
+
* characters (which would corrupt the stored string and never render). The
|
|
39
|
+
* delimiter and escape are ALLOWED — they are escaped on serialize. */
|
|
40
|
+
export function isValidFolderName(name: string): boolean {
|
|
41
|
+
const n = normalizeFolderName(name);
|
|
42
|
+
if (n.length === 0) return false;
|
|
43
|
+
// Reject C0 control chars (0x00–0x1F) and DEL (0x7F): they never render and would
|
|
44
|
+
// corrupt the stored string. Checked by code point so no literal control byte
|
|
45
|
+
// (or the no-control-characters-in-regex lint) ever appears in this source.
|
|
46
|
+
for (const ch of n) {
|
|
47
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
48
|
+
if (code < 0x20 || code === 0x7f) return false;
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Escape one segment: `\` → `\\`, then `/` → `\/`. Order matters — escape the
|
|
54
|
+
* escape first so a name containing `\/` is not double-decoded. */
|
|
55
|
+
function escapeSegment(seg: string): string {
|
|
56
|
+
return seg
|
|
57
|
+
.replaceAll(FOLDER_PATH_ESCAPE, FOLDER_PATH_ESCAPE + FOLDER_PATH_ESCAPE)
|
|
58
|
+
.replaceAll(FOLDER_PATH_DELIMITER, FOLDER_PATH_ESCAPE + FOLDER_PATH_DELIMITER);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Serialize a path (array of segment names) into the stored string. Empty and
|
|
63
|
+
* whitespace-only segments are dropped, and each surviving segment is trimmed, so
|
|
64
|
+
* the output is canonical: `serialize(parse(s))` normalizes any spelling of `s`.
|
|
65
|
+
*/
|
|
66
|
+
export function serializeFolderPath(segments: readonly string[]): string {
|
|
67
|
+
return segments
|
|
68
|
+
.map(normalizeFolderName)
|
|
69
|
+
.filter((s) => s.length > 0)
|
|
70
|
+
.map(escapeSegment)
|
|
71
|
+
.join(FOLDER_PATH_DELIMITER);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse a stored string back into its segment array. Splits on UNESCAPED delimiters
|
|
76
|
+
* only, then unescapes each segment. Empty segments (a leading/trailing/double
|
|
77
|
+
* delimiter) are dropped rather than becoming blank folders.
|
|
78
|
+
*/
|
|
79
|
+
export function parseFolderPath(serialized: string): string[] {
|
|
80
|
+
const out: string[] = [];
|
|
81
|
+
let seg = "";
|
|
82
|
+
let escaped = false;
|
|
83
|
+
for (const ch of serialized) {
|
|
84
|
+
if (escaped) {
|
|
85
|
+
seg += ch;
|
|
86
|
+
escaped = false;
|
|
87
|
+
} else if (ch === FOLDER_PATH_ESCAPE) {
|
|
88
|
+
escaped = true;
|
|
89
|
+
} else if (ch === FOLDER_PATH_DELIMITER) {
|
|
90
|
+
out.push(seg);
|
|
91
|
+
seg = "";
|
|
92
|
+
} else {
|
|
93
|
+
seg += ch;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
out.push(seg);
|
|
97
|
+
// A trailing lone escape (malformed input) is dropped with the empty tail.
|
|
98
|
+
return out.map(normalizeFolderName).filter((s) => s.length > 0);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A case-insensitive identity key for a path — the thing two paths compare equal by.
|
|
103
|
+
* Folder names dedupe case-insensitively (like the flat rail always did), so "Work"
|
|
104
|
+
* and "work" are one folder, but the key is still per-SEGMENT so "Home/Phones" and
|
|
105
|
+
* "Homework/hones" never collide (which a naive lowercased-string prefix would).
|
|
106
|
+
*/
|
|
107
|
+
export function folderPathKey(segments: readonly string[]): string {
|
|
108
|
+
return serializeFolderPath(segments.map((s) => normalizeFolderName(s).toLowerCase()));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** True when `child` is `parent` or sits anywhere beneath it (segment-aligned, so
|
|
112
|
+
* `Home` contains `Home/Phones` but NOT `Homework`). An empty `parent` (the root)
|
|
113
|
+
* contains everything. */
|
|
114
|
+
export function isFolderPathUnder(child: readonly string[], parent: readonly string[]): boolean {
|
|
115
|
+
if (parent.length === 0) return true;
|
|
116
|
+
if (child.length < parent.length) return false;
|
|
117
|
+
for (let i = 0; i < parent.length; i++) {
|
|
118
|
+
const c = normalizeFolderName(child[i] ?? "").toLowerCase();
|
|
119
|
+
const p = normalizeFolderName(parent[i] ?? "").toLowerCase();
|
|
120
|
+
if (c !== p) return false;
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The stored-string prefix EVERY descendant of `segments` starts with, and nothing
|
|
127
|
+
* else does — `serialize(segments) + "/"`. It exists so a store that keeps the
|
|
128
|
+
* serialized path in one column (a SQL `LIKE 'Home/%'`, a `startsWith` on a key/value
|
|
129
|
+
* store) can select a whole subtree without parsing every row.
|
|
130
|
+
*
|
|
131
|
+
* It is exact BECAUSE the delimiter is escaped: a folder literally named `TV/Film`
|
|
132
|
+
* serializes as `TV\/Film`, whose fifth character is the escape, so it can never be
|
|
133
|
+
* mistaken for `TV` + a child. `Homework` does not start with `Home/` either. The one
|
|
134
|
+
* thing the prefix does NOT cover is the folder itself — a caller wants
|
|
135
|
+
* `path = serialized OR path LIKE prefix || '%'`, which is what `isFolderPathUnder`
|
|
136
|
+
* says in memory. The root (an empty path) has no prefix that means "everything", so
|
|
137
|
+
* it returns `""` and the caller should skip the filter entirely.
|
|
138
|
+
*/
|
|
139
|
+
export function folderDescendantPrefix(segments: readonly string[]): string {
|
|
140
|
+
const serialized = serializeFolderPath(segments);
|
|
141
|
+
return serialized === "" ? "" : serialized + FOLDER_PATH_DELIMITER;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Render a path as a human breadcrumb: `Home › Phones › Noah`. The separator is a
|
|
145
|
+
* display choice; the default is the one the picker and rail use. */
|
|
146
|
+
export function formatFolderPath(segments: readonly string[], separator = " › "): string {
|
|
147
|
+
return segments.map(normalizeFolderName).join(separator);
|
|
148
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildFolderIndex,
|
|
4
|
+
buildFolderTree,
|
|
5
|
+
flattenFolderTree,
|
|
6
|
+
type FolderNode,
|
|
7
|
+
planFolderDelete,
|
|
8
|
+
planFolderMove,
|
|
9
|
+
planFolderRename,
|
|
10
|
+
} from "./tree";
|
|
11
|
+
|
|
12
|
+
/** A compact "name(child,child)" view of a subtree, for readable assertions. */
|
|
13
|
+
function shape(nodes: readonly FolderNode[]): string {
|
|
14
|
+
return nodes
|
|
15
|
+
.map((n) => (n.children.length ? `${n.name}(${shape(n.children)})` : n.name))
|
|
16
|
+
.join(",");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("buildFolderTree", () => {
|
|
20
|
+
test("derives intermediate ancestors from a deep leaf path", () => {
|
|
21
|
+
const tree = buildFolderTree(["Home/Phones/Noah"]);
|
|
22
|
+
expect(shape(tree)).toBe("Home(Phones(Noah))");
|
|
23
|
+
expect(tree[0]?.serialized).toBe("Home");
|
|
24
|
+
expect(tree[0]?.children[0]?.serialized).toBe("Home/Phones");
|
|
25
|
+
expect(tree[0]?.children[0]?.children[0]?.serialized).toBe("Home/Phones/Noah");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("merges siblings and dedupes case-insensitively (first spelling wins)", () => {
|
|
29
|
+
const tree = buildFolderTree(["Home/Phones", "home/Tablets", "Work"]);
|
|
30
|
+
expect(shape(tree)).toBe("Home(Phones,Tablets),Work");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("distinct people under distinct parents do NOT merge", () => {
|
|
34
|
+
const tree = buildFolderTree(["Home/Phones/Noah", "Work/Phones/Alex"]);
|
|
35
|
+
expect(shape(tree)).toBe("Home(Phones(Noah)),Work(Phones(Alex))");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("flatten is depth-first, parent before child", () => {
|
|
39
|
+
const tree = buildFolderTree(["Home/Phones", "Work"]);
|
|
40
|
+
expect(flattenFolderTree(tree).map((n) => n.serialized)).toEqual([
|
|
41
|
+
"Home",
|
|
42
|
+
"Home/Phones",
|
|
43
|
+
"Work",
|
|
44
|
+
]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("planFolderRename", () => {
|
|
49
|
+
const paths = ["Home", "Home/Phones", "Home/Phones/Noah", "Work"];
|
|
50
|
+
|
|
51
|
+
test("renames the folder and moves every descendant with it", () => {
|
|
52
|
+
const plan = planFolderRename(paths, ["Home", "Phones"], "Mobiles");
|
|
53
|
+
expect(plan).toEqual([
|
|
54
|
+
{ from: "Home/Phones", to: "Home/Mobiles" },
|
|
55
|
+
{ from: "Home/Phones/Noah", to: "Home/Mobiles/Noah" },
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("renaming a root moves its whole subtree", () => {
|
|
60
|
+
const plan = planFolderRename(paths, ["Home"], "House");
|
|
61
|
+
expect(plan).toEqual([
|
|
62
|
+
{ from: "Home", to: "House" },
|
|
63
|
+
{ from: "Home/Phones", to: "House/Phones" },
|
|
64
|
+
{ from: "Home/Phones/Noah", to: "House/Phones/Noah" },
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("does not touch unrelated folders", () => {
|
|
69
|
+
const plan = planFolderRename(paths, ["Home", "Phones"], "Mobiles");
|
|
70
|
+
expect(plan.some((r) => r.from === "Work")).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("rejects a blank new name", () => {
|
|
74
|
+
expect(() => planFolderRename(paths, ["Home"], " ")).toThrow();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("planFolderMove", () => {
|
|
79
|
+
const paths = ["Home", "Home/Phones", "Home/Phones/Noah", "Work"];
|
|
80
|
+
|
|
81
|
+
test("moves a subtree under a new parent", () => {
|
|
82
|
+
const plan = planFolderMove(paths, ["Home", "Phones"], ["Work"]);
|
|
83
|
+
expect(plan).toEqual([
|
|
84
|
+
{ from: "Home/Phones", to: "Work/Phones" },
|
|
85
|
+
{ from: "Home/Phones/Noah", to: "Work/Phones/Noah" },
|
|
86
|
+
]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("moving to the root drops the parent segment", () => {
|
|
90
|
+
const plan = planFolderMove(paths, ["Home", "Phones"], []);
|
|
91
|
+
expect(plan).toEqual([
|
|
92
|
+
{ from: "Home/Phones", to: "Phones" },
|
|
93
|
+
{ from: "Home/Phones/Noah", to: "Phones/Noah" },
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("refuses to move a folder into its own descendant", () => {
|
|
98
|
+
expect(() => planFolderMove(paths, ["Home"], ["Home", "Phones"])).toThrow();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a no-op move (already there) yields no rewrites", () => {
|
|
102
|
+
expect(planFolderMove(paths, ["Home", "Phones"], ["Home"])).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("planFolderDelete", () => {
|
|
107
|
+
const paths = ["Home", "Home/Phones", "Home/Phones/Noah", "Work"];
|
|
108
|
+
|
|
109
|
+
test("splices out the folder, re-homing descendants to the parent", () => {
|
|
110
|
+
const plan = planFolderDelete(paths, ["Home", "Phones"]);
|
|
111
|
+
expect(plan).toEqual([
|
|
112
|
+
{ from: "Home/Phones", to: "Home" },
|
|
113
|
+
{ from: "Home/Phones/Noah", to: "Home/Noah" },
|
|
114
|
+
]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("deleting a root re-homes its direct items to no-folder", () => {
|
|
118
|
+
const plan = planFolderDelete(paths, ["Home"]);
|
|
119
|
+
expect(plan).toContainEqual({ from: "Home", to: "" });
|
|
120
|
+
expect(plan).toContainEqual({ from: "Home/Phones", to: "Phones" });
|
|
121
|
+
expect(plan).toContainEqual({ from: "Home/Phones/Noah", to: "Phones/Noah" });
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("duplicate names under different parents", () => {
|
|
126
|
+
test("rename touches only the targeted branch (only STORED paths are remapped)", () => {
|
|
127
|
+
// Only leaf paths are stored on items here — "Home/Phones" exists as an ancestor
|
|
128
|
+
// node but no item sits directly in it, so it needs no rewrite.
|
|
129
|
+
const paths = ["Home/Phones/Noah", "Work/Phones/Alex"];
|
|
130
|
+
const plan = planFolderRename(paths, ["Home", "Phones"], "Mobiles");
|
|
131
|
+
expect(plan).toEqual([{ from: "Home/Phones/Noah", to: "Home/Mobiles/Noah" }]);
|
|
132
|
+
expect(plan.some((r) => r.from.startsWith("Work"))).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("buildFolderIndex", () => {
|
|
137
|
+
const paths = ["Home/Phones/Noah", "Home/Phones/Noah", "Home/Taxes", "Work", ""];
|
|
138
|
+
|
|
139
|
+
test("counts are CUMULATIVE, so a collapsed parent still totals its subtree", () => {
|
|
140
|
+
const index = buildFolderIndex(paths);
|
|
141
|
+
expect(index.count("Home")).toBe(3);
|
|
142
|
+
expect(index.count("Home/Phones")).toBe(2);
|
|
143
|
+
expect(index.count("Home/Phones/Noah")).toBe(2);
|
|
144
|
+
expect(index.count("Work")).toBe(1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("the tree is the same one buildFolderTree derives, and empty paths are ignored", () => {
|
|
148
|
+
const index = buildFolderIndex(paths);
|
|
149
|
+
expect(index.tree.map((n) => n.name)).toEqual(["Home", "Work"]);
|
|
150
|
+
expect(index.count("Nowhere")).toBe(0);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("counts are case-insensitive on the path, like every other folder identity", () => {
|
|
154
|
+
const index = buildFolderIndex(["Home/Taxes"]);
|
|
155
|
+
expect(index.count("home/taxes")).toBe(1);
|
|
156
|
+
});
|
|
157
|
+
});
|