|
| 1 | +/** |
| 2 | + * Content identity for runtime toolboxes. |
| 3 | + * |
| 4 | + * A toolbox `id` is a UI-construction artifact: catalog entries have a fixed |
| 5 | + * id, the PyPI tab builds `pypi:<pkg>`, the URL tab `url:<url>`, the file |
| 6 | + * upload `inline:<...>`. The id therefore depends on *how* the user added a |
| 7 | + * toolbox, not on *what* it is. Resolving dependencies on the raw id breaks |
| 8 | + * in two directions: |
| 9 | + * |
| 10 | + * - the same PyPI package added via the catalog vs the PyPI tab gets two |
| 11 | + * different ids and counts as two separate toolboxes; |
| 12 | + * - two different uploaded `.py` files that happen to share a filename get |
| 13 | + * the same id and count as one. |
| 14 | + * |
| 15 | + * `toolboxSourceKey` derives a stable key purely from the install source, so |
| 16 | + * both sides of a comparison agree regardless of the id. |
| 17 | + */ |
| 18 | + |
| 19 | +import type { ToolboxSource } from './types'; |
| 20 | + |
| 21 | +/** |
| 22 | + * Small, stable string hash (djb2 variant). Used to content-address inline |
| 23 | + * toolbox sources — not security-sensitive, just deterministic and |
| 24 | + * collision-resistant enough to tell pasted Python files apart. |
| 25 | + */ |
| 26 | +export function hashString(s: string): string { |
| 27 | + let h = 5381; |
| 28 | + for (let i = 0; i < s.length; i++) { |
| 29 | + h = (h * 33) ^ s.charCodeAt(i); |
| 30 | + } |
| 31 | + return (h >>> 0).toString(36); |
| 32 | +} |
| 33 | + |
| 34 | +/** |
| 35 | + * Normalize a PyPI project name per PEP 503: lowercase, with runs of `-`, |
| 36 | + * `_` and `.` collapsed to a single `-`. `Pathsim_Chem` and `pathsim-chem` |
| 37 | + * resolve to the same project. |
| 38 | + */ |
| 39 | +function normalizePypiName(pkg: string): string { |
| 40 | + return pkg.trim().toLowerCase().replace(/[-_.]+/g, '-'); |
| 41 | +} |
| 42 | + |
| 43 | +/** |
| 44 | + * Canonical content identity for a toolbox source. Two sources that install |
| 45 | + * the same toolbox produce the same key; two that don't, don't. |
| 46 | + * |
| 47 | + * Note: the PyPI key intentionally ignores `version` — a pinned and an |
| 48 | + * unpinned install of the same package are still the same toolbox for |
| 49 | + * "is it installed" purposes. |
| 50 | + */ |
| 51 | +export function toolboxSourceKey(source: ToolboxSource): string { |
| 52 | + if (!source || typeof source !== 'object') return 'unknown:'; |
| 53 | + switch (source.type) { |
| 54 | + case 'pypi': |
| 55 | + return `pypi:${normalizePypiName(source.pkg)}`; |
| 56 | + case 'url': |
| 57 | + return `url:${source.url.trim()}`; |
| 58 | + case 'inline': |
| 59 | + return `inline:${hashString(source.code)}`; |
| 60 | + default: |
| 61 | + return `unknown:${JSON.stringify(source)}`; |
| 62 | + } |
| 63 | +} |
0 commit comments