[verified] feat: add diagram toolbox capabilities
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { OFFLINE_SHAPE_CATALOG, searchShapes } from "./shape-catalog.js";
|
||||
|
||||
test("offline catalog entries carry explicit public provenance and licensing", () => {
|
||||
assert.ok(OFFLINE_SHAPE_CATALOG.length >= 6);
|
||||
for (const entry of OFFLINE_SHAPE_CATALOG) {
|
||||
assert.equal(entry.provenance.license, "Apache-2.0");
|
||||
assert.match(entry.provenance.sourceUrl, /^https:\/\//);
|
||||
assert.match(entry.style, /shape=/);
|
||||
}
|
||||
});
|
||||
|
||||
test("shape search ranks exact, alias, and fuzzy matches deterministically", () => {
|
||||
assert.deepEqual(searchShapes("database"), searchShapes("database"));
|
||||
assert.equal(searchShapes("database").matches[0].id, "cylinder");
|
||||
assert.equal(searchShapes("database").matches[0].matchType, "alias");
|
||||
assert.equal(searchShapes("rhombus").matches[0].matchType, "exact");
|
||||
const fuzzy = searchShapes("proces box").matches[0];
|
||||
assert.equal(fuzzy.id, "rectangle");
|
||||
assert.equal(fuzzy.matchType, "fuzzy");
|
||||
assert.ok(fuzzy.confidence > 0 && fuzzy.confidence < 0.9);
|
||||
});
|
||||
|
||||
test("shape search returns an explicit generic fallback for unknown or blank queries", () => {
|
||||
for (const query of ["quantum-flibbertigibbet", " "]) {
|
||||
const result = searchShapes(query);
|
||||
assert.equal(result.matches.length, 0);
|
||||
assert.equal(result.fallback.id, "rectangle");
|
||||
assert.equal(result.fallback.matchType, "fallback");
|
||||
assert.equal(result.fallback.confidence, 0);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
export interface ShapeProvenance {
|
||||
sourceName: string;
|
||||
sourceUrl: string;
|
||||
license: "Apache-2.0";
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface ShapeCatalogEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
aliases: readonly string[];
|
||||
style: string;
|
||||
provenance: ShapeProvenance;
|
||||
}
|
||||
|
||||
export type ShapeMatchType = "exact" | "alias" | "fuzzy" | "fallback";
|
||||
export interface ShapeSearchMatch extends ShapeCatalogEntry {
|
||||
confidence: number;
|
||||
matchType: ShapeMatchType;
|
||||
}
|
||||
export interface ShapeSearchResult {
|
||||
query: string;
|
||||
matches: ShapeSearchMatch[];
|
||||
fallback: ShapeSearchMatch;
|
||||
}
|
||||
|
||||
const PROVENANCE: ShapeProvenance = {
|
||||
sourceName: "diagrams.net generic built-in shape vocabulary",
|
||||
sourceUrl: "https://github.com/jgraph/drawio",
|
||||
license: "Apache-2.0",
|
||||
note: "Hand-curated metadata for generic native Draw.io shapes; no third-party assets are bundled.",
|
||||
};
|
||||
|
||||
export const OFFLINE_SHAPE_CATALOG: readonly ShapeCatalogEntry[] = Object.freeze([
|
||||
entry("actor", "Actor", ["person", "user", "human"], "shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;"),
|
||||
entry("cloud", "Cloud", ["internet", "external cloud"], "shape=cloud;"),
|
||||
entry("cylinder", "Cylinder", ["database", "data store", "storage"], "shape=cylinder;"),
|
||||
entry("document", "Document", ["file", "report", "paper"], "shape=document;"),
|
||||
entry("ellipse", "Ellipse", ["oval", "start", "end"], "shape=ellipse;"),
|
||||
entry("hexagon", "Hexagon", ["preparation", "compute"], "shape=hexagon;"),
|
||||
entry("rectangle", "Rectangle", ["process", "box", "generic"], "shape=rectangle;"),
|
||||
entry("rhombus", "Rhombus", ["decision", "diamond", "choice"], "shape=rhombus;"),
|
||||
]);
|
||||
|
||||
function entry(id: string, name: string, aliases: readonly string[], style: string): ShapeCatalogEntry {
|
||||
return Object.freeze({ id, name, aliases: Object.freeze([...aliases]), style, provenance: Object.freeze({ ...PROVENANCE }) });
|
||||
}
|
||||
|
||||
function normalize(value: string): string {
|
||||
return value.trim().toLocaleLowerCase("en-US").replace(/[_-]+/g, " ").replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function similarity(left: string, right: string): number {
|
||||
if (left === right) return 1;
|
||||
const a = [...left];
|
||||
const b = [...right];
|
||||
if (a.length === 0 || b.length === 0) return 0;
|
||||
const previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
||||
for (let row = 1; row <= a.length; row += 1) {
|
||||
const current = [row];
|
||||
for (let column = 1; column <= b.length; column += 1) {
|
||||
current[column] = Math.min(
|
||||
current[column - 1] + 1,
|
||||
previous[column] + 1,
|
||||
previous[column - 1] + (a[row - 1] === b[column - 1] ? 0 : 1),
|
||||
);
|
||||
}
|
||||
previous.splice(0, previous.length, ...current);
|
||||
}
|
||||
return 1 - previous[b.length] / Math.max(a.length, b.length);
|
||||
}
|
||||
|
||||
function match(entryValue: ShapeCatalogEntry, query: string): ShapeSearchMatch | undefined {
|
||||
const id = normalize(entryValue.id);
|
||||
const name = normalize(entryValue.name);
|
||||
const aliases = entryValue.aliases.map(normalize);
|
||||
if (query === id || query === name) return { ...entryValue, confidence: 1, matchType: "exact" };
|
||||
if (aliases.includes(query)) return { ...entryValue, confidence: 0.95, matchType: "alias" };
|
||||
const terms = [id, name, ...aliases];
|
||||
const score = Math.max(...terms.map((term) => similarity(query, term)));
|
||||
if (score < 0.45) return undefined;
|
||||
return { ...entryValue, confidence: Number((score * 0.85).toFixed(6)), matchType: "fuzzy" };
|
||||
}
|
||||
|
||||
export function searchShapes(rawQuery: string, limit = 5): ShapeSearchResult {
|
||||
if (!Number.isInteger(limit) || limit < 1) throw new Error("Shape search limit must be a positive integer");
|
||||
const query = normalize(rawQuery);
|
||||
const generic = OFFLINE_SHAPE_CATALOG.find(({ id }) => id === "rectangle")!;
|
||||
const fallback: ShapeSearchMatch = { ...generic, confidence: 0, matchType: "fallback" };
|
||||
const matches = query === "" ? [] : OFFLINE_SHAPE_CATALOG
|
||||
.map((candidate) => match(candidate, query))
|
||||
.filter((candidate): candidate is ShapeSearchMatch => candidate !== undefined)
|
||||
.sort((left, right) => right.confidence - left.confidence || left.id.localeCompare(right.id, "en-US"))
|
||||
.slice(0, limit);
|
||||
return { query: rawQuery, matches, fallback };
|
||||
}
|
||||
|
||||
export const searchShapeCatalog = searchShapes;
|
||||
Reference in New Issue
Block a user