diff --git a/src/lib/components/dialogs/ToolboxManagerDialog.svelte b/src/lib/components/dialogs/ToolboxManagerDialog.svelte index 25122af6..18f2ae8c 100644 --- a/src/lib/components/dialogs/ToolboxManagerDialog.svelte +++ b/src/lib/components/dialogs/ToolboxManagerDialog.svelte @@ -13,6 +13,7 @@ upsertToolbox, removeToolbox, toolboxes, + toolboxSourceKey, type CatalogEntry, type ToolboxConfig, type ToolboxSource, @@ -162,7 +163,7 @@ resolvedImportPath = (pypiImportPath.trim() || pkg).replace(/-/g, '_'); resolvedDisplayName = displayNameInput.trim() || pkg; resolvedEventsImportPath = eventsImportPathInput.trim() || undefined; - toolboxId = `pypi:${pkg}`; + toolboxId = toolboxSourceKey(resolvedSource); categoryByClass = {}; defaultCategory = undefined; step = 'trust'; @@ -174,7 +175,7 @@ resolvedImportPath = urlImportPath.trim(); resolvedDisplayName = displayNameInput.trim() || urlImportPath.trim(); resolvedEventsImportPath = eventsImportPathInput.trim() || undefined; - toolboxId = `url:${urlValue.trim()}`; + toolboxId = toolboxSourceKey(resolvedSource); categoryByClass = {}; defaultCategory = undefined; step = 'trust'; @@ -194,7 +195,9 @@ resolvedImportPath = ''; resolvedDisplayName = displayNameInput.trim() || fileName.replace(/\.py$/, ''); resolvedEventsImportPath = undefined; - toolboxId = `inline:${fileName}`; + // Content-addressed: keying the id on the code (not the filename) + // keeps two different uploads with the same name distinct. + toolboxId = toolboxSourceKey(resolvedSource); categoryByClass = {}; defaultCategory = undefined; step = 'trust'; @@ -326,10 +329,12 @@ onClose(); } - // Catalog entries that aren't already installed + // Catalog entries that aren't already installed. Matched on source + // identity, not id, so a catalog entry installed via the PyPI tab still + // counts as installed. const availableCatalog = $derived.by(() => { - const installedIds = new Set(installed.map((t) => t.id)); - return TOOLBOX_CATALOG.filter((e) => !installedIds.has(e.id)); + const installedKeys = new Set(installed.map((t) => toolboxSourceKey(t.source))); + return TOOLBOX_CATALOG.filter((e) => !installedKeys.has(toolboxSourceKey(e.source))); }); // Dot index across the add-toolbox flow (manager view = no progress dots) diff --git a/src/lib/schema/componentOps.ts b/src/lib/schema/componentOps.ts index 154187ee..92393689 100644 --- a/src/lib/schema/componentOps.ts +++ b/src/lib/schema/componentOps.ts @@ -11,6 +11,7 @@ import { COMPONENT_VERSION } from '$lib/types/component'; import { graphStore } from '$lib/stores/graph'; import { NODE_TYPES } from '$lib/constants/nodeTypes'; import { downloadJson } from '$lib/utils/download'; +import { collectRequiredToolboxes } from '$lib/toolbox'; import { cleanNodeForExport } from './cleanParams'; import { hasFileSystemAccess } from './fileOps'; @@ -25,6 +26,10 @@ export function createBlockFile(node: NodeInstance): ComponentFile { // Remove graph property for blocks (only subsystems have graphs) delete cleanedNode.graph; + // Record the toolbox this block needs (empty for builtin blocks) so a + // direct .blk import can resolve the dependency instead of hard-failing. + const requiredToolboxes = collectRequiredToolboxes([cleanedNode]); + return { version: COMPONENT_VERSION, type: 'block', @@ -34,7 +39,8 @@ export function createBlockFile(node: NodeInstance): ComponentFile { modified: new Date().toISOString() }, content: { - node: cleanedNode + node: cleanedNode, + ...(requiredToolboxes.length > 0 ? { requiredToolboxes } : {}) } as BlockContent }; } @@ -51,6 +57,10 @@ export function createSubsystemFile(node: NodeInstance): ComponentFile { const clonedNode = structuredClone(node); const cleanedNode = cleanNodeForExport(clonedNode); + // Walk the nested graph for any toolbox blocks (collectRequiredToolboxes + // recurses into subsystem graphs) so a direct .sub import can resolve them. + const requiredToolboxes = collectRequiredToolboxes([cleanedNode]); + return { version: COMPONENT_VERSION, type: 'subsystem', @@ -60,7 +70,8 @@ export function createSubsystemFile(node: NodeInstance): ComponentFile { modified: new Date().toISOString() }, content: { - node: cleanedNode + node: cleanedNode, + ...(requiredToolboxes.length > 0 ? { requiredToolboxes } : {}) } as SubsystemContent }; } diff --git a/src/lib/schema/fileOps.ts b/src/lib/schema/fileOps.ts index efdc1bc9..23e32397 100644 --- a/src/lib/schema/fileOps.ts +++ b/src/lib/schema/fileOps.ts @@ -643,9 +643,20 @@ export function validateNodeTypes(nodes: NodeInstance[]): string[] { * Import a block or subsystem component at the given position * Uses shared cloneNodeForPaste utility for consistent ID regeneration */ -function importComponent(content: BlockContent | SubsystemContent, position: Position): string[] { +async function importComponent( + content: BlockContent | SubsystemContent, + position: Position +): Promise { const node = content.node; + // Install any runtime toolboxes the component declared before validating + // node types — without this, a .blk/.sub using a toolbox block would + // hard-fail even though the file records what it needs. Best-effort: + // a skipped/failed install just falls through to the unknown-type error. + if (content.requiredToolboxes && content.requiredToolboxes.length > 0) { + await installRequiredToolboxes(content.requiredToolboxes); + } + // Validate all node types are registered (recursive for subsystems) const invalidTypes = validateNodeTypes([node]); if (invalidTypes.length > 0) { @@ -733,7 +744,10 @@ async function processImportContent( case 'block': case 'subsystem': { const position = options.position || { x: 100, y: 100 }; - const nodeIds = importComponent(componentFile.content as BlockContent | SubsystemContent, position); + const nodeIds = await importComponent( + componentFile.content as BlockContent | SubsystemContent, + position + ); return { success: true, type: componentFile.type, nodeIds }; } diff --git a/src/lib/toolbox/dependencies.ts b/src/lib/toolbox/dependencies.ts index c366fad5..0ecbce37 100644 --- a/src/lib/toolbox/dependencies.ts +++ b/src/lib/toolbox/dependencies.ts @@ -14,6 +14,7 @@ import { NODE_TYPES } from '$lib/constants/nodeTypes'; import type { NodeInstance } from '$lib/types/nodes'; import type { ToolboxRequirement } from '$lib/types/schema'; import { toolboxes } from './store'; +import { toolboxSourceKey } from './identity'; import type { ToolboxConfig } from './types'; function walkNodeTypes(nodes: NodeInstance[], out: Set): void { @@ -62,9 +63,16 @@ export function collectRequiredToolboxes(nodes: NodeInstance[]): ToolboxRequirem /** * Filter a list of toolbox requirements to those that are NOT currently * installed. Used at load time to figure out what to prompt for. + * + * Matching is done on the source content identity (`toolboxSourceKey`), not + * the raw `id`: the id depends on how a toolbox was added (catalog vs PyPI + * tab vs file upload), so the same package can carry different ids across + * machines. Comparing source keys means a file that references + * `pypi:pathsim-chem` resolves against a catalog-installed `pathsim-chem`, + * and an inline toolbox is matched by its code rather than its filename. */ export function findMissingRequirements(reqs: ToolboxRequirement[]): ToolboxRequirement[] { if (!reqs || reqs.length === 0) return []; - const installed = new Set(get(toolboxes).map((t) => t.id)); - return reqs.filter((r) => !installed.has(r.id)); + const installedKeys = new Set(get(toolboxes).map((t) => toolboxSourceKey(t.source))); + return reqs.filter((r) => !installedKeys.has(toolboxSourceKey(r.source))); } diff --git a/src/lib/toolbox/identity.ts b/src/lib/toolbox/identity.ts new file mode 100644 index 00000000..899e753a --- /dev/null +++ b/src/lib/toolbox/identity.ts @@ -0,0 +1,63 @@ +/** + * Content identity for runtime toolboxes. + * + * A toolbox `id` is a UI-construction artifact: catalog entries have a fixed + * id, the PyPI tab builds `pypi:`, the URL tab `url:`, the file + * upload `inline:<...>`. The id therefore depends on *how* the user added a + * toolbox, not on *what* it is. Resolving dependencies on the raw id breaks + * in two directions: + * + * - the same PyPI package added via the catalog vs the PyPI tab gets two + * different ids and counts as two separate toolboxes; + * - two different uploaded `.py` files that happen to share a filename get + * the same id and count as one. + * + * `toolboxSourceKey` derives a stable key purely from the install source, so + * both sides of a comparison agree regardless of the id. + */ + +import type { ToolboxSource } from './types'; + +/** + * Small, stable string hash (djb2 variant). Used to content-address inline + * toolbox sources — not security-sensitive, just deterministic and + * collision-resistant enough to tell pasted Python files apart. + */ +export function hashString(s: string): string { + let h = 5381; + for (let i = 0; i < s.length; i++) { + h = (h * 33) ^ s.charCodeAt(i); + } + return (h >>> 0).toString(36); +} + +/** + * Normalize a PyPI project name per PEP 503: lowercase, with runs of `-`, + * `_` and `.` collapsed to a single `-`. `Pathsim_Chem` and `pathsim-chem` + * resolve to the same project. + */ +function normalizePypiName(pkg: string): string { + return pkg.trim().toLowerCase().replace(/[-_.]+/g, '-'); +} + +/** + * Canonical content identity for a toolbox source. Two sources that install + * the same toolbox produce the same key; two that don't, don't. + * + * Note: the PyPI key intentionally ignores `version` — a pinned and an + * unpinned install of the same package are still the same toolbox for + * "is it installed" purposes. + */ +export function toolboxSourceKey(source: ToolboxSource): string { + if (!source || typeof source !== 'object') return 'unknown:'; + switch (source.type) { + case 'pypi': + return `pypi:${normalizePypiName(source.pkg)}`; + case 'url': + return `url:${source.url.trim()}`; + case 'inline': + return `inline:${hashString(source.code)}`; + default: + return `unknown:${JSON.stringify(source)}`; + } +} diff --git a/src/lib/toolbox/index.ts b/src/lib/toolbox/index.ts index 8e38e5ee..58ce4d4a 100644 --- a/src/lib/toolbox/index.ts +++ b/src/lib/toolbox/index.ts @@ -39,3 +39,5 @@ export { installAndRegisterToolbox, type InstallSpec } from './installFlow'; export { seedPreloadedToolboxes } from './store'; export { collectRequiredToolboxes, findMissingRequirements } from './dependencies'; + +export { toolboxSourceKey, hashString } from './identity'; diff --git a/src/lib/types/component.ts b/src/lib/types/component.ts index 17442068..9525a96a 100644 --- a/src/lib/types/component.ts +++ b/src/lib/types/component.ts @@ -3,7 +3,7 @@ */ import type { NodeInstance } from './nodes'; -import type { GraphContent } from './schema'; +import type { GraphContent, ToolboxRequirement } from './schema'; /** Component types that can be saved/loaded */ export type ComponentType = 'block' | 'subsystem' | 'model'; @@ -24,11 +24,15 @@ export interface ComponentFile { /** Single block (no connections) */ export interface BlockContent { node: NodeInstance; + /** Runtime toolboxes this block needs (absent for builtin blocks). */ + requiredToolboxes?: ToolboxRequirement[]; } /** Subsystem (nested graph) */ export interface SubsystemContent { node: NodeInstance; // The subsystem node (includes .graph) + /** Runtime toolboxes the subsystem's blocks need (absent if all builtin). */ + requiredToolboxes?: ToolboxRequirement[]; } /** Full model - uses shared GraphContent structure */