flexdesk 0.1.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 +21 -0
- package/README.md +195 -0
- package/THIRD_PARTY_LICENSES.md +53 -0
- package/css/base.css +3439 -0
- package/css/flexdesk.css +5797 -0
- package/css/overrides.css +2349 -0
- package/css/reset.css +46 -0
- package/css/tokens.css +137 -0
- package/dist/charts.js +780 -0
- package/dist/charts.js.map +7 -0
- package/dist/chunk-3PHCPZHT.js +222 -0
- package/dist/chunk-3PHCPZHT.js.map +7 -0
- package/dist/chunk-CT4YXXLP.js +1770 -0
- package/dist/chunk-CT4YXXLP.js.map +7 -0
- package/dist/chunk-DRYCDMEG.js +2217 -0
- package/dist/chunk-DRYCDMEG.js.map +7 -0
- package/dist/chunk-DVU44T77.js +855 -0
- package/dist/chunk-DVU44T77.js.map +7 -0
- package/dist/chunk-FL5KFNQH.js +640 -0
- package/dist/chunk-FL5KFNQH.js.map +7 -0
- package/dist/chunk-FOOS3T5L.js +117 -0
- package/dist/chunk-FOOS3T5L.js.map +7 -0
- package/dist/chunk-JYWURG5T.js +32 -0
- package/dist/chunk-JYWURG5T.js.map +7 -0
- package/dist/chunk-TLZUUFOE.js +276 -0
- package/dist/chunk-TLZUUFOE.js.map +7 -0
- package/dist/chunk-UCJ2WD4D.js +625 -0
- package/dist/chunk-UCJ2WD4D.js.map +7 -0
- package/dist/chunk-WVFGV5FT.js +205 -0
- package/dist/chunk-WVFGV5FT.js.map +7 -0
- package/dist/core.js +1098 -0
- package/dist/core.js.map +7 -0
- package/dist/editor.js +1661 -0
- package/dist/editor.js.map +7 -0
- package/dist/flexdesk.css +5797 -0
- package/dist/host.js +132 -0
- package/dist/host.js.map +7 -0
- package/dist/reset.css +46 -0
- package/dist/tile_registry-6WZPTUZV.js +28 -0
- package/dist/tile_registry-6WZPTUZV.js.map +7 -0
- package/dist/tiles.js +2068 -0
- package/dist/tiles.js.map +7 -0
- package/dist/tokens.css +137 -0
- package/dist/widgets.js +5438 -0
- package/dist/widgets.js.map +7 -0
- package/dist/wm.js +4830 -0
- package/dist/wm.js.map +7 -0
- package/package.json +80 -0
- package/src/charts/chart_types.js +699 -0
- package/src/charts/data_series_plot_window.js +761 -0
- package/src/charts/downsample.js +119 -0
- package/src/charts/plot_config.js +176 -0
- package/src/charts/plot_popout_window.js +1560 -0
- package/src/charts/plotly_wrapper.js +432 -0
- package/src/core/event_bus.js +230 -0
- package/src/core/logging.js +300 -0
- package/src/core/settings.js +813 -0
- package/src/core/state_guard.js +382 -0
- package/src/core/state_guard_config.js +309 -0
- package/src/core/state_machine.js +258 -0
- package/src/editor/editor_pane.js +264 -0
- package/src/editor/monaco_editor_factory.js +449 -0
- package/src/editor/monaco_loader.js +74 -0
- package/src/editor/notebook_search.js +460 -0
- package/src/editor/notebook_tab_bar.js +456 -0
- package/src/editor/notebook_undo_manager.js +108 -0
- package/src/editor/split_pane_container.js +197 -0
- package/src/help/help_modal.js +409 -0
- package/src/help/help_registry.js +65 -0
- package/src/host/host.js +69 -0
- package/src/host/pywebview_host.js +133 -0
- package/src/tiles/config_schema.js +256 -0
- package/src/tiles/layout_persistence.js +662 -0
- package/src/tiles/tile_base.js +785 -0
- package/src/tiles/tile_grid.js +905 -0
- package/src/tiles/tile_registry.js +136 -0
- package/src/tiling/command_palette.js +328 -0
- package/src/tiling/content_registry.js +110 -0
- package/src/tiling/desktops.js +133 -0
- package/src/tiling/entity_sources.js +179 -0
- package/src/tiling/keymap.js +156 -0
- package/src/tiling/kind_taxonomy.js +172 -0
- package/src/tiling/landing_table.js +717 -0
- package/src/tiling/loading_overlay.js +33 -0
- package/src/tiling/nav_panel.js +142 -0
- package/src/tiling/page_factory.js +215 -0
- package/src/tiling/panel_keys.js +145 -0
- package/src/tiling/shell.js +437 -0
- package/src/tiling/tile_breadcrumb.js +222 -0
- package/src/tiling/tile_renderer.js +459 -0
- package/src/tiling/tile_tab_menu.js +340 -0
- package/src/tiling/tile_tree.js +880 -0
- package/src/tiling/wm.js +1352 -0
- package/src/ui/base/component_base.js +30 -0
- package/src/ui/base/controller_base.js +48 -0
- package/src/ui/base/page_base.js +102 -0
- package/src/ui/components/about_dialog.js +224 -0
- package/src/ui/components/action_dropdown.js +389 -0
- package/src/ui/components/attribute_list_editor.js +652 -0
- package/src/ui/components/autocomplete_field.js +732 -0
- package/src/ui/components/computing_status_window.js +81 -0
- package/src/ui/components/confirm_dialog.js +307 -0
- package/src/ui/components/context_menu.js +87 -0
- package/src/ui/components/data_table.js +2228 -0
- package/src/ui/components/detail_header.js +177 -0
- package/src/ui/components/drag_reorder.js +0 -0
- package/src/ui/components/gallery_picker.js +147 -0
- package/src/ui/components/inline_renamer.js +129 -0
- package/src/ui/components/managed_window.js +804 -0
- package/src/ui/components/modal.js +512 -0
- package/src/ui/components/notification_history.js +264 -0
- package/src/ui/components/slide_out_panel.js +185 -0
- package/src/ui/components/slider_field.js +122 -0
- package/src/ui/components/sortable_list.js +309 -0
- package/src/ui/components/table_state_store.js +81 -0
- package/src/ui/components/toast.js +59 -0
- package/src/ui/components/tree_view.js +460 -0
- package/src/ui/components/window_taskbar.js +143 -0
- package/src/ui/controllers/panel_state_machine.js +634 -0
- package/src/ui/controllers/window_chrome_controller.js +659 -0
- package/src/ui/notification_center.js +576 -0
- package/src/ui/utils/overlay_scrollbar.js +492 -0
- package/src/ui/utils/raf_resize_observer.js +31 -0
- package/src/ui/utils/tooltip_service.js +406 -0
package/src/host/host.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* host.js — the host port.
|
|
3
|
+
*
|
|
4
|
+
* The framework declares what it needs from its embedder. A Host is a bag of
|
|
5
|
+
* INDEPENDENTLY OPTIONAL capability objects:
|
|
6
|
+
*
|
|
7
|
+
* - absent capability => absent key (never a stub)
|
|
8
|
+
* - present capability => must be complete (validated at boot)
|
|
9
|
+
*
|
|
10
|
+
* There is NO module-level default host and NO getHost(). Hosts are injected —
|
|
11
|
+
* a module singleton here would just be `window.pywebview` wearing a hat, and
|
|
12
|
+
* would make a second host (mock, browser tab, VS Code webview) impossible.
|
|
13
|
+
*
|
|
14
|
+
* Zero pywebview. Zero EcoAgent. See pywebview_host.js for the reference
|
|
15
|
+
* adapter that implements this port against the pywebview / HTTP transports.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const HOST_CONTRACT = Object.freeze({
|
|
19
|
+
window: Object.freeze({
|
|
20
|
+
required: Object.freeze(['chrome', 'minimize', 'close', 'isMaximized', 'setMaximized',
|
|
21
|
+
'isFullscreen', 'toggleFullscreen', 'getBounds', 'setBounds']),
|
|
22
|
+
optional: Object.freeze(['startNativeDrag']),
|
|
23
|
+
}),
|
|
24
|
+
dialogs: Object.freeze({ required: Object.freeze(['saveFile']), optional: Object.freeze([]) }),
|
|
25
|
+
state: Object.freeze({ required: Object.freeze(['read', 'write']), optional: Object.freeze([]) }),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Validate ONE capability object. We validate the capability the adapter
|
|
30
|
+
* authored — a plain object literal with real closures — not the raw bridge:
|
|
31
|
+
* `index.html`'s browser-mode polyfill installs a `new Proxy({}, {get})` that
|
|
32
|
+
* returns a function for EVERY property name, so duck-typing the bridge is
|
|
33
|
+
* vacuously true and validates nothing. A Proxy cannot forge a missing key in
|
|
34
|
+
* an object literal.
|
|
35
|
+
*
|
|
36
|
+
* @returns {{ ok: boolean, missing: string[] }}
|
|
37
|
+
*/
|
|
38
|
+
export function validateCapability(name, cap) {
|
|
39
|
+
const spec = HOST_CONTRACT[name];
|
|
40
|
+
if (!spec) return { ok: false, missing: [`unknown capability '${name}'`] };
|
|
41
|
+
if (!cap || typeof cap !== 'object') return { ok: false, missing: [...spec.required] };
|
|
42
|
+
const missing = spec.required.filter((m) => typeof cap[m] !== 'function');
|
|
43
|
+
return { ok: missing.length === 0, missing };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Validate + freeze a set of capabilities into a Host.
|
|
48
|
+
* Throws at boot on an incomplete capability — a half-wired host is a bug,
|
|
49
|
+
* and a bug at boot beats a silent degrade at click time.
|
|
50
|
+
*
|
|
51
|
+
* @param {{ window?: object, dialogs?: object, state?: object }} caps
|
|
52
|
+
* @returns {Readonly<object>} the Host
|
|
53
|
+
*/
|
|
54
|
+
export function createHost(caps = {}) {
|
|
55
|
+
const host = {};
|
|
56
|
+
for (const name of Object.keys(HOST_CONTRACT)) {
|
|
57
|
+
const cap = caps[name];
|
|
58
|
+
if (cap == null) continue; // legitimately absent
|
|
59
|
+
const v = validateCapability(name, cap);
|
|
60
|
+
if (!v.ok) {
|
|
61
|
+
throw new Error(`Host capability '${name}' is incomplete: missing ${v.missing.join(', ')}`);
|
|
62
|
+
}
|
|
63
|
+
host[name] = Object.freeze(cap);
|
|
64
|
+
}
|
|
65
|
+
return Object.freeze(host);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The zero-capability host. Every framework consumer MUST survive it. */
|
|
69
|
+
export const NULL_HOST = createHost({});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pywebview_host.js — the REFERENCE host adapter.
|
|
3
|
+
*
|
|
4
|
+
* Knows pywebview. Knows NOTHING about EcoAgent: every domain fact (which
|
|
5
|
+
* bridge ref to use, where a state key lands on disk) arrives as a parameter.
|
|
6
|
+
* This is the adapter a standalone consumer copies; EcoAgent is just its first
|
|
7
|
+
* caller (see ui/js/bootstrap/app_bootstrap.js).
|
|
8
|
+
*
|
|
9
|
+
* It speaks to whatever `window.pywebview.api` currently is — the real
|
|
10
|
+
* pywebview bridge in the desktop app, or the `POST /api/<method>` Proxy
|
|
11
|
+
* polyfill that ui/index.html installs in --browser mode. Both transports are
|
|
12
|
+
* positional-on-the-wire, which is why setBounds() spreads its object back out
|
|
13
|
+
* into positional args.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createHost } from './host.js';
|
|
17
|
+
|
|
18
|
+
const FRAMELESS_BACKENDS = new Set(['edgechromium', 'mshtml']);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Custom (frameless) window chrome — the min/max/close buttons, edge resize
|
|
22
|
+
* handles and drag-to-move top bar — only makes sense when our top bar IS the
|
|
23
|
+
* window's title bar. That holds on a native pywebview window the OS draws
|
|
24
|
+
* *without* decorations of its own: Windows (frameless). On Linux/macOS the
|
|
25
|
+
* window manager already draws a native title bar above our top bar, and in
|
|
26
|
+
* `--browser` mode the browser supplies its own chrome — in both cases the
|
|
27
|
+
* custom controls are a redundant second set, so we neither render nor wire
|
|
28
|
+
* them (which also stops us swallowing native F11 fullscreen and showing dead
|
|
29
|
+
* resize cursors at the window edges).
|
|
30
|
+
*
|
|
31
|
+
* Detection keys off the pywebview backend string, which is unambiguous: the
|
|
32
|
+
* browser-mode polyfill in index.html injects only `.api`, never `.platform`,
|
|
33
|
+
* so any `.platform` at all ⇒ running as a native pywebview app. The frameless
|
|
34
|
+
* (custom-title-bar) backends are the Windows WebViews — 'edgechromium' /
|
|
35
|
+
* 'mshtml'. Linux ('gtkwebkit2' / 'qtwebengine') and macOS ('cocoa') draw
|
|
36
|
+
* native decorations, so they return false. Verified live: WebKitGTK reports
|
|
37
|
+
* platform 'gtkwebkit2'.
|
|
38
|
+
*/
|
|
39
|
+
export function usesCustomWindowChrome() {
|
|
40
|
+
// Authoritative signal from the Python host: run_pywebview_mode() appends
|
|
41
|
+
// `?frameless=1` to the window URL exactly when it created a frameless native
|
|
42
|
+
// window (Windows). Prefer it over the backend sniff below because it's
|
|
43
|
+
// present at document parse — before pywebview finishes injecting
|
|
44
|
+
// `window.pywebview` (and before the browser-mode polyfill, which sets `.api`
|
|
45
|
+
// but no `.platform`, can clobber it). Reading the sniff at shell-build time
|
|
46
|
+
// otherwise races and the whole custom chrome silently fails to render.
|
|
47
|
+
try {
|
|
48
|
+
if (new URLSearchParams(window.location.search).get('frameless') === '1') {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
} catch (_) { /* location unavailable (non-browser env) — fall through */ }
|
|
52
|
+
const platform = window.pywebview && window.pywebview.platform;
|
|
53
|
+
return !!platform && FRAMELESS_BACKENDS.has(platform);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build a Host backed by the pywebview bridge.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} opts
|
|
60
|
+
* @param {{current: object|null}} [opts.bridgeRef] LIVE ref to the bridge.
|
|
61
|
+
* MUST be a ref, never a snapshot: `setupHostBridgeListener` mutates
|
|
62
|
+
* `.current` when a late bridge arrives, and `resolveHostBridge` can
|
|
63
|
+
* resolve `null` after its timeout. Snapshotting the bridge here would
|
|
64
|
+
* freeze that null and reproduce as an intermittent boot failure.
|
|
65
|
+
* @param {(key: string) => string} [opts.resolvePath] logical state key ->
|
|
66
|
+
* storage path. THE domain seam: the framework only ever names keys
|
|
67
|
+
* ('desktops', 'datatable_state'); the embedder decides where they land.
|
|
68
|
+
* @param {object} [opts.logger]
|
|
69
|
+
*/
|
|
70
|
+
export function createPywebviewHost({ bridgeRef = null, resolvePath = (k) => `${k}.json`, logger = console } = {}) {
|
|
71
|
+
const api = () => bridgeRef?.current ?? window.pywebview?.api ?? null;
|
|
72
|
+
|
|
73
|
+
const call = async (name, args = []) => {
|
|
74
|
+
const a = api();
|
|
75
|
+
if (!a || typeof a[name] !== 'function') return null;
|
|
76
|
+
try { return await a[name](...args); }
|
|
77
|
+
catch (e) { logger.warn?.('[host] call failed', name, e); return null; }
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Envelope unwrapping, in ONE place. Transcribed from the call sites this
|
|
81
|
+
// replaces, so app.py's catch-all `{ok: true}` reply (which carries no
|
|
82
|
+
// `maximized` key) still reads as false, exactly as it does today.
|
|
83
|
+
const okMax = (r) => Boolean(r && r.ok && r.maximized === true);
|
|
84
|
+
const okFs = (r) => Boolean(r && r.ok && r.fullscreen);
|
|
85
|
+
|
|
86
|
+
// ALL THREE capabilities are ALWAYS advertised. Do NOT gate them on api()
|
|
87
|
+
// being non-null at construction time: the methods late-bind through the
|
|
88
|
+
// ref, and gating here would permanently strand a late-arriving bridge on
|
|
89
|
+
// the fallback paths (Blob download instead of the native save dialog).
|
|
90
|
+
return createHost({
|
|
91
|
+
window: {
|
|
92
|
+
chrome: () => (usesCustomWindowChrome() ? 'custom' : 'native'),
|
|
93
|
+
minimize: () => { void call('window_minimize'); },
|
|
94
|
+
close: () => { void call('window_close'); },
|
|
95
|
+
isMaximized: async () => okMax(await call('window_is_maximized')),
|
|
96
|
+
setMaximized: async (on) => okMax(await call('window_set_maximized', [on])),
|
|
97
|
+
isFullscreen: async () => okFs(await call('window_is_fullscreen')),
|
|
98
|
+
toggleFullscreen: async () => okFs(await call('window_toggle_fullscreen')),
|
|
99
|
+
async getBounds() {
|
|
100
|
+
const r = await call('window_get_bounds');
|
|
101
|
+
if (!r || !r.ok) return null;
|
|
102
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
103
|
+
},
|
|
104
|
+
// POSITIONAL on the wire — both transports depend on it. Not async:
|
|
105
|
+
// this is a rAF hot path during a window drag/resize; nobody awaits.
|
|
106
|
+
setBounds({ x = null, y = null, width = null, height = null } = {}) {
|
|
107
|
+
void call('window_set_bounds', [x, y, width, height]);
|
|
108
|
+
},
|
|
109
|
+
startNativeDrag: async () => Boolean((await call('window_start_native_drag'))?.ok),
|
|
110
|
+
},
|
|
111
|
+
dialogs: {
|
|
112
|
+
// Resolves to null when the transport has no save_file_dialog behind it.
|
|
113
|
+
// Per host.js that is the contract for "unavailable", and every caller
|
|
114
|
+
// treats it exactly like an absent `dialogs` capability: fall back to a
|
|
115
|
+
// Blob download, never report a failed save. Covered by
|
|
116
|
+
// tests/ui/test_host_save_fallback.mjs.
|
|
117
|
+
saveFile: ({ data, filename, kind }) => call('save_file_dialog', [data, filename, kind]),
|
|
118
|
+
},
|
|
119
|
+
state: {
|
|
120
|
+
async read(key) {
|
|
121
|
+
const blob = await call('workspace_state_read', [{ path: resolvePath(key) }]);
|
|
122
|
+
if (!blob) return null;
|
|
123
|
+
try { return typeof blob === 'string' ? JSON.parse(blob) : blob; }
|
|
124
|
+
catch (e) { logger.warn?.('[host] bad JSON for state key', key, e); return null; }
|
|
125
|
+
},
|
|
126
|
+
async write(key, value) {
|
|
127
|
+
const r = await call('workspace_state_write',
|
|
128
|
+
[{ path: resolvePath(key), data: JSON.stringify(value) }]);
|
|
129
|
+
return r !== null;
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config_schema.js
|
|
3
|
+
*
|
|
4
|
+
* Configuration schema definitions for widget types.
|
|
5
|
+
* Defines field types, validation rules, and default values.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Field type definitions for widget configuration.
|
|
10
|
+
*/
|
|
11
|
+
export const FieldTypes = {
|
|
12
|
+
TEXT: 'text',
|
|
13
|
+
NUMBER: 'number',
|
|
14
|
+
CHECKBOX: 'checkbox',
|
|
15
|
+
SELECT: 'select',
|
|
16
|
+
MULTISELECT: 'multiselect',
|
|
17
|
+
COLOR: 'color',
|
|
18
|
+
RANGE: 'range'
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validate a configuration value against its field schema.
|
|
23
|
+
* @param {any} value - Value to validate
|
|
24
|
+
* @param {Object} field - Field schema
|
|
25
|
+
* @returns {{valid: boolean, error?: string}} Validation result
|
|
26
|
+
*/
|
|
27
|
+
export function validateField(value, field) {
|
|
28
|
+
// Check required
|
|
29
|
+
if (field.required && (value == null || value === '')) {
|
|
30
|
+
return { valid: false, error: `${field.label} is required` };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Type-specific validation
|
|
34
|
+
switch (field.type) {
|
|
35
|
+
case FieldTypes.NUMBER:
|
|
36
|
+
case FieldTypes.RANGE:
|
|
37
|
+
if (value != null && value !== '') {
|
|
38
|
+
const num = parseFloat(value);
|
|
39
|
+
if (isNaN(num)) {
|
|
40
|
+
return { valid: false, error: `${field.label} must be a number` };
|
|
41
|
+
}
|
|
42
|
+
if (field.min != null && num < field.min) {
|
|
43
|
+
return { valid: false, error: `${field.label} must be at least ${field.min}` };
|
|
44
|
+
}
|
|
45
|
+
if (field.max != null && num > field.max) {
|
|
46
|
+
return { valid: false, error: `${field.label} must be at most ${field.max}` };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
break;
|
|
50
|
+
|
|
51
|
+
case FieldTypes.SELECT:
|
|
52
|
+
if (field.required && !value) {
|
|
53
|
+
return { valid: false, error: `${field.label} must be selected` };
|
|
54
|
+
}
|
|
55
|
+
break;
|
|
56
|
+
|
|
57
|
+
case FieldTypes.MULTISELECT:
|
|
58
|
+
if (field.required && (!Array.isArray(value) || value.length === 0)) {
|
|
59
|
+
return { valid: false, error: `${field.label} must have at least one selection` };
|
|
60
|
+
}
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { valid: true };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Validate an entire configuration object against a schema.
|
|
69
|
+
* @param {Object} config - Configuration object
|
|
70
|
+
* @param {Object} schema - Schema with fields array
|
|
71
|
+
* @returns {{valid: boolean, errors: Object}} Validation result
|
|
72
|
+
*/
|
|
73
|
+
export function validateConfig(config, schema) {
|
|
74
|
+
const errors = {};
|
|
75
|
+
let valid = true;
|
|
76
|
+
|
|
77
|
+
if (!schema?.fields) {
|
|
78
|
+
return { valid: true, errors: {} };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
schema.fields.forEach(field => {
|
|
82
|
+
const result = validateField(config[field.key], field);
|
|
83
|
+
if (!result.valid) {
|
|
84
|
+
errors[field.key] = result.error;
|
|
85
|
+
valid = false;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return { valid, errors };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Merge user config with defaults from schema.
|
|
94
|
+
* @param {Object} config - User configuration
|
|
95
|
+
* @param {Object} schema - Schema with fields array
|
|
96
|
+
* @returns {Object} Merged configuration
|
|
97
|
+
*/
|
|
98
|
+
export function mergeWithDefaults(config, schema) {
|
|
99
|
+
const result = { ...config };
|
|
100
|
+
|
|
101
|
+
if (schema?.fields) {
|
|
102
|
+
schema.fields.forEach(field => {
|
|
103
|
+
if (result[field.key] === undefined && field.default !== undefined) {
|
|
104
|
+
result[field.key] = field.default;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get options for a select/multiselect field.
|
|
114
|
+
* Handles dynamic options (e.g., 'variables' which gets populated from data).
|
|
115
|
+
* @param {Object} field - Field schema
|
|
116
|
+
* @param {Object} data - Current data context
|
|
117
|
+
* @returns {Array<{value: string, label: string}>} Options array
|
|
118
|
+
*/
|
|
119
|
+
export function getFieldOptions(field, data) {
|
|
120
|
+
if (Array.isArray(field.options)) {
|
|
121
|
+
return field.options;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Dynamic options based on string identifier
|
|
125
|
+
if (field.options === 'variables') {
|
|
126
|
+
return getVariableOptions(data);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (field.options === 'namespaces') {
|
|
130
|
+
return getNamespaceOptions(data);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Format a raw analytics variable key into a user-friendly display label.
|
|
138
|
+
* Strips namespace prefixes, internal Godley patterns, and stock type suffixes.
|
|
139
|
+
*
|
|
140
|
+
* Examples:
|
|
141
|
+
* "Agriculture.__SimpleStocks__::arable_land[State]" → "arable_land"
|
|
142
|
+
* "Population.population" → "population"
|
|
143
|
+
* "Banks::Assets[Cash]" → "Assets / Cash"
|
|
144
|
+
* "my_variable" → "my_variable"
|
|
145
|
+
*
|
|
146
|
+
* @param {string} name - Raw variable key from analytics
|
|
147
|
+
* @returns {string} Friendly display label
|
|
148
|
+
*/
|
|
149
|
+
export function formatVariableLabel(name) {
|
|
150
|
+
if (!name) return '';
|
|
151
|
+
let label = name;
|
|
152
|
+
|
|
153
|
+
// Strip namespace prefix: "Namespace.rest" → "rest"
|
|
154
|
+
const dotIdx = label.indexOf('.');
|
|
155
|
+
if (dotIdx > 0) {
|
|
156
|
+
label = label.slice(dotIdx + 1);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Strip __SimpleStocks__:: or similar double-underscore sector prefixes
|
|
160
|
+
label = label.replace(/^__\w+__::/, '');
|
|
161
|
+
|
|
162
|
+
// Handle Godley stock pattern: "Sector::AccountType[Account]" → "AccountType / Account"
|
|
163
|
+
// But first strip [State] / [Rate] suffixes that are internal
|
|
164
|
+
label = label.replace(/\[State\]$/i, '');
|
|
165
|
+
label = label.replace(/\[Rate\]$/i, '');
|
|
166
|
+
|
|
167
|
+
// Clean up trailing empty brackets or colons from stripping
|
|
168
|
+
label = label.replace(/\[\]$/, '');
|
|
169
|
+
|
|
170
|
+
// Replace :: with readable separator
|
|
171
|
+
label = label.replace(/::/g, ' / ');
|
|
172
|
+
|
|
173
|
+
// Replace single colons used as separators with underscore
|
|
174
|
+
label = label.replace(/:/g, '_');
|
|
175
|
+
|
|
176
|
+
// Clean up any double spaces or leading/trailing whitespace
|
|
177
|
+
return label.replace(/\s{2,}/g, ' ').trim();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Get variable options from data.
|
|
182
|
+
* @param {Object} data - Analytics data
|
|
183
|
+
* @returns {Array<{value: string, label: string, category?: string}>} Variable options
|
|
184
|
+
*/
|
|
185
|
+
export function getVariableOptions(data) {
|
|
186
|
+
const options = [];
|
|
187
|
+
|
|
188
|
+
if (!data) return options;
|
|
189
|
+
|
|
190
|
+
// Add stocks
|
|
191
|
+
if (data.stocks) {
|
|
192
|
+
Object.keys(data.stocks).forEach(name => {
|
|
193
|
+
options.push({
|
|
194
|
+
value: name,
|
|
195
|
+
label: formatVariableLabel(name),
|
|
196
|
+
category: 'Stocks'
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Add flows
|
|
202
|
+
if (data.flows) {
|
|
203
|
+
Object.keys(data.flows).forEach(name => {
|
|
204
|
+
options.push({
|
|
205
|
+
value: name,
|
|
206
|
+
label: formatVariableLabel(name),
|
|
207
|
+
category: 'Flows'
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Add indicators
|
|
213
|
+
if (data.indicators) {
|
|
214
|
+
Object.keys(data.indicators).forEach(name => {
|
|
215
|
+
options.push({
|
|
216
|
+
value: name,
|
|
217
|
+
label: formatVariableLabel(name),
|
|
218
|
+
category: 'Indicators'
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return options;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Extract unique namespace names from variable keys in data.
|
|
228
|
+
* Stock keys follow the pattern "Namespace.Sector::Type[Account]".
|
|
229
|
+
* @param {Object} data - Analytics data
|
|
230
|
+
* @returns {Array<{value: string, label: string}>}
|
|
231
|
+
*/
|
|
232
|
+
export function getNamespaceOptions(data) {
|
|
233
|
+
const namespaces = new Set();
|
|
234
|
+
const sources = [data?.stocks, data?.flows, data?.indicators];
|
|
235
|
+
for (const source of sources) {
|
|
236
|
+
if (!source) continue;
|
|
237
|
+
for (const key of Object.keys(source)) {
|
|
238
|
+
const dotIdx = key.indexOf('.');
|
|
239
|
+
if (dotIdx > 0) {
|
|
240
|
+
namespaces.add(key.slice(0, dotIdx));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (namespaces.size === 0) namespaces.add('Main');
|
|
245
|
+
return Array.from(namespaces).sort().map(ns => ({ value: ns, label: ns }));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export default {
|
|
249
|
+
FieldTypes,
|
|
250
|
+
validateField,
|
|
251
|
+
validateConfig,
|
|
252
|
+
mergeWithDefaults,
|
|
253
|
+
getFieldOptions,
|
|
254
|
+
getVariableOptions,
|
|
255
|
+
formatVariableLabel
|
|
256
|
+
};
|