enterprise-architect-mcp 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +190 -0
- package/README.md +226 -0
- package/dist/database.d.ts +3 -0
- package/dist/database.js +25 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +68 -0
- package/dist/model-session.d.ts +29 -0
- package/dist/model-session.js +172 -0
- package/dist/package-path.d.ts +2 -0
- package/dist/package-path.js +36 -0
- package/dist/remembered-path.d.ts +5 -0
- package/dist/remembered-path.js +43 -0
- package/dist/resolve-qea-path.d.ts +26 -0
- package/dist/resolve-qea-path.js +58 -0
- package/dist/text.d.ts +37 -0
- package/dist/text.js +152 -0
- package/dist/tools/annotations.d.ts +3 -0
- package/dist/tools/annotations.js +5 -0
- package/dist/tools/connectors.d.ts +3 -0
- package/dist/tools/connectors.js +152 -0
- package/dist/tools/diagrams.d.ts +3 -0
- package/dist/tools/diagrams.js +225 -0
- package/dist/tools/elements.d.ts +3 -0
- package/dist/tools/elements.js +210 -0
- package/dist/tools/packages.d.ts +3 -0
- package/dist/tools/packages.js +80 -0
- package/dist/tools/resolve.d.ts +3 -0
- package/dist/tools/resolve.js +135 -0
- package/dist/tools/scenarios.d.ts +3 -0
- package/dist/tools/scenarios.js +106 -0
- package/dist/tools/schema.d.ts +3 -0
- package/dist/tools/schema.js +164 -0
- package/dist/tools/search.d.ts +3 -0
- package/dist/tools/search.js +217 -0
- package/dist/tools/windowing.d.ts +37 -0
- package/dist/tools/windowing.js +92 -0
- package/dist/tools.d.ts +3 -0
- package/dist/tools.js +18 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/package.json +68 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { openDatabase } from "./database.js";
|
|
5
|
+
import { listQeaPathCandidates, resolveQeaTarget, } from "./resolve-qea-path.js";
|
|
6
|
+
import { readRememberedPath, rememberPath } from "./remembered-path.js";
|
|
7
|
+
/** Long enough to go and find the file, short enough not to hang the conversation. */
|
|
8
|
+
const PROMPT_TIMEOUT_MS = 5 * 60_000;
|
|
9
|
+
const HOW_TO_CONFIGURE = "Provide it as a CLI argument, in the EA_QEA_PATH environment variable, " +
|
|
10
|
+
"or as EA_QEA_PATH in a .env file in the working directory.";
|
|
11
|
+
export function describeSource(source) {
|
|
12
|
+
switch (source) {
|
|
13
|
+
case "argument":
|
|
14
|
+
return "the command line";
|
|
15
|
+
case "environment":
|
|
16
|
+
return "the EA_QEA_PATH environment variable";
|
|
17
|
+
case "dotenv":
|
|
18
|
+
return "EA_QEA_PATH in .env";
|
|
19
|
+
case "remembered":
|
|
20
|
+
return "a previous answer to the path prompt";
|
|
21
|
+
case "prompt":
|
|
22
|
+
return "your answer to the path prompt";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const reasonOf = (err) => (err instanceof Error ? err.message : String(err));
|
|
26
|
+
/** Tool errors reach the client as the message verbatim, which still owes callers structured JSON. */
|
|
27
|
+
const modelUnavailable = (message) => new Error(JSON.stringify({ error: "no_model", message, howToConfigure: HOW_TO_CONFIGURE }, null, 2));
|
|
28
|
+
export class ModelSession {
|
|
29
|
+
server;
|
|
30
|
+
cliArg;
|
|
31
|
+
db;
|
|
32
|
+
opened;
|
|
33
|
+
opening;
|
|
34
|
+
/** A rejected answer is not remembered, so the next prompt has to carry the reason itself. */
|
|
35
|
+
lastPromptFailure;
|
|
36
|
+
constructor(server, cliArg) {
|
|
37
|
+
this.server = server;
|
|
38
|
+
this.cliArg = cliArg;
|
|
39
|
+
}
|
|
40
|
+
origin() {
|
|
41
|
+
return this.opened;
|
|
42
|
+
}
|
|
43
|
+
/** Startup diagnostics only: reports what is configured without opening anything. */
|
|
44
|
+
reportConfiguration() {
|
|
45
|
+
const candidates = this.candidates();
|
|
46
|
+
if (candidates.length === 0) {
|
|
47
|
+
console.error("mcp-server-ea: no model configured — will ask for one on first use.");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
for (const candidate of candidates) {
|
|
51
|
+
const target = resolve(candidate.configured);
|
|
52
|
+
const status = existsSync(target) ? "exists, not opened yet" : "NOT FOUND";
|
|
53
|
+
console.error(`mcp-server-ea: ${describeSource(candidate.source)} → "${target}" (${status})`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
database() {
|
|
57
|
+
if (this.db)
|
|
58
|
+
return Promise.resolve(this.db);
|
|
59
|
+
// Concurrent tool calls must share one attempt, or they each raise a prompt.
|
|
60
|
+
this.opening ??= this.open().finally(() => {
|
|
61
|
+
this.opening = undefined;
|
|
62
|
+
});
|
|
63
|
+
return this.opening;
|
|
64
|
+
}
|
|
65
|
+
candidates() {
|
|
66
|
+
const configured = listQeaPathCandidates(this.cliArg);
|
|
67
|
+
const remembered = readRememberedPath();
|
|
68
|
+
return remembered
|
|
69
|
+
? [...configured, { source: "remembered", configured: remembered }]
|
|
70
|
+
: configured;
|
|
71
|
+
}
|
|
72
|
+
async open() {
|
|
73
|
+
const candidates = this.candidates();
|
|
74
|
+
const ignored = [];
|
|
75
|
+
const canAsk = Boolean(this.server.server.getClientCapabilities()?.elicitation);
|
|
76
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
77
|
+
try {
|
|
78
|
+
return this.adopt(candidate, {
|
|
79
|
+
ignored,
|
|
80
|
+
shadowed: candidates.slice(index + 1).filter((c) => c.configured !== candidate.configured),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
const reason = reasonOf(err);
|
|
85
|
+
// Skipping a broken source only buys the user's answer a turn where an answer is possible.
|
|
86
|
+
// Without a prompt there is nothing to protect, and falling through would quietly open some
|
|
87
|
+
// other session's model. An explicit argument is this run's intent, never a stale default.
|
|
88
|
+
if (candidate.source === "argument" || !canAsk) {
|
|
89
|
+
throw modelUnavailable(`${reason} — configured by ${describeSource(candidate.source)}.`);
|
|
90
|
+
}
|
|
91
|
+
ignored.push({ ...candidate, reason });
|
|
92
|
+
console.error(`mcp-server-ea: ignoring ${describeSource(candidate.source)} ("${candidate.configured}") — ${reason}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const answer = await this.ask(this.promptReason(ignored));
|
|
96
|
+
let db;
|
|
97
|
+
try {
|
|
98
|
+
db = this.adopt(answer, { ignored, shadowed: [] });
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
// Remembering a path that does not open would make every later session start broken.
|
|
102
|
+
this.lastPromptFailure = `${reasonOf(err)}.`;
|
|
103
|
+
throw modelUnavailable(`${this.lastPromptFailure} Ask again to try another path.`);
|
|
104
|
+
}
|
|
105
|
+
this.lastPromptFailure = undefined;
|
|
106
|
+
try {
|
|
107
|
+
rememberPath(answer.configured);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
// Persisting the answer is a convenience; the model is already open and the call must not fail.
|
|
111
|
+
console.error(`mcp-server-ea: could not remember the path — ${reasonOf(err)}`);
|
|
112
|
+
}
|
|
113
|
+
return db;
|
|
114
|
+
}
|
|
115
|
+
/** Opens a candidate and, on success, makes it this session's model. */
|
|
116
|
+
adopt(candidate, context) {
|
|
117
|
+
const path = resolveQeaTarget(candidate.configured);
|
|
118
|
+
const db = openDatabase(path);
|
|
119
|
+
this.db = db;
|
|
120
|
+
this.opened = { ...candidate, ...context };
|
|
121
|
+
console.error(`mcp-server-ea: opened "${path}" from ${describeSource(candidate.source)}`);
|
|
122
|
+
return db;
|
|
123
|
+
}
|
|
124
|
+
promptReason(ignored) {
|
|
125
|
+
if (this.lastPromptFailure) {
|
|
126
|
+
return `That path did not work — ${this.lastPromptFailure}`;
|
|
127
|
+
}
|
|
128
|
+
if (ignored.length > 0) {
|
|
129
|
+
return `The model configured by ${describeSource(ignored[0].source)} could not be opened — ${ignored[0].reason}.`;
|
|
130
|
+
}
|
|
131
|
+
return "No Enterprise Architect model is configured yet.";
|
|
132
|
+
}
|
|
133
|
+
async ask(reason) {
|
|
134
|
+
if (!this.server.server.getClientCapabilities()?.elicitation) {
|
|
135
|
+
throw modelUnavailable(`${reason} This client cannot prompt for one.`);
|
|
136
|
+
}
|
|
137
|
+
let result;
|
|
138
|
+
try {
|
|
139
|
+
result = await this.server.server.elicitInput({
|
|
140
|
+
message: `${reason} Where is your .qea export?`,
|
|
141
|
+
requestedSchema: {
|
|
142
|
+
type: "object",
|
|
143
|
+
properties: {
|
|
144
|
+
qea_path: {
|
|
145
|
+
type: "string",
|
|
146
|
+
title: "Model path",
|
|
147
|
+
description: "Full path to a .qea file, or to a folder containing one — " +
|
|
148
|
+
"the newest .qea in that folder is used.",
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
required: ["qea_path"],
|
|
152
|
+
},
|
|
153
|
+
}, { timeout: PROMPT_TIMEOUT_MS });
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
if (err.code === ErrorCode.RequestTimeout) {
|
|
157
|
+
throw modelUnavailable("The model path prompt went unanswered. Ask again to retry, or set it up permanently.");
|
|
158
|
+
}
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
if (result.action !== "accept") {
|
|
162
|
+
throw modelUnavailable("No model path was given, so there is nothing to read. Ask again to retry, or set it up permanently.");
|
|
163
|
+
}
|
|
164
|
+
const configured = String(result.content?.qea_path ?? "").trim();
|
|
165
|
+
if (!configured) {
|
|
166
|
+
// An empty value would resolve to the working directory and open whatever it finds there.
|
|
167
|
+
this.lastPromptFailure = "the answer was empty.";
|
|
168
|
+
throw modelUnavailable("No path was entered. Ask again to try another path.");
|
|
169
|
+
}
|
|
170
|
+
return { source: "prompt", configured };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the full package path from root to the given package, dot-separated.
|
|
3
|
+
* Uses a preloaded package map memoized per database connection.
|
|
4
|
+
*/
|
|
5
|
+
const packageMaps = new WeakMap();
|
|
6
|
+
function getPackageMap(db) {
|
|
7
|
+
let map = packageMaps.get(db);
|
|
8
|
+
if (map)
|
|
9
|
+
return map;
|
|
10
|
+
map = new Map();
|
|
11
|
+
const rows = db
|
|
12
|
+
.prepare("SELECT Package_ID, Name, Parent_ID FROM t_package")
|
|
13
|
+
.all();
|
|
14
|
+
for (const row of rows) {
|
|
15
|
+
map.set(row.Package_ID, { name: row.Name, parentId: row.Parent_ID });
|
|
16
|
+
}
|
|
17
|
+
packageMaps.set(db, map);
|
|
18
|
+
return map;
|
|
19
|
+
}
|
|
20
|
+
export function buildPackagePath(db, packageId) {
|
|
21
|
+
const map = getPackageMap(db);
|
|
22
|
+
const parts = [];
|
|
23
|
+
let current = packageId;
|
|
24
|
+
const visited = new Set();
|
|
25
|
+
while (current > 0) {
|
|
26
|
+
if (visited.has(current))
|
|
27
|
+
break;
|
|
28
|
+
visited.add(current);
|
|
29
|
+
const pkg = map.get(current);
|
|
30
|
+
if (!pkg)
|
|
31
|
+
break;
|
|
32
|
+
parts.unshift(pkg.name);
|
|
33
|
+
current = pkg.parentId;
|
|
34
|
+
}
|
|
35
|
+
return parts.join(".");
|
|
36
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Where the answer to the path prompt is kept, so it is asked once and not every start. */
|
|
2
|
+
export declare function configFilePath(): string;
|
|
3
|
+
export declare function readRememberedPath(): string | undefined;
|
|
4
|
+
export declare function rememberPath(qeaPath: string): void;
|
|
5
|
+
export declare function forgetPath(): void;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
const APP_DIR = "enterprise-architect-mcp";
|
|
5
|
+
/** Where the answer to the path prompt is kept, so it is asked once and not every start. */
|
|
6
|
+
export function configFilePath() {
|
|
7
|
+
const override = process.env.EA_MCP_CONFIG_DIR;
|
|
8
|
+
if (override)
|
|
9
|
+
return join(override, "config.json");
|
|
10
|
+
const base = process.platform === "win32"
|
|
11
|
+
? process.env.APPDATA || join(homedir(), "AppData", "Roaming")
|
|
12
|
+
: process.platform === "darwin"
|
|
13
|
+
? join(homedir(), "Library", "Application Support")
|
|
14
|
+
: process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
15
|
+
return join(base, APP_DIR, "config.json");
|
|
16
|
+
}
|
|
17
|
+
export function readRememberedPath() {
|
|
18
|
+
const file = configFilePath();
|
|
19
|
+
if (!existsSync(file))
|
|
20
|
+
return undefined;
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(readFileSync(file, "utf-8"));
|
|
23
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
24
|
+
return undefined;
|
|
25
|
+
const value = parsed.qeaPath;
|
|
26
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// A corrupt config must not stop the server — it just means nothing is remembered.
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function rememberPath(qeaPath) {
|
|
34
|
+
const file = configFilePath();
|
|
35
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
36
|
+
// The file records where one person's model lives, so it is written for that person only.
|
|
37
|
+
writeFileSync(file, `${JSON.stringify({ qeaPath }, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
|
|
38
|
+
}
|
|
39
|
+
export function forgetPath() {
|
|
40
|
+
const file = configFilePath();
|
|
41
|
+
if (existsSync(file))
|
|
42
|
+
writeFileSync(file, `${JSON.stringify({}, null, 2)}\n`, "utf-8");
|
|
43
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Which configuration source supplied the path. */
|
|
2
|
+
export type QeaPathSource = "argument" | "environment" | "dotenv" | "remembered" | "prompt";
|
|
3
|
+
export interface QeaPathCandidate {
|
|
4
|
+
source: QeaPathSource;
|
|
5
|
+
/** The value as configured, before resolution or directory scanning. */
|
|
6
|
+
configured: string;
|
|
7
|
+
}
|
|
8
|
+
export interface RejectedCandidate extends QeaPathCandidate {
|
|
9
|
+
reason: string;
|
|
10
|
+
}
|
|
11
|
+
export interface QeaPathOrigin extends QeaPathCandidate {
|
|
12
|
+
/** Higher-priority sources that were tried and could not be opened. */
|
|
13
|
+
ignored: RejectedCandidate[];
|
|
14
|
+
/** Lower-priority sources carrying a different value — a forgotten setting shows up here. */
|
|
15
|
+
shadowed: QeaPathCandidate[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The configured sources in priority order, without touching the filesystem
|
|
19
|
+
* beyond reading .env.
|
|
20
|
+
*
|
|
21
|
+
* An empty source counts as unset, so a skipped ${input:...} in VS Code
|
|
22
|
+
* (substituted as "") drops out here.
|
|
23
|
+
*/
|
|
24
|
+
export declare function listQeaPathCandidates(cliArg?: string): QeaPathCandidate[];
|
|
25
|
+
/** Turns a configured value into a concrete .qea file path. */
|
|
26
|
+
export declare function resolveQeaTarget(target: string): string;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync, statSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* The configured sources in priority order, without touching the filesystem
|
|
5
|
+
* beyond reading .env.
|
|
6
|
+
*
|
|
7
|
+
* An empty source counts as unset, so a skipped ${input:...} in VS Code
|
|
8
|
+
* (substituted as "") drops out here.
|
|
9
|
+
*/
|
|
10
|
+
export function listQeaPathCandidates(cliArg) {
|
|
11
|
+
const candidates = [
|
|
12
|
+
{ source: "argument", configured: cliArg || undefined },
|
|
13
|
+
{ source: "environment", configured: process.env.EA_QEA_PATH || undefined },
|
|
14
|
+
{ source: "dotenv", configured: loadFromDotEnv() },
|
|
15
|
+
];
|
|
16
|
+
return candidates.filter((c) => c.configured !== undefined);
|
|
17
|
+
}
|
|
18
|
+
/** Turns a configured value into a concrete .qea file path. */
|
|
19
|
+
export function resolveQeaTarget(target) {
|
|
20
|
+
const resolved = resolve(target);
|
|
21
|
+
if (!existsSync(resolved)) {
|
|
22
|
+
// Quoted so leading/trailing whitespace in the path is visible.
|
|
23
|
+
throw new Error(`Path not found: "${resolved}"`);
|
|
24
|
+
}
|
|
25
|
+
if (statSync(resolved).isDirectory()) {
|
|
26
|
+
return findNewestQea(resolved);
|
|
27
|
+
}
|
|
28
|
+
return resolved;
|
|
29
|
+
}
|
|
30
|
+
function findNewestQea(dir) {
|
|
31
|
+
const files = readdirSync(dir)
|
|
32
|
+
.filter((f) => f.endsWith(".qea"))
|
|
33
|
+
.map((f) => {
|
|
34
|
+
const fullPath = join(dir, f);
|
|
35
|
+
return { path: fullPath, mtime: statSync(fullPath).mtimeMs };
|
|
36
|
+
})
|
|
37
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
38
|
+
if (files.length === 0) {
|
|
39
|
+
throw new Error(`No .qea files found in directory: "${dir}"`);
|
|
40
|
+
}
|
|
41
|
+
return files[0].path;
|
|
42
|
+
}
|
|
43
|
+
function loadFromDotEnv() {
|
|
44
|
+
const envPath = join(process.cwd(), ".env");
|
|
45
|
+
if (!existsSync(envPath))
|
|
46
|
+
return undefined;
|
|
47
|
+
const content = readFileSync(envPath, "utf-8");
|
|
48
|
+
for (const line of content.split("\n")) {
|
|
49
|
+
const trimmed = line.trim();
|
|
50
|
+
if (trimmed.startsWith("#") || !trimmed)
|
|
51
|
+
continue;
|
|
52
|
+
const match = trimmed.match(/^EA_QEA_PATH\s*=\s*(.+)$/);
|
|
53
|
+
if (match) {
|
|
54
|
+
return match[1].trim().replace(/^["']|["']$/g, "");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
package/dist/text.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text handling for EA model content: entity decoding, case folding, markup type, link extraction.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Decode HTML numeric and named character entities, preserving structural escapes.
|
|
6
|
+
* &#NNN; and &#xHH; → character. Named entities (e.g. á) → character.
|
|
7
|
+
* < > & are preserved — they are structural escapes, not encoded text.
|
|
8
|
+
*/
|
|
9
|
+
export declare function decodeEntities(html: string | null): string | null;
|
|
10
|
+
/**
|
|
11
|
+
* Fold text for case- and diacritic-insensitive matching.
|
|
12
|
+
* Lowercase → NFD decomposition → strip combining marks → map non-decomposing letters.
|
|
13
|
+
*
|
|
14
|
+
* Lowercasing first is what lets `İ` reduce to `i`: its lowercase form carries a
|
|
15
|
+
* combining dot that the mark strip then removes.
|
|
16
|
+
*/
|
|
17
|
+
export declare function foldText(s: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* Locale used to order names for display. `EA_LOCALE` overrides; otherwise the
|
|
20
|
+
* host default applies, since the model's language is not knowable from the export.
|
|
21
|
+
*/
|
|
22
|
+
export declare const orderingLocale: string | undefined;
|
|
23
|
+
/** Order two model names, tolerating nulls. Accented initials sort in place, not after `Z`. */
|
|
24
|
+
export declare function compareNames(a: string | null | undefined, b: string | null | undefined): number;
|
|
25
|
+
/** The content type declared for all EA model text fields. */
|
|
26
|
+
export declare const EA_TEXT_CONTENT_TYPE = "text/html; ea-dialect";
|
|
27
|
+
export interface ExtractedLink {
|
|
28
|
+
href: string;
|
|
29
|
+
scheme: string | null;
|
|
30
|
+
resolvable: boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Extract <a href="..."> targets from EA HTML text.
|
|
34
|
+
* Model-internal schemes ($element://, $diagram://, $feature://, $package://) are resolvable.
|
|
35
|
+
* Everything else is external.
|
|
36
|
+
*/
|
|
37
|
+
export declare function extractLinks(html: string | null): ExtractedLink[];
|
package/dist/text.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text handling for EA model content: entity decoding, case folding, markup type, link extraction.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Named HTML character entities appearing in EA notes.
|
|
6
|
+
*
|
|
7
|
+
* Covers Latin-1 Supplement and Latin Extended-A in full — every accented letter
|
|
8
|
+
* used by a European Latin-script language — rather than one language's subset,
|
|
9
|
+
* so an export in any of them decodes the same way. Numeric entities dominate in
|
|
10
|
+
* practice; named ones arrive with text pasted from Word and other HTML sources.
|
|
11
|
+
*/
|
|
12
|
+
const NAMED_ENTITIES = {
|
|
13
|
+
// Latin-1 Supplement, uppercase
|
|
14
|
+
Agrave: "À", Aacute: "Á", Acirc: "Â", Atilde: "Ã", Auml: "Ä", Aring: "Å", AElig: "Æ",
|
|
15
|
+
Ccedil: "Ç", Egrave: "È", Eacute: "É", Ecirc: "Ê", Euml: "Ë",
|
|
16
|
+
Igrave: "Ì", Iacute: "Í", Icirc: "Î", Iuml: "Ï",
|
|
17
|
+
ETH: "Ð", Ntilde: "Ñ", Ograve: "Ò", Oacute: "Ó", Ocirc: "Ô", Otilde: "Õ", Ouml: "Ö", Oslash: "Ø",
|
|
18
|
+
Ugrave: "Ù", Uacute: "Ú", Ucirc: "Û", Uuml: "Ü", Yacute: "Ý", THORN: "Þ",
|
|
19
|
+
// Latin-1 Supplement, lowercase
|
|
20
|
+
szlig: "ß",
|
|
21
|
+
agrave: "à", aacute: "á", acirc: "â", atilde: "ã", auml: "ä", aring: "å", aelig: "æ",
|
|
22
|
+
ccedil: "ç", egrave: "è", eacute: "é", ecirc: "ê", euml: "ë",
|
|
23
|
+
igrave: "ì", iacute: "í", icirc: "î", iuml: "ï",
|
|
24
|
+
eth: "ð", ntilde: "ñ", ograve: "ò", oacute: "ó", ocirc: "ô", otilde: "õ", ouml: "ö", oslash: "ø",
|
|
25
|
+
ugrave: "ù", uacute: "ú", ucirc: "û", uuml: "ü", yacute: "ý", thorn: "þ", yuml: "ÿ",
|
|
26
|
+
// Latin Extended-A
|
|
27
|
+
Amacr: "Ā", amacr: "ā", Abreve: "Ă", abreve: "ă", Aogon: "Ą", aogon: "ą",
|
|
28
|
+
Cacute: "Ć", cacute: "ć", Ccirc: "Ĉ", ccirc: "ĉ", Cdot: "Ċ", cdot: "ċ", Ccaron: "Č", ccaron: "č",
|
|
29
|
+
Dcaron: "Ď", dcaron: "ď", Dstrok: "Đ", dstrok: "đ",
|
|
30
|
+
Emacr: "Ē", emacr: "ē", Ebreve: "Ĕ", ebreve: "ĕ", Edot: "Ė", edot: "ė",
|
|
31
|
+
Eogon: "Ę", eogon: "ę", Ecaron: "Ě", ecaron: "ě",
|
|
32
|
+
Gcirc: "Ĝ", gcirc: "ĝ", Gbreve: "Ğ", gbreve: "ğ", Gdot: "Ġ", gdot: "ġ", Gcedil: "Ģ", gcedil: "ģ",
|
|
33
|
+
Hcirc: "Ĥ", hcirc: "ĥ", Hstrok: "Ħ", hstrok: "ħ",
|
|
34
|
+
Itilde: "Ĩ", itilde: "ĩ", Imacr: "Ī", imacr: "ī", Ibreve: "Ĭ", ibreve: "ĭ",
|
|
35
|
+
Iogon: "Į", iogon: "į", Idot: "İ", imath: "ı", IJlig: "IJ", ijlig: "ij",
|
|
36
|
+
Jcirc: "Ĵ", jcirc: "ĵ",
|
|
37
|
+
Kcedil: "Ķ", kcedil: "ķ", kgreen: "ĸ",
|
|
38
|
+
Lacute: "Ĺ", lacute: "ĺ", Lcedil: "Ļ", lcedil: "ļ", Lcaron: "Ľ", lcaron: "ľ",
|
|
39
|
+
Lmidot: "Ŀ", lmidot: "ŀ", Lstrok: "Ł", lstrok: "ł",
|
|
40
|
+
Nacute: "Ń", nacute: "ń", Ncedil: "Ņ", ncedil: "ņ", Ncaron: "Ň", ncaron: "ň",
|
|
41
|
+
napos: "ʼn", ENG: "Ŋ", eng: "ŋ",
|
|
42
|
+
Omacr: "Ō", omacr: "ō", Obreve: "Ŏ", obreve: "ŏ", Odblac: "Ő", odblac: "ő",
|
|
43
|
+
OElig: "Œ", oelig: "œ",
|
|
44
|
+
Racute: "Ŕ", racute: "ŕ", Rcedil: "Ŗ", rcedil: "ŗ", Rcaron: "Ř", rcaron: "ř",
|
|
45
|
+
Sacute: "Ś", sacute: "ś", Scirc: "Ŝ", scirc: "ŝ", Scedil: "Ş", scedil: "ş",
|
|
46
|
+
Scaron: "Š", scaron: "š",
|
|
47
|
+
Tcedil: "Ţ", tcedil: "ţ", Tcaron: "Ť", tcaron: "ť", Tstrok: "Ŧ", tstrok: "ŧ",
|
|
48
|
+
Utilde: "Ũ", utilde: "ũ", Umacr: "Ū", umacr: "ū", Ubreve: "Ŭ", ubreve: "ŭ",
|
|
49
|
+
Uring: "Ů", uring: "ů", Udblac: "Ű", udblac: "ű", Uogon: "Ų", uogon: "ų",
|
|
50
|
+
Wcirc: "Ŵ", wcirc: "ŵ", Ycirc: "Ŷ", ycirc: "ŷ", Yuml: "Ÿ",
|
|
51
|
+
Zacute: "Ź", zacute: "ź", Zdot: "Ż", zdot: "ż", Zcaron: "Ž", zcaron: "ž",
|
|
52
|
+
// Punctuation and symbols common in analyst prose
|
|
53
|
+
nbsp: "\u00A0", quot: '"', apos: "'", shy: "\u00AD",
|
|
54
|
+
ndash: "–", mdash: "—", lsquo: "‘", rsquo: "’", sbquo: "‚",
|
|
55
|
+
ldquo: "“", rdquo: "”", bdquo: "„", lsaquo: "‹", rsaquo: "›",
|
|
56
|
+
laquo: "«", raquo: "»", hellip: "…", bull: "•",
|
|
57
|
+
dagger: "†", Dagger: "‡", permil: "‰",
|
|
58
|
+
euro: "€", cent: "¢", pound: "£", yen: "¥", curren: "¤",
|
|
59
|
+
copy: "©", reg: "®", trade: "™", sect: "§", para: "¶", middot: "·",
|
|
60
|
+
deg: "°", plusmn: "±", times: "×", divide: "÷", micro: "µ", not: "¬",
|
|
61
|
+
iexcl: "¡", iquest: "¿", brvbar: "¦", uml: "¨", macr: "¯", acute: "´", cedil: "¸",
|
|
62
|
+
ordf: "ª", ordm: "º", sup1: "¹", sup2: "²", sup3: "³",
|
|
63
|
+
frac14: "¼", frac12: "½", frac34: "¾",
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Decode HTML numeric and named character entities, preserving structural escapes.
|
|
67
|
+
* &#NNN; and &#xHH; → character. Named entities (e.g. á) → character.
|
|
68
|
+
* < > & are preserved — they are structural escapes, not encoded text.
|
|
69
|
+
*/
|
|
70
|
+
export function decodeEntities(html) {
|
|
71
|
+
if (html == null)
|
|
72
|
+
return null;
|
|
73
|
+
// Named entities may carry digits (frac12, sup2), so the name branch is alphanumeric.
|
|
74
|
+
return html.replace(/&(#[xX]?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, (match, entity) => {
|
|
75
|
+
// Preserve structural escapes
|
|
76
|
+
if (entity === "lt" || entity === "gt" || entity === "amp")
|
|
77
|
+
return match;
|
|
78
|
+
// Numeric: &#NNN; or &#xHH; (case-insensitive x)
|
|
79
|
+
if (entity.startsWith("#")) {
|
|
80
|
+
const isHex = entity[1] === "x" || entity[1] === "X";
|
|
81
|
+
const code = isHex
|
|
82
|
+
? parseInt(entity.slice(2), 16)
|
|
83
|
+
: parseInt(entity.slice(1), 10);
|
|
84
|
+
if (isNaN(code) || code < 0 || code > 0x10FFFF)
|
|
85
|
+
return match;
|
|
86
|
+
return String.fromCodePoint(code);
|
|
87
|
+
}
|
|
88
|
+
// Named entity
|
|
89
|
+
return NAMED_ENTITIES[entity] ?? match;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Latin letters that carry no canonical decomposition, so NFD leaves them intact.
|
|
94
|
+
* The stroke, ligature and sharp-s forms are the ones a mark-stripping fold misses:
|
|
95
|
+
* "Łódź" folds to "lodz" only if `ł` is mapped explicitly.
|
|
96
|
+
*/
|
|
97
|
+
const NON_DECOMPOSING_FOLDS = {
|
|
98
|
+
"ß": "ss", "æ": "ae", "œ": "oe", "ij": "ij",
|
|
99
|
+
"ø": "o", "ł": "l", "ŀ": "l", "đ": "d", "ð": "d", "þ": "th",
|
|
100
|
+
"ħ": "h", "ŧ": "t", "ŋ": "n", "ı": "i", "ĸ": "k", "ʼn": "n",
|
|
101
|
+
};
|
|
102
|
+
const NON_DECOMPOSING_RE = new RegExp(`[${Object.keys(NON_DECOMPOSING_FOLDS).join("")}]`, "g");
|
|
103
|
+
/**
|
|
104
|
+
* Fold text for case- and diacritic-insensitive matching.
|
|
105
|
+
* Lowercase → NFD decomposition → strip combining marks → map non-decomposing letters.
|
|
106
|
+
*
|
|
107
|
+
* Lowercasing first is what lets `İ` reduce to `i`: its lowercase form carries a
|
|
108
|
+
* combining dot that the mark strip then removes.
|
|
109
|
+
*/
|
|
110
|
+
export function foldText(s) {
|
|
111
|
+
return s
|
|
112
|
+
.toLowerCase()
|
|
113
|
+
.normalize("NFD")
|
|
114
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
115
|
+
.replace(NON_DECOMPOSING_RE, (c) => NON_DECOMPOSING_FOLDS[c]);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Locale used to order names for display. `EA_LOCALE` overrides; otherwise the
|
|
119
|
+
* host default applies, since the model's language is not knowable from the export.
|
|
120
|
+
*/
|
|
121
|
+
export const orderingLocale = process.env.EA_LOCALE || undefined;
|
|
122
|
+
const nameCollator = new Intl.Collator(orderingLocale);
|
|
123
|
+
/** Order two model names, tolerating nulls. Accented initials sort in place, not after `Z`. */
|
|
124
|
+
export function compareNames(a, b) {
|
|
125
|
+
return nameCollator.compare(a || "", b || "");
|
|
126
|
+
}
|
|
127
|
+
/** The content type declared for all EA model text fields. */
|
|
128
|
+
export const EA_TEXT_CONTENT_TYPE = "text/html; ea-dialect";
|
|
129
|
+
const MODEL_INTERNAL_SCHEMES = new Set(["$element", "$diagram", "$feature", "$package"]);
|
|
130
|
+
/**
|
|
131
|
+
* Extract <a href="..."> targets from EA HTML text.
|
|
132
|
+
* Model-internal schemes ($element://, $diagram://, $feature://, $package://) are resolvable.
|
|
133
|
+
* Everything else is external.
|
|
134
|
+
*/
|
|
135
|
+
export function extractLinks(html) {
|
|
136
|
+
if (html == null)
|
|
137
|
+
return [];
|
|
138
|
+
const links = [];
|
|
139
|
+
const re = /href="([^"]+)"/g;
|
|
140
|
+
let m;
|
|
141
|
+
while ((m = re.exec(html)) !== null) {
|
|
142
|
+
const href = m[1];
|
|
143
|
+
const schemeMatch = href.match(/^(\$?[a-zA-Z][a-zA-Z0-9+.-]*):/);
|
|
144
|
+
const scheme = schemeMatch ? schemeMatch[1] : null;
|
|
145
|
+
links.push({
|
|
146
|
+
href,
|
|
147
|
+
scheme,
|
|
148
|
+
resolvable: scheme != null && MODEL_INTERNAL_SCHEMES.has(scheme),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return links;
|
|
152
|
+
}
|