@happyvertical/smrt-svelte 0.55.7 → 0.55.9
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/AGENTS.md +7 -44
- package/README.md +112 -1
- package/agents/browser-ai.md +47 -0
- package/agents/command-palette.md +85 -0
- package/agents/import-export.md +62 -0
- package/agents/overview-surfaces.md +255 -0
- package/agents/screens.md +57 -0
- package/dist/browser-ai/adapters/tts/browser-synthesis.d.ts +5 -0
- package/dist/browser-ai/adapters/tts/browser-synthesis.d.ts.map +1 -1
- package/dist/browser-ai/adapters/tts/browser-synthesis.js +83 -55
- package/dist/components/command-palette/CommandPalette.svelte +75 -0
- package/dist/components/command-palette/CommandPalette.svelte.d.ts +22 -0
- package/dist/components/command-palette/CommandPalette.svelte.d.ts.map +1 -0
- package/dist/components/command-palette/CommandPaletteDialog.svelte +406 -0
- package/dist/components/command-palette/CommandPaletteDialog.svelte.d.ts +18 -0
- package/dist/components/command-palette/CommandPaletteDialog.svelte.d.ts.map +1 -0
- package/dist/components/command-palette/CommandPaletteTrigger.svelte +91 -0
- package/dist/components/command-palette/CommandPaletteTrigger.svelte.d.ts +17 -0
- package/dist/components/command-palette/CommandPaletteTrigger.svelte.d.ts.map +1 -0
- package/dist/components/command-palette/context.d.ts +18 -0
- package/dist/components/command-palette/context.d.ts.map +1 -0
- package/dist/components/command-palette/context.js +38 -0
- package/dist/components/command-palette/controller.svelte.d.ts +70 -0
- package/dist/components/command-palette/controller.svelte.d.ts.map +1 -0
- package/dist/components/command-palette/controller.svelte.js +379 -0
- package/dist/components/command-palette/hotkeys.d.ts +30 -0
- package/dist/components/command-palette/hotkeys.d.ts.map +1 -0
- package/dist/components/command-palette/hotkeys.js +161 -0
- package/dist/components/command-palette/index.d.ts +19 -0
- package/dist/components/command-palette/index.d.ts.map +1 -0
- package/dist/components/command-palette/index.js +17 -0
- package/dist/components/command-palette/match.d.ts +38 -0
- package/dist/components/command-palette/match.d.ts.map +1 -0
- package/dist/components/command-palette/match.js +172 -0
- package/dist/components/command-palette/providers.d.ts +159 -0
- package/dist/components/command-palette/providers.d.ts.map +1 -0
- package/dist/components/command-palette/providers.js +345 -0
- package/dist/components/command-palette/rank.d.ts +20 -0
- package/dist/components/command-palette/rank.d.ts.map +1 -0
- package/dist/components/command-palette/rank.js +74 -0
- package/dist/components/command-palette/types.d.ts +119 -0
- package/dist/components/command-palette/types.d.ts.map +1 -0
- package/dist/components/command-palette/types.js +10 -0
- package/dist/components/command-palette/use-provider.svelte.d.ts +14 -0
- package/dist/components/command-palette/use-provider.svelte.d.ts.map +1 -0
- package/dist/components/command-palette/use-provider.svelte.js +23 -0
- package/dist/components/import-export/ImportExport.svelte +678 -0
- package/dist/components/import-export/ImportExport.svelte.d.ts +5 -0
- package/dist/components/import-export/ImportExport.svelte.d.ts.map +1 -0
- package/dist/components/import-export/collection-adapter.d.ts +35 -0
- package/dist/components/import-export/collection-adapter.d.ts.map +1 -0
- package/dist/components/import-export/collection-adapter.js +42 -0
- package/dist/components/import-export/csv.d.ts +60 -0
- package/dist/components/import-export/csv.d.ts.map +1 -0
- package/dist/components/import-export/csv.js +192 -0
- package/dist/components/import-export/download.d.ts +4 -0
- package/dist/components/import-export/download.d.ts.map +1 -0
- package/dist/components/import-export/download.js +17 -0
- package/dist/components/import-export/export.d.ts +25 -0
- package/dist/components/import-export/export.d.ts.map +1 -0
- package/dist/components/import-export/export.js +68 -0
- package/dist/components/import-export/fields.d.ts +48 -0
- package/dist/components/import-export/fields.d.ts.map +1 -0
- package/dist/components/import-export/fields.js +103 -0
- package/dist/components/import-export/index.d.ts +12 -0
- package/dist/components/import-export/index.d.ts.map +1 -0
- package/dist/components/import-export/index.js +9 -0
- package/dist/components/import-export/mapping.d.ts +12 -0
- package/dist/components/import-export/mapping.d.ts.map +1 -0
- package/dist/components/import-export/mapping.js +38 -0
- package/dist/components/import-export/props.d.ts +34 -0
- package/dist/components/import-export/props.d.ts.map +1 -0
- package/dist/components/import-export/props.js +1 -0
- package/dist/components/import-export/report.d.ts +13 -0
- package/dist/components/import-export/report.d.ts.map +1 -0
- package/dist/components/import-export/report.js +50 -0
- package/dist/components/import-export/runner.d.ts +17 -0
- package/dist/components/import-export/runner.d.ts.map +1 -0
- package/dist/components/import-export/runner.js +39 -0
- package/dist/components/import-export/types.d.ts +100 -0
- package/dist/components/import-export/types.d.ts.map +1 -0
- package/dist/components/import-export/types.js +6 -0
- package/dist/components/import-export/validate.d.ts +39 -0
- package/dist/components/import-export/validate.d.ts.map +1 -0
- package/dist/components/import-export/validate.js +268 -0
- package/dist/components/overview/OverviewGrid.svelte +579 -0
- package/dist/components/overview/OverviewGrid.svelte.d.ts +5 -0
- package/dist/components/overview/OverviewGrid.svelte.d.ts.map +1 -0
- package/dist/components/overview/WidgetOptionsForm.svelte +193 -0
- package/dist/components/overview/WidgetOptionsForm.svelte.d.ts +5 -0
- package/dist/components/overview/WidgetOptionsForm.svelte.d.ts.map +1 -0
- package/dist/components/overview/controller.svelte.d.ts +92 -0
- package/dist/components/overview/controller.svelte.d.ts.map +1 -0
- package/dist/components/overview/controller.svelte.js +336 -0
- package/dist/components/overview/format.d.ts +13 -0
- package/dist/components/overview/format.d.ts.map +1 -0
- package/dist/components/overview/format.js +45 -0
- package/dist/components/overview/grid-types.d.ts +52 -0
- package/dist/components/overview/grid-types.d.ts.map +1 -0
- package/dist/components/overview/grid-types.js +1 -0
- package/dist/components/overview/index.d.ts +24 -0
- package/dist/components/overview/index.d.ts.map +1 -0
- package/dist/components/overview/index.js +22 -0
- package/dist/components/overview/load.d.ts +30 -0
- package/dist/components/overview/load.d.ts.map +1 -0
- package/dist/components/overview/load.js +92 -0
- package/dist/components/overview/markdown.d.ts +53 -0
- package/dist/components/overview/markdown.d.ts.map +1 -0
- package/dist/components/overview/markdown.js +203 -0
- package/dist/components/overview/model.d.ts +100 -0
- package/dist/components/overview/model.d.ts.map +1 -0
- package/dist/components/overview/model.js +460 -0
- package/dist/components/overview/recipe-widgets.d.ts +77 -0
- package/dist/components/overview/recipe-widgets.d.ts.map +1 -0
- package/dist/components/overview/recipe-widgets.js +151 -0
- package/dist/components/overview/registry.d.ts +45 -0
- package/dist/components/overview/registry.d.ts.map +1 -0
- package/dist/components/overview/registry.js +112 -0
- package/dist/components/overview/resize.d.ts +25 -0
- package/dist/components/overview/resize.d.ts.map +1 -0
- package/dist/components/overview/resize.js +41 -0
- package/dist/components/overview/schema.d.ts +34 -0
- package/dist/components/overview/schema.d.ts.map +1 -0
- package/dist/components/overview/schema.js +180 -0
- package/dist/components/overview/server.d.ts +14 -0
- package/dist/components/overview/server.d.ts.map +1 -0
- package/dist/components/overview/server.js +13 -0
- package/dist/components/overview/types.d.ts +235 -0
- package/dist/components/overview/types.d.ts.map +1 -0
- package/dist/components/overview/types.js +17 -0
- package/dist/components/overview/widgets/ChartWidget.svelte +111 -0
- package/dist/components/overview/widgets/ChartWidget.svelte.d.ts +6 -0
- package/dist/components/overview/widgets/ChartWidget.svelte.d.ts.map +1 -0
- package/dist/components/overview/widgets/MetricWidget.svelte +86 -0
- package/dist/components/overview/widgets/MetricWidget.svelte.d.ts +6 -0
- package/dist/components/overview/widgets/MetricWidget.svelte.d.ts.map +1 -0
- package/dist/components/overview/widgets/NoteWidget.svelte +61 -0
- package/dist/components/overview/widgets/NoteWidget.svelte.d.ts +5 -0
- package/dist/components/overview/widgets/NoteWidget.svelte.d.ts.map +1 -0
- package/dist/components/overview/widgets/RecordListWidget.svelte +91 -0
- package/dist/components/overview/widgets/RecordListWidget.svelte.d.ts +6 -0
- package/dist/components/overview/widgets/RecordListWidget.svelte.d.ts.map +1 -0
- package/dist/components/overview/widgets/ShortcutsWidget.svelte +80 -0
- package/dist/components/overview/widgets/ShortcutsWidget.svelte.d.ts +6 -0
- package/dist/components/overview/widgets/ShortcutsWidget.svelte.d.ts.map +1 -0
- package/dist/components/overview/widgets/core.d.ts +44 -0
- package/dist/components/overview/widgets/core.d.ts.map +1 -0
- package/dist/components/overview/widgets/core.js +222 -0
- package/dist/components/overview/widgets/data.d.ts +76 -0
- package/dist/components/overview/widgets/data.d.ts.map +1 -0
- package/dist/components/overview/widgets/data.js +40 -0
- package/dist/components/screens/DetailScreen.svelte +220 -0
- package/dist/components/screens/DetailScreen.svelte.d.ts +5 -0
- package/dist/components/screens/DetailScreen.svelte.d.ts.map +1 -0
- package/dist/components/screens/EditForm.svelte +313 -0
- package/dist/components/screens/EditForm.svelte.d.ts +5 -0
- package/dist/components/screens/EditForm.svelte.d.ts.map +1 -0
- package/dist/components/screens/ListScreen.svelte +191 -0
- package/dist/components/screens/ListScreen.svelte.d.ts +5 -0
- package/dist/components/screens/ListScreen.svelte.d.ts.map +1 -0
- package/dist/components/screens/RecipeScreens.svelte +237 -0
- package/dist/components/screens/RecipeScreens.svelte.d.ts +5 -0
- package/dist/components/screens/RecipeScreens.svelte.d.ts.map +1 -0
- package/dist/components/screens/fields.d.ts +54 -0
- package/dist/components/screens/fields.d.ts.map +1 -0
- package/dist/components/screens/fields.js +229 -0
- package/dist/components/screens/index.d.ts +8 -0
- package/dist/components/screens/index.d.ts.map +1 -0
- package/dist/components/screens/index.js +6 -0
- package/dist/components/screens/types.d.ts +215 -0
- package/dist/components/screens/types.d.ts.map +1 -0
- package/dist/components/screens/types.js +1 -0
- package/dist/components/screens/values.d.ts +62 -0
- package/dist/components/screens/values.d.ts.map +1 -0
- package/dist/components/screens/values.js +289 -0
- package/dist/components/sortable/controller.svelte.d.ts +10 -0
- package/dist/components/sortable/controller.svelte.d.ts.map +1 -1
- package/dist/components/sortable/controller.svelte.js +29 -7
- package/dist/components/workspace/manifest-nav.d.ts +20 -0
- package/dist/components/workspace/manifest-nav.d.ts.map +1 -1
- package/dist/components/workspace/manifest-nav.js +42 -13
- package/dist/i18n/server.d.ts +6 -0
- package/dist/i18n/server.d.ts.map +1 -1
- package/dist/i18n/server.js +6 -0
- package/dist/i18n/strings.import-export.d.ts +88 -0
- package/dist/i18n/strings.import-export.d.ts.map +1 -0
- package/dist/i18n/strings.import-export.js +88 -0
- package/dist/i18n/strings.overview.d.ts +100 -0
- package/dist/i18n/strings.overview.d.ts.map +1 -0
- package/dist/i18n/strings.overview.js +107 -0
- package/dist/i18n/strings.palette.d.ts +18 -0
- package/dist/i18n/strings.palette.d.ts.map +1 -0
- package/dist/i18n/strings.palette.js +18 -0
- package/dist/i18n/strings.screens.d.ts +31 -0
- package/dist/i18n/strings.screens.d.ts.map +1 -0
- package/dist/i18n/strings.screens.js +31 -0
- package/package.json +45 -16
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
const MARKS = /\p{M}/gu;
|
|
2
|
+
/** Fold one code point; may yield 0..n characters (marks vanish, `İ` splits). */
|
|
3
|
+
function foldChar(char) {
|
|
4
|
+
return char.normalize('NFD').replace(MARKS, '').toLowerCase();
|
|
5
|
+
}
|
|
6
|
+
function fold(source) {
|
|
7
|
+
let text = '';
|
|
8
|
+
const origin = [];
|
|
9
|
+
const originEnd = [];
|
|
10
|
+
let index = 0;
|
|
11
|
+
for (const char of source) {
|
|
12
|
+
const folded = foldChar(char);
|
|
13
|
+
for (let i = 0; i < folded.length; i += 1) {
|
|
14
|
+
origin.push(index);
|
|
15
|
+
originEnd.push(index + char.length);
|
|
16
|
+
}
|
|
17
|
+
text += folded;
|
|
18
|
+
index += char.length;
|
|
19
|
+
}
|
|
20
|
+
return { text, origin, originEnd };
|
|
21
|
+
}
|
|
22
|
+
/** Lower-case and strip accents; the form both sides of a match are compared in. */
|
|
23
|
+
export function foldForMatch(source) {
|
|
24
|
+
return fold(source).text;
|
|
25
|
+
}
|
|
26
|
+
/** Split a query into its terms (whitespace separated, folded, de-duplicated). */
|
|
27
|
+
export function queryTerms(query) {
|
|
28
|
+
const terms = foldForMatch(query)
|
|
29
|
+
.split(/\s+/)
|
|
30
|
+
.filter((term) => term.length > 0);
|
|
31
|
+
return [...new Set(terms)];
|
|
32
|
+
}
|
|
33
|
+
const WORD_CHAR = /[\p{L}\p{N}]/u;
|
|
34
|
+
function isWordChar(char) {
|
|
35
|
+
return char !== undefined && WORD_CHAR.test(char);
|
|
36
|
+
}
|
|
37
|
+
/** Best match of one term in folded text, or null. */
|
|
38
|
+
function matchTerm(term, text) {
|
|
39
|
+
let best = null;
|
|
40
|
+
let at = text.indexOf(term);
|
|
41
|
+
while (at !== -1) {
|
|
42
|
+
const wordStart = at === 0 || !isWordChar(text[at - 1]);
|
|
43
|
+
const score = wordStart ? (at === 0 ? 150 : 100) : 60;
|
|
44
|
+
if (!best || score > best.score) {
|
|
45
|
+
best = { score, ranges: [[at, at + term.length]] };
|
|
46
|
+
}
|
|
47
|
+
at = text.indexOf(term, at + 1);
|
|
48
|
+
}
|
|
49
|
+
if (best)
|
|
50
|
+
return best;
|
|
51
|
+
// In-order subsequence ("nwinv" -> "New Invoice"), compact spans only so
|
|
52
|
+
// short terms do not match everything.
|
|
53
|
+
if (term.length < 2)
|
|
54
|
+
return null;
|
|
55
|
+
const hits = [];
|
|
56
|
+
let cursor = 0;
|
|
57
|
+
for (const char of term) {
|
|
58
|
+
const found = text.indexOf(char, cursor);
|
|
59
|
+
if (found === -1)
|
|
60
|
+
return null;
|
|
61
|
+
hits.push(found);
|
|
62
|
+
cursor = found + 1;
|
|
63
|
+
}
|
|
64
|
+
const span = hits[hits.length - 1] - hits[0] + 1;
|
|
65
|
+
if (span > term.length * 4)
|
|
66
|
+
return null;
|
|
67
|
+
const ranges = [];
|
|
68
|
+
for (const hit of hits) {
|
|
69
|
+
const last = ranges[ranges.length - 1];
|
|
70
|
+
if (last && last[1] === hit)
|
|
71
|
+
last[1] = hit + 1;
|
|
72
|
+
else
|
|
73
|
+
ranges.push([hit, hit + 1]);
|
|
74
|
+
}
|
|
75
|
+
const startsWord = hits[0] === 0 || !isWordChar(text[hits[0] - 1]);
|
|
76
|
+
return {
|
|
77
|
+
score: Math.max(5, 30 - (span - term.length) + (startsWord ? 10 : 0)),
|
|
78
|
+
ranges,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function mergeRanges(ranges) {
|
|
82
|
+
const sorted = [...ranges].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
83
|
+
const merged = [];
|
|
84
|
+
for (const range of sorted) {
|
|
85
|
+
const last = merged[merged.length - 1];
|
|
86
|
+
if (last && range[0] <= last[1])
|
|
87
|
+
last[1] = Math.max(last[1], range[1]);
|
|
88
|
+
else
|
|
89
|
+
merged.push([range[0], range[1]]);
|
|
90
|
+
}
|
|
91
|
+
return merged;
|
|
92
|
+
}
|
|
93
|
+
function toOriginal(folded, ranges) {
|
|
94
|
+
return mergeRanges(ranges.map(([start, end]) => [folded.origin[start], folded.originEnd[end - 1]]));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Match every term of `query` against `text`. All terms must match somewhere
|
|
98
|
+
* (a missing term is no match). An empty query matches everything with score 0.
|
|
99
|
+
*/
|
|
100
|
+
export function matchText(query, text) {
|
|
101
|
+
const terms = queryTerms(query);
|
|
102
|
+
if (terms.length === 0)
|
|
103
|
+
return { score: 0, ranges: [] };
|
|
104
|
+
const folded = fold(text);
|
|
105
|
+
let score = 0;
|
|
106
|
+
const ranges = [];
|
|
107
|
+
for (const term of terms) {
|
|
108
|
+
const match = matchTerm(term, folded.text);
|
|
109
|
+
if (!match)
|
|
110
|
+
return null;
|
|
111
|
+
score += match.score;
|
|
112
|
+
ranges.push(...match.ranges);
|
|
113
|
+
}
|
|
114
|
+
const whole = foldForMatch(query).trim();
|
|
115
|
+
if (folded.text === whole)
|
|
116
|
+
score += 200;
|
|
117
|
+
else if (folded.text.startsWith(whole))
|
|
118
|
+
score += 50;
|
|
119
|
+
// Of equal matches, the shorter text is the closer one.
|
|
120
|
+
score -= Math.min(folded.text.length, 100) * 0.1;
|
|
121
|
+
return { score, ranges: toOriginal(folded, ranges) };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Match `query` against an item's title (full weight), and its subtitle and
|
|
125
|
+
* keywords (half weight). Each term may match any of them; all must match.
|
|
126
|
+
*/
|
|
127
|
+
export function matchItem(query, item) {
|
|
128
|
+
const terms = queryTerms(query);
|
|
129
|
+
if (terms.length === 0)
|
|
130
|
+
return { score: 0, titleRanges: [] };
|
|
131
|
+
const title = fold(item.title);
|
|
132
|
+
const secondary = foldForMatch([item.subtitle ?? '', ...(item.keywords ?? [])].join(' '));
|
|
133
|
+
let score = 0;
|
|
134
|
+
const titleRanges = [];
|
|
135
|
+
for (const term of terms) {
|
|
136
|
+
const inTitle = matchTerm(term, title.text);
|
|
137
|
+
if (inTitle) {
|
|
138
|
+
score += inTitle.score;
|
|
139
|
+
titleRanges.push(...inTitle.ranges);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const inSecondary = matchTerm(term, secondary);
|
|
143
|
+
if (!inSecondary)
|
|
144
|
+
return null;
|
|
145
|
+
score += inSecondary.score * 0.5;
|
|
146
|
+
}
|
|
147
|
+
const whole = foldForMatch(query).trim();
|
|
148
|
+
if (title.text === whole)
|
|
149
|
+
score += 200;
|
|
150
|
+
else if (title.text.startsWith(whole))
|
|
151
|
+
score += 50;
|
|
152
|
+
score -= Math.min(title.text.length, 100) * 0.1;
|
|
153
|
+
return { score, titleRanges: toOriginal(title, titleRanges) };
|
|
154
|
+
}
|
|
155
|
+
/** Split `text` into alternating plain and matched segments for rendering. */
|
|
156
|
+
export function highlightSegments(text, ranges) {
|
|
157
|
+
const segments = [];
|
|
158
|
+
let cursor = 0;
|
|
159
|
+
for (const [start, end] of ranges) {
|
|
160
|
+
if (start > cursor) {
|
|
161
|
+
segments.push({ text: text.slice(cursor, start), match: false });
|
|
162
|
+
}
|
|
163
|
+
if (end > start) {
|
|
164
|
+
segments.push({ text: text.slice(start, end), match: true });
|
|
165
|
+
}
|
|
166
|
+
cursor = Math.max(cursor, end);
|
|
167
|
+
}
|
|
168
|
+
if (cursor < text.length) {
|
|
169
|
+
segments.push({ text: text.slice(cursor), match: false });
|
|
170
|
+
}
|
|
171
|
+
return segments;
|
|
172
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { ShellNavGroup, ShellNavItem } from '../workspace/admin-shell/types.js';
|
|
2
|
+
import { type SmrtManifestEntryLike } from '../workspace/manifest-nav.js';
|
|
3
|
+
import type { PaletteProvider } from './types.js';
|
|
4
|
+
type MaybeGetter<T> = T | (() => T);
|
|
5
|
+
export interface NavigationProviderOptions {
|
|
6
|
+
/** Flat nav items (the `AppShell` `nav` prop). A getter follows reactive state. */
|
|
7
|
+
nav?: MaybeGetter<readonly ShellNavItem[]>;
|
|
8
|
+
/** Grouped nav (the `AppShell` `groups` prop). */
|
|
9
|
+
groups?: MaybeGetter<readonly ShellNavGroup[]>;
|
|
10
|
+
/** Provider id (default `navigation`). */
|
|
11
|
+
id?: string;
|
|
12
|
+
/** Group heading (default `Go to`). */
|
|
13
|
+
label?: string;
|
|
14
|
+
/** Sort position among providers (default 10). */
|
|
15
|
+
order?: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Rows that open each page of the shell navigation. Pass the *applied* nav
|
|
19
|
+
* (`useShellLayout().applied`) to honour sections a user hid or renamed.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* createNavigationProvider({
|
|
24
|
+
* nav: () => layout.applied.nav,
|
|
25
|
+
* groups: () => layout.applied.groups,
|
|
26
|
+
* });
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export declare function createNavigationProvider(options?: NavigationProviderOptions): PaletteProvider;
|
|
30
|
+
/** The slice of a manifest field the palette reads. */
|
|
31
|
+
export interface PaletteManifestFieldLike {
|
|
32
|
+
type?: string;
|
|
33
|
+
sensitive?: boolean;
|
|
34
|
+
transient?: boolean;
|
|
35
|
+
enum?: readonly unknown[];
|
|
36
|
+
}
|
|
37
|
+
/** A manifest entry with its fields (a subset of `SmartObjectDefinition`). */
|
|
38
|
+
export interface PaletteManifestEntryLike extends SmrtManifestEntryLike {
|
|
39
|
+
fields?: Record<string, PaletteManifestFieldLike>;
|
|
40
|
+
}
|
|
41
|
+
/** A manifest (a subset of `SmartObjectManifest`). */
|
|
42
|
+
export interface PaletteManifestLike {
|
|
43
|
+
objects: Record<string, PaletteManifestEntryLike>;
|
|
44
|
+
}
|
|
45
|
+
/** A model with the metadata the model providers work from. */
|
|
46
|
+
export interface PaletteModel {
|
|
47
|
+
entry: PaletteManifestEntryLike;
|
|
48
|
+
/** Qualified name (`@pkg/name:Class`). */
|
|
49
|
+
qualifier: string;
|
|
50
|
+
/** Plural, display-cased label ("Invoices"). */
|
|
51
|
+
label: string;
|
|
52
|
+
/** Singular, display-cased label ("Invoice"). */
|
|
53
|
+
singular: string;
|
|
54
|
+
/** REST collection path segment. */
|
|
55
|
+
collection: string;
|
|
56
|
+
/** Whether the REST `create` route exists. */
|
|
57
|
+
creatable: boolean;
|
|
58
|
+
/** Text fields searched with a `like` filter. */
|
|
59
|
+
searchFields: readonly string[];
|
|
60
|
+
/** Field read as a record's title. */
|
|
61
|
+
titleField: string | null;
|
|
62
|
+
}
|
|
63
|
+
/** A row from the REST list route. */
|
|
64
|
+
export type PaletteRecordRow = Record<string, unknown>;
|
|
65
|
+
/** Request handed to {@link ModelProvidersOptions.searchRows}. */
|
|
66
|
+
export interface PaletteRecordSearch {
|
|
67
|
+
model: PaletteModel;
|
|
68
|
+
field: string;
|
|
69
|
+
query: string;
|
|
70
|
+
limit: number;
|
|
71
|
+
signal: AbortSignal;
|
|
72
|
+
}
|
|
73
|
+
export interface ModelProvidersOptions {
|
|
74
|
+
/** The app manifest. */
|
|
75
|
+
manifest: PaletteManifestLike;
|
|
76
|
+
/**
|
|
77
|
+
* Qualified class names the user may reach (the nav helper's allow-list).
|
|
78
|
+
* Omit to include every navigable model; the server still decides what rows
|
|
79
|
+
* come back.
|
|
80
|
+
*/
|
|
81
|
+
permittedResources?: readonly string[];
|
|
82
|
+
/** Only these models (class or qualified names) get search. */
|
|
83
|
+
searchModels?: readonly string[];
|
|
84
|
+
/**
|
|
85
|
+
* Text fields to search per model, keyed by class or qualified name.
|
|
86
|
+
* Default: the model's `title`, `name`, `displayName`, `label`, `subject`,
|
|
87
|
+
* `headline`, `number`, `code`, `sku`, `email` or `slug` field, first found.
|
|
88
|
+
*/
|
|
89
|
+
searchFields?: Readonly<Record<string, readonly string[]>>;
|
|
90
|
+
/** How many of those fields to search per model (default 1). */
|
|
91
|
+
fieldsPerModel?: number;
|
|
92
|
+
/** Most models searched per query (default 20). */
|
|
93
|
+
maxModels?: number;
|
|
94
|
+
/** Rows taken from each model (default 3). */
|
|
95
|
+
perModelLimit?: number;
|
|
96
|
+
/** Concurrent requests per query (default 4). */
|
|
97
|
+
concurrency?: number;
|
|
98
|
+
/** Base of the generated REST routes (default `/api/v1`). */
|
|
99
|
+
apiBasePath?: string;
|
|
100
|
+
/** Base of the app's pages, used by the default hrefs (default ``). */
|
|
101
|
+
pagesBasePath?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Page for a model's list, new-record form and a record. Return null to
|
|
104
|
+
* leave a row without a destination (it is then omitted). Defaults:
|
|
105
|
+
* `<pages>/<collection>`, `<pages>/<collection>/new`,
|
|
106
|
+
* `<pages>/<collection>/<id>`.
|
|
107
|
+
*/
|
|
108
|
+
hrefs?: {
|
|
109
|
+
list?: (model: PaletteModel) => string | null;
|
|
110
|
+
create?: (model: PaletteModel) => string | null;
|
|
111
|
+
record?: (model: PaletteModel, row: PaletteRecordRow) => string | null;
|
|
112
|
+
};
|
|
113
|
+
/** Fetch used by the default `searchRows` (default: global `fetch`). */
|
|
114
|
+
fetch?: typeof fetch;
|
|
115
|
+
/**
|
|
116
|
+
* Replace the default `GET <api>/<collection>?<field>[like]=%q%` request,
|
|
117
|
+
* for example with a server search endpoint. The default's `LIKE` is
|
|
118
|
+
* case-insensitive on SQLite and case-sensitive on PostgreSQL.
|
|
119
|
+
*/
|
|
120
|
+
searchRows?: (request: PaletteRecordSearch) => Promise<PaletteRecordRow[]>;
|
|
121
|
+
/** Also offer an "open <list>" row per model (default false; nav covers it). */
|
|
122
|
+
lists?: boolean;
|
|
123
|
+
/** Text of a create row (default `New <singular>`). */
|
|
124
|
+
createTitle?: (model: PaletteModel) => string;
|
|
125
|
+
/** Text of a list row (default the plural label). */
|
|
126
|
+
listTitle?: (model: PaletteModel) => string;
|
|
127
|
+
/** Group heading of the commands provider (default `Create`). */
|
|
128
|
+
commandsLabel?: string;
|
|
129
|
+
/** Group heading of the search provider (default `Records`). */
|
|
130
|
+
recordsLabel?: string;
|
|
131
|
+
/** Sort positions (defaults 20 and 30). */
|
|
132
|
+
commandsOrder?: number;
|
|
133
|
+
recordsOrder?: number;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Resolve each navigable model's palette metadata from the manifest. Exported
|
|
137
|
+
* for apps that build their own rows from the same model list.
|
|
138
|
+
*/
|
|
139
|
+
export declare function paletteModelsFromManifest(options: Pick<ModelProvidersOptions, 'manifest' | 'permittedResources' | 'searchFields' | 'fieldsPerModel'>): PaletteModel[];
|
|
140
|
+
/**
|
|
141
|
+
* The model-driven providers: a `Create` commands provider ("New invoice")
|
|
142
|
+
* and a `Records` provider that searches the models' text fields through the
|
|
143
|
+
* generated REST routes. Models and their eligibility come from the same
|
|
144
|
+
* filters as the manifest-derived navigation.
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```ts
|
|
148
|
+
* const palette = createCommandPalette({
|
|
149
|
+
* providers: [
|
|
150
|
+
* createNavigationProvider({ nav }),
|
|
151
|
+
* ...createModelProviders({ manifest, pagesBasePath: '/app' }),
|
|
152
|
+
* ],
|
|
153
|
+
* navigate: goto,
|
|
154
|
+
* });
|
|
155
|
+
* ```
|
|
156
|
+
*/
|
|
157
|
+
export declare function createModelProviders(options: ModelProvidersOptions): PaletteProvider[];
|
|
158
|
+
export {};
|
|
159
|
+
//# sourceMappingURL=providers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"providers.d.ts","sourceRoot":"","sources":["../../../src/components/command-palette/providers.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Providers the shell ships with (#3713):
|
|
3
|
+
*
|
|
4
|
+
* - {@link createNavigationProvider}: "go to" rows from the shell nav model.
|
|
5
|
+
* - {@link createModelProviders}: "New <Noun>" commands and cross-model record
|
|
6
|
+
* search derived from the SMRT manifest, over the generated REST routes.
|
|
7
|
+
*
|
|
8
|
+
* Both are plain `PaletteProvider`s; an app is free to ignore them and
|
|
9
|
+
* register its own.
|
|
10
|
+
*/
|
|
11
|
+
import { unwrapListResult } from '@happyvertical/smrt-web';
|
|
12
|
+
import { navigableManifestEntries, pluralizeClassName, } from '../workspace/manifest-nav.js';
|
|
13
|
+
function read(value, fallback) {
|
|
14
|
+
if (value === undefined)
|
|
15
|
+
return fallback;
|
|
16
|
+
return typeof value === 'function' ? value() : value;
|
|
17
|
+
}
|
|
18
|
+
function flattenNav(items, trail, out, seen) {
|
|
19
|
+
for (const item of items) {
|
|
20
|
+
const id = `nav:${item.id ?? item.href}`;
|
|
21
|
+
if (!seen.has(id)) {
|
|
22
|
+
seen.add(id);
|
|
23
|
+
out.push({
|
|
24
|
+
id,
|
|
25
|
+
title: item.label,
|
|
26
|
+
subtitle: trail,
|
|
27
|
+
keywords: item.description ? [item.description] : undefined,
|
|
28
|
+
icon: item.icon,
|
|
29
|
+
kind: 'navigation',
|
|
30
|
+
href: item.href,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
if (item.children?.length) {
|
|
34
|
+
flattenNav(item.children, trail ?? item.label, out, seen);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Rows that open each page of the shell navigation. Pass the *applied* nav
|
|
40
|
+
* (`useShellLayout().applied`) to honour sections a user hid or renamed.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```ts
|
|
44
|
+
* createNavigationProvider({
|
|
45
|
+
* nav: () => layout.applied.nav,
|
|
46
|
+
* groups: () => layout.applied.groups,
|
|
47
|
+
* });
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export function createNavigationProvider(options = {}) {
|
|
51
|
+
return {
|
|
52
|
+
id: options.id ?? 'navigation',
|
|
53
|
+
label: options.label ?? 'Go to',
|
|
54
|
+
order: options.order ?? 10,
|
|
55
|
+
items() {
|
|
56
|
+
const out = [];
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
flattenNav(read(options.nav, []), undefined, out, seen);
|
|
59
|
+
for (const group of read(options.groups, [])) {
|
|
60
|
+
flattenNav(group.items, group.heading, out, seen);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const TITLE_FIELDS = [
|
|
67
|
+
'title',
|
|
68
|
+
'name',
|
|
69
|
+
'displayName',
|
|
70
|
+
'label',
|
|
71
|
+
'subject',
|
|
72
|
+
'headline',
|
|
73
|
+
'number',
|
|
74
|
+
'code',
|
|
75
|
+
'sku',
|
|
76
|
+
'email',
|
|
77
|
+
'slug',
|
|
78
|
+
];
|
|
79
|
+
function kebab(name) {
|
|
80
|
+
return name
|
|
81
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
82
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
|
|
83
|
+
.toLowerCase();
|
|
84
|
+
}
|
|
85
|
+
/** "SalesInvoice" -> "Sales invoice". */
|
|
86
|
+
function humanize(className) {
|
|
87
|
+
const words = className
|
|
88
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
89
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
90
|
+
.trim();
|
|
91
|
+
if (!words)
|
|
92
|
+
return className;
|
|
93
|
+
return words[0].toUpperCase() + words.slice(1).toLowerCase();
|
|
94
|
+
}
|
|
95
|
+
function isSearchableField(field) {
|
|
96
|
+
return (field?.type === 'text' &&
|
|
97
|
+
!field.sensitive &&
|
|
98
|
+
!field.transient &&
|
|
99
|
+
!(field.enum && field.enum.length > 0));
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Resolve each navigable model's palette metadata from the manifest. Exported
|
|
103
|
+
* for apps that build their own rows from the same model list.
|
|
104
|
+
*/
|
|
105
|
+
export function paletteModelsFromManifest(options) {
|
|
106
|
+
const perModel = Math.max(1, options.fieldsPerModel ?? 1);
|
|
107
|
+
const models = navigableManifestEntries(options.manifest, { permittedResources: options.permittedResources }).map(({ entry, qualifier, creatable, }) => {
|
|
108
|
+
const fields = entry.fields ?? {};
|
|
109
|
+
const override = options.searchFields?.[qualifier] ??
|
|
110
|
+
options.searchFields?.[entry.className];
|
|
111
|
+
const candidates = (override ?? TITLE_FIELDS).filter((name) => override ? true : isSearchableField(fields[name]));
|
|
112
|
+
const titleField = TITLE_FIELDS.find((name) => isSearchableField(fields[name])) ??
|
|
113
|
+
override?.[0] ??
|
|
114
|
+
null;
|
|
115
|
+
return {
|
|
116
|
+
entry,
|
|
117
|
+
qualifier,
|
|
118
|
+
label: entry.decoratorConfig?.ui?.label ??
|
|
119
|
+
pluralizeClassName(humanize(entry.className)),
|
|
120
|
+
singular: humanize(entry.className),
|
|
121
|
+
collection: entry.collection ?? kebab(entry.className),
|
|
122
|
+
creatable,
|
|
123
|
+
searchFields: candidates.slice(0, perModel),
|
|
124
|
+
titleField,
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
return models.sort((a, b) => a.label.localeCompare(b.label) || a.qualifier.localeCompare(b.qualifier));
|
|
128
|
+
}
|
|
129
|
+
async function defaultSearchRows(request, options) {
|
|
130
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
131
|
+
if (!doFetch)
|
|
132
|
+
throw new Error('No fetch available for palette search.');
|
|
133
|
+
// `%` is a wildcard in LIKE; a typed one would only widen the match.
|
|
134
|
+
const term = request.query.replaceAll('%', '');
|
|
135
|
+
const params = new URLSearchParams();
|
|
136
|
+
params.set(`${request.field}[like]`, `%${term}%`);
|
|
137
|
+
params.set('limit', String(request.limit));
|
|
138
|
+
const response = await doFetch(`${options.apiBasePath}/${request.model.collection}?${params}`, {
|
|
139
|
+
signal: request.signal,
|
|
140
|
+
credentials: 'same-origin',
|
|
141
|
+
headers: { accept: 'application/json' },
|
|
142
|
+
});
|
|
143
|
+
// A model the user may not read (403) or a route the app does not mount
|
|
144
|
+
// (404) is a model without results, not a failed search.
|
|
145
|
+
if (response.status === 401 ||
|
|
146
|
+
response.status === 403 ||
|
|
147
|
+
response.status === 404) {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
if (!response.ok) {
|
|
151
|
+
throw new Error(`Search of ${request.model.collection} failed (${response.status}).`);
|
|
152
|
+
}
|
|
153
|
+
const body = await response.json();
|
|
154
|
+
// The generated list routes answer `{ items, count, limit, offset }`; a
|
|
155
|
+
// bare array is accepted too. Any other shape is a model without results.
|
|
156
|
+
try {
|
|
157
|
+
return unwrapListResult(body, request.model.collection).filter((row) => row !== null && typeof row === 'object' && !Array.isArray(row));
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** Run `task` over `inputs` with at most `limit` in flight; keeps input order. */
|
|
164
|
+
async function mapPool(inputs, limit, task) {
|
|
165
|
+
const results = new Array(inputs.length);
|
|
166
|
+
let next = 0;
|
|
167
|
+
const worker = async () => {
|
|
168
|
+
while (next < inputs.length) {
|
|
169
|
+
const index = next;
|
|
170
|
+
next += 1;
|
|
171
|
+
try {
|
|
172
|
+
results[index] = {
|
|
173
|
+
status: 'fulfilled',
|
|
174
|
+
value: await task(inputs[index]),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
catch (reason) {
|
|
178
|
+
results[index] = { status: 'rejected', reason };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
await Promise.all(Array.from({ length: Math.min(limit, inputs.length) }, worker));
|
|
183
|
+
return results;
|
|
184
|
+
}
|
|
185
|
+
function rowTitle(model, row) {
|
|
186
|
+
const fields = [model.titleField, ...TITLE_FIELDS];
|
|
187
|
+
for (const name of fields) {
|
|
188
|
+
if (!name)
|
|
189
|
+
continue;
|
|
190
|
+
const value = row[name];
|
|
191
|
+
if (typeof value === 'string' && value.trim())
|
|
192
|
+
return value.trim();
|
|
193
|
+
if (typeof value === 'number')
|
|
194
|
+
return String(value);
|
|
195
|
+
}
|
|
196
|
+
return typeof row.id === 'string' ? row.id : model.singular;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* The model-driven providers: a `Create` commands provider ("New invoice")
|
|
200
|
+
* and a `Records` provider that searches the models' text fields through the
|
|
201
|
+
* generated REST routes. Models and their eligibility come from the same
|
|
202
|
+
* filters as the manifest-derived navigation.
|
|
203
|
+
*
|
|
204
|
+
* @example
|
|
205
|
+
* ```ts
|
|
206
|
+
* const palette = createCommandPalette({
|
|
207
|
+
* providers: [
|
|
208
|
+
* createNavigationProvider({ nav }),
|
|
209
|
+
* ...createModelProviders({ manifest, pagesBasePath: '/app' }),
|
|
210
|
+
* ],
|
|
211
|
+
* navigate: goto,
|
|
212
|
+
* });
|
|
213
|
+
* ```
|
|
214
|
+
*/
|
|
215
|
+
export function createModelProviders(options) {
|
|
216
|
+
const models = paletteModelsFromManifest(options);
|
|
217
|
+
const pagesBase = options.pagesBasePath ?? '';
|
|
218
|
+
const apiBase = options.apiBasePath ?? '/api/v1';
|
|
219
|
+
const listHref = (model) => options.hrefs?.list
|
|
220
|
+
? options.hrefs.list(model)
|
|
221
|
+
: `${pagesBase}/${model.collection}`;
|
|
222
|
+
const createHref = (model) => options.hrefs?.create
|
|
223
|
+
? options.hrefs.create(model)
|
|
224
|
+
: `${pagesBase}/${model.collection}/new`;
|
|
225
|
+
const recordHref = (model, row) => options.hrefs?.record
|
|
226
|
+
? options.hrefs.record(model, row)
|
|
227
|
+
: typeof row.id === 'string' || typeof row.id === 'number'
|
|
228
|
+
? `${pagesBase}/${model.collection}/${encodeURIComponent(String(row.id))}`
|
|
229
|
+
: null;
|
|
230
|
+
const commands = {
|
|
231
|
+
id: 'models.commands',
|
|
232
|
+
label: options.commandsLabel ?? 'Create',
|
|
233
|
+
order: options.commandsOrder ?? 20,
|
|
234
|
+
items() {
|
|
235
|
+
const items = [];
|
|
236
|
+
for (const model of models) {
|
|
237
|
+
if (model.creatable) {
|
|
238
|
+
const href = createHref(model);
|
|
239
|
+
if (href) {
|
|
240
|
+
items.push({
|
|
241
|
+
id: `create:${model.qualifier}`,
|
|
242
|
+
title: options.createTitle?.(model) ??
|
|
243
|
+
`New ${model.singular.toLowerCase()}`,
|
|
244
|
+
subtitle: model.label,
|
|
245
|
+
keywords: ['create', 'add', model.singular],
|
|
246
|
+
icon: 'add',
|
|
247
|
+
kind: 'create',
|
|
248
|
+
href,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (options.lists) {
|
|
253
|
+
const href = listHref(model);
|
|
254
|
+
if (href) {
|
|
255
|
+
items.push({
|
|
256
|
+
id: `list:${model.qualifier}`,
|
|
257
|
+
title: options.listTitle?.(model) ?? model.label,
|
|
258
|
+
keywords: [model.singular],
|
|
259
|
+
kind: 'navigation',
|
|
260
|
+
href,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return items;
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
const allow = options.searchModels ? new Set(options.searchModels) : null;
|
|
269
|
+
const targets = models
|
|
270
|
+
.filter((model) => model.searchFields.length > 0)
|
|
271
|
+
.filter((model) => !allow ||
|
|
272
|
+
allow.has(model.qualifier) ||
|
|
273
|
+
allow.has(model.entry.className))
|
|
274
|
+
.slice(0, options.maxModels ?? 20);
|
|
275
|
+
const records = {
|
|
276
|
+
id: 'models.records',
|
|
277
|
+
label: options.recordsLabel ?? 'Records',
|
|
278
|
+
order: options.recordsOrder ?? 30,
|
|
279
|
+
minQueryLength: 2,
|
|
280
|
+
async search(query, { signal, limit }) {
|
|
281
|
+
if (targets.length === 0)
|
|
282
|
+
return [];
|
|
283
|
+
const perModel = Math.max(1, options.perModelLimit ?? 3);
|
|
284
|
+
const jobs = targets.flatMap((model) => model.searchFields.map((field) => ({ model, field })));
|
|
285
|
+
const run = options.searchRows
|
|
286
|
+
? options.searchRows
|
|
287
|
+
: (request) => defaultSearchRows(request, {
|
|
288
|
+
fetch: options.fetch,
|
|
289
|
+
apiBasePath: apiBase,
|
|
290
|
+
});
|
|
291
|
+
const settled = await mapPool(jobs, Math.max(1, options.concurrency ?? 4), ({ model, field }) => run({ model, field, query, limit: perModel, signal }));
|
|
292
|
+
if (signal.aborted)
|
|
293
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
294
|
+
if (settled.every((outcome) => outcome.status === 'rejected')) {
|
|
295
|
+
const first = settled[0];
|
|
296
|
+
throw first.reason instanceof Error
|
|
297
|
+
? first.reason
|
|
298
|
+
: new Error('Record search failed.');
|
|
299
|
+
}
|
|
300
|
+
// One list per model (a model with several search fields merges, by id),
|
|
301
|
+
// then interleaved so a chatty model cannot crowd out the rest.
|
|
302
|
+
const perModelItems = new Map();
|
|
303
|
+
jobs.forEach(({ model }, index) => {
|
|
304
|
+
const outcome = settled[index];
|
|
305
|
+
if (outcome.status !== 'fulfilled')
|
|
306
|
+
return;
|
|
307
|
+
const list = perModelItems.get(model) ?? [];
|
|
308
|
+
for (const row of outcome.value) {
|
|
309
|
+
const id = row.id;
|
|
310
|
+
if (typeof id !== 'string' && typeof id !== 'number')
|
|
311
|
+
continue;
|
|
312
|
+
const key = `${model.qualifier}:${id}`;
|
|
313
|
+
if (list.some((item) => item.id === key))
|
|
314
|
+
continue;
|
|
315
|
+
const href = recordHref(model, row);
|
|
316
|
+
if (!href)
|
|
317
|
+
continue;
|
|
318
|
+
list.push({
|
|
319
|
+
id: key,
|
|
320
|
+
title: rowTitle(model, row),
|
|
321
|
+
subtitle: model.singular,
|
|
322
|
+
kind: 'record',
|
|
323
|
+
href,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
perModelItems.set(model, list);
|
|
327
|
+
});
|
|
328
|
+
const lists = [...perModelItems.values()].map((list) => list.slice(0, perModel));
|
|
329
|
+
const merged = [];
|
|
330
|
+
for (let round = 0; merged.length < limit; round += 1) {
|
|
331
|
+
let added = false;
|
|
332
|
+
for (const list of lists) {
|
|
333
|
+
if (round < list.length && merged.length < limit) {
|
|
334
|
+
merged.push(list[round]);
|
|
335
|
+
added = true;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (!added)
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
return merged;
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
return [commands, records];
|
|
345
|
+
}
|