+173
frontend-v2/src/__generated__/PublishedLexiconsListQuery.graphql.ts
+173
frontend-v2/src/__generated__/PublishedLexiconsListQuery.graphql.ts
···
1
+
/**
2
+
* @generated SignedSource<<af4ff104aee13ec3df07677ea9ef9ce7>>
3
+
* @lightSyntaxTransform
4
+
* @nogrep
5
+
*/
6
+
7
+
/* tslint:disable */
8
+
/* eslint-disable */
9
+
// @ts-nocheck
10
+
11
+
import { ConcreteRequest } from 'relay-runtime';
12
+
export type SliceRecordsWhereInput = {
13
+
cid?: StringFilter | null | undefined;
14
+
collection?: StringFilter | null | undefined;
15
+
did?: StringFilter | null | undefined;
16
+
indexedAt?: DateTimeFilter | null | undefined;
17
+
json?: StringFilter | null | undefined;
18
+
or?: ReadonlyArray<SliceRecordsWhereInput | null | undefined> | null | undefined;
19
+
uri?: StringFilter | null | undefined;
20
+
};
21
+
export type StringFilter = {
22
+
contains?: string | null | undefined;
23
+
eq?: string | null | undefined;
24
+
fuzzy?: string | null | undefined;
25
+
gt?: string | null | undefined;
26
+
gte?: string | null | undefined;
27
+
in?: ReadonlyArray<string | null | undefined> | null | undefined;
28
+
lt?: string | null | undefined;
29
+
lte?: string | null | undefined;
30
+
};
31
+
export type DateTimeFilter = {
32
+
eq?: string | null | undefined;
33
+
gt?: string | null | undefined;
34
+
gte?: string | null | undefined;
35
+
lt?: string | null | undefined;
36
+
lte?: string | null | undefined;
37
+
};
38
+
export type PublishedLexiconsListQuery$variables = {
39
+
sliceUri: string;
40
+
where?: SliceRecordsWhereInput | null | undefined;
41
+
};
42
+
export type PublishedLexiconsListQuery$data = {
43
+
readonly sliceRecords: {
44
+
readonly edges: ReadonlyArray<{
45
+
readonly node: {
46
+
readonly collection: string;
47
+
readonly uri: string;
48
+
readonly value: string;
49
+
};
50
+
}>;
51
+
};
52
+
};
53
+
export type PublishedLexiconsListQuery = {
54
+
response: PublishedLexiconsListQuery$data;
55
+
variables: PublishedLexiconsListQuery$variables;
56
+
};
57
+
58
+
const node: ConcreteRequest = (function(){
59
+
var v0 = [
60
+
{
61
+
"defaultValue": null,
62
+
"kind": "LocalArgument",
63
+
"name": "sliceUri"
64
+
},
65
+
{
66
+
"defaultValue": null,
67
+
"kind": "LocalArgument",
68
+
"name": "where"
69
+
}
70
+
],
71
+
v1 = [
72
+
{
73
+
"alias": null,
74
+
"args": [
75
+
{
76
+
"kind": "Literal",
77
+
"name": "first",
78
+
"value": 1000
79
+
},
80
+
{
81
+
"kind": "Variable",
82
+
"name": "sliceUri",
83
+
"variableName": "sliceUri"
84
+
},
85
+
{
86
+
"kind": "Variable",
87
+
"name": "where",
88
+
"variableName": "where"
89
+
}
90
+
],
91
+
"concreteType": "SliceRecordsConnection",
92
+
"kind": "LinkedField",
93
+
"name": "sliceRecords",
94
+
"plural": false,
95
+
"selections": [
96
+
{
97
+
"alias": null,
98
+
"args": null,
99
+
"concreteType": "SliceRecordEdge",
100
+
"kind": "LinkedField",
101
+
"name": "edges",
102
+
"plural": true,
103
+
"selections": [
104
+
{
105
+
"alias": null,
106
+
"args": null,
107
+
"concreteType": "SliceRecord",
108
+
"kind": "LinkedField",
109
+
"name": "node",
110
+
"plural": false,
111
+
"selections": [
112
+
{
113
+
"alias": null,
114
+
"args": null,
115
+
"kind": "ScalarField",
116
+
"name": "uri",
117
+
"storageKey": null
118
+
},
119
+
{
120
+
"alias": null,
121
+
"args": null,
122
+
"kind": "ScalarField",
123
+
"name": "collection",
124
+
"storageKey": null
125
+
},
126
+
{
127
+
"alias": null,
128
+
"args": null,
129
+
"kind": "ScalarField",
130
+
"name": "value",
131
+
"storageKey": null
132
+
}
133
+
],
134
+
"storageKey": null
135
+
}
136
+
],
137
+
"storageKey": null
138
+
}
139
+
],
140
+
"storageKey": null
141
+
}
142
+
];
143
+
return {
144
+
"fragment": {
145
+
"argumentDefinitions": (v0/*: any*/),
146
+
"kind": "Fragment",
147
+
"metadata": null,
148
+
"name": "PublishedLexiconsListQuery",
149
+
"selections": (v1/*: any*/),
150
+
"type": "Query",
151
+
"abstractKey": null
152
+
},
153
+
"kind": "Request",
154
+
"operation": {
155
+
"argumentDefinitions": (v0/*: any*/),
156
+
"kind": "Operation",
157
+
"name": "PublishedLexiconsListQuery",
158
+
"selections": (v1/*: any*/)
159
+
},
160
+
"params": {
161
+
"cacheID": "9376c8881ba959a67c32afef675e6baa",
162
+
"id": null,
163
+
"metadata": {},
164
+
"name": "PublishedLexiconsListQuery",
165
+
"operationKind": "query",
166
+
"text": "query PublishedLexiconsListQuery(\n $sliceUri: String!\n $where: SliceRecordsWhereInput\n) {\n sliceRecords(sliceUri: $sliceUri, first: 1000, where: $where) {\n edges {\n node {\n uri\n collection\n value\n }\n }\n }\n}\n"
167
+
}
168
+
};
169
+
})();
170
+
171
+
(node as any).hash = "c51dfb1274d633dc4db99d903954a58e";
172
+
173
+
export default node;
+183
-38
frontend-v2/src/components/CreateLexiconDialog.tsx
+183
-38
frontend-v2/src/components/CreateLexiconDialog.tsx
···
5
5
import { FormControl } from "./FormControl.tsx";
6
6
import { Textarea } from "./Textarea.tsx";
7
7
import { Button } from "./Button.tsx";
8
+
import { PublishedLexiconsList } from "./PublishedLexiconsList.tsx";
8
9
import type { CreateLexiconDialogMutation } from "../__generated__/CreateLexiconDialogMutation.graphql.ts";
9
10
import "../components/LexiconTree.tsx"; // Import for fragment
10
11
···
14
15
sliceUri: string;
15
16
existingNsids: string[];
16
17
}
18
+
19
+
type SourceType = 'published' | 'new' | null;
17
20
18
21
export function CreateLexiconDialog({
19
22
open,
···
21
24
sliceUri,
22
25
existingNsids,
23
26
}: CreateLexiconDialogProps) {
27
+
const [step, setStep] = useState<1 | 2>(1);
28
+
const [sourceType, setSourceType] = useState<SourceType>(null);
24
29
const [lexiconJson, setLexiconJson] = useState("");
25
30
const [error, setError] = useState("");
26
31
const [isValidating, setIsValidating] = useState(false);
···
161
166
if (isValidating) {
162
167
return; // Prevent closing while validation is in progress
163
168
}
169
+
setStep(1);
170
+
setSourceType(null);
164
171
setLexiconJson("");
165
172
setError("");
166
173
setIsValidating(false);
167
174
onClose();
168
175
};
169
176
177
+
const handleSourceSelect = (type: SourceType) => {
178
+
setSourceType(type);
179
+
setStep(2);
180
+
setError("");
181
+
};
182
+
183
+
const handleBack = () => {
184
+
setStep(1);
185
+
setSourceType(null);
186
+
setLexiconJson("");
187
+
setError("");
188
+
};
189
+
170
190
return (
171
191
<Dialog
172
192
open={open}
173
193
onClose={handleClose}
174
-
title="Add Lexicon Definition"
194
+
title={step === 1 ? "Add Lexicon Definition" : sourceType === 'published' ? "Select Published Lexicon" : "Create New Lexicon"}
175
195
maxWidth="xl"
176
196
>
177
197
{error && (
···
180
200
</div>
181
201
)}
182
202
183
-
<form className="space-y-4">
184
-
<FormControl label="Lexicon JSON">
185
-
<Textarea
186
-
value={lexiconJson}
187
-
onChange={(e) => setLexiconJson(e.target.value)}
188
-
rows={16}
189
-
className="font-mono"
190
-
placeholder={`{
203
+
{step === 1 ? (
204
+
<div className="space-y-4">
205
+
<p className="text-sm text-zinc-400 mb-4">
206
+
Choose how you'd like to add a lexicon:
207
+
</p>
208
+
209
+
<div className="space-y-3">
210
+
<button
211
+
type="button"
212
+
onClick={() => handleSourceSelect('published')}
213
+
className="w-full text-left p-4 bg-zinc-900/50 hover:bg-zinc-800/50 border border-zinc-800 hover:border-zinc-700 rounded transition-colors"
214
+
>
215
+
<h3 className="text-sm font-medium text-zinc-200 mb-1">
216
+
Add from Published Lexicons
217
+
</h3>
218
+
<p className="text-xs text-zinc-500">
219
+
Browse and select from community-published AT Protocol lexicons
220
+
</p>
221
+
</button>
222
+
223
+
<button
224
+
type="button"
225
+
onClick={() => handleSourceSelect('new')}
226
+
className="w-full text-left p-4 bg-zinc-900/50 hover:bg-zinc-800/50 border border-zinc-800 hover:border-zinc-700 rounded transition-colors"
227
+
>
228
+
<h3 className="text-sm font-medium text-zinc-200 mb-1">
229
+
Create New Lexicon
230
+
</h3>
231
+
<p className="text-xs text-zinc-500">
232
+
Write a custom lexicon definition from scratch
233
+
</p>
234
+
</button>
235
+
</div>
236
+
237
+
<div className="flex justify-end gap-3 pt-4">
238
+
<Button
239
+
type="button"
240
+
variant="default"
241
+
onClick={handleClose}
242
+
>
243
+
Cancel
244
+
</Button>
245
+
</div>
246
+
</div>
247
+
) : sourceType === 'new' ? (
248
+
<form className="space-y-4">
249
+
<FormControl label="Lexicon JSON">
250
+
<Textarea
251
+
value={lexiconJson}
252
+
onChange={(e) => setLexiconJson(e.target.value)}
253
+
rows={16}
254
+
className="font-mono"
255
+
placeholder={`{
191
256
"lexicon": 1,
192
257
"id": "network.slices.example",
193
258
"description": "Example record type",
···
212
277
}
213
278
}
214
279
}`}
215
-
disabled={isMutationInFlight}
216
-
/>
217
-
<p className="mt-1 text-xs text-zinc-500">
218
-
Paste a valid AT Protocol lexicon definition in JSON format
219
-
</p>
220
-
</FormControl>
280
+
disabled={isMutationInFlight}
281
+
/>
282
+
<p className="mt-1 text-xs text-zinc-500">
283
+
Paste a valid AT Protocol lexicon definition in JSON format
284
+
</p>
285
+
</FormControl>
286
+
287
+
<div className="flex justify-between gap-3 pt-4">
288
+
<Button
289
+
type="button"
290
+
variant="default"
291
+
onClick={handleBack}
292
+
disabled={isMutationInFlight}
293
+
>
294
+
Back
295
+
</Button>
296
+
<div className="flex gap-3">
297
+
<Button
298
+
type="button"
299
+
variant="default"
300
+
onClick={handleClose}
301
+
disabled={isMutationInFlight}
302
+
>
303
+
Cancel
304
+
</Button>
305
+
<Button
306
+
type="button"
307
+
variant="primary"
308
+
onClick={(e) => {
309
+
e.preventDefault();
310
+
e.stopPropagation();
311
+
handleSubmit(e);
312
+
}}
313
+
disabled={isMutationInFlight || isValidating}
314
+
>
315
+
{isMutationInFlight ? "Adding..." : "Add Lexicon"}
316
+
</Button>
317
+
</div>
318
+
</div>
319
+
</form>
320
+
) : (
321
+
<PublishedLexiconsList
322
+
existingNsids={existingNsids}
323
+
onSelect={(lexicons) => {
324
+
// Add all lexicons directly without going to JSON editor
325
+
lexicons.forEach((lexicon) => {
326
+
const lexiconData = lexicon.data as Record<string, unknown>;
327
+
const defs = lexiconData.defs || lexiconData.definitions;
328
+
const nsid = lexicon.nsid;
329
+
const definitionsString = JSON.stringify(defs);
221
330
222
-
<div className="flex justify-end gap-3 pt-4">
223
-
<Button
224
-
type="button"
225
-
variant="default"
226
-
onClick={handleClose}
227
-
disabled={isMutationInFlight}
228
-
>
229
-
Cancel
230
-
</Button>
231
-
<Button
232
-
type="button"
233
-
variant="primary"
234
-
onClick={(e) => {
235
-
e.preventDefault();
236
-
e.stopPropagation();
237
-
handleSubmit(e);
238
-
}}
239
-
disabled={isMutationInFlight || isValidating}
240
-
>
241
-
{isMutationInFlight ? "Adding..." : "Add Lexicon"}
242
-
</Button>
243
-
</div>
244
-
</form>
331
+
commitMutation({
332
+
variables: {
333
+
input: {
334
+
nsid,
335
+
description: (lexiconData.description as string) || "",
336
+
definitions: definitionsString,
337
+
slice: sliceUri,
338
+
createdAt: new Date().toISOString(),
339
+
excludedFromSync: false,
340
+
},
341
+
},
342
+
onCompleted: () => {
343
+
// Only close dialog after all mutations complete
344
+
// (This will be called for each lexicon)
345
+
},
346
+
onError: (err) => {
347
+
setError(err.message || "Failed to create lexicon");
348
+
},
349
+
updater: (store) => {
350
+
const newLexicon = store.getRootField("createNetworkSlicesLexicon");
351
+
if (!newLexicon) return;
352
+
353
+
// Extract the rkey from the slice URI (e.g., "at://did/collection/rkey" -> "rkey")
354
+
const sliceRkey = sliceUri.split("/").pop();
355
+
if (!sliceRkey) return;
356
+
357
+
// Use ConnectionHandler to get the connection
358
+
const root = store.getRoot();
359
+
const connection = ConnectionHandler.getConnection(
360
+
root,
361
+
"SliceOverview_networkSlicesLexicons",
362
+
{
363
+
where: {
364
+
slice: { contains: sliceRkey }
365
+
}
366
+
}
367
+
);
368
+
369
+
if (connection) {
370
+
// Create and insert a new edge
371
+
const newEdge = ConnectionHandler.createEdge(
372
+
store,
373
+
connection,
374
+
newLexicon,
375
+
"NetworkSlicesLexiconEdge"
376
+
);
377
+
ConnectionHandler.insertEdgeAfter(connection, newEdge);
378
+
}
379
+
},
380
+
});
381
+
});
382
+
383
+
// Close dialog after submitting all mutations
384
+
handleClose();
385
+
}}
386
+
onBack={handleBack}
387
+
onCancel={handleClose}
388
+
/>
389
+
)}
245
390
</Dialog>
246
391
);
247
392
}
+74
frontend-v2/src/components/LexiconDependencyConfirmationDialog.tsx
+74
frontend-v2/src/components/LexiconDependencyConfirmationDialog.tsx
···
1
+
import { Dialog } from "./Dialog.tsx";
2
+
import { Button } from "./Button.tsx";
3
+
4
+
interface LexiconDependencyConfirmationDialogProps {
5
+
open: boolean;
6
+
mainLexiconNsid: string;
7
+
dependencies: string[];
8
+
onConfirm: () => void;
9
+
onCancel: () => void;
10
+
}
11
+
12
+
export function LexiconDependencyConfirmationDialog({
13
+
open,
14
+
mainLexiconNsid,
15
+
dependencies,
16
+
onConfirm,
17
+
onCancel,
18
+
}: LexiconDependencyConfirmationDialogProps) {
19
+
const totalCount = 1 + dependencies.length;
20
+
21
+
return (
22
+
<Dialog
23
+
open={open}
24
+
onClose={onCancel}
25
+
title="Add Lexicon with Dependencies"
26
+
maxWidth="md"
27
+
>
28
+
<div className="space-y-4">
29
+
<p className="text-sm text-zinc-400">
30
+
This lexicon requires {dependencies.length} {dependencies.length === 1 ? "dependency" : "dependencies"}.
31
+
All {totalCount} lexicons will be added to your slice.
32
+
</p>
33
+
34
+
<div className="space-y-3">
35
+
<div>
36
+
<h3 className="text-xs font-medium text-zinc-500 uppercase tracking-wider mb-2">
37
+
Selected Lexicon
38
+
</h3>
39
+
<div className="font-mono text-sm text-zinc-200">
40
+
{mainLexiconNsid}
41
+
</div>
42
+
</div>
43
+
44
+
{dependencies.length > 0 && (
45
+
<div>
46
+
<h3 className="text-xs font-medium text-zinc-500 uppercase tracking-wider mb-2">
47
+
Dependencies ({dependencies.length})
48
+
</h3>
49
+
<div className="space-y-1">
50
+
{dependencies.map((nsid) => (
51
+
<div
52
+
key={nsid}
53
+
className="font-mono text-sm text-zinc-400 pl-4"
54
+
>
55
+
{nsid}
56
+
</div>
57
+
))}
58
+
</div>
59
+
</div>
60
+
)}
61
+
</div>
62
+
63
+
<div className="flex justify-end gap-3 pt-4">
64
+
<Button type="button" variant="default" onClick={onCancel}>
65
+
Cancel
66
+
</Button>
67
+
<Button type="button" variant="primary" onClick={onConfirm}>
68
+
Add All ({totalCount})
69
+
</Button>
70
+
</div>
71
+
</div>
72
+
</Dialog>
73
+
);
74
+
}
+266
frontend-v2/src/components/PublishedLexiconsList.tsx
+266
frontend-v2/src/components/PublishedLexiconsList.tsx
···
1
+
import { useState } from "react";
2
+
import { graphql, useLazyLoadQuery } from "react-relay";
3
+
import { FormControl } from "./FormControl.tsx";
4
+
import { Input } from "./Input.tsx";
5
+
import { Button } from "./Button.tsx";
6
+
import { LexiconDependencyConfirmationDialog } from "./LexiconDependencyConfirmationDialog.tsx";
7
+
import { resolveDependencies } from "../utils/lexiconDependencies.ts";
8
+
import type { PublishedLexiconsListQuery } from "../__generated__/PublishedLexiconsListQuery.graphql.ts";
9
+
10
+
interface PublishedLexicon {
11
+
uri: string;
12
+
nsid: string;
13
+
description?: string;
14
+
defs: unknown;
15
+
fullData: unknown;
16
+
}
17
+
18
+
interface LexiconWithData {
19
+
nsid: string;
20
+
data: unknown;
21
+
}
22
+
23
+
interface PublishedLexiconsListProps {
24
+
existingNsids: string[];
25
+
onSelect: (lexicons: LexiconWithData[]) => void;
26
+
onBack: () => void;
27
+
onCancel: () => void;
28
+
}
29
+
30
+
const PUBLISHED_LEXICONS_SLICE_URI = "at://did:plc:dzmqinfp7efnofbqg5npjmth/network.slices.slice/3m3fsrppc3p2h";
31
+
32
+
export function PublishedLexiconsList({
33
+
existingNsids,
34
+
onSelect,
35
+
onBack,
36
+
onCancel,
37
+
}: PublishedLexiconsListProps) {
38
+
const [searchQuery, setSearchQuery] = useState("");
39
+
const [showDepsDialog, setShowDepsDialog] = useState(false);
40
+
const [selectedLexicon, setSelectedLexicon] = useState<LexiconWithData | null>(null);
41
+
const [resolvedDeps, setResolvedDeps] = useState<LexiconWithData[]>([]);
42
+
43
+
const data = useLazyLoadQuery<PublishedLexiconsListQuery>(
44
+
graphql`
45
+
query PublishedLexiconsListQuery(
46
+
$sliceUri: String!
47
+
$where: SliceRecordsWhereInput
48
+
) {
49
+
sliceRecords(sliceUri: $sliceUri, first: 1000, where: $where) {
50
+
edges {
51
+
node {
52
+
uri
53
+
collection
54
+
value
55
+
}
56
+
}
57
+
}
58
+
}
59
+
`,
60
+
{
61
+
sliceUri: PUBLISHED_LEXICONS_SLICE_URI,
62
+
where: {
63
+
collection: { eq: "com.atproto.lexicon.schema" },
64
+
},
65
+
},
66
+
{
67
+
fetchPolicy: "store-and-network",
68
+
}
69
+
);
70
+
71
+
// Parse and filter published lexicons
72
+
const publishedLexicons = data.sliceRecords.edges
73
+
.map((edge) => {
74
+
try {
75
+
const lexiconData = JSON.parse(edge.node.value);
76
+
const nsid = lexiconData.id || lexiconData.nsid;
77
+
const defs = lexiconData.defs || lexiconData.definitions;
78
+
79
+
if (!nsid || !defs) return null;
80
+
81
+
return {
82
+
uri: edge.node.uri,
83
+
nsid,
84
+
description: lexiconData.description,
85
+
defs,
86
+
fullData: lexiconData,
87
+
} as PublishedLexicon;
88
+
} catch {
89
+
return null;
90
+
}
91
+
})
92
+
.filter((lex): lex is PublishedLexicon => lex !== null);
93
+
94
+
// Filter by search query
95
+
const filteredLexicons = publishedLexicons.filter((lex) => {
96
+
if (!searchQuery) return true;
97
+
const query = searchQuery.toLowerCase();
98
+
return (
99
+
lex.nsid.toLowerCase().includes(query) ||
100
+
lex.description?.toLowerCase().includes(query)
101
+
);
102
+
});
103
+
104
+
// Check if lexicon already exists in slice
105
+
const isAlreadyAdded = (nsid: string) => existingNsids.includes(nsid);
106
+
107
+
// Handle lexicon selection with dependency resolution
108
+
const handleLexiconClick = (lexicon: PublishedLexicon) => {
109
+
if (isAlreadyAdded(lexicon.nsid)) return;
110
+
111
+
// Convert to LexiconWithData format
112
+
const mainLexicon: LexiconWithData = {
113
+
nsid: lexicon.nsid,
114
+
data: lexicon.fullData,
115
+
};
116
+
117
+
// Convert all published lexicons to LexiconWithData format
118
+
const allLexicons: LexiconWithData[] = publishedLexicons.map(lex => ({
119
+
nsid: lex.nsid,
120
+
data: lex.fullData,
121
+
}));
122
+
123
+
// Resolve dependencies
124
+
const dependencies = resolveDependencies(mainLexicon, allLexicons, existingNsids);
125
+
126
+
// If there are dependencies, show confirmation dialog
127
+
if (dependencies.length > 0) {
128
+
setSelectedLexicon(mainLexicon);
129
+
setResolvedDeps(dependencies);
130
+
setShowDepsDialog(true);
131
+
} else {
132
+
// No dependencies, add directly
133
+
onSelect([mainLexicon]);
134
+
}
135
+
};
136
+
137
+
// Handle confirmation dialog confirmation
138
+
const handleConfirmDeps = () => {
139
+
if (selectedLexicon) {
140
+
onSelect([selectedLexicon, ...resolvedDeps]);
141
+
}
142
+
setShowDepsDialog(false);
143
+
setSelectedLexicon(null);
144
+
setResolvedDeps([]);
145
+
};
146
+
147
+
// Handle confirmation dialog cancellation
148
+
const handleCancelDeps = () => {
149
+
setShowDepsDialog(false);
150
+
setSelectedLexicon(null);
151
+
setResolvedDeps([]);
152
+
};
153
+
154
+
return (
155
+
<div className="space-y-4">
156
+
<FormControl label="Search Lexicons" htmlFor="search">
157
+
<Input
158
+
id="search"
159
+
type="text"
160
+
value={searchQuery}
161
+
onChange={(e) => setSearchQuery(e.target.value)}
162
+
placeholder="Filter by NSID or description..."
163
+
/>
164
+
</FormControl>
165
+
166
+
<div className="h-96 overflow-y-auto">
167
+
{filteredLexicons.length === 0 ? (
168
+
<div className="text-center py-8 text-sm text-zinc-500">
169
+
{searchQuery ? "No lexicons match your search" : "No published lexicons found"}
170
+
</div>
171
+
) : (
172
+
filteredLexicons.map((lexicon) => {
173
+
const alreadyAdded = isAlreadyAdded(lexicon.nsid);
174
+
const parts = lexicon.nsid.split(".");
175
+
const authority = parts.length >= 2 ? `${parts[0]}.${parts[1]}` : parts[0];
176
+
const rest = parts.length >= 2 ? parts.slice(2).join(".") : "";
177
+
178
+
// Check if this is a record type lexicon
179
+
let isRecordType = false;
180
+
try {
181
+
const defs = lexicon.defs as Record<string, { type?: string }> | undefined;
182
+
isRecordType = defs?.main?.type === "record";
183
+
} catch {
184
+
// ignore
185
+
}
186
+
187
+
// Split the rest into middle and last part if it's a record type
188
+
let middle = rest;
189
+
let lastPart = "";
190
+
if (isRecordType && rest) {
191
+
const restParts = rest.split(".");
192
+
if (restParts.length > 1) {
193
+
lastPart = restParts[restParts.length - 1];
194
+
middle = restParts.slice(0, -1).join(".");
195
+
} else {
196
+
lastPart = rest;
197
+
middle = "";
198
+
}
199
+
}
200
+
201
+
return (
202
+
<button
203
+
key={lexicon.uri}
204
+
type="button"
205
+
onClick={() => handleLexiconClick(lexicon)}
206
+
disabled={alreadyAdded}
207
+
className={`w-full text-left py-1 rounded group transition-colors ${
208
+
alreadyAdded
209
+
? "opacity-50 cursor-not-allowed"
210
+
: "hover:bg-zinc-900/50 cursor-pointer"
211
+
}`}
212
+
>
213
+
<div className="flex items-center gap-2">
214
+
<span className="text-sm font-medium font-mono">
215
+
<span className="text-zinc-200">{authority}</span>
216
+
{isRecordType ? (
217
+
<>
218
+
{middle && <span className="text-zinc-400">.{middle}</span>}
219
+
{lastPart && (
220
+
<>
221
+
<span className="text-zinc-400">.</span>
222
+
<span className="text-cyan-400">{lastPart}</span>
223
+
</>
224
+
)}
225
+
</>
226
+
) : (
227
+
rest && <span className="text-zinc-400">.{rest}</span>
228
+
)}
229
+
</span>
230
+
{alreadyAdded && (
231
+
<span className="text-xs text-zinc-600">
232
+
(added)
233
+
</span>
234
+
)}
235
+
{lexicon.description && (
236
+
<span className="text-xs text-zinc-600 truncate">
237
+
{lexicon.description}
238
+
</span>
239
+
)}
240
+
</div>
241
+
</button>
242
+
);
243
+
})
244
+
)}
245
+
</div>
246
+
247
+
<div className="flex justify-between gap-3 pt-4">
248
+
<Button type="button" variant="default" onClick={onBack}>
249
+
Back
250
+
</Button>
251
+
<Button type="button" variant="default" onClick={onCancel}>
252
+
Cancel
253
+
</Button>
254
+
</div>
255
+
256
+
{/* Dependency confirmation dialog */}
257
+
<LexiconDependencyConfirmationDialog
258
+
open={showDepsDialog}
259
+
mainLexiconNsid={selectedLexicon?.nsid || ""}
260
+
dependencies={resolvedDeps.map(dep => dep.nsid)}
261
+
onConfirm={handleConfirmDeps}
262
+
onCancel={handleCancelDeps}
263
+
/>
264
+
</div>
265
+
);
266
+
}
+5
-2
frontend-v2/src/generateChartData.ts
+5
-2
frontend-v2/src/generateChartData.ts
···
4
4
}
5
5
6
6
export function generateChartData(
7
-
plays: readonly { readonly playedTime?: string | null; readonly [key: string]: any }[],
8
-
days = 90
7
+
plays: readonly {
8
+
readonly playedTime?: string | null;
9
+
readonly [key: string]: unknown;
10
+
}[],
11
+
days = 90,
9
12
): DataPoint[] {
10
13
const counts = new Map<string, number>();
11
14
const now = new Date();
+106
frontend-v2/src/utils/lexiconDependencies.ts
+106
frontend-v2/src/utils/lexiconDependencies.ts
···
1
+
/**
2
+
* Extracts all external lexicon references from a lexicon's defs
3
+
* Returns unique NSIDs (without #defName fragments)
4
+
*/
5
+
export function extractExternalRefs(defs: unknown): string[] {
6
+
const refs = new Set<string>();
7
+
8
+
function traverse(obj: unknown): void {
9
+
if (Array.isArray(obj)) {
10
+
obj.forEach(traverse);
11
+
} else if (obj !== null && typeof obj === "object") {
12
+
const objRecord = obj as Record<string, unknown>;
13
+
14
+
// Check if this is a ref object (single ref)
15
+
if (objRecord.type === "ref" && typeof objRecord.ref === "string") {
16
+
const ref = objRecord.ref;
17
+
18
+
// Only include external refs (not starting with #)
19
+
if (!ref.startsWith("#")) {
20
+
// Strip #defName suffix if present
21
+
const nsid = ref.split("#")[0];
22
+
refs.add(nsid);
23
+
}
24
+
}
25
+
26
+
// Check if this is a union type with multiple refs
27
+
if (objRecord.type === "union" && Array.isArray(objRecord.refs)) {
28
+
for (const ref of objRecord.refs) {
29
+
if (typeof ref === "string" && !ref.startsWith("#")) {
30
+
// Strip #defName suffix if present
31
+
const nsid = ref.split("#")[0];
32
+
refs.add(nsid);
33
+
}
34
+
}
35
+
}
36
+
37
+
// Recursively traverse all properties
38
+
for (const value of Object.values(objRecord)) {
39
+
traverse(value);
40
+
}
41
+
}
42
+
}
43
+
44
+
traverse(defs);
45
+
return Array.from(refs);
46
+
}
47
+
48
+
interface LexiconWithData {
49
+
nsid: string;
50
+
data: unknown;
51
+
}
52
+
53
+
/**
54
+
* Resolves all transitive dependencies for a lexicon
55
+
* Uses BFS to find all required lexicons, avoiding circular references
56
+
*/
57
+
export function resolveDependencies(
58
+
mainLexicon: LexiconWithData,
59
+
allAvailableLexicons: LexiconWithData[],
60
+
existingNsids: string[]
61
+
): LexiconWithData[] {
62
+
const dependencies: LexiconWithData[] = [];
63
+
const visited = new Set<string>();
64
+
const queue: LexiconWithData[] = [mainLexicon];
65
+
66
+
// Create a map for quick lookups
67
+
const lexiconMap = new Map<string, LexiconWithData>();
68
+
for (const lex of allAvailableLexicons) {
69
+
lexiconMap.set(lex.nsid, lex);
70
+
}
71
+
72
+
while (queue.length > 0) {
73
+
const current = queue.shift()!;
74
+
75
+
// Skip if already visited or already in user's slice
76
+
if (visited.has(current.nsid) || existingNsids.includes(current.nsid)) {
77
+
continue;
78
+
}
79
+
80
+
visited.add(current.nsid);
81
+
82
+
// Add to dependencies (except for the main lexicon)
83
+
if (current.nsid !== mainLexicon.nsid) {
84
+
dependencies.push(current);
85
+
}
86
+
87
+
// Extract refs from this lexicon's defs
88
+
try {
89
+
const lexData = current.data as Record<string, unknown>;
90
+
const defs = lexData.defs || lexData.definitions;
91
+
const refs = extractExternalRefs(defs);
92
+
93
+
// Queue up any dependencies we haven't visited yet
94
+
for (const refNsid of refs) {
95
+
if (!visited.has(refNsid) && lexiconMap.has(refNsid)) {
96
+
queue.push(lexiconMap.get(refNsid)!);
97
+
}
98
+
}
99
+
} catch {
100
+
// If we can't parse the lexicon, skip it
101
+
continue;
102
+
}
103
+
}
104
+
105
+
return dependencies;
106
+
}