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,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marking parts of a video to REMOVE — the model, with no browser and no ffmpeg in it.
|
|
3
|
+
*
|
|
4
|
+
* ── The owner's ask, 2026-08-25 ────────────────────────────────────────────────────────
|
|
5
|
+
* *"We will use a unique strategy and not have live video changes in the UI. What I want is
|
|
6
|
+
* to be able to mark points in the video to indicate where to cut out sections. The browser
|
|
7
|
+
* is not great at actually removing segments in the UI even with the cc editor for video. So
|
|
8
|
+
* I will just mark them and send them to binary-server to handle it with the video software
|
|
9
|
+
* it uses."*
|
|
10
|
+
*
|
|
11
|
+
* 🔴 That is a decision about WHERE the work happens, and it is the right one for a reason
|
|
12
|
+
* worth writing down: `ffmpeg.wasm` decodes a multi-gigabyte clip inside a tab, at a few
|
|
13
|
+
* frames a second, holding the whole thing in memory — and this library's files are phone
|
|
14
|
+
* and camera masters. binary-server already runs native ffmpeg on this Mac with measured
|
|
15
|
+
* encoder settings (`ffmpegJobs.ts`), so the browser's only job is to say WHERE, which is
|
|
16
|
+
* exactly what a browser is good at.
|
|
17
|
+
*
|
|
18
|
+
* So a cut list is DATA. It is produced here, travels as JSON, and is executed somewhere
|
|
19
|
+
* else — which is why every rule about what makes a list valid lives in this file rather
|
|
20
|
+
* than being re-derived by the editor, by the app's route and by the ffmpeg arg builder. A
|
|
21
|
+
* cut list the UI thinks is fine and the encoder thinks is empty is the failure mode this
|
|
22
|
+
* shape exists to make impossible.
|
|
23
|
+
*
|
|
24
|
+
* ── 🔴 Why it lives in `core/`, not beside the editor in `react/media/` ────────────────
|
|
25
|
+
* Because the SERVER needs it. `apps/collections`' route refuses a cut list with the same
|
|
26
|
+
* function the dialog's Send button was disabled by, and a second definition of "a cut list
|
|
27
|
+
* that cannot be executed" is exactly the drift this module exists to prevent. Importing it
|
|
28
|
+
* from `cursedbelt/react/media` would pull the mask painter, the picture editor and React
|
|
29
|
+
* itself into a Hono server — so the model is `cursedbelt-core/media`, which is framework-free
|
|
30
|
+
* by construction, and `react/media` re-exports it for the dialog's convenience.
|
|
31
|
+
*
|
|
32
|
+
* ── Half-open, in seconds ──────────────────────────────────────────────────────────────
|
|
33
|
+
* A cut is `[start, end)`. Adjacent cuts therefore MERGE without leaving a frame between
|
|
34
|
+
* them, and a cut ending exactly where the next begins is one cut rather than two with an
|
|
35
|
+
* invisible sliver of footage in the middle.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/*
|
|
39
|
+
* ── 🔴 The MARKING model (owner, 2026-08-26) ──────────────────────────────────────────
|
|
40
|
+
*
|
|
41
|
+
* *"I could also cut many sections from the middle instead of just one … There are always
|
|
42
|
+
* start and end points needed for mid section removals to be valid. I think there could be
|
|
43
|
+
* ambiguity about which side to cut in some cases if the same type of marker is used for
|
|
44
|
+
* every cut marker. You can make it allow clicking 'x' on a section to indicate remove it. If
|
|
45
|
+
* I make one cut in the middle then I can choose the left of it and click 'x' to indicate the
|
|
46
|
+
* left should be the deleted side."*
|
|
47
|
+
*
|
|
48
|
+
* He identified the defect in the first design exactly. Marks that PAIR — an in point and an
|
|
49
|
+
* out point — are ambiguous the moment there is more than one pair, because nothing in a row
|
|
50
|
+
* of identical markers says which gap is a cut and which is footage. `-[--|-|-]-` has to be
|
|
51
|
+
* read, and two people read it differently.
|
|
52
|
+
*
|
|
53
|
+
* So the model is his: a CUT is a split point, and splits divide the clip into SEGMENTS. A
|
|
54
|
+
* segment is either kept or removed, and you say which by clicking it. There is no pairing,
|
|
55
|
+
* no ordering rule, and no "which side" question left to ask — every point in the video
|
|
56
|
+
* belongs to exactly one segment, and that segment is either in or out.
|
|
57
|
+
*
|
|
58
|
+
* It also makes the two things he listed as separate cases the same case: one cut and remove
|
|
59
|
+
* the left is `[X][ keep ]`, and cutting several sections out of the middle is
|
|
60
|
+
* `[keep][X][keep][X][keep]`. Nothing special-cases the head or the tail.
|
|
61
|
+
*
|
|
62
|
+
* `VideoCut[]` remains the wire format — the encoder still receives spans to remove — so the
|
|
63
|
+
* server, binary-server and every test below are unchanged by this. What changed is only how
|
|
64
|
+
* a person produces one.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/** One removed span. Seconds from the start of the source, half-open: `[start, end)`. */
|
|
68
|
+
export interface VideoCut {
|
|
69
|
+
start: number;
|
|
70
|
+
end: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The shortest span worth cutting, in seconds.
|
|
75
|
+
*
|
|
76
|
+
* 🔴 Not zero, and not "whatever the person dragged". Below roughly a frame the request is
|
|
77
|
+
* indistinguishable from a mis-click, and it is a request ffmpeg cannot honour anyway — a
|
|
78
|
+
* cut has to land on a frame boundary, so a 4 ms span asks the encoder to remove nothing and
|
|
79
|
+
* charges a full re-encode of the file for it. 40 ms is one frame at 25fps, the slowest rate
|
|
80
|
+
* anything in this library was shot at.
|
|
81
|
+
*/
|
|
82
|
+
export const MIN_CUT_SECONDS = 0.04;
|
|
83
|
+
|
|
84
|
+
const clamp = (value: number, low: number, high: number): number =>
|
|
85
|
+
Math.min(high, Math.max(low, value));
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Put a raw list into the one canonical form everything downstream may assume: inside the
|
|
89
|
+
* clip, ordered, non-overlapping, and free of spans too short to mean anything.
|
|
90
|
+
*
|
|
91
|
+
* 🔴 Overlaps are MERGED rather than refused. They are not a mistake to report — they are
|
|
92
|
+
* what marking naturally produces: you cut a passage, watch it back, and cut a span that
|
|
93
|
+
* happens to include part of what you already removed. Refusing that would make the editor
|
|
94
|
+
* argue with the person using it about an intention that is perfectly clear.
|
|
95
|
+
*
|
|
96
|
+
* A non-finite bound (a `NaN` duration from a video element that has not loaded yet is the
|
|
97
|
+
* usual source) drops its cut instead of poisoning the sort — every comparison against `NaN`
|
|
98
|
+
* is false, which silently produces an unsorted "sorted" array and a merge pass that leaves
|
|
99
|
+
* overlaps in.
|
|
100
|
+
*/
|
|
101
|
+
export function normalizeCuts(cuts: readonly VideoCut[], duration: number): VideoCut[] {
|
|
102
|
+
if (!Number.isFinite(duration) || duration <= 0) return [];
|
|
103
|
+
const clean = cuts
|
|
104
|
+
.filter((cut) => Number.isFinite(cut.start) && Number.isFinite(cut.end))
|
|
105
|
+
.map((cut) => ({
|
|
106
|
+
start: clamp(Math.min(cut.start, cut.end), 0, duration),
|
|
107
|
+
end: clamp(Math.max(cut.start, cut.end), 0, duration),
|
|
108
|
+
}))
|
|
109
|
+
.filter((cut) => cut.end - cut.start >= MIN_CUT_SECONDS)
|
|
110
|
+
.sort((a, b) => a.start - b.start);
|
|
111
|
+
|
|
112
|
+
const merged: VideoCut[] = [];
|
|
113
|
+
for (const cut of clean) {
|
|
114
|
+
const last = merged[merged.length - 1];
|
|
115
|
+
// `>=` not `>`: half-open spans that touch are one span, not two with a gap of zero
|
|
116
|
+
// seconds between them that no frame can live in.
|
|
117
|
+
if (last && cut.start <= last.end) last.end = Math.max(last.end, cut.end);
|
|
118
|
+
else merged.push({ ...cut });
|
|
119
|
+
}
|
|
120
|
+
return merged;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* What SURVIVES — the complement of the cut list, which is what the encoder actually
|
|
125
|
+
* concatenates.
|
|
126
|
+
*
|
|
127
|
+
* The editor shows this and the encoder builds from it, from one function, because the
|
|
128
|
+
* alternative is two independent readings of "the parts you keep" and a preview that
|
|
129
|
+
* disagrees with the file that comes back.
|
|
130
|
+
*/
|
|
131
|
+
export function keptSegments(cuts: readonly VideoCut[], duration: number): VideoCut[] {
|
|
132
|
+
const removed = normalizeCuts(cuts, duration);
|
|
133
|
+
if (!Number.isFinite(duration) || duration <= 0) return [];
|
|
134
|
+
const kept: VideoCut[] = [];
|
|
135
|
+
let at = 0;
|
|
136
|
+
for (const cut of removed) {
|
|
137
|
+
if (cut.start - at >= MIN_CUT_SECONDS) kept.push({ start: at, end: cut.start });
|
|
138
|
+
at = Math.max(at, cut.end);
|
|
139
|
+
}
|
|
140
|
+
if (duration - at >= MIN_CUT_SECONDS) kept.push({ start: at, end: duration });
|
|
141
|
+
return kept;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** How much of the clip the marks remove. */
|
|
145
|
+
export function cutSeconds(cuts: readonly VideoCut[], duration: number): number {
|
|
146
|
+
return normalizeCuts(cuts, duration).reduce((total, cut) => total + (cut.end - cut.start), 0);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** How long the result will be. */
|
|
150
|
+
export function keptSeconds(cuts: readonly VideoCut[], duration: number): number {
|
|
151
|
+
return keptSegments(cuts, duration).reduce((total, seg) => total + (seg.end - seg.start), 0);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Why this list cannot be sent, or `null` when it can.
|
|
156
|
+
*
|
|
157
|
+
* 🔴 A SENTENCE, not a boolean, and the same sentence in the editor and on the server. The
|
|
158
|
+
* two refusals below are the only ones, and both are things a person can reach by accident
|
|
159
|
+
* in a few seconds of marking:
|
|
160
|
+
*
|
|
161
|
+
* · nothing marked — pressing Send with no cuts would queue a full re-encode that produces
|
|
162
|
+
* a byte-for-byte-pointless second copy of a multi-gigabyte file, and then ask the owner
|
|
163
|
+
* to choose between two identical videos.
|
|
164
|
+
* · everything marked — the result is an empty file. ffmpeg's `concat` with zero segments
|
|
165
|
+
* does not fail loudly; it writes a valid container with no frames in it, which arrives
|
|
166
|
+
* as a "finished" edit that plays nothing.
|
|
167
|
+
*/
|
|
168
|
+
export function cutListProblem(cuts: readonly VideoCut[], duration: number): string | null {
|
|
169
|
+
if (!Number.isFinite(duration) || duration <= 0)
|
|
170
|
+
return "This video's length is not known yet, so a cut cannot be placed in it.";
|
|
171
|
+
if (normalizeCuts(cuts, duration).length === 0)
|
|
172
|
+
return "Nothing is marked for removal yet — mark at least one section first.";
|
|
173
|
+
if (keptSegments(cuts, duration).length === 0)
|
|
174
|
+
return "That removes the whole video. Leave at least one section to keep.";
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Seconds as `m:ss.s` / `h:mm:ss.s` — a TENTH of a second, unlike the timecode the player's
|
|
180
|
+
* scrub bar shows.
|
|
181
|
+
*
|
|
182
|
+
* 🔴 The extra digit is the difference between a readable clock and a usable cut point. At
|
|
183
|
+
* whole seconds two marks a few frames apart render identically, so a list of cuts around
|
|
184
|
+
* one moment reads as the same row repeated — and there is no way to tell, from the list,
|
|
185
|
+
* which of them is the one you meant to delete.
|
|
186
|
+
*/
|
|
187
|
+
export function cutTimecode(seconds: number): string {
|
|
188
|
+
if (!Number.isFinite(seconds) || seconds < 0) return '0:00.0';
|
|
189
|
+
/*
|
|
190
|
+
* 🔴 Integer tenths, with an epsilon, rather than `Math.floor((seconds % 1) * 10)`.
|
|
191
|
+
*
|
|
192
|
+
* A playhead is a float from a decoder, and the fractional part of one is not the number
|
|
193
|
+
* you wrote down: `3725.2 % 1` is 0.19999999999981810, which floors to ONE tenth — the
|
|
194
|
+
* timecode reads 1:02:05.1 for a mark placed at 1:02:05.2, so a person nudging a cut watches
|
|
195
|
+
* the clock disagree with the frame. Truncating in tenths (and nudging past the
|
|
196
|
+
* representation error first) keeps the readout monotonic with the playhead.
|
|
197
|
+
*/
|
|
198
|
+
const totalTenths = Math.floor(seconds * 10 + 1e-6);
|
|
199
|
+
const tenths = totalTenths % 10;
|
|
200
|
+
const whole = Math.floor(totalTenths / 10);
|
|
201
|
+
const s = whole % 60;
|
|
202
|
+
const m = Math.floor(whole / 60) % 60;
|
|
203
|
+
const h = Math.floor(whole / 3600);
|
|
204
|
+
const mm = h > 0 ? String(m).padStart(2, '0') : String(m);
|
|
205
|
+
return `${h > 0 ? `${h}:` : ''}${mm}:${String(s).padStart(2, '0')}.${tenths}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
// ── The plan: split points, and which pieces are out ────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* What the editor holds: where the scissors have been, and which pieces are marked out.
|
|
213
|
+
*
|
|
214
|
+
* 🔴 `removed` names segments by their BOUNDS, not by an index. Indices shift the moment a
|
|
215
|
+
* new split lands anywhere to their left, so a plan stored as `removed: [2]` silently comes
|
|
216
|
+
* to mean a different piece of footage — and it would do so during the one gesture people use
|
|
217
|
+
* most, which is cutting again after looking at what they have. Bounds cannot drift: a
|
|
218
|
+
* segment either still exists with those bounds or it has been divided, and dividing one is
|
|
219
|
+
* handled explicitly below.
|
|
220
|
+
*/
|
|
221
|
+
export interface CutPlan {
|
|
222
|
+
/** Split points in seconds, sorted, unique, strictly inside `(0, duration)`. */
|
|
223
|
+
splits: number[];
|
|
224
|
+
/** The pieces marked for removal, by their `[start, end)` bounds. */
|
|
225
|
+
removed: VideoCut[];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export const EMPTY_PLAN: CutPlan = { splits: [], removed: [] };
|
|
229
|
+
|
|
230
|
+
/** A segment of the clip, and whether it survives. */
|
|
231
|
+
export interface PlanSegment extends VideoCut {
|
|
232
|
+
removed: boolean;
|
|
233
|
+
/** Stable across re-renders and safe as a React key — bounds, not position. */
|
|
234
|
+
key: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const segmentKey = (start: number, end: number): string => `${start.toFixed(3)}-${end.toFixed(3)}`;
|
|
238
|
+
|
|
239
|
+
/** Sorted, unique, inside the clip, and never closer together than a frame. */
|
|
240
|
+
function tidySplits(splits: readonly number[], duration: number): number[] {
|
|
241
|
+
if (!Number.isFinite(duration) || duration <= 0) return [];
|
|
242
|
+
const out: number[] = [];
|
|
243
|
+
for (const at of [...splits].filter(Number.isFinite).sort((a, b) => a - b)) {
|
|
244
|
+
if (at <= MIN_CUT_SECONDS || at >= duration - MIN_CUT_SECONDS) continue;
|
|
245
|
+
// A split a frame away from the previous one makes a segment no frame can live in — and
|
|
246
|
+
// an unclickable sliver on the strip, which is worse than refusing the cut.
|
|
247
|
+
if (out.length > 0 && at - (out[out.length - 1] as number) < MIN_CUT_SECONDS) continue;
|
|
248
|
+
out.push(at);
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The clip, divided. Always covers `[0, duration)` exactly, with no gaps. */
|
|
254
|
+
export function planSegments(plan: CutPlan, duration: number): PlanSegment[] {
|
|
255
|
+
if (!Number.isFinite(duration) || duration <= 0) return [];
|
|
256
|
+
const splits = tidySplits(plan.splits, duration);
|
|
257
|
+
const bounds = [0, ...splits, duration];
|
|
258
|
+
const out: PlanSegment[] = [];
|
|
259
|
+
for (let i = 0; i < bounds.length - 1; i++) {
|
|
260
|
+
const start = bounds[i] as number;
|
|
261
|
+
const end = bounds[i + 1] as number;
|
|
262
|
+
out.push({
|
|
263
|
+
start,
|
|
264
|
+
end,
|
|
265
|
+
key: segmentKey(start, end),
|
|
266
|
+
// A segment is removed when the plan holds a removal that COVERS it. Covers, not
|
|
267
|
+
// equals: a segment that was marked out and has since been split in two is two removed
|
|
268
|
+
// segments, and the alternative — dropping the mark on a re-split — silently restores
|
|
269
|
+
// footage somebody had already decided against.
|
|
270
|
+
removed: plan.removed.some((cut) => cut.start <= start + 1e-6 && cut.end >= end - 1e-6),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Cut here. A split inside a removed piece divides it into two removed pieces. */
|
|
277
|
+
export function addSplit(plan: CutPlan, at: number, duration: number): CutPlan {
|
|
278
|
+
const splits = tidySplits([...plan.splits, at], duration);
|
|
279
|
+
if (splits.length === tidySplits(plan.splits, duration).length) return plan;
|
|
280
|
+
return { splits, removed: plan.removed };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Undo a cut, merging the two pieces either side.
|
|
285
|
+
*
|
|
286
|
+
* 🔴 The merged piece is removed only if BOTH halves were. Anything else invents an
|
|
287
|
+
* intention: promoting a half-removed merge to "removed" deletes footage nobody marked, and
|
|
288
|
+
* demoting it to "kept" restores footage somebody did mark. Requiring agreement means the
|
|
289
|
+
* only way to lose a decision is to have made two conflicting ones, and then the safe
|
|
290
|
+
* reading — keep it — is the one that costs nothing but another click.
|
|
291
|
+
*/
|
|
292
|
+
export function removeSplit(plan: CutPlan, at: number, duration: number): CutPlan {
|
|
293
|
+
const splits = tidySplits(plan.splits, duration);
|
|
294
|
+
const index = splits.findIndex((split) => Math.abs(split - at) < 1e-6);
|
|
295
|
+
if (index === -1) return plan;
|
|
296
|
+
const before = planSegments(plan, duration);
|
|
297
|
+
const left = before.find((segment) => Math.abs(segment.end - at) < 1e-6);
|
|
298
|
+
const right = before.find((segment) => Math.abs(segment.start - at) < 1e-6);
|
|
299
|
+
const nextSplits = [...splits.slice(0, index), ...splits.slice(index + 1)];
|
|
300
|
+
const nextPlan: CutPlan = { splits: nextSplits, removed: plan.removed };
|
|
301
|
+
if (!left || !right) return nextPlan;
|
|
302
|
+
const merged = { start: left.start, end: right.end };
|
|
303
|
+
const kept = plan.removed.filter(
|
|
304
|
+
(cut) => !(cut.start >= merged.start - 1e-6 && cut.end <= merged.end + 1e-6),
|
|
305
|
+
);
|
|
306
|
+
return {
|
|
307
|
+
splits: nextSplits,
|
|
308
|
+
removed: left.removed && right.removed ? [...kept, merged] : kept,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Mark a piece out, or put it back — the ✗ on a segment. */
|
|
313
|
+
export function toggleSegment(plan: CutPlan, segment: VideoCut, duration: number): CutPlan {
|
|
314
|
+
const current = planSegments(plan, duration).find(
|
|
315
|
+
(candidate) => candidate.key === segmentKey(segment.start, segment.end),
|
|
316
|
+
);
|
|
317
|
+
if (!current) return plan;
|
|
318
|
+
if (current.removed) {
|
|
319
|
+
return {
|
|
320
|
+
splits: plan.splits,
|
|
321
|
+
// Drop every removal this segment sits inside, then put back the parts of a WIDER
|
|
322
|
+
// removal that are still meant to go — un-marking one piece of a span that was marked
|
|
323
|
+
// before it was split must not resurrect its neighbours.
|
|
324
|
+
removed: plan.removed.flatMap((cut) => {
|
|
325
|
+
if (!(cut.start <= current.start + 1e-6 && cut.end >= current.end - 1e-6)) return [cut];
|
|
326
|
+
const rest: VideoCut[] = [];
|
|
327
|
+
if (current.start - cut.start >= MIN_CUT_SECONDS)
|
|
328
|
+
rest.push({ start: cut.start, end: current.start });
|
|
329
|
+
if (cut.end - current.end >= MIN_CUT_SECONDS) rest.push({ start: current.end, end: cut.end });
|
|
330
|
+
return rest;
|
|
331
|
+
}),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
return { splits: plan.splits, removed: [...plan.removed, { start: current.start, end: current.end }] };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The plan as the wire format — the spans to remove, normalized.
|
|
339
|
+
*
|
|
340
|
+
* Adjacent removed segments merge here, which is why the editor can let somebody mark three
|
|
341
|
+
* pieces in a row without producing three cuts the encoder has to join back up.
|
|
342
|
+
*/
|
|
343
|
+
export function planCuts(plan: CutPlan, duration: number): VideoCut[] {
|
|
344
|
+
return normalizeCuts(
|
|
345
|
+
planSegments(plan, duration)
|
|
346
|
+
.filter((segment) => segment.removed)
|
|
347
|
+
.map(({ start, end }) => ({ start, end })),
|
|
348
|
+
duration,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Why this plan cannot be sent, or `null`. The same two refusals, asked of a plan. */
|
|
353
|
+
export function planProblem(plan: CutPlan, duration: number): string | null {
|
|
354
|
+
return cutListProblem(planCuts(plan, duration), duration);
|
|
355
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Backward-compatible breadcrumb helpers for apps that maintain a flat node map
|
|
2
|
+
// (key → label + optional parent key) rather than deriving the trail from a full
|
|
3
|
+
// NavigationModel. Apps that have migrated to NavigationModel should use
|
|
4
|
+
// deriveBreadcrumbTrail (navigationSelectors) instead.
|
|
5
|
+
|
|
6
|
+
/** A node in a flat breadcrumb hierarchy map. */
|
|
7
|
+
export interface BreadcrumbNode {
|
|
8
|
+
/** The stable key for this node (usually its path). */
|
|
9
|
+
key: string;
|
|
10
|
+
/** Human-readable label. */
|
|
11
|
+
label: string;
|
|
12
|
+
/** Key of the parent node, if any. Absent for root nodes. */
|
|
13
|
+
parent?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A resolved breadcrumb crumb ready to render. */
|
|
17
|
+
export interface BreadcrumbItem {
|
|
18
|
+
/** Stable key (used as the React list key). */
|
|
19
|
+
key: string;
|
|
20
|
+
/** Human-readable label. */
|
|
21
|
+
label: string;
|
|
22
|
+
/** Navigation target — omit for a non-interactive (current page) leaf. */
|
|
23
|
+
href?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Walk `nodes` upward from `path` to the root and return the ancestor chain
|
|
28
|
+
* ordered root-first (Home → … → `path`). Returns `[]` when `path` is absent
|
|
29
|
+
* from the map.
|
|
30
|
+
*/
|
|
31
|
+
export function buildBreadcrumbTrail(
|
|
32
|
+
nodes: Record<string, BreadcrumbNode>,
|
|
33
|
+
path: string,
|
|
34
|
+
): BreadcrumbItem[] {
|
|
35
|
+
const chain: BreadcrumbItem[] = [];
|
|
36
|
+
let cur: string | undefined = path;
|
|
37
|
+
while (cur) {
|
|
38
|
+
const node: BreadcrumbNode | undefined = nodes[cur];
|
|
39
|
+
if (!node) break;
|
|
40
|
+
chain.unshift({ key: node.key, label: node.label, href: node.key });
|
|
41
|
+
cur = node.parent;
|
|
42
|
+
}
|
|
43
|
+
return chain;
|
|
44
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The anti-drift guarantee. `createCoverageTest` returns an assertion the consuming
|
|
2
|
+
// app runs in its own `navigationModel.spec.ts`: it fails the build the instant a
|
|
3
|
+
// route exists in `routes.tsx` that no one registered in the navigation model. Since
|
|
4
|
+
// the side nav / hub tiles / breadcrumbs / search / palette ALL derive from that one
|
|
5
|
+
// model, a registered route is automatically discoverable — and an unregistered one
|
|
6
|
+
// can never silently bury. Framework-agnostic (throws a plain Error; works under
|
|
7
|
+
// bun:test, vitest, jest) so the same factory drops into any app's spec file.
|
|
8
|
+
|
|
9
|
+
import type { NavigationModel } from './navigationModel';
|
|
10
|
+
import { deriveAllPaths, normalizeNavPath } from './navigationSelectors';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build the coverage assertion for `model` against `allRoutePaths` (the app's full
|
|
14
|
+
* route list, e.g. derived from `routes.tsx`). The returned `() => void` throws —
|
|
15
|
+
* listing every gap — when a route path is neither registered in the model nor
|
|
16
|
+
* registered as `deepLinkOnly` (both forms appear in {@link deriveAllPaths}).
|
|
17
|
+
*
|
|
18
|
+
* ```ts
|
|
19
|
+
* // ca/ru navigationModel.spec.ts
|
|
20
|
+
* import { test } from 'bun:test';
|
|
21
|
+
* test('every route is registered in the navigation model',
|
|
22
|
+
* createCoverageTest(caNavigationModel, caAllRoutePaths));
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Route matching is by {@link normalizeNavPath} on both sides, so a `#tab=…` model
|
|
26
|
+
* entry covers its base route and a trailing slash is ignored.
|
|
27
|
+
*/
|
|
28
|
+
export function createCoverageTest(model: NavigationModel, allRoutePaths: string[]): () => void {
|
|
29
|
+
return () => {
|
|
30
|
+
const covered = new Set(deriveAllPaths(model).map(normalizeNavPath));
|
|
31
|
+
const missing = [...new Set(allRoutePaths.map((p) => p.trim()).filter(Boolean))]
|
|
32
|
+
.filter((p) => !covered.has(normalizeNavPath(p)))
|
|
33
|
+
.sort();
|
|
34
|
+
if (missing.length > 0) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`navigationModel coverage gap — ${missing.length} route path(s) are not registered.\n` +
|
|
37
|
+
'Add each to a domain group (or mark it deepLinkOnly) so it can never silently bury:\n' +
|
|
38
|
+
missing.map((p) => ` • ${p}`).join('\n'),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Non-throwing companion — the raw set of route paths missing from the model. Useful
|
|
46
|
+
* during a migration (N1 of the IA overhaul) to *enumerate* the discovery gaps
|
|
47
|
+
* before fixing them, rather than failing on the first one.
|
|
48
|
+
*/
|
|
49
|
+
export function findCoverageGaps(model: NavigationModel, allRoutePaths: string[]): string[] {
|
|
50
|
+
const covered = new Set(deriveAllPaths(model).map(normalizeNavPath));
|
|
51
|
+
return [...new Set(allRoutePaths.map((p) => p.trim()).filter(Boolean))]
|
|
52
|
+
.filter((p) => !covered.has(normalizeNavPath(p)))
|
|
53
|
+
.sort();
|
|
54
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// `cursedbelt-core/navigation` — the navigation contract + pure selectors. React-free and
|
|
2
|
+
// routing-free: the single source of truth (the IA tree) plus every derivation off
|
|
3
|
+
// it (side nav, hub tiles, breadcrumbs, search catalogue, coverage test, prefs
|
|
4
|
+
// migration). The React components in `cursedbelt/react` consume exactly this.
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
type BreadcrumbItem,
|
|
8
|
+
type BreadcrumbNode,
|
|
9
|
+
buildBreadcrumbTrail,
|
|
10
|
+
} from './breadcrumbCompat';
|
|
11
|
+
export {
|
|
12
|
+
createCoverageTest,
|
|
13
|
+
findCoverageGaps,
|
|
14
|
+
} from './coverageTest';
|
|
15
|
+
export {
|
|
16
|
+
CURRENT_NAV_PREFS_VERSION,
|
|
17
|
+
type LegacyNavPrefs,
|
|
18
|
+
type MigrateNavPrefsOptions,
|
|
19
|
+
migrateNavPreferences,
|
|
20
|
+
type NavPreferences,
|
|
21
|
+
type NavTilePrefs,
|
|
22
|
+
} from './migrateNavPreferences';
|
|
23
|
+
export {
|
|
24
|
+
allowAllGate,
|
|
25
|
+
type GateFn,
|
|
26
|
+
type NavDomain,
|
|
27
|
+
type NavGate,
|
|
28
|
+
type NavGroup,
|
|
29
|
+
type NavHostLimit,
|
|
30
|
+
type NavItem,
|
|
31
|
+
type NavigationModel,
|
|
32
|
+
} from './navigationModel';
|
|
33
|
+
export {
|
|
34
|
+
deriveAllPaths,
|
|
35
|
+
deriveBreadcrumbTrail,
|
|
36
|
+
deriveDomain,
|
|
37
|
+
deriveDomainGroups,
|
|
38
|
+
deriveDomainItems,
|
|
39
|
+
deriveSearchCatalogue,
|
|
40
|
+
deriveSideNavDomains,
|
|
41
|
+
type NavCrumb,
|
|
42
|
+
normalizeNavPath,
|
|
43
|
+
type SearchCatalogueEntry,
|
|
44
|
+
} from './navigationSelectors';
|
|
45
|
+
export {
|
|
46
|
+
type CatalogueHit,
|
|
47
|
+
type CatalogueResultGroup,
|
|
48
|
+
groupCatalogueHits,
|
|
49
|
+
rankCatalogue,
|
|
50
|
+
} from './searchCatalogue';
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// One-time, idempotent preference migration. The IA overhaul re-groups the old
|
|
2
|
+
// 8-hub layout into 6 domains; this remaps the user's saved nav/hub customizations
|
|
3
|
+
// (order, hidden, per-row colors) onto the new ids so nothing resets. The *mapping*
|
|
4
|
+
// (old key → new domain id) is app-supplied data (only the app knows its old hubs);
|
|
5
|
+
// the runner is the framework's. Idempotent via a version stamp — it runs on every
|
|
6
|
+
// cold load but no-ops once `version` is current, so colors/hidden the user set
|
|
7
|
+
// AFTER migrating are never clobbered.
|
|
8
|
+
|
|
9
|
+
import type { NavigationModel } from './navigationModel';
|
|
10
|
+
|
|
11
|
+
/** Bump when the migration logic or the prefs shape changes. */
|
|
12
|
+
export const CURRENT_NAV_PREFS_VERSION = 1;
|
|
13
|
+
|
|
14
|
+
/** Per-hub tile customization (keyed by item path/id within a domain). */
|
|
15
|
+
export interface NavTilePrefs {
|
|
16
|
+
/** Saved tile order (item ids). */
|
|
17
|
+
order?: string[];
|
|
18
|
+
/** Hidden tile ids. */
|
|
19
|
+
hidden?: string[];
|
|
20
|
+
/** Per-tile accent override (item id → hex). */
|
|
21
|
+
colors?: Record<string, string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The post-migration navigation preferences. */
|
|
25
|
+
export interface NavPreferences {
|
|
26
|
+
/** Stamp — equals {@link CURRENT_NAV_PREFS_VERSION} after a successful migration. */
|
|
27
|
+
version: number;
|
|
28
|
+
/** Side-nav domain order (domain ids). */
|
|
29
|
+
order?: string[];
|
|
30
|
+
/** Hidden domain ids (removed from the side-nav rail; restorable). */
|
|
31
|
+
hidden?: string[];
|
|
32
|
+
/** Per-domain accent override (domain id → hex). */
|
|
33
|
+
colors?: Record<string, string>;
|
|
34
|
+
/** Collapsed rail section labels (see `NavDomain.section`). */
|
|
35
|
+
collapsedSections?: string[];
|
|
36
|
+
/** Per-domain hub-tile customization (domain id → {@link NavTilePrefs}). */
|
|
37
|
+
hubs?: Record<string, NavTilePrefs>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The legacy (pre-overhaul) shape: a top-level nav block + per-hub tile prefs. */
|
|
41
|
+
export interface LegacyNavPrefs {
|
|
42
|
+
version?: number;
|
|
43
|
+
/** Old side-nav customization, keyed by route id. */
|
|
44
|
+
nav?: { order?: string[]; colors?: Record<string, string> };
|
|
45
|
+
/** Old hidden side-nav routes (route ids). */
|
|
46
|
+
hiddenNavRoutes?: string[];
|
|
47
|
+
/** Old per-hub tile prefs, keyed by the OLD hub id. */
|
|
48
|
+
hubs?: Record<string, NavTilePrefs>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface MigrateNavPrefsOptions {
|
|
52
|
+
/**
|
|
53
|
+
* Old key → new domain id. Applied to side-nav route ids AND hub keys; unmapped
|
|
54
|
+
* keys pass through unchanged. When two old hubs map to one domain (an 8→6
|
|
55
|
+
* consolidation), their tile prefs are merged.
|
|
56
|
+
*/
|
|
57
|
+
keyMap?: Record<string, string>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const uniq = (xs: string[]): string[] => [...new Set(xs)];
|
|
61
|
+
|
|
62
|
+
/** Merge two tile-pref blocks (union hidden, concat+dedupe order, later colors win). */
|
|
63
|
+
function mergeTilePrefs(a: NavTilePrefs | undefined, b: NavTilePrefs): NavTilePrefs {
|
|
64
|
+
return {
|
|
65
|
+
order: uniq([...(a?.order ?? []), ...(b.order ?? [])]),
|
|
66
|
+
hidden: uniq([...(a?.hidden ?? []), ...(b.hidden ?? [])]),
|
|
67
|
+
colors: { ...(a?.colors ?? {}), ...(b.colors ?? {}) },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Migrate legacy nav prefs onto the new model. Idempotent: returns `old` untouched
|
|
73
|
+
* when it's already at {@link CURRENT_NAV_PREFS_VERSION}. Otherwise remaps via
|
|
74
|
+
* `options.keyMap`, drops domain-level keys whose target isn't a real domain in
|
|
75
|
+
* `model`, merges consolidated hubs, and stamps the version.
|
|
76
|
+
*/
|
|
77
|
+
export function migrateNavPreferences(
|
|
78
|
+
old: LegacyNavPrefs | NavPreferences | null | undefined,
|
|
79
|
+
model: NavigationModel,
|
|
80
|
+
options: MigrateNavPrefsOptions = {},
|
|
81
|
+
): NavPreferences {
|
|
82
|
+
// Already migrated → no-op (don't reset user choices made post-migration).
|
|
83
|
+
if (old && typeof old.version === 'number' && old.version >= CURRENT_NAV_PREFS_VERSION) {
|
|
84
|
+
return old as NavPreferences;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const keyMap = options.keyMap ?? {};
|
|
88
|
+
const remap = (k: string) => keyMap[k] ?? k;
|
|
89
|
+
const domainIds = new Set((model?.domains ?? []).map((d) => d.id));
|
|
90
|
+
const legacy = (old ?? {}) as LegacyNavPrefs;
|
|
91
|
+
|
|
92
|
+
// Domain order: remap each old route id → domain id, dedupe, keep only real domains.
|
|
93
|
+
const order = uniq((legacy.nav?.order ?? []).map(remap)).filter((id) => domainIds.has(id));
|
|
94
|
+
|
|
95
|
+
// Hidden domains: remap, dedupe, keep only real domains.
|
|
96
|
+
const hidden = uniq((legacy.hiddenNavRoutes ?? []).map(remap)).filter((id) => domainIds.has(id));
|
|
97
|
+
|
|
98
|
+
// Domain colors: remap keys, keep only real domains (last write wins on collision).
|
|
99
|
+
const colors: Record<string, string> = {};
|
|
100
|
+
for (const [k, v] of Object.entries(legacy.nav?.colors ?? {})) {
|
|
101
|
+
const id = remap(k);
|
|
102
|
+
if (domainIds.has(id)) colors[id] = v;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Hub tile prefs: remap hub key → domain id, merging when two hubs consolidate.
|
|
106
|
+
const hubs: Record<string, NavTilePrefs> = {};
|
|
107
|
+
for (const [oldHub, prefs] of Object.entries(legacy.hubs ?? {})) {
|
|
108
|
+
const id = remap(oldHub);
|
|
109
|
+
if (!domainIds.has(id)) continue; // a hub with no surviving domain home is dropped
|
|
110
|
+
hubs[id] = mergeTilePrefs(hubs[id], prefs);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const result: NavPreferences = { version: CURRENT_NAV_PREFS_VERSION };
|
|
114
|
+
if (order.length) result.order = order;
|
|
115
|
+
if (hidden.length) result.hidden = hidden;
|
|
116
|
+
if (Object.keys(colors).length) result.colors = colors;
|
|
117
|
+
if (Object.keys(hubs).length) result.hubs = hubs;
|
|
118
|
+
return result;
|
|
119
|
+
}
|