Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions src/lib/components/dialogs/ToolboxManagerDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
upsertToolbox,
removeToolbox,
toolboxes,
toolboxSourceKey,
type CatalogEntry,
type ToolboxConfig,
type ToolboxSource,
Expand Down Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 13 additions & 2 deletions src/lib/schema/componentOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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',
Expand All @@ -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
};
}
Expand All @@ -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',
Expand All @@ -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
};
}
Expand Down
18 changes: 16 additions & 2 deletions src/lib/schema/fileOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> {
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) {
Expand Down Expand Up @@ -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 };
}

Expand Down
12 changes: 10 additions & 2 deletions src/lib/toolbox/dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>): void {
Expand Down Expand Up @@ -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)));
}
63 changes: 63 additions & 0 deletions src/lib/toolbox/identity.ts
Original file line number Diff line number Diff line change
@@ -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:<pkg>`, the URL tab `url:<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)}`;
}
}
2 changes: 2 additions & 0 deletions src/lib/toolbox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,5 @@ export { installAndRegisterToolbox, type InstallSpec } from './installFlow';
export { seedPreloadedToolboxes } from './store';

export { collectRequiredToolboxes, findMissingRequirements } from './dependencies';

export { toolboxSourceKey, hashString } from './identity';
6 changes: 5 additions & 1 deletion src/lib/types/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 */
Expand Down
Loading