feat(doc): F4.1 the shared document — a project in a Y.Doc and back, the janitor
packages/doc holds the editor's model (ImportedProject) losslessly in a Yjs document (schema 1): ports with their settings, each node's settings as maps per field, programs, files as a flat path map in creation order (text as Y.Text), own Java applications, links, documentation items, archive entries. The core's snapshot and the .fls export go through the project. The janitor repairs dangling links, two links at one port and files without their folder, deterministically and idempotently. All 23 corpus files come back through a Yjs update with the same snapshot and export. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
// Public surface of the shared document.
|
||||
export * from './janitor.js';
|
||||
export * from './projectDoc.js';
|
||||
export * from './snapshot.js';
|
||||
export * from './values.js';
|
||||
@@ -0,0 +1,100 @@
|
||||
// The janitor (plan §5.3): what a merge of two documents can leave that the
|
||||
// model cannot hold, repaired deterministically. It depends only on the
|
||||
// document, never on the clock, so that a repair run twice changes nothing
|
||||
// the second time, and two replicas that repair the same document end
|
||||
// alike. Only F runs it, as a client of its own (origin 'janitor'); clients
|
||||
// draw tolerantly instead (a cable without an end is not drawn).
|
||||
//
|
||||
// I1 a cable to a node or port that is gone: removed
|
||||
// I2 two cables at one port (plugged at once): the one with the smaller id stays
|
||||
// I4 a file or folder without its folder: the folder made (paths compared
|
||||
// without case, as the file system does)
|
||||
//
|
||||
// I3 (a switch made smaller than its ports in use) cannot happen here: a
|
||||
// port is an entry of its node, and a cable at a removed port falls under
|
||||
// I1. I5 (a client writing `runtime`) comes with the simulation room (F5);
|
||||
// I6 (values out of range) is marked by the editor, never repaired.
|
||||
|
||||
import * as Y from 'yjs';
|
||||
import { parentPath, swiftLowercased } from '@mdg-filius/core';
|
||||
import { ROOTS } from './projectDoc.js';
|
||||
|
||||
export const JANITOR_ORIGIN = 'janitor';
|
||||
|
||||
/** What one run repaired, by invariant. */
|
||||
export interface Repairs {
|
||||
readonly danglingLinks: string[];
|
||||
readonly sharedPortLinks: string[];
|
||||
readonly madeFolders: string[];
|
||||
}
|
||||
|
||||
/** The ids of a node's ports. */
|
||||
function portIds(node: Y.Map<unknown> | undefined): Set<string> {
|
||||
const ports = node?.get('ports') as Y.Array<Y.Map<unknown>> | undefined;
|
||||
return new Set((ports?.toArray() ?? []).map((port) => port.get('id') as string));
|
||||
}
|
||||
|
||||
/** Repairs the document in one transaction (Yjs sends nothing for one that changed nothing). */
|
||||
export function repair(doc: Y.Doc): Repairs {
|
||||
const nodes = doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
|
||||
const links = doc.getMap<Y.Map<unknown>>(ROOTS.links);
|
||||
const danglingLinks: string[] = [];
|
||||
const sharedPortLinks: string[] = [];
|
||||
const madeFolders: string[] = [];
|
||||
|
||||
// I1, then I2 among the cables left, both in the order of their ids.
|
||||
const ids = [...links.keys()].sort();
|
||||
const plugged = new Set<string>();
|
||||
for (const id of ids) {
|
||||
const link = links.get(id)!;
|
||||
const ends = [
|
||||
[link.get('sourceNode') as string, link.get('sourcePort') as string],
|
||||
[link.get('targetNode') as string, link.get('targetPort') as string],
|
||||
] as const;
|
||||
if (ends.some(([node, port]) => !portIds(nodes.get(node)).has(port))) {
|
||||
danglingLinks.push(id);
|
||||
continue;
|
||||
}
|
||||
const keys = ends.map(([node, port]) => `${node}/${port}`);
|
||||
if (keys.some((key) => plugged.has(key))) {
|
||||
sharedPortLinks.push(id);
|
||||
continue;
|
||||
}
|
||||
for (const key of keys) plugged.add(key);
|
||||
}
|
||||
|
||||
// I4: each node's files, in the order of the nodes' ids.
|
||||
const folders: [Y.Map<Y.Map<unknown>>, string[]][] = [];
|
||||
for (const id of [...nodes.keys()].sort()) {
|
||||
const files = nodes.get(id)!.get('files') as Y.Map<Y.Map<unknown>> | undefined;
|
||||
if (files === undefined) continue;
|
||||
const known = (path: string) => swiftLowercased(path.normalize('NFC'));
|
||||
const present = new Set([...files.keys()].map(known));
|
||||
const missing = new Map<string, string>();
|
||||
for (const path of files.keys()) {
|
||||
for (let folder = parentPath(path); folder !== '/' && !present.has(known(folder)); folder = parentPath(folder)) {
|
||||
if (!missing.has(known(folder))) missing.set(known(folder), folder);
|
||||
}
|
||||
}
|
||||
if (missing.size === 0) continue;
|
||||
// Shallow first, so that a folder's folder comes before it.
|
||||
const made = [...missing.values()].sort((left, right) => left.split('/').length - right.split('/').length || (left < right ? -1 : left > right ? 1 : 0));
|
||||
folders.push([files, made]);
|
||||
madeFolders.push(...made.map((folder) => `${id}:${folder}`));
|
||||
}
|
||||
|
||||
doc.transact(() => {
|
||||
for (const id of [...danglingLinks, ...sharedPortLinks]) links.delete(id);
|
||||
for (const [files, made] of folders) {
|
||||
// A made folder comes before every entry it holds: below the smallest order.
|
||||
let order = Math.min(...[...files.values()].map((entry) => entry.get('order') as number)) - made.length;
|
||||
for (const folder of made) {
|
||||
const entry = new Y.Map<unknown>();
|
||||
entry.set('order', order++);
|
||||
entry.set('kind', 'directory');
|
||||
files.set(folder, entry);
|
||||
}
|
||||
}
|
||||
}, JANITOR_ORIGIN);
|
||||
return { danglingLinks, sharedPortLinks, madeFolders };
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
// 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[];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function nodeMap(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, nodeMap(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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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 = {
|
||||
versionLine: (meta.get('versionLine') as string | null | undefined) ?? 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(doc.getMap<Uint8Array>(ROOTS.archive).entries()),
|
||||
ownApplicationsByNodeId: new Map(),
|
||||
warnings: [],
|
||||
};
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Where the document meets the core and the file format: both through the
|
||||
// project it holds, so that the core never sees Yjs and the document never
|
||||
// the core's snapshot (plan §5.2).
|
||||
|
||||
import type * as Y from 'yjs';
|
||||
import type { TopologySnapshot } from '@mdg-filius/core';
|
||||
import { projectSnapshot, saveProjectArchive } from '@mdg-filius/fls';
|
||||
import { readProject } from './projectDoc.js';
|
||||
|
||||
/** The core's snapshot of the document, to start a simulation with. */
|
||||
export function docSnapshot(doc: Y.Doc): TopologySnapshot {
|
||||
return projectSnapshot(readProject(doc));
|
||||
}
|
||||
|
||||
/** The document as a .fls file, and what the export had to leave out. */
|
||||
export function docArchive(doc: Y.Doc): { readonly archive: Uint8Array; readonly warnings: string[] } {
|
||||
return saveProjectArchive(readProject(doc));
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Plain values in the document and back. A setting's object becomes a Y.Map
|
||||
// of its fields, so that two people changing two fields of one device both
|
||||
// keep their change (the last one wins only on the same field); a list
|
||||
// becomes a Y.Array, so that two rows added at once are both kept. Text,
|
||||
// numbers, booleans, null and bytes stay as they are.
|
||||
|
||||
import * as Y from 'yjs';
|
||||
|
||||
/** What a setting is made of: JSON's values and bytes. */
|
||||
export type PlainValue = null | string | number | boolean | Uint8Array | readonly PlainValue[] | { readonly [key: string]: PlainValue | undefined };
|
||||
|
||||
/** The value as the document holds it; a field that is undefined is left out (exactOptionalPropertyTypes). */
|
||||
export function toY(value: PlainValue): unknown {
|
||||
if (value === null || typeof value !== 'object' || value instanceof Uint8Array) return value;
|
||||
if (Array.isArray(value)) {
|
||||
const list = new Y.Array<unknown>();
|
||||
list.push((value as readonly PlainValue[]).map(toY));
|
||||
return list;
|
||||
}
|
||||
const map = new Y.Map<unknown>();
|
||||
for (const [key, field] of Object.entries(value)) if (field !== undefined) map.set(key, toY(field));
|
||||
return map;
|
||||
}
|
||||
|
||||
/** The plain value of what the document holds. */
|
||||
export function fromY(value: unknown): PlainValue {
|
||||
if (value instanceof Y.Map) return Object.fromEntries([...value.entries()].map(([key, field]) => [key, fromY(field)]));
|
||||
if (value instanceof Y.Array) return value.toArray().map(fromY);
|
||||
return value as PlainValue;
|
||||
}
|
||||
Reference in New Issue
Block a user