feat(fls): import a .fls topology into the editor model

importProject builds what the simulation core runs from Java's view of a
file: nodes with kinds and ports, links between port ids, device and
interface IP configurations, documentation items with Swift's derived
ids. The core gains the model types it needs (node, link, graph,
documentation item), ported from filius-ipad.

Checked against the Swift import, which the Swift oracle now writes in
full (corpus.swift.json, "state"), on the 13 files Swift reads
completely. Two differences are intended and named in the test:
- a device the file leaves unnamed has Java's default name
  ("Switch / WLAN"), not Swift's;
- multicast_server.fls: useIPAsName refers to a shared Boolean that
  Swift cannot follow; Java shows the IP address, and so do we.
All 23 files import with as many devices and cables as Java has, and an
imported example pings across its router in the core.

Port counts of switches and routers follow Java's lists instead of
Swift's guess from references.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 20:47:47 +00:00
co-authored by Claude Opus 5.5
parent a02bb62413
commit da2ec1dae2
16 changed files with 14023 additions and 1 deletions
+277
View File
@@ -0,0 +1,277 @@
// From Java's view of a .fls file (topology.ts) to the editor model the
// simulation core runs: nodes with kinds and ports, links between port ids,
// the IP configurations, documentation items.
//
// Ported from filius-ipad (GPLv2/GPLv3):
// Persistence/TopologyProjectStore.swift — importFiliusConfigurationXML,
// finalizeCurrentNodeCandidate, importedPorts, registerRuntimeConfigurations,
// resolveCableCandidates, finalizeCurrentDocumentationCandidate.
// Checked against the Swift import (corpus.swift.json, `state`) on every
// corpus file the Swift reader reads completely.
//
// Departures from Swift, where the input is Java's rather than Swift's own:
// - port counts of switches and routers are Java's (see importedPorts);
// - a node's name is Java's, kept as it is — also empty (Swift would trim it
// and put a default in place of an empty one);
// - an address Java's setter rejected (null) becomes Swift's default, as
// Swift does for an empty one; Swift would keep invalid text verbatim.
// Not here yet: switch and wireless settings, routes, firewall, DHCP, remote
// links and everything else under a node's system software.
import {
canonicalMacAddress,
deterministicDocumentationUuid,
documentationColor,
documentationItem,
inDeterministicRenderOrder,
interfaceKey,
isSafeFrame,
topologyNode,
DOCUMENTATION_BLACK,
DOCUMENTATION_PALE_YELLOW,
type DeviceConfiguration,
type DocumentationItem,
type HostLabelPolicy,
type Id,
type InterfaceConfiguration,
type InterfaceKey,
type NodeKind,
type PortMetadata,
type TopologyGraph,
type TopologyLink,
type TopologyNode,
} from '@mdg-filius/core';
import type { FlsDocumentationItem, FlsInterface, FlsNode, FlsNodeClass, FlsTopology } from './topology.js';
export interface ImportedProject {
versionLine: string | null;
graph: TopologyGraph;
deviceConfigurations: Map<Id, DeviceConfiguration>;
interfaceConfigurations: Map<InterfaceKey, InterfaceConfiguration>;
/** In render order: rectangles below text, then by order. */
documentationItems: DocumentationItem[];
/** The topology's warnings, then this step's. */
warnings: string[];
}
export const NODE_KIND_OF_CLASS: Readonly<Record<FlsNodeClass, NodeKind>> = {
Rechner: 'pc',
Notebook: 'notebook',
Switch: 'networkSwitch',
Vermittlungsrechner: 'router',
Gateway: 'gateway',
Modem: 'remoteLink',
};
/**
* The editor model of a topology. `newId` makes node, port and link ids
* (Swift uses random UUIDs; tests pass a counter). Documentation ids are
* derived from the item, as in Swift.
*/
export function importProject(topology: FlsTopology, newId: () => Id): ImportedProject {
const warnings = [...topology.warnings];
const deviceConfigurations = new Map<Id, DeviceConfiguration>();
const interfaceConfigurations = new Map<InterfaceKey, InterfaceConfiguration>();
const nodes = topology.nodes.map((source) => {
const node = importNode(source, newId, warnings);
registerConfigurations(source, node, deviceConfigurations, interfaceConfigurations);
return node;
});
const links: TopologyLink[] = [];
const occupied = new Set<Id>();
topology.links.forEach((link, index) => {
const [source, target] = link.ends.map((end) => {
const node = nodes[end.node];
const port = node?.ports[end.port];
return node === undefined || port === undefined ? undefined : { node, port };
});
const where = `cable ${index + 1}`;
if (source === undefined || target === undefined) {
warnings.push(`${where}: an end is not a port of the imported node, left out`);
return;
}
if (source.node.id === target.node.id) {
warnings.push(`${where}: connects a node to itself, left out`);
return;
}
if (occupied.has(source.port.id) || occupied.has(target.port.id)) {
warnings.push(`${where}: a port already has a cable, left out`);
return;
}
occupied.add(source.port.id);
occupied.add(target.port.id);
links.push({
id: newId(),
sourceNodeId: source.node.id,
sourcePortId: source.port.id,
targetNodeId: target.node.id,
targetPortId: target.port.id,
});
});
const documentationItems: DocumentationItem[] = [];
for (const item of topology.documentation) {
const imported = importDocumentationItem(item, documentationItems.length, warnings);
if (imported !== undefined) documentationItems.push(imported);
}
return {
versionLine: topology.versionLine,
graph: { nodes, links },
deviceConfigurations,
interfaceConfigurations,
documentationItems: inDeterministicRenderOrder(documentationItems),
warnings,
};
}
function hostLabelPolicy(node: FlsNode): HostLabelPolicy {
if (node.useIpAsName && node.useMacAsName) return 'ipAndMAC';
if (node.useIpAsName) return 'ipAddress';
if (node.useMacAsName) return 'macAddress';
return 'manual';
}
/**
* Swift's importedPorts: the labels of a kind, the MAC addresses of the
* interfaces. Swift guesses a switch's and a router's port count from the
* references it saw (at least 24, at least one); Java's list is known here
* and counts — a file that sets a shorter list has fewer ports in Java too.
* A Gateway keeps two, since Java's own code needs WAN and LAN; a host and a
* modem have exactly one, as in Swift.
*/
function importedPorts(node: FlsNode, kind: NodeKind, newId: () => Id, warnings: string[]): PortMetadata[] {
const port = (label: string, nic?: FlsInterface): PortMetadata => {
const mac = canonicalMacAddress(nic?.mac);
const id = newId();
return mac === undefined ? { id, label, isOccupied: false } : { id, label, isOccupied: false, importedMacAddress: mac };
};
switch (kind) {
case 'pc':
case 'notebook':
if (node.interfaces.length > 1) {
warnings.push(`node '${node.name ?? ''}': only the first of ${node.interfaces.length} interfaces is kept`);
}
return [port('eth0', node.interfaces[0])];
case 'networkSwitch':
return Array.from({ length: node.portCount }, (_, index) => port(`sw${index + 1}`));
case 'router':
return Array.from({ length: node.interfaces.length }, (_, index) =>
port(`rt${index + 1}`, node.interfaces[index]),
);
case 'gateway':
return Array.from({ length: Math.max(2, node.interfaces.length) }, (_, index) =>
port(index === 0 ? 'wan0' : index === 1 ? 'lan0' : `gw${index}`, node.interfaces[index]),
);
case 'remoteLink':
return [port('remote0')];
case 'unsupported':
return [];
}
}
function importNode(source: FlsNode, newId: () => Id, warnings: string[]): TopologyNode {
const kind = NODE_KIND_OF_CLASS[source.javaClass];
const ports = importedPorts(source, kind, newId, warnings);
return topologyNode({
id: newId(),
kind,
...(source.name === null ? {} : { displayName: source.name }),
hostLabelPolicy: hostLabelPolicy(source),
position: { x: source.x, y: source.y },
ports,
newId,
});
}
/** Swift's normalizedOrDefault; Java's null (a rejected address) counts as empty. */
function orDefault(value: string | null, fallback: string): string {
const trimmed = value?.trim() ?? '';
return trimmed === '' ? fallback : trimmed;
}
/** Swift's registerRuntimeConfigurations. */
function registerConfigurations(
source: FlsNode,
node: TopologyNode,
deviceConfigurations: Map<Id, DeviceConfiguration>,
interfaceConfigurations: Map<InterfaceKey, InterfaceConfiguration>,
): void {
if (node.kind === 'pc' || node.kind === 'notebook') {
const nic = source.interfaces[0];
deviceConfigurations.set(node.id, {
ipAddress: orDefault(nic?.ip ?? null, '192.168.0.10'),
subnetMask: orDefault(nic?.mask ?? null, '255.255.255.0'),
defaultGateway: nic?.gateway?.trim() ?? '',
dnsServer: nic?.dns?.trim() ?? '',
});
return;
}
if (node.kind !== 'router' && node.kind !== 'gateway') return;
node.ports.forEach((port, index) => {
const nic = source.interfaces[index];
const wan = node.kind === 'gateway' && index === 0;
interfaceConfigurations.set(interfaceKey(node.id, port.id), {
ipAddress: orDefault(nic?.ip ?? null, wan ? '42.0.0.10' : '192.168.0.10'),
subnetMask: orDefault(nic?.mask ?? null, wan ? '255.0.0.0' : '255.255.255.0'),
});
});
const wan = source.interfaces[0];
if (node.kind === 'gateway' && wan !== undefined) {
const wanConfiguration = interfaceConfigurations.get(interfaceKey(node.id, node.ports[0]!.id));
deviceConfigurations.set(node.id, {
ipAddress: wanConfiguration?.ipAddress ?? '',
subnetMask: wanConfiguration?.subnetMask ?? '',
defaultGateway: wan.gateway?.trim() ?? '',
dnsServer: wan.dns?.trim() ?? '',
});
}
}
/**
* Swift's finalizeCurrentDocumentationCandidate: RECT = 1, TEXT = 2; a frame
* inside the editor's world; the id from the item's values and its order.
*/
function importDocumentationItem(
item: FlsDocumentationItem,
order: number,
warnings: string[],
): DocumentationItem | undefined {
if (item.width <= 0 || item.height <= 0) {
warnings.push('documentation item without size, left out');
return undefined;
}
const kind = item.type === 1 ? 'rectangle' : item.type === 2 ? 'text' : undefined;
if (kind === undefined) {
warnings.push(`documentation item of type ${item.type}, left out (known: RECT = 1, TEXT = 2)`);
return undefined;
}
const frame = { x: item.x, y: item.y, width: item.width, height: item.height };
if (!isSafeFrame(frame)) {
warnings.push('documentation item outside the editor bounds, left out');
return undefined;
}
const text = item.text ?? '';
const seed = `java-doc|${order}|${item.type}|${item.x}|${item.y}|${item.width}|${item.height}|${text}`;
let color;
if (item.color === null) {
color = kind === 'text' ? DOCUMENTATION_BLACK : DOCUMENTATION_PALE_YELLOW;
warnings.push(`documentation item ${order + 1} without a color, given the default`);
} else {
const [red, green, blue, alpha] = item.color;
color = documentationColor(red / 255, green / 255, blue / 255, alpha / 255);
}
return documentationItem({
id: deterministicDocumentationUuid(seed),
kind,
frame,
text,
color,
fontName: item.font?.name ?? 'Dialog',
fontSize: item.font?.size ?? 12,
isBold: ((item.font?.style ?? 0) & 1) === 1,
order,
});
}