Files
mdg-filius/packages/fls/src/topology.ts
T
julian.kiedaischandClaude Opus 5.5 63273916b5 feat(editor): F4.3 the editor's frame and surface; a switch's cloud read and written
packages/editor (React 18, Vite) draws a document as Java FILIUS does: Java's
default hardware icons, the device button of JSidebarButton, cables as
JCablePanel's quadratic curve between the buttons' middles, wireless cables
dashed, documentation items below. The surface moves with mouse and finger
(the pointer captured only once it moved) and zooms with Ctrl and the
wheel, a trackpad pinch, two fingers and the zoom bar; the first view shows
all. German and English catalogs with Java's device names, the dashboard's
colour tokens, the bridge with its origin check. packages/doc gets the
drawing's light view of a document.

Java's Switch.cloud (the Internet in the examples) was lost on reading; it
is now read, kept in the model and written, with FlsOracle and the corpus,
written and exported checks renewed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:05:25 +00:00

440 lines
18 KiB
TypeScript

// The network drawn in a .fls file — devices, cables, documentation items —
// read from the decoded JavaBeans graph (javabeans.ts) the way Java FILIUS
// loads it (SzenarioVerwaltung.netzwerkLaden), with the system software
// settings the topology needs (systemSoftware.ts). File systems and
// applications are later layers, which start from `FlsNode.source`.
//
// The result is Java's view, indexed like Java's lists, and is checked
// against Java itself (corpus.java.json). The editor model (node kinds, UUIDs,
// ports as Swift's TopologyPortMetadata, runtime configurations) is built
// from it in a separate step.
//
// Where this departs from filius-ipad's reader, Java is right (plan §13, M2):
// - the device kind comes from the Java class; the palette label ("Heimrouter")
// is not file data — Java overwrites it with the class's type name;
// - a device without coordinates stands at 0,0, as in Java, instead of
// being dropped;
// - interface values go through Java's setters (IP.ipCheck for address and
// mask, upper-cased MAC).
import { isJavaObject, isUnresolved, listItems, simpleClassName, type JavaObject, type JavaValue } from './javabeans.js';
import { javaIpCheck } from '@mdg-filius/core';
import { readSystemSoftware, type FlsSystemSoftware } from './systemSoftware.js';
export type FlsNodeClass = 'Rechner' | 'Notebook' | 'Switch' | 'Vermittlungsrechner' | 'Gateway' | 'Modem';
export interface FlsInterface {
/** After IP.ipCheck: a canonical dotted quad, or null for text that is not one. */
ip: string | null;
mask: string | null;
/** Upper-cased, as getMac() returns it; undefined when the file has none (Java rolls a random one). */
mac: string | undefined;
gateway: string | null;
dns: string | null;
/** A notebook's or PC's card joining a switch's wireless network (Betriebssystem.wireless). */
wireless: boolean;
}
export interface FlsNode {
javaClass: FlsNodeClass;
name: string | null;
x: number;
y: number;
/** Hosts only (Rechner, Notebook); false for the others. */
useIpAsName: boolean;
useMacAsName: boolean;
/** A Switch drawn as a cloud (Switch.cloud, the Internet in the examples); false for the others. */
cloud: boolean;
/** The interfaces of a Rechner, Notebook, Vermittlungsrechner or Gateway; empty for the others. */
interfaces: FlsInterface[];
/** Interfaces, or the ports of a Switch or Modem. */
portCount: number;
/** SSIDs, routes, RIP, firewall (systemSoftware.ts). */
system: FlsSystemSoftware;
/** The node's JavaBean (`filius.hardware.knoten.*`), for the software layers. */
source: JavaObject;
}
/** A cable end: the node's index and the port's index on it, as in Java's lists. */
export interface FlsPortReference {
node: number;
port: number;
}
export interface FlsLink {
ends: [FlsPortReference, FlsPortReference];
wireless: boolean;
/**
* The nodes the editor draws the cable between (JCablePanel ziel1, ziel2),
* by index; null where the file names none.
*/
panel: [number | null, number | null];
}
export type FlsColor = [red: number, green: number, blue: number, alpha: number];
export interface FlsFont {
name: string;
size: number;
style: number;
}
export interface FlsDocumentationItem {
/** GUIDocuItem.RECT = 1, TEXT = 2; 0 when the file sets none. */
type: number;
text: string | null;
x: number;
y: number;
width: number;
height: number;
color: FlsColor | null;
font: FlsFont | null;
}
export interface FlsTopology {
/** The version line newer files start with ("Filius version: 1.7.4 (…)"), else null. */
versionLine: string | null;
nodes: FlsNode[];
links: FlsLink[];
documentation: FlsDocumentationItem[];
/** What Java would read and this reader could not, and what it left out. */
warnings: string[];
}
interface NodeClassInfo {
/** Elements the constructor creates: interfaces (InternetKnoten) or ports (LokalerKnoten). */
constructed: number;
local: boolean;
host: boolean;
/**
* The name the constructor gives, in German — the locale Java FILIUS starts
* in (and the one corpus.java.json was measured with). An English FILIUS
* would say "Computer" or "Router" here.
*/
defaultName: string;
}
const NODE_CLASSES: Record<FlsNodeClass, NodeClassInfo> = {
Rechner: { constructed: 1, local: false, host: true, defaultName: 'Rechner' },
Notebook: { constructed: 1, local: false, host: true, defaultName: 'Notebook' },
Vermittlungsrechner: {
constructed: 1,
local: false,
host: false,
defaultName: 'Vermittlungsrechner',
},
Gateway: { constructed: 2, local: false, host: false, defaultName: 'Heimrouter' },
Switch: { constructed: 24, local: true, host: false, defaultName: 'Switch / WLAN' },
Modem: { constructed: 1, local: true, host: false, defaultName: 'Modem' },
};
const DEVICE_PACKAGE = 'filius.hardware.knoten.';
function isFlsNodeClass(name: string): name is FlsNodeClass {
return Object.hasOwn(NODE_CLASSES, name);
}
/** Maps the top-level objects of `konfiguration.xml` to the topology. */
export function readTopology(values: readonly JavaValue[]): FlsTopology {
const warnings: string[] = [];
const context = new Reader(warnings);
// netzwerkLaden: an optional version string, then the node list, the
// cable list and the documentation list, each by position. A list counts
// only if its first element has the expected class.
let position = 0;
let versionLine: string | null = null;
if (typeof values[0] === 'string') {
versionLine = values[0];
position = 1;
}
const nodeItems = listOf(values[position], 'filius.gui.netzwerksicht.GUIKnotenItem');
const cableItems = listOf(values[position + 1], 'filius.gui.netzwerksicht.GUIKabelItem');
const documentationItems = listOf(values[position + 2], 'filius.gui.netzwerksicht.GUIDocuItem');
const nodes: FlsNode[] = [];
for (const [index, item] of nodeItems.entries()) {
const node = context.node(item, index, nodes.length);
if (node !== undefined) {
if (isJavaObject(item)) context.nodeItems.set(item, nodes.length);
nodes.push(node);
}
}
const links: FlsLink[] = [];
for (const [index, item] of cableItems.entries()) {
const link = context.link(item, index);
if (link !== undefined) links.push(link);
}
const documentation = documentationItems.flatMap((item) => (isJavaObject(item) ? [context.documentation(item)] : []));
return { versionLine, nodes, links, documentation, warnings };
}
/** The elements of `value` if it is a list whose first element is an object of `className`. */
function listOf(value: JavaValue | undefined, className: string): JavaValue[] {
const items = listItems(value);
const first = items[0];
return isJavaObject(first) && first.className === className ? items : [];
}
class Reader {
/** Every port object a node owns, to resolve cable ends by identity as Java does. */
private readonly portOwners = new Map<JavaObject, FlsPortReference>();
/** Every node item read, to resolve a cable panel's ends. */
readonly nodeItems = new Map<JavaObject, number>();
constructor(private readonly warnings: string[]) {}
node(item: JavaValue, itemIndex: number, nodeIndex: number): FlsNode | undefined {
const device = isJavaObject(item) ? item.properties.get('knoten') : undefined;
if (!isJavaObject(device)) {
this.warnings.push(`node ${itemIndex + 1}: no device (knoten), left out`);
return undefined;
}
const simpleName = simpleClassName(device.className);
if (!device.className.startsWith(DEVICE_PACKAGE) || !isFlsNodeClass(simpleName)) {
this.warnings.push(`node ${itemIndex + 1}: unknown device class ${device.className}, left out`);
return undefined;
}
const info = NODE_CLASSES[simpleName];
const [x, y] = this.position(item as JavaObject, itemIndex);
const node: FlsNode = {
javaClass: simpleName,
name: this.stringProperty(device, 'name', info.defaultName, `node ${itemIndex + 1}`),
x,
y,
useIpAsName: info.host && this.booleanProperty(device, 'useIPAsName', `node ${itemIndex + 1}`),
useMacAsName: info.host && this.booleanProperty(device, 'useMACAsName', `node ${itemIndex + 1}`),
cloud: simpleName === 'Switch' && this.booleanProperty(device, 'cloud', `node ${itemIndex + 1}`),
interfaces: [],
portCount: 0,
system: readSystemSoftware(device, simpleName, (message) =>
this.warnings.push(`node ${itemIndex + 1}: ${message}`),
),
source: device,
};
if (info.local) {
const ports = this.constructedList(device, 'anschluesse', info.constructed);
node.portCount = ports.length;
ports.forEach((port, portIndex) => this.ownPort(port, nodeIndex, portIndex));
} else {
const networkInterfaces = this.constructedList(device, 'netzwerkInterfaces', info.constructed);
node.interfaces = networkInterfaces.map((nic, nicIndex) =>
this.networkInterface(nic, simpleName === 'Gateway' && nicIndex === 0, `node ${itemIndex + 1}`),
);
node.portCount = networkInterfaces.length;
networkInterfaces.forEach((nic, nicIndex) => {
if (isJavaObject(nic)) this.ownPort(nic.properties.get('port'), nodeIndex, nicIndex);
});
}
return node;
}
/**
* A list property the constructor filled: a getter's result keeps the
* constructed elements, a list the file sets replaces them.
*/
private constructedList(device: JavaObject, property: string, constructed: number): JavaValue[] {
const list = device.properties.get(property);
// Untouched: the constructed elements, which carry nothing but defaults.
if (list === undefined) return Array.from({ length: constructed }, () => null);
return listItems(list, isJavaObject(list) && list.fromGetter ? constructed : 0);
}
private ownPort(port: JavaValue | undefined, node: number, index: number): void {
if (isJavaObject(port) && !this.portOwners.has(port)) this.portOwners.set(port, { node, port: index });
}
/**
* The imageLabel's bounds: fields set one by one in newer files,
* `new Rectangle(x, y, width, height)` in older ones. Without either, Java's
* label sits at 0,0.
*/
private position(item: JavaObject, itemIndex: number): [number, number] {
const label = item.properties.get('imageLabel');
const bounds = isJavaObject(label) ? label.properties.get('bounds') : undefined;
if (!isJavaObject(bounds)) return [0, 0];
return (['x', 'y'] as const).map((field, index) => {
const value = bounds.properties.get(field) ?? bounds.args[index] ?? 0;
if (typeof value === 'number') return value;
this.warnings.push(`node ${itemIndex + 1}: position ${field} is ${describe(value)}, used 0`);
return 0;
}) as [number, number];
}
/**
* NetzwerkInterface as its constructor leaves it, then the file's setters.
* A Gateway's constructor gives its first interface (WAN) its own address.
*/
private networkInterface(nic: JavaValue, gatewayWan: boolean, where: string): FlsInterface {
const result: FlsInterface = {
ip: gatewayWan ? '42.0.0.10' : '192.168.0.10',
mask: gatewayWan ? '255.0.0.0' : '255.255.255.0',
mac: undefined,
gateway: '',
dns: '',
wireless: false,
};
if (!isJavaObject(nic)) return result;
// Only the constructor's own interface gets the WAN address; one the file
// creates with `new NetzwerkInterface()` starts like any other.
if (gatewayWan && !nic.fromGetter) {
result.ip = '192.168.0.10';
result.mask = '255.255.255.0';
}
const at = `${where}, interface`;
for (const [property, field] of [
['ip', 'ip'],
['subnetzMaske', 'mask'],
] as const) {
const value = nic.properties.get(property);
if (value === undefined) continue;
// setIp(null) throws inside Java's setter; XMLDecoder swallows that and
// the constructor's value stays.
if (typeof value === 'string') result[field] = javaIpCheck(value);
else this.unexpected(at, property, value);
}
const mac = nic.properties.get('mac');
if (typeof mac === 'string') result.mac = mac.toUpperCase();
else if (mac !== undefined && mac !== null) this.unexpected(at, 'mac', mac);
for (const [property, field] of [
['gateway', 'gateway'],
['dns', 'dns'],
] as const) {
const value = nic.properties.get(property);
if (value === undefined) continue;
if (typeof value === 'string' || value === null) result[field] = value;
else this.unexpected(at, property, value);
}
const wireless = nic.properties.get('wireless');
if (typeof wireless === 'boolean') result.wireless = wireless;
else if (wireless !== undefined) this.unexpected(at, 'wireless', wireless);
return result;
}
link(item: JavaValue, itemIndex: number): FlsLink | undefined {
const cable = isJavaObject(item) ? item.properties.get('dasKabel') : undefined;
if (!isJavaObject(cable)) {
this.warnings.push(`cable ${itemIndex + 1}: no cable (dasKabel), left out`);
return undefined;
}
// Kabel.setAnschluesse(Port[]) in newer files, new Kabel(port, port) in older ones.
const declared = cable.properties.get('anschluesse');
const ports = declared === undefined ? cable.args : listItems(declared);
const ends = ports.map((port) => (isJavaObject(port) ? this.portOwners.get(port) : undefined));
const [first, second] = ends;
if (ends.length !== 2 || first === undefined || second === undefined) {
this.warnings.push(`cable ${itemIndex + 1}: an end is not a port of a device, left out`);
return undefined;
}
const panel = (item as JavaObject).properties.get('kabelpanel');
const panelEnd = (property: string) => {
const target = isJavaObject(panel) ? panel.properties.get(property) : undefined;
return isJavaObject(target) ? (this.nodeItems.get(target) ?? null) : null;
};
return {
ends: [first, second],
wireless: this.booleanProperty(cable, 'wireless', `cable ${itemIndex + 1}`),
panel: [panelEnd('ziel1'), panelEnd('ziel2')],
};
}
documentation(item: JavaObject): FlsDocumentationItem {
const where = 'documentation item';
const integer = (property: string): number => {
const value = item.properties.get(property);
if (value === undefined) return 0;
if (typeof value === 'number' && Number.isInteger(value)) return value;
this.unexpected(where, property, value);
return 0;
};
const text = item.properties.get('text');
return {
type: integer('type'),
text: typeof text === 'string' ? text : null,
x: integer('x'),
y: integer('y'),
width: integer('width'),
height: integer('height'),
color: this.color(item.properties.get('color'), where),
font: this.font(item.properties.get('font'), where),
};
}
/**
* `java.awt.Color` as XMLEncoder writes it: `new Color(r, g, b, a)`. Also
* `new Color(rgb)` and `new Color(r, g, b)` (ColorUIResource). A component
* outside 0…255 makes Java's constructor throw — the color stays unset.
*/
private color(value: JavaValue | undefined, where: string): FlsColor | null {
if (value === undefined || value === null) return null;
if (!isJavaObject(value)) {
this.unexpected(where, 'color', value);
return null;
}
const args = value.args;
if (!args.every((arg) => typeof arg === 'number' && Number.isInteger(arg))) {
this.warnings.push(`${where}: color ${value.className} with arguments this reader does not know, left unset`);
return null;
}
const numbers = args as number[];
if (numbers.length === 1) {
const rgb = numbers[0]!;
return [(rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff, 255];
}
if (numbers.length === 3 || numbers.length === 4) {
const rgba = numbers.length === 3 ? [...numbers, 255] : numbers;
if (rgba.every((component) => component >= 0 && component <= 255)) return rgba as FlsColor;
this.warnings.push(`${where}: color component outside 0…255, left unset (as in Java)`);
return null;
}
this.warnings.push(`${where}: color with ${numbers.length} arguments, left unset`);
return null;
}
/** `new Font(name, style, size)`, also as FontUIResource. Java keeps style 0…3 and makes others 0. */
private font(value: JavaValue | undefined, where: string): FlsFont | null {
if (value === undefined || value === null) return null;
const [name, style, size] = isJavaObject(value) ? value.args : [];
if (
!isJavaObject(value) ||
value.args.length !== 3 ||
(typeof name !== 'string' && name !== null) ||
typeof style !== 'number' ||
typeof size !== 'number'
) {
this.unexpected(where, 'font', value);
return null;
}
return { name: name ?? 'Default', size, style: (style & ~0x03) === 0 ? style : 0 };
}
private stringProperty(target: JavaObject, property: string, fallback: string, where: string): string | null {
const value = target.properties.get(property);
if (value === undefined) return fallback;
if (typeof value === 'string' || value === null) return value;
this.unexpected(where, property, value);
return fallback;
}
private booleanProperty(target: JavaObject, property: string, where: string): boolean {
const value = target.properties.get(property);
if (value === undefined) return false;
if (typeof value === 'boolean') return value;
this.unexpected(where, property, value);
return false;
}
private unexpected(where: string, property: string, value: JavaValue): void {
this.warnings.push(`${where}: ${property} is ${describe(value)}, ignored`);
}
}
function describe(value: JavaValue): string {
if (isUnresolved(value)) return `unresolved (${value.reason})`;
if (isJavaObject(value)) return `an object of ${value.className}`;
return JSON.stringify(typeof value === 'bigint' ? String(value) : value) ?? String(value);
}