Highly ambitious ATProtocol AppView service and sdks

add published lexicons flow when adding a lexicon

+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
··· 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
··· 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
··· 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
··· 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
··· 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 + }