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,132 @@
|
|
|
1
|
+
// Platform-portable keyboard-shortcut parsing, matching, and display formatting.
|
|
2
|
+
// React-free (pure functions + DOM event/`navigator` reads, no React) so the
|
|
3
|
+
// ShortcutRegistry, the `useHotkeys` hook, and the palette badge all share one
|
|
4
|
+
// source of truth for "what does `$mod+k` mean and how is it drawn?".
|
|
5
|
+
//
|
|
6
|
+
// Binding syntax is the tinykeys format — `'+'`-separated modifiers then a key:
|
|
7
|
+
// `$mod+k` → ⌘K on macOS, Ctrl K elsewhere (the portable command modifier)
|
|
8
|
+
// `alt+b` → ⌥B on macOS, Alt B elsewhere
|
|
9
|
+
// `shift+?` → ⇧?
|
|
10
|
+
// `$mod` is the whole point: one binding, correct on every platform.
|
|
11
|
+
|
|
12
|
+
/** The four modifier flags a binding can require. */
|
|
13
|
+
interface HotkeyMods {
|
|
14
|
+
meta: boolean;
|
|
15
|
+
ctrl: boolean;
|
|
16
|
+
alt: boolean;
|
|
17
|
+
shift: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A parsed binding: the required modifier set + the final (lower-cased) key. */
|
|
21
|
+
export interface ParsedHotkey extends HotkeyMods {
|
|
22
|
+
key: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Is this an Apple platform (macOS / iOS)? Decides whether `$mod` means ⌘ (Meta) or
|
|
27
|
+
* Ctrl, and which glyphs the badge draws. Guarded for non-DOM (SSR / unit) hosts —
|
|
28
|
+
* returns `false` when there's no `navigator`.
|
|
29
|
+
*/
|
|
30
|
+
export function isApplePlatform(): boolean {
|
|
31
|
+
if (typeof navigator === 'undefined') return false;
|
|
32
|
+
// `navigator.platform` is the most reliable signal but is being deprecated, so fall
|
|
33
|
+
// back to the UA string. Covers Mac, iPhone, iPad, iPod (incl. iPadOS desktop-UA).
|
|
34
|
+
const probe = `${navigator.platform ?? ''} ${navigator.userAgent ?? ''}`;
|
|
35
|
+
return /mac|iphone|ipad|ipod/i.test(probe);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Split a `'+'`-separated binding into its required modifiers + final key. */
|
|
39
|
+
export function parseHotkey(binding: string, isApple = isApplePlatform()): ParsedHotkey {
|
|
40
|
+
const parts = binding
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.split('+')
|
|
43
|
+
.map((p) => p.trim())
|
|
44
|
+
.filter(Boolean);
|
|
45
|
+
const key = parts.pop() ?? '';
|
|
46
|
+
const tokens = new Set(parts);
|
|
47
|
+
const mod = tokens.has('$mod');
|
|
48
|
+
return {
|
|
49
|
+
key,
|
|
50
|
+
meta: tokens.has('meta') || tokens.has('cmd') || tokens.has('command') || (mod && isApple),
|
|
51
|
+
ctrl: tokens.has('ctrl') || tokens.has('control') || (mod && !isApple),
|
|
52
|
+
alt: tokens.has('alt') || tokens.has('option') || tokens.has('opt'),
|
|
53
|
+
shift: tokens.has('shift'),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Does a keyboard event satisfy `binding` — exact modifier set + the key? */
|
|
58
|
+
export function matchHotkey(
|
|
59
|
+
e: KeyboardEvent,
|
|
60
|
+
binding: string,
|
|
61
|
+
isApple = isApplePlatform(),
|
|
62
|
+
): boolean {
|
|
63
|
+
const want = parseHotkey(binding, isApple);
|
|
64
|
+
// Exact modifier match (not "at least") so `$mod+k` never fires on ⌘⇧K.
|
|
65
|
+
if (e.metaKey !== want.meta) return false;
|
|
66
|
+
if (e.ctrlKey !== want.ctrl) return false;
|
|
67
|
+
if (e.altKey !== want.alt) return false;
|
|
68
|
+
if (e.shiftKey !== want.shift) return false;
|
|
69
|
+
return eventMatchesKey(e, want.key);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Match the produced `key`, falling back to the physical `code` — because on macOS a
|
|
74
|
+
* held Option mutates `event.key` (⌥B yields "∫"), so an `alt+b` binding must match on
|
|
75
|
+
* `KeyB` too. Letters + digits are covered (the practical shortcut surface).
|
|
76
|
+
*/
|
|
77
|
+
function eventMatchesKey(e: KeyboardEvent, key: string): boolean {
|
|
78
|
+
if (!key) return false;
|
|
79
|
+
if (e.key.toLowerCase() === key) return true;
|
|
80
|
+
if (key.length === 1 && key >= 'a' && key <= 'z' && e.code === `Key${key.toUpperCase()}`) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
if (key.length === 1 && key >= '0' && key <= '9' && e.code === `Digit${key}`) return true;
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const MAC_GLYPH: Record<keyof HotkeyMods, string> = {
|
|
88
|
+
meta: '⌘',
|
|
89
|
+
ctrl: '⌃',
|
|
90
|
+
alt: '⌥',
|
|
91
|
+
shift: '⇧',
|
|
92
|
+
};
|
|
93
|
+
const PC_WORD: Record<keyof HotkeyMods, string> = {
|
|
94
|
+
meta: 'Win',
|
|
95
|
+
ctrl: 'Ctrl',
|
|
96
|
+
alt: 'Alt',
|
|
97
|
+
shift: 'Shift',
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Pretty-print a key token for display: a letter/digit upper-cases, named keys title-case. */
|
|
101
|
+
function displayKey(key: string): string {
|
|
102
|
+
if (key.length === 1) return key.toUpperCase();
|
|
103
|
+
const named: Record<string, string> = {
|
|
104
|
+
arrowup: '↑',
|
|
105
|
+
arrowdown: '↓',
|
|
106
|
+
arrowleft: '←',
|
|
107
|
+
arrowright: '→',
|
|
108
|
+
enter: '↵',
|
|
109
|
+
escape: 'Esc',
|
|
110
|
+
backspace: '⌫',
|
|
111
|
+
delete: 'Del',
|
|
112
|
+
' ': 'Space',
|
|
113
|
+
space: 'Space',
|
|
114
|
+
};
|
|
115
|
+
return named[key] ?? key.charAt(0).toUpperCase() + key.slice(1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Render a binding as a human badge for the platform: macOS draws glyphs with no
|
|
120
|
+
* separator (`⌘K`, `⌥B`, `⌃⇧P`); elsewhere words joined with a thin space (`Ctrl K`,
|
|
121
|
+
* `Alt B`). The palette input badge + the per-row shortcut labels both use this.
|
|
122
|
+
*/
|
|
123
|
+
export function formatHotkey(binding: string, isApple = isApplePlatform()): string {
|
|
124
|
+
const p = parseHotkey(binding, isApple);
|
|
125
|
+
const order: (keyof HotkeyMods)[] = isApple
|
|
126
|
+
? ['ctrl', 'alt', 'shift', 'meta'] // Apple convention: ⌃⌥⇧⌘ then key
|
|
127
|
+
: ['ctrl', 'meta', 'alt', 'shift'];
|
|
128
|
+
const mods = order.filter((m) => p[m]).map((m) => (isApple ? MAC_GLYPH[m] : PC_WORD[m]));
|
|
129
|
+
const key = displayKey(p.key);
|
|
130
|
+
if (isApple) return [...mods, key].join('');
|
|
131
|
+
return [...mods, key].join(' ');
|
|
132
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// `cursedbelt-core/palette` — the React-free command-palette core: the uFuzzy ranking
|
|
2
|
+
// layer, the shortcut registry, and the portable hotkey utilities. No React, no
|
|
3
|
+
// browser dependency beyond optional `navigator`/event reads — usable in a startup
|
|
4
|
+
// module, a server test, or the React layer alike. The `CommandPalette` component +
|
|
5
|
+
// its hooks live in `cursedbelt/react` and build on exactly this.
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
fuzzyRankItems,
|
|
9
|
+
searchInitials,
|
|
10
|
+
} from './fuzzyRank';
|
|
11
|
+
export {
|
|
12
|
+
formatHotkey,
|
|
13
|
+
isApplePlatform,
|
|
14
|
+
matchHotkey,
|
|
15
|
+
type ParsedHotkey,
|
|
16
|
+
parseHotkey,
|
|
17
|
+
} from './hotkeys';
|
|
18
|
+
export {
|
|
19
|
+
type ShortcutEntry,
|
|
20
|
+
ShortcutRegistry,
|
|
21
|
+
shortcutRegistry,
|
|
22
|
+
} from './ShortcutRegistry';
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
import type { NavItem } from '../navigation';
|
|
3
|
+
import { fuzzyRankItems, searchInitials } from './fuzzyRank';
|
|
4
|
+
import { formatHotkey, matchHotkey, parseHotkey } from './hotkeys';
|
|
5
|
+
import { ShortcutRegistry } from './ShortcutRegistry';
|
|
6
|
+
|
|
7
|
+
// ── fuzzyRank ────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
const item = (label: string, keywords?: string[]): NavItem => ({
|
|
10
|
+
id: label.toLowerCase().replace(/\s+/g, '-'),
|
|
11
|
+
path: `/${label.toLowerCase().replace(/\s+/g, '-')}`,
|
|
12
|
+
label,
|
|
13
|
+
keywords,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const ITEMS: NavItem[] = [
|
|
17
|
+
item('Fractal', ['mandelbrot', 'zoom']),
|
|
18
|
+
item('Format Lab'),
|
|
19
|
+
item('Query Builder'),
|
|
20
|
+
item('Contacts'),
|
|
21
|
+
item('Env Sets', ['environment', 'variables']),
|
|
22
|
+
item('Shell Aliases'),
|
|
23
|
+
item('System Health'),
|
|
24
|
+
item('Calendar'),
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const labels = (xs: NavItem[]) => xs.map((x) => x.label);
|
|
28
|
+
|
|
29
|
+
describe('searchInitials', () => {
|
|
30
|
+
test('multi-word labels yield their initials; single-word yield ""', () => {
|
|
31
|
+
expect(searchInitials('Query Builder')).toBe('qb');
|
|
32
|
+
expect(searchInitials('System Health')).toBe('sh');
|
|
33
|
+
expect(searchInitials('Env Sets')).toBe('es');
|
|
34
|
+
expect(searchInitials('Fractal')).toBe('');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('fuzzyRankItems', () => {
|
|
39
|
+
test('contiguous prefix ranks first: "frac" → Fractal', () => {
|
|
40
|
+
expect(labels(fuzzyRankItems('frac', ITEMS))[0]).toBe('Fractal');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('"frac" ranks Fractal above the also-matching Format Lab', () => {
|
|
44
|
+
const ranked = labels(fuzzyRankItems('frac', ITEMS));
|
|
45
|
+
expect(ranked).toContain('Fractal');
|
|
46
|
+
if (ranked.includes('Format Lab')) {
|
|
47
|
+
expect(ranked.indexOf('Fractal')).toBeLessThan(ranked.indexOf('Format Lab'));
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('initialism matching: "qb" surfaces Query Builder (was a search gap)', () => {
|
|
52
|
+
expect(labels(fuzzyRankItems('qb', ITEMS))).toContain('Query Builder');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('full-word: "query" surfaces Query Builder', () => {
|
|
56
|
+
expect(labels(fuzzyRankItems('query', ITEMS))[0]).toBe('Query Builder');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('keyword matching: "environment" finds Env Sets', () => {
|
|
60
|
+
expect(labels(fuzzyRankItems('environment', ITEMS))).toContain('Env Sets');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('blank / non-matching queries return []', () => {
|
|
64
|
+
expect(fuzzyRankItems('', ITEMS)).toEqual([]);
|
|
65
|
+
expect(fuzzyRankItems(' ', ITEMS)).toEqual([]);
|
|
66
|
+
expect(fuzzyRankItems('zzqzzx', ITEMS)).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('carries through extra fields on richer entry types (domain tags)', () => {
|
|
70
|
+
type Tagged = NavItem & { domainId: string };
|
|
71
|
+
const tagged: Tagged[] = [{ ...item('Fractal'), domainId: 'play' }];
|
|
72
|
+
const out = fuzzyRankItems('frac', tagged);
|
|
73
|
+
expect(out[0]?.domainId).toBe('play');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ── hotkeys ──────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
const kd = (init: KeyboardEventInit) => new KeyboardEvent('keydown', init);
|
|
80
|
+
|
|
81
|
+
describe('parseHotkey', () => {
|
|
82
|
+
test('$mod resolves to Meta on Apple, Control elsewhere', () => {
|
|
83
|
+
expect(parseHotkey('$mod+k', true)).toMatchObject({ meta: true, ctrl: false, key: 'k' });
|
|
84
|
+
expect(parseHotkey('$mod+k', false)).toMatchObject({ meta: false, ctrl: true, key: 'k' });
|
|
85
|
+
});
|
|
86
|
+
test('named modifiers + final key', () => {
|
|
87
|
+
expect(parseHotkey('alt+shift+b')).toMatchObject({ alt: true, shift: true, key: 'b' });
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('matchHotkey', () => {
|
|
92
|
+
test('$mod+k matches Ctrl-K off Apple, Cmd-K on Apple', () => {
|
|
93
|
+
expect(matchHotkey(kd({ key: 'k', ctrlKey: true }), '$mod+k', false)).toBe(true);
|
|
94
|
+
expect(matchHotkey(kd({ key: 'k', metaKey: true }), '$mod+k', true)).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
test('exact modifier set — ⌘⇧K does not satisfy $mod+k', () => {
|
|
97
|
+
expect(matchHotkey(kd({ key: 'k', metaKey: true, shiftKey: true }), '$mod+k', true)).toBe(
|
|
98
|
+
false,
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
test('wrong platform modifier does not match', () => {
|
|
102
|
+
// Cmd-K on a non-Apple host (where $mod means Ctrl) must not fire.
|
|
103
|
+
expect(matchHotkey(kd({ key: 'k', metaKey: true }), '$mod+k', false)).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
test('falls back to physical code (macOS Option mutates event.key)', () => {
|
|
106
|
+
// ⌥B yields key "∫" on macOS — matching must still recognize it via code KeyB.
|
|
107
|
+
expect(matchHotkey(kd({ key: '∫', code: 'KeyB', altKey: true }), 'alt+b', true)).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('formatHotkey', () => {
|
|
112
|
+
test('Apple draws glyphs with no separator; PC draws words', () => {
|
|
113
|
+
expect(formatHotkey('$mod+k', true)).toBe('⌘K');
|
|
114
|
+
expect(formatHotkey('$mod+k', false)).toBe('Ctrl K');
|
|
115
|
+
expect(formatHotkey('alt+b', true)).toBe('⌥B');
|
|
116
|
+
expect(formatHotkey('alt+b', false)).toBe('Alt B');
|
|
117
|
+
});
|
|
118
|
+
test('multiple modifiers order Apple-style (⌃⌥⇧⌘)', () => {
|
|
119
|
+
expect(formatHotkey('$mod+shift+p', true)).toBe('⇧⌘P');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── ShortcutRegistry ──────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
describe('ShortcutRegistry', () => {
|
|
126
|
+
test('register / getAll / replace-by-keys / unregister', () => {
|
|
127
|
+
const reg = new ShortcutRegistry();
|
|
128
|
+
reg.register({ label: 'Board', keys: 'alt+b', action: 'Open the board' });
|
|
129
|
+
reg.register({ label: 'Notes', keys: 'alt+n', action: 'Open notes' });
|
|
130
|
+
expect(reg.getAll()).toHaveLength(2);
|
|
131
|
+
|
|
132
|
+
// Same keys → replace, not duplicate.
|
|
133
|
+
reg.register({ label: 'Board v2', keys: 'alt+b', action: 'Open the board' });
|
|
134
|
+
expect(reg.getAll()).toHaveLength(2);
|
|
135
|
+
expect(reg.getAll().find((s) => s.keys === 'alt+b')?.label).toBe('Board v2');
|
|
136
|
+
|
|
137
|
+
reg.unregister('alt+n');
|
|
138
|
+
expect(reg.getAll().map((s) => s.keys)).toEqual(['alt+b']);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('getAll snapshot is stable between mutations (for useSyncExternalStore)', () => {
|
|
142
|
+
const reg = new ShortcutRegistry();
|
|
143
|
+
reg.register({ label: 'A', keys: 'alt+a', action: 'a' });
|
|
144
|
+
expect(reg.getAll()).toBe(reg.getAll());
|
|
145
|
+
reg.register({ label: 'B', keys: 'alt+b', action: 'b' });
|
|
146
|
+
// A new snapshot after a mutation.
|
|
147
|
+
expect(reg.getAll()).toHaveLength(2);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('subscribe fires on change and unsubscribes', () => {
|
|
151
|
+
const reg = new ShortcutRegistry();
|
|
152
|
+
const listener = mock(() => {});
|
|
153
|
+
const off = reg.subscribe(listener);
|
|
154
|
+
reg.register({ label: 'A', keys: 'alt+a', action: 'a' });
|
|
155
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
156
|
+
off();
|
|
157
|
+
reg.register({ label: 'B', keys: 'alt+b', action: 'b' });
|
|
158
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic Schema Projection (02 §7.2) — the guarantee is that user structure
|
|
3
|
+
* never becomes DDL, and that a value that will not cast is preserved rather
|
|
4
|
+
* than dropped.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from 'bun:test';
|
|
7
|
+
import {
|
|
8
|
+
castValue,
|
|
9
|
+
mapExternalType,
|
|
10
|
+
type ProjectedColumn,
|
|
11
|
+
projectColumn,
|
|
12
|
+
projectRow,
|
|
13
|
+
STORAGE_COLUMN,
|
|
14
|
+
} from './projection';
|
|
15
|
+
|
|
16
|
+
const column = (over: Partial<ProjectedColumn> & Pick<ProjectedColumn, 'colType'>): ProjectedColumn => ({
|
|
17
|
+
id: 'col_1',
|
|
18
|
+
name: 'Column',
|
|
19
|
+
position: 0,
|
|
20
|
+
...over,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('castValue — typed storage columns', () => {
|
|
24
|
+
it('lands each type in the column its storage class owns', () => {
|
|
25
|
+
expect(castValue(column({ colType: 'number' }), '42').row.v_num).toBe(42);
|
|
26
|
+
expect(castValue(column({ colType: 'text' }), 'hi').row.v_text).toBe('hi');
|
|
27
|
+
expect(castValue(column({ colType: 'bool' }), 'yes').row.v_bool).toBe(1);
|
|
28
|
+
expect(castValue(column({ colType: 'date' }), '2026-07-22').row.v_date).toBe('2026-07-22T00:00:00.000Z');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('sorts numerically because numbers live in a numeric column', () => {
|
|
32
|
+
// The bug this design kills: in a single TEXT column, '10' sorts before '9'.
|
|
33
|
+
const values = ['10', '9', '100'].map((v) => castValue(column({ colType: 'number' }), v).row.v_num);
|
|
34
|
+
expect([...values].sort((a, b) => (a ?? 0) - (b ?? 0))).toEqual([9, 10, 100]);
|
|
35
|
+
expect(STORAGE_COLUMN.number).toBe('v_num');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('preserves an uncastable value as json with a lossy-cast diagnostic', () => {
|
|
39
|
+
const result = castValue(column({ colType: 'number' }), 'not a number');
|
|
40
|
+
// Dropping is what silently loses a user's imported data.
|
|
41
|
+
expect(result.row.v_num).toBeNull();
|
|
42
|
+
expect(result.row.v_json).toBe('"not a number"');
|
|
43
|
+
expect(result.diagnostics[0]?.code).toBe('projection-lossy-cast');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('refuses a select value outside its declared choices, keeping the data', () => {
|
|
47
|
+
const result = castValue(column({ colType: 'select', options: { choices: ['a', 'b'] } }), 'c');
|
|
48
|
+
expect(result.row.v_text).toBeNull();
|
|
49
|
+
expect(result.diagnostics[0]?.code).toBe('projection-lossy-cast');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('anchors dates to UTC so the same CSV imports identically in any timezone', () => {
|
|
53
|
+
expect(castValue(column({ colType: 'date' }), '2026-01-01').row.v_date).toBe('2026-01-01T00:00:00.000Z');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('treats empty and null as absent rather than as a failed cast', () => {
|
|
57
|
+
for (const empty of [null, undefined, '']) {
|
|
58
|
+
const result = castValue(column({ colType: 'number' }), empty);
|
|
59
|
+
expect(result.diagnostics).toEqual([]);
|
|
60
|
+
expect(result.row.v_num).toBeNull();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('projectColumn — the unified layout object', () => {
|
|
66
|
+
it('reads only the slot the declared type owns, ignoring a stale slot', () => {
|
|
67
|
+
// A leftover value from before the column's type changed must not resurface.
|
|
68
|
+
const field = projectColumn(column({ colType: 'number' }), {
|
|
69
|
+
v_text: 'stale',
|
|
70
|
+
v_num: 7,
|
|
71
|
+
v_bool: null,
|
|
72
|
+
v_date: null,
|
|
73
|
+
v_json: null,
|
|
74
|
+
});
|
|
75
|
+
expect(field.value).toBe(7);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('turns SQLite 0/1 into a real boolean', () => {
|
|
79
|
+
const field = projectColumn(column({ colType: 'bool' }), {
|
|
80
|
+
v_text: null,
|
|
81
|
+
v_num: null,
|
|
82
|
+
v_bool: 0,
|
|
83
|
+
v_date: null,
|
|
84
|
+
v_json: null,
|
|
85
|
+
});
|
|
86
|
+
expect(field.value).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('surfaces malformed json as raw text rather than throwing', () => {
|
|
90
|
+
const field = projectColumn(column({ colType: 'json' }), {
|
|
91
|
+
v_text: null,
|
|
92
|
+
v_num: null,
|
|
93
|
+
v_bool: null,
|
|
94
|
+
v_date: null,
|
|
95
|
+
v_json: '{ broken',
|
|
96
|
+
});
|
|
97
|
+
// One bad cell must not blank an entire table render.
|
|
98
|
+
expect(field.value).toBe('{ broken');
|
|
99
|
+
expect(field.json).toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('projects a whole row in position order', () => {
|
|
103
|
+
const columns = [
|
|
104
|
+
column({ id: 'b', colType: 'text', position: 2, name: 'Second' }),
|
|
105
|
+
column({ id: 'a', colType: 'text', position: 1, name: 'First' }),
|
|
106
|
+
];
|
|
107
|
+
const values = new Map([
|
|
108
|
+
['a', { v_text: '1', v_num: null, v_bool: null, v_date: null, v_json: null }],
|
|
109
|
+
['b', { v_text: '2', v_num: null, v_bool: null, v_date: null, v_json: null }],
|
|
110
|
+
]);
|
|
111
|
+
expect(projectRow(columns, values).map((f) => f.name)).toEqual(['First', 'Second']);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('mapExternalType', () => {
|
|
116
|
+
it('maps known external types and falls back to lossless json', () => {
|
|
117
|
+
expect(mapExternalType('VARCHAR')).toBe('text');
|
|
118
|
+
expect(mapExternalType('integer')).toBe('number');
|
|
119
|
+
expect(mapExternalType('timestamp')).toBe('date');
|
|
120
|
+
expect(mapExternalType('some-vendor-blob')).toBe('json');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic Schema Projection — the Virtual Schema Adapter (02 §7.2).
|
|
3
|
+
*
|
|
4
|
+
* User-defined structure (imported CSVs, external tables, custom columns)
|
|
5
|
+
* projects through `l_columns` typed metadata (01 §2.1). Components read a
|
|
6
|
+
* unified layout object and **no user action ever generates DDL**.
|
|
7
|
+
*
|
|
8
|
+
* That last rule is the whole design. The obvious implementation — `ALTER TABLE`
|
|
9
|
+
* per user column — makes every import a schema migration: it needs a write lock
|
|
10
|
+
* on a live DB, it cannot be rolled back cleanly mid-import, and a tenant with
|
|
11
|
+
* 300 custom columns produces a 300-column table that no index helps. Projecting
|
|
12
|
+
* instead means the physical schema is fixed (`l_values` with one typed column
|
|
13
|
+
* per storage class) and the *logical* schema is data.
|
|
14
|
+
*
|
|
15
|
+
* Typed value columns also fix a real SQLite failure: a single `value TEXT`
|
|
16
|
+
* column holding mixed storage classes sorts `10` before `9`, and a date next to
|
|
17
|
+
* a number compares by its type tag first. Landing each value in the column
|
|
18
|
+
* matching its declared type keeps `ORDER BY` single-storage-class correct.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The column types `l_columns.col_type` admits (01 §2.1). */
|
|
22
|
+
export const PROJECTED_COLUMN_TYPES = ['text', 'number', 'bool', 'date', 'select', 'json'] as const;
|
|
23
|
+
export type ProjectedColumnType = (typeof PROJECTED_COLUMN_TYPES)[number];
|
|
24
|
+
|
|
25
|
+
/** One user-defined column's metadata — a row of `l_columns`. */
|
|
26
|
+
export interface ProjectedColumn {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
colType: ProjectedColumnType;
|
|
30
|
+
position: number;
|
|
31
|
+
/** Type-specific options — `select` choices, number precision, date format. */
|
|
32
|
+
options?: Record<string, unknown>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The physical `l_values` slots. Exactly one is non-null per (row, column). */
|
|
36
|
+
export interface ProjectedValueRow {
|
|
37
|
+
v_text: string | null;
|
|
38
|
+
v_num: number | null;
|
|
39
|
+
v_bool: number | null;
|
|
40
|
+
v_date: string | null;
|
|
41
|
+
v_json: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Which physical column a logical type lands in. */
|
|
45
|
+
export const STORAGE_COLUMN: Readonly<Record<ProjectedColumnType, keyof ProjectedValueRow>> = {
|
|
46
|
+
text: 'v_text',
|
|
47
|
+
number: 'v_num',
|
|
48
|
+
bool: 'v_bool',
|
|
49
|
+
date: 'v_date',
|
|
50
|
+
// A `select` is a constrained string, so it sorts and indexes as text.
|
|
51
|
+
select: 'v_text',
|
|
52
|
+
json: 'v_json',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** What a component sees — never the physical row. */
|
|
56
|
+
export interface ProjectedField {
|
|
57
|
+
columnId: string;
|
|
58
|
+
name: string;
|
|
59
|
+
type: ProjectedColumnType;
|
|
60
|
+
value: string | number | boolean | null;
|
|
61
|
+
/** Parsed `json` payload; undefined for every other type. */
|
|
62
|
+
json?: unknown;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ProjectionDiagnostic {
|
|
66
|
+
code: 'projection-lossy-cast';
|
|
67
|
+
columnId: string;
|
|
68
|
+
message: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Project a stored row into the unified layout object components read.
|
|
73
|
+
*
|
|
74
|
+
* Reads only the slot the column's declared type owns. Deliberately: a row whose
|
|
75
|
+
* `v_text` still holds a value from before a column's type was changed must not
|
|
76
|
+
* resurface — the declared type is the truth, and a stale slot is stale data.
|
|
77
|
+
*/
|
|
78
|
+
export function projectColumn(column: ProjectedColumn, row: ProjectedValueRow | null): ProjectedField {
|
|
79
|
+
const base = { columnId: column.id, name: column.name, type: column.colType };
|
|
80
|
+
if (row === null) return { ...base, value: null };
|
|
81
|
+
|
|
82
|
+
switch (column.colType) {
|
|
83
|
+
case 'number':
|
|
84
|
+
return { ...base, value: row.v_num };
|
|
85
|
+
case 'bool':
|
|
86
|
+
// SQLite has no boolean storage class; 0/1 becomes a real boolean here so
|
|
87
|
+
// components never write `=== 1`.
|
|
88
|
+
return { ...base, value: row.v_bool === null ? null : row.v_bool !== 0 };
|
|
89
|
+
case 'date':
|
|
90
|
+
return { ...base, value: row.v_date };
|
|
91
|
+
case 'json': {
|
|
92
|
+
if (row.v_json === null) return { ...base, value: null };
|
|
93
|
+
try {
|
|
94
|
+
return { ...base, value: row.v_json, json: JSON.parse(row.v_json) };
|
|
95
|
+
} catch {
|
|
96
|
+
// Malformed JSON surfaces as raw text rather than throwing — one bad
|
|
97
|
+
// cell must not blank an entire table render.
|
|
98
|
+
return { ...base, value: row.v_json };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
default:
|
|
102
|
+
return { ...base, value: row.v_text };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface CastResult {
|
|
107
|
+
row: ProjectedValueRow;
|
|
108
|
+
diagnostics: readonly ProjectionDiagnostic[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const EMPTY_ROW: ProjectedValueRow = { v_text: null, v_num: null, v_bool: null, v_date: null, v_json: null };
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Cast an incoming value into the slot its column's type owns.
|
|
115
|
+
*
|
|
116
|
+
* A value that cannot be cast is preserved as `json` with a WARNING
|
|
117
|
+
* `projection-lossy-cast` rather than being dropped. Dropping is the behavior
|
|
118
|
+
* that loses a user's imported data silently; keeping it means the cell is
|
|
119
|
+
* visibly wrong and recoverable.
|
|
120
|
+
*/
|
|
121
|
+
export function castValue(column: ProjectedColumn, value: unknown): CastResult {
|
|
122
|
+
const row: ProjectedValueRow = { ...EMPTY_ROW };
|
|
123
|
+
const diagnostics: ProjectionDiagnostic[] = [];
|
|
124
|
+
|
|
125
|
+
if (value === null || value === undefined || value === '') return { row, diagnostics };
|
|
126
|
+
|
|
127
|
+
const lossy = (message: string): void => {
|
|
128
|
+
diagnostics.push({ code: 'projection-lossy-cast', columnId: column.id, message });
|
|
129
|
+
row.v_json = JSON.stringify(value);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
switch (column.colType) {
|
|
133
|
+
case 'number': {
|
|
134
|
+
const numeric = typeof value === 'number' ? value : Number(String(value).replace(/[,\s]/g, ''));
|
|
135
|
+
if (Number.isFinite(numeric)) row.v_num = numeric;
|
|
136
|
+
else lossy(`'${String(value)}' is not a number; stored as json.`);
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
case 'bool': {
|
|
140
|
+
const text = String(value).trim().toLowerCase();
|
|
141
|
+
if (typeof value === 'boolean') row.v_bool = value ? 1 : 0;
|
|
142
|
+
else if (['true', 'yes', 'y', '1'].includes(text)) row.v_bool = 1;
|
|
143
|
+
else if (['false', 'no', 'n', '0'].includes(text)) row.v_bool = 0;
|
|
144
|
+
else lossy(`'${String(value)}' is not a boolean; stored as json.`);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case 'date': {
|
|
148
|
+
const iso = toIsoDate(value);
|
|
149
|
+
if (iso !== null) row.v_date = iso;
|
|
150
|
+
else lossy(`'${String(value)}' is not a recognizable date; stored as json.`);
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
case 'select': {
|
|
154
|
+
const choices = Array.isArray(column.options?.choices) ? (column.options.choices as unknown[]) : null;
|
|
155
|
+
const text = String(value);
|
|
156
|
+
if (choices === null || choices.map(String).includes(text)) row.v_text = text;
|
|
157
|
+
else lossy(`'${text}' is not one of the declared choices; stored as json.`);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
case 'json':
|
|
161
|
+
row.v_json = typeof value === 'string' ? value : JSON.stringify(value);
|
|
162
|
+
break;
|
|
163
|
+
default:
|
|
164
|
+
row.v_text = typeof value === 'string' ? value : String(value);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { row, diagnostics };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* ISO-8601 date, or null. Anchored to UTC so the same CSV imported in two
|
|
172
|
+
* timezones produces the same stored day — the off-by-one-day class of import
|
|
173
|
+
* bug.
|
|
174
|
+
*/
|
|
175
|
+
function toIsoDate(value: unknown): string | null {
|
|
176
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
|
177
|
+
const text = String(value).trim();
|
|
178
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return `${text}T00:00:00.000Z`;
|
|
179
|
+
const parsed = Date.parse(text);
|
|
180
|
+
return Number.isNaN(parsed) ? null : new Date(parsed).toISOString();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Map an external field type (CSV sniff, sheet cell type, API schema) onto a
|
|
185
|
+
* projected column type. Anything unrecognized becomes `json` — the lossless
|
|
186
|
+
* default, per 02 §7.2.
|
|
187
|
+
*/
|
|
188
|
+
export function mapExternalType(externalType: string): ProjectedColumnType {
|
|
189
|
+
switch (externalType.toLowerCase()) {
|
|
190
|
+
case 'string':
|
|
191
|
+
case 'text':
|
|
192
|
+
case 'varchar':
|
|
193
|
+
return 'text';
|
|
194
|
+
case 'number':
|
|
195
|
+
case 'numeric':
|
|
196
|
+
case 'int':
|
|
197
|
+
case 'integer':
|
|
198
|
+
case 'float':
|
|
199
|
+
case 'double':
|
|
200
|
+
case 'decimal':
|
|
201
|
+
return 'number';
|
|
202
|
+
case 'bool':
|
|
203
|
+
case 'boolean':
|
|
204
|
+
return 'bool';
|
|
205
|
+
case 'date':
|
|
206
|
+
case 'datetime':
|
|
207
|
+
case 'timestamp':
|
|
208
|
+
return 'date';
|
|
209
|
+
case 'enum':
|
|
210
|
+
case 'select':
|
|
211
|
+
return 'select';
|
|
212
|
+
default:
|
|
213
|
+
return 'json';
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Project a whole row: every declared column, in `position` order. */
|
|
218
|
+
export function projectRow(
|
|
219
|
+
columns: readonly ProjectedColumn[],
|
|
220
|
+
values: ReadonlyMap<string, ProjectedValueRow>,
|
|
221
|
+
): readonly ProjectedField[] {
|
|
222
|
+
return [...columns]
|
|
223
|
+
.sort((a, b) => a.position - b.position)
|
|
224
|
+
.map((column) => projectColumn(column, values.get(column.id) ?? null));
|
|
225
|
+
}
|