Oracle TextEditorWindowRun uses Java's text editor window and its file dialog as a person does. The open file's text is its Y.Text in the document (plan 7.3): typed is in the file at once, for everyone and for the running engine. Core openText/saveTextAs without Java's mistakes (lost saves after save-as onto a file or a removed file, files named 'ordner/d.txt' or ''); doc files.ts (changeFiles, fileText, nodeFiles, readFilesInto); the room takes a person's files into the engine at once and writes the engine's files back at the tick they changed; the editor's window, Java's dialog, the text area following others' changes with the caret in place, its own undo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
344 lines
15 KiB
TypeScript
344 lines
15 KiB
TypeScript
// 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<Record<NodeSettingsField, string>> = {
|
|
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<unknown> {
|
|
const entry = new Y.Map<unknown>();
|
|
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<Y.Map<unknown>> {
|
|
const map = new Y.Map<Y.Map<unknown>>();
|
|
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<unknown> {
|
|
const map = new Y.Map<unknown>();
|
|
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<Y.Map<unknown>>();
|
|
ports.push(
|
|
node.ports.map((port) => {
|
|
const entry = new Y.Map<unknown>();
|
|
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<unknown>();
|
|
for (const field of SETTINGS_FIELDS) {
|
|
const value = (project[field] as ReadonlyMap<Id, unknown>).get(node.id);
|
|
if (value !== undefined) settings.set(NODE_SETTINGS[field], toY(value as PlainValue));
|
|
}
|
|
map.set('settings', settings);
|
|
const programs = new Y.Map<Y.Map<unknown>>();
|
|
for (const program of project.installedPrograms.get(node.id) ?? []) {
|
|
const entry = new Y.Map<unknown>();
|
|
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<unknown> {
|
|
const map = new Y.Map<unknown>();
|
|
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<unknown> {
|
|
const map = new Y.Map<unknown>();
|
|
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<Y.Map<unknown>>(ROOTS.nodes);
|
|
project.graph.nodes.forEach((node, index) => nodes.set(node.id, nodeEntry(node, index, project)));
|
|
const links = doc.getMap<Y.Map<unknown>>(ROOTS.links);
|
|
project.graph.links.forEach((link, index) => links.set(link.id, linkMap(link, index)));
|
|
const docs = doc.getMap<Y.Map<unknown>>(ROOTS.docs);
|
|
for (const item of project.documentationItems) docs.set(item.id, documentationMap(item));
|
|
const archive = doc.getMap<Uint8Array>(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<Y.Map<unknown>>): [string, Y.Map<unknown>][] {
|
|
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<Y.Map<unknown>>): 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<Uint8Array>(ROOTS.archive).entries()),
|
|
};
|
|
for (const [id, map] of inOrder(doc.getMap<Y.Map<unknown>>(ROOTS.nodes))) {
|
|
const ports = (map.get('ports') as Y.Array<Y.Map<unknown>>).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<unknown>;
|
|
for (const field of SETTINGS_FIELDS) {
|
|
const value = settings.get(NODE_SETTINGS[field]);
|
|
if (value !== undefined) (project[field] as Map<Id, unknown>).set(id, fromY(value));
|
|
}
|
|
const programs = map.get('programs') as Y.Map<Y.Map<unknown>>;
|
|
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<Y.Map<unknown>> | 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<Y.Map<unknown>>(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<Y.Map<unknown>>(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;
|
|
}
|