// A project in its shared document and back (plan §5.2, schema 1). The // document holds the editor's model — what the .fls reader makes of a file, // `ImportedProject` — so that its snapshot for the core (projectSnapshot) // and its export (saveProjectArchive) stay the one translation each, and a // project read back from its document is the project written into it. // // meta schema, versionLine // nodes id → { order, kind, name, labelPolicy, x, y, // ports: [{ id, label, occupied, mac?, config? }], // settings: { device?, routes?, firewall?, dnsServer?, … }, // programs: program → { active }, // files?: path → { order, kind, text | data, mediaType? }, // own? } // links id → { order, sourceNode, sourcePort, targetNode, targetPort } // docs id → { kind, x, y, width, height, text, red, green, blue, alpha, // font, size, bold, order } // archive name → bytes (the project's own Java applications) // // Departures from §5.2, to keep the model whole: a port's settings are the // model's InterfaceConfiguration under `config`, a node's settings its // configurations by kind under `settings`; a node's and a link's place in // the file is `order`; bytes stay in the document until F holds blobs (F5). import * as Y from 'yjs'; import { inDeterministicRenderOrder, interfaceKey, VirtualFileSystem, type DocumentationItem, type Id, type InstallableProgram, type TopologyLink, type TopologyNode, type VirtualFileContent, } from '@mdg-filius/core'; import type { FlsOwnApplication, ImportedProject } from '@mdg-filius/fls'; import { fromY, toY, type PlainValue } from './values.js'; export const SCHEMA_VERSION = 1; /** The document's root types. */ export const ROOTS = { meta: 'meta', nodes: 'nodes', links: 'links', docs: 'docs', archive: 'archive' } as const; type NodeSettingsField = | 'deviceConfigurations' | 'switchConfigurations' | 'hostWirelessConfigurations' | 'manualRoutesByNodeId' | 'ripEnabledByNodeId' | 'ipForwardingByNodeId' | 'firewallConfigurationsByNodeId' | 'dhcpClientConfigurations' | 'dhcpServerConfigurations' | 'portForwardingRowsByNodeId' | 'remoteLinkConfigurations' | 'webServerConfigurations' | 'gnutellaConfigurations' | 'dnsServerConfigurations' | 'emailClientConfigurations' | 'emailServerConfigurations' | 'echoServerConfigurations' | 'simpleClientConfigurations'; /** A node's settings in the document, by the model's map they come from. */ export const NODE_SETTINGS: Readonly> = { deviceConfigurations: 'device', switchConfigurations: 'switch', hostWirelessConfigurations: 'wireless', manualRoutesByNodeId: 'routes', ripEnabledByNodeId: 'rip', ipForwardingByNodeId: 'ipForwarding', firewallConfigurationsByNodeId: 'firewall', dhcpClientConfigurations: 'dhcpClient', dhcpServerConfigurations: 'dhcpServer', portForwardingRowsByNodeId: 'portForwarding', remoteLinkConfigurations: 'remoteLink', webServerConfigurations: 'webServer', gnutellaConfigurations: 'gnutella', dnsServerConfigurations: 'dnsServer', emailClientConfigurations: 'emailClient', emailServerConfigurations: 'emailServer', echoServerConfigurations: 'echoServer', simpleClientConfigurations: 'simpleClient', }; const SETTINGS_FIELDS = Object.keys(NODE_SETTINGS) as NodeSettingsField[]; /** The project's own applications as text: their beans are never edited, and hold Java longs (bigint). */ function ownText(applications: readonly FlsOwnApplication[]): string { return JSON.stringify(applications, (_, value: unknown) => (typeof value === 'bigint' ? { bigint: value.toString() } : value)); } function ownApplications(text: string): FlsOwnApplication[] { return JSON.parse(text, (_, value: unknown) => value !== null && typeof value === 'object' && Object.keys(value).length === 1 && typeof (value as { bigint?: unknown }).bigint === 'string' ? BigInt((value as { bigint: string }).bigint) : value, ) as FlsOwnApplication[]; } export function fileEntry(order: number, content: VirtualFileContent): Y.Map { const entry = new Y.Map(); entry.set('order', order); entry.set('kind', content.kind); if (content.kind === 'text') entry.set('text', new Y.Text(content.text)); if (content.kind === 'binary' || content.kind === 'image') entry.set('data', content.data); if ((content.kind === 'binary' || content.kind === 'image') && content.mediaType !== undefined) entry.set('mediaType', content.mediaType); return entry; } /** A device's files, each folder's entries in the order they were made (parents before their entries). */ function filesMap(files: VirtualFileSystem): Y.Map> { const map = new Y.Map>(); let order = 0; const walk = (folder: string): void => { for (const entry of files.entriesInCreationOrder(folder)) { map.set(entry.path, fileEntry(order++, entry.content)); if (entry.content.kind === 'directory') walk(entry.path); } }; walk('/'); return map; } /** A device's entry in the document, with what the project holds for it. */ export function nodeEntry(node: TopologyNode, order: number, project: ImportedProject): Y.Map { const map = new Y.Map(); map.set('order', order); map.set('kind', node.kind); map.set('name', node.displayName); map.set('labelPolicy', node.hostLabelPolicy); map.set('x', node.position.x); map.set('y', node.position.y); const ports = new Y.Array>(); ports.push( node.ports.map((port) => { const entry = new Y.Map(); entry.set('id', port.id); entry.set('label', port.label); entry.set('occupied', port.isOccupied); if (port.importedMacAddress !== undefined) entry.set('mac', port.importedMacAddress); const config = project.interfaceConfigurations.get(interfaceKey(node.id, port.id)); if (config !== undefined) entry.set('config', toY(config as unknown as PlainValue)); return entry; }), ); map.set('ports', ports); const settings = new Y.Map(); for (const field of SETTINGS_FIELDS) { const value = (project[field] as ReadonlyMap).get(node.id); if (value !== undefined) settings.set(NODE_SETTINGS[field], toY(value as PlainValue)); } map.set('settings', settings); const programs = new Y.Map>(); for (const program of project.installedPrograms.get(node.id) ?? []) { const entry = new Y.Map(); entry.set('active', project.activePrograms.get(node.id)?.has(program) === true); programs.set(program, entry); } map.set('programs', programs); const files = project.virtualFileSystems.get(node.id); if (files !== undefined) map.set('files', filesMap(files)); const own = project.ownApplicationsByNodeId.get(node.id); if (own !== undefined) map.set('own', ownText(own)); return map; } function linkMap(link: TopologyLink, order: number): Y.Map { const map = new Y.Map(); map.set('order', order); map.set('sourceNode', link.sourceNodeId); map.set('sourcePort', link.sourcePortId); map.set('targetNode', link.targetNodeId); map.set('targetPort', link.targetPortId); return map; } function documentationMap(item: DocumentationItem): Y.Map { const map = new Y.Map(); map.set('kind', item.kind); map.set('x', item.frame.x); map.set('y', item.frame.y); map.set('width', item.frame.width); map.set('height', item.frame.height); map.set('text', new Y.Text(item.text)); map.set('red', item.color.red); map.set('green', item.color.green); map.set('blue', item.color.blue); map.set('alpha', item.color.alpha); map.set('font', item.fontName); map.set('size', item.fontSize); map.set('bold', item.isBold); map.set('order', item.order); return map; } /** Writes the project into an empty document, in one transaction. */ export function writeProject(doc: Y.Doc, project: ImportedProject, origin: unknown = null): void { doc.transact(() => { const meta = doc.getMap(ROOTS.meta); meta.set('schema', SCHEMA_VERSION); meta.set('versionLine', project.versionLine); const nodes = doc.getMap>(ROOTS.nodes); project.graph.nodes.forEach((node, index) => nodes.set(node.id, nodeEntry(node, index, project))); const links = doc.getMap>(ROOTS.links); project.graph.links.forEach((link, index) => links.set(link.id, linkMap(link, index))); const docs = doc.getMap>(ROOTS.docs); for (const item of project.documentationItems) docs.set(item.id, documentationMap(item)); const archive = doc.getMap(ROOTS.archive); for (const [name, bytes] of project.archiveEntries) archive.set(name, bytes); }, origin); } /** The entries of a Y.Map in their `order`, then by key: a place two people gave at once is decided the same everywhere. */ export function inOrder(map: Y.Map>): [string, Y.Map][] { return [...map.entries()].sort(([leftKey, left], [rightKey, right]) => { const byOrder = (left.get('order') as number) - (right.get('order') as number); return byOrder !== 0 ? byOrder : leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; }); } /** * A device's files. Read tolerantly, as the editor draws: an entry the file * system refuses (its folder missing before the janitor ran, or a name two * people gave at once in different case) is left out, not the project. */ export function fileSystemOf(files: Y.Map>): VirtualFileSystem { const fileSystem = new VirtualFileSystem(); for (const [path, entry] of inOrder(files)) { const kind = entry.get('kind') as VirtualFileContent['kind']; const mediaType = entry.get('mediaType') as string | undefined; try { if (kind === 'directory') fileSystem.createDirectory(path); else if (kind === 'text') fileSystem.writeTextFile(path, (entry.get('text') as Y.Text).toString()); else if (kind === 'image') fileSystem.writeImageFile(path, entry.get('data') as Uint8Array, mediaType!); else fileSystem.writeBinaryFile(path, entry.get('data') as Uint8Array, mediaType); } catch { // Left out (see above). } } return fileSystem; } /** A project with nothing in it. */ export function emptyProject(): ImportedProject { return { versionLine: null, graph: { nodes: [], links: [] }, deviceConfigurations: new Map(), interfaceConfigurations: new Map(), switchConfigurations: new Map(), hostWirelessConfigurations: new Map(), manualRoutesByNodeId: new Map(), ripEnabledByNodeId: new Map(), ipForwardingByNodeId: new Map(), firewallConfigurationsByNodeId: new Map(), dhcpClientConfigurations: new Map(), dhcpServerConfigurations: new Map(), portForwardingRowsByNodeId: new Map(), remoteLinkConfigurations: new Map(), virtualFileSystems: new Map(), installedPrograms: new Map(), activePrograms: new Map(), webServerConfigurations: new Map(), gnutellaConfigurations: new Map(), dnsServerConfigurations: new Map(), emailClientConfigurations: new Map(), emailServerConfigurations: new Map(), echoServerConfigurations: new Map(), simpleClientConfigurations: new Map(), documentationItems: [], archiveEntries: new Map(), ownApplicationsByNodeId: new Map(), warnings: [], }; } /** The project the document holds; its warnings are the reader's and stay with the import. */ export function readProject(doc: Y.Doc): ImportedProject { const meta = doc.getMap(ROOTS.meta); const project: ImportedProject = { ...emptyProject(), versionLine: (meta.get('versionLine') as string | null | undefined) ?? null, archiveEntries: new Map(doc.getMap(ROOTS.archive).entries()), }; for (const [id, map] of inOrder(doc.getMap>(ROOTS.nodes))) { const ports = (map.get('ports') as Y.Array>).toArray(); project.graph.nodes.push({ id, kind: map.get('kind') as TopologyNode['kind'], displayName: map.get('name') as string, hostLabelPolicy: map.get('labelPolicy') as TopologyNode['hostLabelPolicy'], position: { x: map.get('x') as number, y: map.get('y') as number }, ports: ports.map((port) => { const mac = port.get('mac') as string | undefined; const fields = { id: port.get('id') as Id, label: port.get('label') as string, isOccupied: port.get('occupied') as boolean }; return mac === undefined ? fields : { ...fields, importedMacAddress: mac }; }), }); for (const port of ports) { const config = port.get('config'); if (config !== undefined) project.interfaceConfigurations.set(interfaceKey(id, port.get('id') as Id), fromY(config) as never); } const settings = map.get('settings') as Y.Map; for (const field of SETTINGS_FIELDS) { const value = settings.get(NODE_SETTINGS[field]); if (value !== undefined) (project[field] as Map).set(id, fromY(value)); } const programs = map.get('programs') as Y.Map>; if (programs.size > 0) { project.installedPrograms.set(id, new Set([...programs.keys()] as InstallableProgram[])); const active = [...programs.entries()].filter(([, entry]) => entry.get('active') === true).map(([program]) => program as InstallableProgram); if (active.length > 0) project.activePrograms.set(id, new Set(active)); } const files = map.get('files') as Y.Map> | undefined; if (files !== undefined) project.virtualFileSystems.set(id, fileSystemOf(files)); const own = map.get('own') as string | undefined; if (own !== undefined) project.ownApplicationsByNodeId.set(id, ownApplications(own)); } for (const [id, map] of inOrder(doc.getMap>(ROOTS.links))) { project.graph.links.push({ id, sourceNodeId: map.get('sourceNode') as Id, sourcePortId: map.get('sourcePort') as Id, targetNodeId: map.get('targetNode') as Id, targetPortId: map.get('targetPort') as Id, }); } project.documentationItems = inDeterministicRenderOrder( [...doc.getMap>(ROOTS.docs).entries()].map(([id, map]) => ({ id, kind: map.get('kind') as DocumentationItem['kind'], frame: { x: map.get('x') as number, y: map.get('y') as number, width: map.get('width') as number, height: map.get('height') as number }, text: (map.get('text') as Y.Text).toString(), color: { red: map.get('red') as number, green: map.get('green') as number, blue: map.get('blue') as number, alpha: map.get('alpha') as number }, fontName: map.get('font') as string, fontSize: map.get('size') as number, isBold: map.get('bold') as boolean, order: map.get('order') as number, })), ); return project; }