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,33 @@
|
|
|
1
|
+
/** A registered keyboard shortcut — its binding plus how it reads in the palette. */
|
|
2
|
+
export interface ShortcutEntry {
|
|
3
|
+
/** Short name of the thing it acts on (e.g. `'Board'`). */
|
|
4
|
+
label: string;
|
|
5
|
+
/** Binding in tinykeys format (`'$mod+k'`, `'alt+b'`) — also the registry key. */
|
|
6
|
+
keys: string;
|
|
7
|
+
/** What the shortcut does, shown as the palette row's description (e.g. `'Open the board'`). */
|
|
8
|
+
action: string;
|
|
9
|
+
}
|
|
10
|
+
type Listener = () => void;
|
|
11
|
+
/**
|
|
12
|
+
* Keyed by `keys`, so registering the same binding twice replaces it (last write wins
|
|
13
|
+
* — no duplicate rows) and a feature can re-register on every mount idempotently.
|
|
14
|
+
*/
|
|
15
|
+
export declare class ShortcutRegistry {
|
|
16
|
+
private entries;
|
|
17
|
+
private listeners;
|
|
18
|
+
private snapshot;
|
|
19
|
+
/** Add or replace the shortcut for `entry.keys`. */
|
|
20
|
+
register(entry: ShortcutEntry): void;
|
|
21
|
+
/** Remove the shortcut bound to `keys` (no-op if absent). */
|
|
22
|
+
unregister(keys: string): void;
|
|
23
|
+
/** Every registered shortcut, in insertion order. Stable until the next mutation. */
|
|
24
|
+
getAll(): ShortcutEntry[];
|
|
25
|
+
/** Subscribe to registry changes (returns an unsubscribe). Powers the live palette list. */
|
|
26
|
+
subscribe(listener: Listener): () => void;
|
|
27
|
+
/** Drop every shortcut (mainly for tests / teardown). */
|
|
28
|
+
clear(): void;
|
|
29
|
+
private commit;
|
|
30
|
+
}
|
|
31
|
+
/** The app-wide shortcut registry singleton. */
|
|
32
|
+
export declare const shortcutRegistry: ShortcutRegistry;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// A tiny global registry of the app's keyboard shortcuts — the single source of truth
|
|
2
|
+
// that BOTH the keybinding layer (`useHotkeys`) and the command palette read from. An
|
|
3
|
+
// app registers a shortcut once; the palette can then list "Board → ⌥B" in its
|
|
4
|
+
// cold-state reference without the binding and its documentation drifting apart.
|
|
5
|
+
//
|
|
6
|
+
// Register in a mount effect (never at module load) so the palette's list reflects the
|
|
7
|
+
// shortcuts actually wired up for the current screen; the registry notifies subscribers
|
|
8
|
+
// on every change so a mounted palette stays live as shortcuts come and go.
|
|
9
|
+
/**
|
|
10
|
+
* Keyed by `keys`, so registering the same binding twice replaces it (last write wins
|
|
11
|
+
* — no duplicate rows) and a feature can re-register on every mount idempotently.
|
|
12
|
+
*/
|
|
13
|
+
export class ShortcutRegistry {
|
|
14
|
+
entries = new Map();
|
|
15
|
+
listeners = new Set();
|
|
16
|
+
// A frozen snapshot, rebuilt only on mutation, so `getAll()` is referentially stable
|
|
17
|
+
// between changes — required for `useSyncExternalStore` to not loop.
|
|
18
|
+
snapshot = [];
|
|
19
|
+
/** Add or replace the shortcut for `entry.keys`. */
|
|
20
|
+
register(entry) {
|
|
21
|
+
this.entries.set(entry.keys, entry);
|
|
22
|
+
this.commit();
|
|
23
|
+
}
|
|
24
|
+
/** Remove the shortcut bound to `keys` (no-op if absent). */
|
|
25
|
+
unregister(keys) {
|
|
26
|
+
if (this.entries.delete(keys))
|
|
27
|
+
this.commit();
|
|
28
|
+
}
|
|
29
|
+
/** Every registered shortcut, in insertion order. Stable until the next mutation. */
|
|
30
|
+
getAll() {
|
|
31
|
+
return this.snapshot;
|
|
32
|
+
}
|
|
33
|
+
/** Subscribe to registry changes (returns an unsubscribe). Powers the live palette list. */
|
|
34
|
+
subscribe(listener) {
|
|
35
|
+
this.listeners.add(listener);
|
|
36
|
+
return () => this.listeners.delete(listener);
|
|
37
|
+
}
|
|
38
|
+
/** Drop every shortcut (mainly for tests / teardown). */
|
|
39
|
+
clear() {
|
|
40
|
+
if (this.entries.size === 0)
|
|
41
|
+
return;
|
|
42
|
+
this.entries.clear();
|
|
43
|
+
this.commit();
|
|
44
|
+
}
|
|
45
|
+
commit() {
|
|
46
|
+
this.snapshot = [...this.entries.values()];
|
|
47
|
+
for (const listener of this.listeners)
|
|
48
|
+
listener();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** The app-wide shortcut registry singleton. */
|
|
52
|
+
export const shortcutRegistry = new ShortcutRegistry();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { NavItem } from '../navigation';
|
|
2
|
+
/**
|
|
3
|
+
* The initials of a multi-word label (`'Query Builder'` → `'qb'`). Single-word labels
|
|
4
|
+
* return `''` (their prefix already matches via the label itself). Powers initialism
|
|
5
|
+
* matching ("sh" → "System Health") under the strict contiguous matcher.
|
|
6
|
+
*/
|
|
7
|
+
export declare function searchInitials(label: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Rank `items` against `query`, best match first; non-matches dropped. Generic over the
|
|
10
|
+
* item type (so callers can pass plain {@link NavItem}s *or* richer catalogue entries
|
|
11
|
+
* that carry domain/group tags for grouping — the extra fields ride straight through).
|
|
12
|
+
* An empty/blank query returns `[]` (the palette shows its cold state instead).
|
|
13
|
+
*
|
|
14
|
+
* Returns *all* matches in rank order; callers cap the count for display (the palette
|
|
15
|
+
* shows 30). uFuzzy's contiguous scorer means "frac" ranks "Fractal" above "Format
|
|
16
|
+
* Lab", and the initials augmentation means "qb" surfaces "Query Builder".
|
|
17
|
+
*/
|
|
18
|
+
export declare function fuzzyRankItems<T extends NavItem>(query: string, items: T[]): T[];
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The command-palette ranking layer — uFuzzy over the nav catalogue. This is the
|
|
2
|
+
// *upgrade* HeaderSearch's `rankCatalogue` (a substring scorer) cannot match: uFuzzy
|
|
3
|
+
// scores by contiguous-match proximity, so abbreviated/typeahead input ranks the
|
|
4
|
+
// right page first. Pure + synchronous + React-free (uFuzzy is regex-based, no DOM),
|
|
5
|
+
// so it runs in the client, a server test, or the kitchen-sink demo identically.
|
|
6
|
+
//
|
|
7
|
+
// Why `intraMode: 1` (SingleError, contiguous) over `0` (MultiInsert, scattered):
|
|
8
|
+
// scattered mode treats every needle char as "appears somewhere later", which is far
|
|
9
|
+
// too permissive for short nav labels (every query matches half the menu). Contiguous
|
|
10
|
+
// mode keeps results tight — the documented tradeoff is lower typo tolerance, which is
|
|
11
|
+
// the right call for a fixed, known set of navigation labels.
|
|
12
|
+
//
|
|
13
|
+
// The catch: contiguous mode means a bare initialism ("qb") would NOT reach "Query
|
|
14
|
+
// Builder" (the chars aren't adjacent). So each haystack string is augmented with the
|
|
15
|
+
// label's **initials** as an explicit token ("Query Builder" → "query builder qb") —
|
|
16
|
+
// now "qb" matches that token contiguously, giving free initialism support without
|
|
17
|
+
// loosening the matcher. Keywords (synonyms / old names / abbreviations) are appended
|
|
18
|
+
// too, so "mandelbrot" still finds "Fractal".
|
|
19
|
+
import uFuzzy from '@leeoniya/ufuzzy';
|
|
20
|
+
// One singleton instance — uFuzzy compiles regexes from its options on construction,
|
|
21
|
+
// so re-creating it per query would be wasteful. `intraMode: 1` = contiguous match.
|
|
22
|
+
const uf = new uFuzzy({ intraMode: 1 });
|
|
23
|
+
/** Split a label into words on whitespace and the separators nav labels actually use. */
|
|
24
|
+
const WORD_SPLIT = /[\s/&·,_-]+/;
|
|
25
|
+
/**
|
|
26
|
+
* The initials of a multi-word label (`'Query Builder'` → `'qb'`). Single-word labels
|
|
27
|
+
* return `''` (their prefix already matches via the label itself). Powers initialism
|
|
28
|
+
* matching ("sh" → "System Health") under the strict contiguous matcher.
|
|
29
|
+
*/
|
|
30
|
+
export function searchInitials(label) {
|
|
31
|
+
const words = label.split(WORD_SPLIT).filter(Boolean);
|
|
32
|
+
if (words.length < 2)
|
|
33
|
+
return '';
|
|
34
|
+
// Lower-cased for a predictable token; uFuzzy matches case-insensitively anyway.
|
|
35
|
+
return words.map((w) => (w[0] ?? '').toLowerCase()).join('');
|
|
36
|
+
}
|
|
37
|
+
/** Build the per-item haystack string: label + its initials + any keywords, space-joined. */
|
|
38
|
+
function haystackFor(item) {
|
|
39
|
+
return [item.label, searchInitials(item.label), ...(item.keywords ?? [])]
|
|
40
|
+
.filter(Boolean)
|
|
41
|
+
.join(' ');
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Rank `items` against `query`, best match first; non-matches dropped. Generic over the
|
|
45
|
+
* item type (so callers can pass plain {@link NavItem}s *or* richer catalogue entries
|
|
46
|
+
* that carry domain/group tags for grouping — the extra fields ride straight through).
|
|
47
|
+
* An empty/blank query returns `[]` (the palette shows its cold state instead).
|
|
48
|
+
*
|
|
49
|
+
* Returns *all* matches in rank order; callers cap the count for display (the palette
|
|
50
|
+
* shows 30). uFuzzy's contiguous scorer means "frac" ranks "Fractal" above "Format
|
|
51
|
+
* Lab", and the initials augmentation means "qb" surfaces "Query Builder".
|
|
52
|
+
*/
|
|
53
|
+
export function fuzzyRankItems(query, items) {
|
|
54
|
+
const q = query.trim();
|
|
55
|
+
if (!q || items.length === 0)
|
|
56
|
+
return [];
|
|
57
|
+
const haystack = items.map(haystackFor);
|
|
58
|
+
const [idxs, , order] = uf.search(haystack, q);
|
|
59
|
+
// `idxs` is null when nothing matched and an empty array when filtered to zero; both
|
|
60
|
+
// mean "no results". `order` (the ranked iteration order into `idxs`) is present
|
|
61
|
+
// whenever there are matches under uFuzzy's info threshold (1e3 — far above any nav
|
|
62
|
+
// set); fall back to raw `idxs` order if it's ever absent.
|
|
63
|
+
if (!idxs || idxs.length === 0)
|
|
64
|
+
return [];
|
|
65
|
+
const ranked = order ? order.map((o) => idxs[o]) : idxs;
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const haystackIdx of ranked) {
|
|
68
|
+
const item = items[haystackIdx];
|
|
69
|
+
if (item)
|
|
70
|
+
out.push(item);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** The four modifier flags a binding can require. */
|
|
2
|
+
interface HotkeyMods {
|
|
3
|
+
meta: boolean;
|
|
4
|
+
ctrl: boolean;
|
|
5
|
+
alt: boolean;
|
|
6
|
+
shift: boolean;
|
|
7
|
+
}
|
|
8
|
+
/** A parsed binding: the required modifier set + the final (lower-cased) key. */
|
|
9
|
+
export interface ParsedHotkey extends HotkeyMods {
|
|
10
|
+
key: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Is this an Apple platform (macOS / iOS)? Decides whether `$mod` means ⌘ (Meta) or
|
|
14
|
+
* Ctrl, and which glyphs the badge draws. Guarded for non-DOM (SSR / unit) hosts —
|
|
15
|
+
* returns `false` when there's no `navigator`.
|
|
16
|
+
*/
|
|
17
|
+
export declare function isApplePlatform(): boolean;
|
|
18
|
+
/** Split a `'+'`-separated binding into its required modifiers + final key. */
|
|
19
|
+
export declare function parseHotkey(binding: string, isApple?: boolean): ParsedHotkey;
|
|
20
|
+
/** Does a keyboard event satisfy `binding` — exact modifier set + the key? */
|
|
21
|
+
export declare function matchHotkey(e: KeyboardEvent, binding: string, isApple?: boolean): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Render a binding as a human badge for the platform: macOS draws glyphs with no
|
|
24
|
+
* separator (`⌘K`, `⌥B`, `⌃⇧P`); elsewhere words joined with a thin space (`Ctrl K`,
|
|
25
|
+
* `Alt B`). The palette input badge + the per-row shortcut labels both use this.
|
|
26
|
+
*/
|
|
27
|
+
export declare function formatHotkey(binding: string, isApple?: boolean): string;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
* Is this an Apple platform (macOS / iOS)? Decides whether `$mod` means ⌘ (Meta) or
|
|
13
|
+
* Ctrl, and which glyphs the badge draws. Guarded for non-DOM (SSR / unit) hosts —
|
|
14
|
+
* returns `false` when there's no `navigator`.
|
|
15
|
+
*/
|
|
16
|
+
export function isApplePlatform() {
|
|
17
|
+
if (typeof navigator === 'undefined')
|
|
18
|
+
return false;
|
|
19
|
+
// `navigator.platform` is the most reliable signal but is being deprecated, so fall
|
|
20
|
+
// back to the UA string. Covers Mac, iPhone, iPad, iPod (incl. iPadOS desktop-UA).
|
|
21
|
+
const probe = `${navigator.platform ?? ''} ${navigator.userAgent ?? ''}`;
|
|
22
|
+
return /mac|iphone|ipad|ipod/i.test(probe);
|
|
23
|
+
}
|
|
24
|
+
/** Split a `'+'`-separated binding into its required modifiers + final key. */
|
|
25
|
+
export function parseHotkey(binding, isApple = isApplePlatform()) {
|
|
26
|
+
const parts = binding
|
|
27
|
+
.toLowerCase()
|
|
28
|
+
.split('+')
|
|
29
|
+
.map((p) => p.trim())
|
|
30
|
+
.filter(Boolean);
|
|
31
|
+
const key = parts.pop() ?? '';
|
|
32
|
+
const tokens = new Set(parts);
|
|
33
|
+
const mod = tokens.has('$mod');
|
|
34
|
+
return {
|
|
35
|
+
key,
|
|
36
|
+
meta: tokens.has('meta') || tokens.has('cmd') || tokens.has('command') || (mod && isApple),
|
|
37
|
+
ctrl: tokens.has('ctrl') || tokens.has('control') || (mod && !isApple),
|
|
38
|
+
alt: tokens.has('alt') || tokens.has('option') || tokens.has('opt'),
|
|
39
|
+
shift: tokens.has('shift'),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** Does a keyboard event satisfy `binding` — exact modifier set + the key? */
|
|
43
|
+
export function matchHotkey(e, binding, isApple = isApplePlatform()) {
|
|
44
|
+
const want = parseHotkey(binding, isApple);
|
|
45
|
+
// Exact modifier match (not "at least") so `$mod+k` never fires on ⌘⇧K.
|
|
46
|
+
if (e.metaKey !== want.meta)
|
|
47
|
+
return false;
|
|
48
|
+
if (e.ctrlKey !== want.ctrl)
|
|
49
|
+
return false;
|
|
50
|
+
if (e.altKey !== want.alt)
|
|
51
|
+
return false;
|
|
52
|
+
if (e.shiftKey !== want.shift)
|
|
53
|
+
return false;
|
|
54
|
+
return eventMatchesKey(e, want.key);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Match the produced `key`, falling back to the physical `code` — because on macOS a
|
|
58
|
+
* held Option mutates `event.key` (⌥B yields "∫"), so an `alt+b` binding must match on
|
|
59
|
+
* `KeyB` too. Letters + digits are covered (the practical shortcut surface).
|
|
60
|
+
*/
|
|
61
|
+
function eventMatchesKey(e, key) {
|
|
62
|
+
if (!key)
|
|
63
|
+
return false;
|
|
64
|
+
if (e.key.toLowerCase() === key)
|
|
65
|
+
return true;
|
|
66
|
+
if (key.length === 1 && key >= 'a' && key <= 'z' && e.code === `Key${key.toUpperCase()}`) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
if (key.length === 1 && key >= '0' && key <= '9' && e.code === `Digit${key}`)
|
|
70
|
+
return true;
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
const MAC_GLYPH = {
|
|
74
|
+
meta: '⌘',
|
|
75
|
+
ctrl: '⌃',
|
|
76
|
+
alt: '⌥',
|
|
77
|
+
shift: '⇧',
|
|
78
|
+
};
|
|
79
|
+
const PC_WORD = {
|
|
80
|
+
meta: 'Win',
|
|
81
|
+
ctrl: 'Ctrl',
|
|
82
|
+
alt: 'Alt',
|
|
83
|
+
shift: 'Shift',
|
|
84
|
+
};
|
|
85
|
+
/** Pretty-print a key token for display: a letter/digit upper-cases, named keys title-case. */
|
|
86
|
+
function displayKey(key) {
|
|
87
|
+
if (key.length === 1)
|
|
88
|
+
return key.toUpperCase();
|
|
89
|
+
const named = {
|
|
90
|
+
arrowup: '↑',
|
|
91
|
+
arrowdown: '↓',
|
|
92
|
+
arrowleft: '←',
|
|
93
|
+
arrowright: '→',
|
|
94
|
+
enter: '↵',
|
|
95
|
+
escape: 'Esc',
|
|
96
|
+
backspace: '⌫',
|
|
97
|
+
delete: 'Del',
|
|
98
|
+
' ': 'Space',
|
|
99
|
+
space: 'Space',
|
|
100
|
+
};
|
|
101
|
+
return named[key] ?? key.charAt(0).toUpperCase() + key.slice(1);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Render a binding as a human badge for the platform: macOS draws glyphs with no
|
|
105
|
+
* separator (`⌘K`, `⌥B`, `⌃⇧P`); elsewhere words joined with a thin space (`Ctrl K`,
|
|
106
|
+
* `Alt B`). The palette input badge + the per-row shortcut labels both use this.
|
|
107
|
+
*/
|
|
108
|
+
export function formatHotkey(binding, isApple = isApplePlatform()) {
|
|
109
|
+
const p = parseHotkey(binding, isApple);
|
|
110
|
+
const order = isApple
|
|
111
|
+
? ['ctrl', 'alt', 'shift', 'meta'] // Apple convention: ⌃⌥⇧⌘ then key
|
|
112
|
+
: ['ctrl', 'meta', 'alt', 'shift'];
|
|
113
|
+
const mods = order.filter((m) => p[m]).map((m) => (isApple ? MAC_GLYPH[m] : PC_WORD[m]));
|
|
114
|
+
const key = displayKey(p.key);
|
|
115
|
+
if (isApple)
|
|
116
|
+
return [...mods, key].join('');
|
|
117
|
+
return [...mods, key].join(' ');
|
|
118
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
export { fuzzyRankItems, searchInitials, } from './fuzzyRank';
|
|
7
|
+
export { formatHotkey, isApplePlatform, matchHotkey, parseHotkey, } from './hotkeys';
|
|
8
|
+
export { ShortcutRegistry, shortcutRegistry, } from './ShortcutRegistry';
|
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
/** The column types `l_columns.col_type` admits (01 §2.1). */
|
|
21
|
+
export declare const PROJECTED_COLUMN_TYPES: readonly ["text", "number", "bool", "date", "select", "json"];
|
|
22
|
+
export type ProjectedColumnType = (typeof PROJECTED_COLUMN_TYPES)[number];
|
|
23
|
+
/** One user-defined column's metadata — a row of `l_columns`. */
|
|
24
|
+
export interface ProjectedColumn {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
colType: ProjectedColumnType;
|
|
28
|
+
position: number;
|
|
29
|
+
/** Type-specific options — `select` choices, number precision, date format. */
|
|
30
|
+
options?: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
/** The physical `l_values` slots. Exactly one is non-null per (row, column). */
|
|
33
|
+
export interface ProjectedValueRow {
|
|
34
|
+
v_text: string | null;
|
|
35
|
+
v_num: number | null;
|
|
36
|
+
v_bool: number | null;
|
|
37
|
+
v_date: string | null;
|
|
38
|
+
v_json: string | null;
|
|
39
|
+
}
|
|
40
|
+
/** Which physical column a logical type lands in. */
|
|
41
|
+
export declare const STORAGE_COLUMN: Readonly<Record<ProjectedColumnType, keyof ProjectedValueRow>>;
|
|
42
|
+
/** What a component sees — never the physical row. */
|
|
43
|
+
export interface ProjectedField {
|
|
44
|
+
columnId: string;
|
|
45
|
+
name: string;
|
|
46
|
+
type: ProjectedColumnType;
|
|
47
|
+
value: string | number | boolean | null;
|
|
48
|
+
/** Parsed `json` payload; undefined for every other type. */
|
|
49
|
+
json?: unknown;
|
|
50
|
+
}
|
|
51
|
+
export interface ProjectionDiagnostic {
|
|
52
|
+
code: 'projection-lossy-cast';
|
|
53
|
+
columnId: string;
|
|
54
|
+
message: string;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Project a stored row into the unified layout object components read.
|
|
58
|
+
*
|
|
59
|
+
* Reads only the slot the column's declared type owns. Deliberately: a row whose
|
|
60
|
+
* `v_text` still holds a value from before a column's type was changed must not
|
|
61
|
+
* resurface — the declared type is the truth, and a stale slot is stale data.
|
|
62
|
+
*/
|
|
63
|
+
export declare function projectColumn(column: ProjectedColumn, row: ProjectedValueRow | null): ProjectedField;
|
|
64
|
+
export interface CastResult {
|
|
65
|
+
row: ProjectedValueRow;
|
|
66
|
+
diagnostics: readonly ProjectionDiagnostic[];
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Cast an incoming value into the slot its column's type owns.
|
|
70
|
+
*
|
|
71
|
+
* A value that cannot be cast is preserved as `json` with a WARNING
|
|
72
|
+
* `projection-lossy-cast` rather than being dropped. Dropping is the behavior
|
|
73
|
+
* that loses a user's imported data silently; keeping it means the cell is
|
|
74
|
+
* visibly wrong and recoverable.
|
|
75
|
+
*/
|
|
76
|
+
export declare function castValue(column: ProjectedColumn, value: unknown): CastResult;
|
|
77
|
+
/**
|
|
78
|
+
* Map an external field type (CSV sniff, sheet cell type, API schema) onto a
|
|
79
|
+
* projected column type. Anything unrecognized becomes `json` — the lossless
|
|
80
|
+
* default, per 02 §7.2.
|
|
81
|
+
*/
|
|
82
|
+
export declare function mapExternalType(externalType: string): ProjectedColumnType;
|
|
83
|
+
/** Project a whole row: every declared column, in `position` order. */
|
|
84
|
+
export declare function projectRow(columns: readonly ProjectedColumn[], values: ReadonlyMap<string, ProjectedValueRow>): readonly ProjectedField[];
|
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
/** The column types `l_columns.col_type` admits (01 §2.1). */
|
|
21
|
+
export const PROJECTED_COLUMN_TYPES = ['text', 'number', 'bool', 'date', 'select', 'json'];
|
|
22
|
+
/** Which physical column a logical type lands in. */
|
|
23
|
+
export const STORAGE_COLUMN = {
|
|
24
|
+
text: 'v_text',
|
|
25
|
+
number: 'v_num',
|
|
26
|
+
bool: 'v_bool',
|
|
27
|
+
date: 'v_date',
|
|
28
|
+
// A `select` is a constrained string, so it sorts and indexes as text.
|
|
29
|
+
select: 'v_text',
|
|
30
|
+
json: 'v_json',
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Project a stored row into the unified layout object components read.
|
|
34
|
+
*
|
|
35
|
+
* Reads only the slot the column's declared type owns. Deliberately: a row whose
|
|
36
|
+
* `v_text` still holds a value from before a column's type was changed must not
|
|
37
|
+
* resurface — the declared type is the truth, and a stale slot is stale data.
|
|
38
|
+
*/
|
|
39
|
+
export function projectColumn(column, row) {
|
|
40
|
+
const base = { columnId: column.id, name: column.name, type: column.colType };
|
|
41
|
+
if (row === null)
|
|
42
|
+
return { ...base, value: null };
|
|
43
|
+
switch (column.colType) {
|
|
44
|
+
case 'number':
|
|
45
|
+
return { ...base, value: row.v_num };
|
|
46
|
+
case 'bool':
|
|
47
|
+
// SQLite has no boolean storage class; 0/1 becomes a real boolean here so
|
|
48
|
+
// components never write `=== 1`.
|
|
49
|
+
return { ...base, value: row.v_bool === null ? null : row.v_bool !== 0 };
|
|
50
|
+
case 'date':
|
|
51
|
+
return { ...base, value: row.v_date };
|
|
52
|
+
case 'json': {
|
|
53
|
+
if (row.v_json === null)
|
|
54
|
+
return { ...base, value: null };
|
|
55
|
+
try {
|
|
56
|
+
return { ...base, value: row.v_json, json: JSON.parse(row.v_json) };
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Malformed JSON surfaces as raw text rather than throwing — one bad
|
|
60
|
+
// cell must not blank an entire table render.
|
|
61
|
+
return { ...base, value: row.v_json };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
default:
|
|
65
|
+
return { ...base, value: row.v_text };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const EMPTY_ROW = { v_text: null, v_num: null, v_bool: null, v_date: null, v_json: null };
|
|
69
|
+
/**
|
|
70
|
+
* Cast an incoming value into the slot its column's type owns.
|
|
71
|
+
*
|
|
72
|
+
* A value that cannot be cast is preserved as `json` with a WARNING
|
|
73
|
+
* `projection-lossy-cast` rather than being dropped. Dropping is the behavior
|
|
74
|
+
* that loses a user's imported data silently; keeping it means the cell is
|
|
75
|
+
* visibly wrong and recoverable.
|
|
76
|
+
*/
|
|
77
|
+
export function castValue(column, value) {
|
|
78
|
+
const row = { ...EMPTY_ROW };
|
|
79
|
+
const diagnostics = [];
|
|
80
|
+
if (value === null || value === undefined || value === '')
|
|
81
|
+
return { row, diagnostics };
|
|
82
|
+
const lossy = (message) => {
|
|
83
|
+
diagnostics.push({ code: 'projection-lossy-cast', columnId: column.id, message });
|
|
84
|
+
row.v_json = JSON.stringify(value);
|
|
85
|
+
};
|
|
86
|
+
switch (column.colType) {
|
|
87
|
+
case 'number': {
|
|
88
|
+
const numeric = typeof value === 'number' ? value : Number(String(value).replace(/[,\s]/g, ''));
|
|
89
|
+
if (Number.isFinite(numeric))
|
|
90
|
+
row.v_num = numeric;
|
|
91
|
+
else
|
|
92
|
+
lossy(`'${String(value)}' is not a number; stored as json.`);
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
case 'bool': {
|
|
96
|
+
const text = String(value).trim().toLowerCase();
|
|
97
|
+
if (typeof value === 'boolean')
|
|
98
|
+
row.v_bool = value ? 1 : 0;
|
|
99
|
+
else if (['true', 'yes', 'y', '1'].includes(text))
|
|
100
|
+
row.v_bool = 1;
|
|
101
|
+
else if (['false', 'no', 'n', '0'].includes(text))
|
|
102
|
+
row.v_bool = 0;
|
|
103
|
+
else
|
|
104
|
+
lossy(`'${String(value)}' is not a boolean; stored as json.`);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
case 'date': {
|
|
108
|
+
const iso = toIsoDate(value);
|
|
109
|
+
if (iso !== null)
|
|
110
|
+
row.v_date = iso;
|
|
111
|
+
else
|
|
112
|
+
lossy(`'${String(value)}' is not a recognizable date; stored as json.`);
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
case 'select': {
|
|
116
|
+
const choices = Array.isArray(column.options?.choices) ? column.options.choices : null;
|
|
117
|
+
const text = String(value);
|
|
118
|
+
if (choices === null || choices.map(String).includes(text))
|
|
119
|
+
row.v_text = text;
|
|
120
|
+
else
|
|
121
|
+
lossy(`'${text}' is not one of the declared choices; stored as json.`);
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
case 'json':
|
|
125
|
+
row.v_json = typeof value === 'string' ? value : JSON.stringify(value);
|
|
126
|
+
break;
|
|
127
|
+
default:
|
|
128
|
+
row.v_text = typeof value === 'string' ? value : String(value);
|
|
129
|
+
}
|
|
130
|
+
return { row, diagnostics };
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* ISO-8601 date, or null. Anchored to UTC so the same CSV imported in two
|
|
134
|
+
* timezones produces the same stored day — the off-by-one-day class of import
|
|
135
|
+
* bug.
|
|
136
|
+
*/
|
|
137
|
+
function toIsoDate(value) {
|
|
138
|
+
if (value instanceof Date)
|
|
139
|
+
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
|
140
|
+
const text = String(value).trim();
|
|
141
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(text))
|
|
142
|
+
return `${text}T00:00:00.000Z`;
|
|
143
|
+
const parsed = Date.parse(text);
|
|
144
|
+
return Number.isNaN(parsed) ? null : new Date(parsed).toISOString();
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Map an external field type (CSV sniff, sheet cell type, API schema) onto a
|
|
148
|
+
* projected column type. Anything unrecognized becomes `json` — the lossless
|
|
149
|
+
* default, per 02 §7.2.
|
|
150
|
+
*/
|
|
151
|
+
export function mapExternalType(externalType) {
|
|
152
|
+
switch (externalType.toLowerCase()) {
|
|
153
|
+
case 'string':
|
|
154
|
+
case 'text':
|
|
155
|
+
case 'varchar':
|
|
156
|
+
return 'text';
|
|
157
|
+
case 'number':
|
|
158
|
+
case 'numeric':
|
|
159
|
+
case 'int':
|
|
160
|
+
case 'integer':
|
|
161
|
+
case 'float':
|
|
162
|
+
case 'double':
|
|
163
|
+
case 'decimal':
|
|
164
|
+
return 'number';
|
|
165
|
+
case 'bool':
|
|
166
|
+
case 'boolean':
|
|
167
|
+
return 'bool';
|
|
168
|
+
case 'date':
|
|
169
|
+
case 'datetime':
|
|
170
|
+
case 'timestamp':
|
|
171
|
+
return 'date';
|
|
172
|
+
case 'enum':
|
|
173
|
+
case 'select':
|
|
174
|
+
return 'select';
|
|
175
|
+
default:
|
|
176
|
+
return 'json';
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** Project a whole row: every declared column, in `position` order. */
|
|
180
|
+
export function projectRow(columns, values) {
|
|
181
|
+
return [...columns]
|
|
182
|
+
.sort((a, b) => a.position - b.position)
|
|
183
|
+
.map((column) => projectColumn(column, values.get(column.id) ?? null));
|
|
184
|
+
}
|