feat(core): the file explorer, text editor and image viewer's file operations after Java, measured against Java
FileCases does what the windows of FILIUS' file explorer and text editor do to a host's files: new folder, delete, copy, cut and paste, rename, import, save and save as. The core's filePrograms.ts does the same on the node's file system; the file system renames an entry in its place, as Java's tree does. Departures from Java: a folder name that comes to no name makes nothing, a paste onto a name there already is refused, a rename to a name with a slash or blanks at its ends is refused and one of the case only is done, save as and import onto a folder's name are refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -26,6 +26,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
|
||||
| `src/runtime/emailAccountsFile.ts` | Java `EmailServer.listeBenutzerkontenZuString` | neu (F3.5a): die Kontendatei des Mailservers aus seinen Konten, aus `packages/fls` (Export) hierher verschoben, damit Export und Laufzeit dasselbe schreiben; Javas Fehler wie dort nicht übernommen |
|
||||
| `src/runtime/apps/emailServer.ts` | Java `EmailServer`, `SMTPServer`, `SMTPMitarbeiter`, `POP3Server`, `POP3Mitarbeiter`, `EmailKonto` | neu nach Java (F3.3a): Konten einer Domain, SMTP auf Port 25 speichert Mails an eigene Konten (wie Java neu gelesen) und schickt bei unbekannten Empfängern eine Benachrichtigung an einen Absender der Domain, POP3 auf Port 110 gibt Mails ab 0 nummeriert aus; beide zusammen ein- und ausgeschaltet, POP3 zuerst; SMTP-Antworten in der Sprache des Projekts; Fensterzeilen wie Java. An Java-Filius gemessen (`EmailRun`, `MailCases`). Abweichend von Java: nach einer Mail ist der Umschlag neu (Java antwortet dem nächsten Befehl 503 und behält Absender, Empfänger und Text); ein Befehl, den der Zustand nicht nimmt, bekommt 503 (Java schweigt), HELO beginnt neu; in DATA ist alles Text, auch `quit`; die Domain gilt in jeder Schreibweise; ein Empfänger ohne `@` ist unbekannt, ein leerer Absender bekommt keine Benachrichtigung (Java wirft), eine Benachrichtigung an eine Mail ohne From geht an den Absender des Umschlags (Java: `To: null`); POP3: eine Mail lässt sich mehrmals abrufen, eine fehlende Mail, `LIST` ohne Mails und falsche Argumente geben `-ERR` (Java: leere Mail, leere Antwort, Abbruch), `LIST n` listet die eine Mail, Nummern bleiben nach `DELE` (Javas `LIST` zählt neu), vor der Anmeldung `-ERR unknown command`, `QUIT` immer `+OK`. Seit F3.3b leitet der Server Mails an fremde Domains per MX weiter (in seinem eigenen Thread, je Weiterleitung ein neuer SMTP-Client) und schickt eine Benachrichtigung an einen fremden Absender mit leerem Absender an dessen Domain. Noch nicht: die Kontodatei `/mailserver/konten.txt` zur Laufzeit |
|
||||
| `src/runtime/apps/emailClient.ts` | Java `EmailAnwendung`, `SMTPClient`, `POP3Client`, das Senden aus `GUIApplicationEmailAnwendungWindow` | neu nach Java (F3.3b): das E-Mail-Programm sendet eine Mail an den SMTP-Server seines Kontos, eine Verbindung je Empfänger (An, Cc, Bcc), und holt die Mails seines Kontos vom POP3-Server (jede abgerufen, dann gelöscht); die Prüfungen des Fensters (kein Konto, Adresse, die Javas Muster nicht nimmt, kein Empfänger); Meldungen wie Javas Beobachter (Verbindung, versendet, getrennt, Ausnahmen); der SMTP-Client dient auch dem Server zum Weiterleiten. An Java-Filius gemessen (`EmailClientRun`). Abweichend von Java: der SMTP-Port des Kontos gilt (Java verbindet immer zu 25); drei SYN statt einem |
|
||||
| `src/runtime/apps/filePrograms.ts` | Java `FileExplorer`, `TextEditor`, `ImageViewer` und ihre Fenster, `config/filetypes.txt`, die Meldungen `sw_fileexplorer_msg1`–`3`, `fileexplorer_msg19` | neu nach Java (F3.5b): die Dateioperationen hinter den Knöpfen von Datei-Explorer (Ordnerliste, neuer Ordner, löschen, kopieren/ausschneiden/einfügen, umbenennen, importieren), Texteditor (speichern, speichern unter) und Bildbetrachter, auf den Dateien des Knotens; an Java-Filius gemessen (`FileCases`). Abweichend von Java: ein Ordnername, der zu keinem Namen wird, legt nichts an; Einfügen auf einen vorhandenen Namen wird abgelehnt (Java: zweiter Eintrag gleichen Namens); Umbenennen auf einen Namen mit `/` oder mit Leerzeichen am Rand wird abgelehnt, eine reine Änderung der Groß-/Kleinschreibung ausgeführt (Java lehnt sie ab); „Speichern unter“ und Import auf einen Ordnernamen werden abgelehnt (Java wirft); die Größe einer Textdatei sind ihre Zeichen |
|
||||
| `src/runtime/apps/terminal.ts`, `src/runtime/apps/terminalTexts.ts` | Java `filius.software.lokal.Terminal`, die Eingabe aus `GUIApplicationTerminalWindow`, die Texte `sw_terminal_*` und `terminal_msg*` des Bündels | neu nach Java (F3.4a): die Befehlszeile mit Javas Zerlegung der Eingabe, Verteilung nach dem Befehlsnamen und den lokalen Befehlen `ls`/`dir`, `cd`, `pwd`, `mkdir`, `touch`, `cp`/`copy`, `mv`/`move`, `rm`/`del`, `cat`/`type`, `test`, `help`, `ipconfig`, `route`; Texte in der Sprache des Projekts, aus dem von Java geladenen Bündel erzeugt. An Java-Filius gemessen (`TerminalCases`). Abweichend von Java: die Größe einer Textdatei sind ihre Zeichen (Java dekodiert Base64); ein fehlender Ordner ist für `ls` „nicht vorhanden“, `ls /` nennt die Wurzel; `cd` auf eine Datei, `touch`/`mkdir` in fehlenden Ordnern und `cp` auf einen Ordnernamen scheitern; `..` bleibt an der Wurzel; `rm` und `mv` nehmen absolute Pfade absolut, die Wurzel ist nicht löschbar, nach dem Löschen des Arbeitsordners gilt der nächste verbliebene darüber; `mv` auf die Datei selbst benennt sie um. Seit F3.4b die Netzbefehle `ping` (Strg+C über `interrupt`), `host`, `nslookup`, `arp` und `netstat`, mit Javas Laufmarken; ein unbekannter Befehl wird wie bei Java sofort gemeldet, auch während ein anderer läuft; seit F3.4c `traceroute` und `tcpdump` (`arpsend` nicht gebaut, der Befehl ist unbekannt); an Java-Filius gemessen (`TerminalRun`). Abweichend von Java: `ping` nennt den Host, wo Java seinen ersten Parameter nennt (`-b` in der Statistik), und nimmt vor dem Host nur `-b` (Java nimmt jedes Wort und pingt einen Broadcast); `arp` und `netstat` listen in der Reihenfolge des Lernens bzw. Anlegens (Java: Reihenfolge einer `HashMap`); `netstat` zeigt von einem UDP-Server nur den lauschenden Socket (Java auch einen für jede je beantwortete Adresse, nie geschlossen); `traceroute` sagt sein Fazit vor der Endmarke (Java danach, wo sein Fenster keinen Text mehr zeigt) und nennt ein Ziel beim 20. Sprung erreicht (Java: nicht erreicht); `tcpdump` meldet jeden Frame sofort (Java schaut alle 100 ms) |
|
||||
| `src/runtime/apps/echo.ts` | Java `filius.software.clientserver` (`ServerBaustein`, `ServerBausteinMitarbeiter`, `ClientAnwendung`, `ClientBaustein`) | neu nach Java statt nach Swift (F3.1): Echo-Server (auf `server.ts`, sendet jede Nachricht zurück) und einfacher Client (verbinden über Adresse oder Namen, senden, empfangen, trennen), mit Javas Fensterzeilen. An Java-Filius gemessen. Abweichend von Java: Der Server merkt das Ausschalten, der Client die Verbindung sofort (Java alle 100 ms); ohne Route scheitert der Client sofort (Java wartet ewig ohne Meldung); ausgeschaltet bestätigt der Server noch das FIN seiner Verbindungen (Java gibt den Port frei, das FIN bleibt unbeantwortet) |
|
||||
| `src/runtime/dns.ts` | Java `filius.software.dns.DNSNachricht`, `Query`, `ResourceRecord` | neu nach Java statt nach Swift (Swift ersetzt Javas Textnachrichten durch `FILIUS-DNS-TYPED/1`): Kopfzeile `ID=… QR=… RCODE=… QDCOUNT=… …`, Frage- und Eintragszeilen, Suche nach Namen ohne Rücksicht auf Groß- und Kleinschreibung, NS der längsten Zone, sonst der Wurzel |
|
||||
@@ -40,7 +41,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
|
||||
| `src/runtime/capture.ts` | Java `filius.rahmenprogramm.nachrichten.Lauscher` | neu nach Java (Swift hat stattdessen die Paketspur je Schritt): der Mitschnitt je Netzwerkkarte, jeder gesendete und empfangene Frame einmal mit Zeit und Nummer, wie Java auch Frames an andere Karten und solche, die die Firewall verwirft; Switches ohne Liste; verlorene Frames getrennt; bleibt über Stopp und Neustart, geleert nur auf Wunsch. Abweichend von Java: je Port statt je MAC-Adresse (Java legt zwei Karten mit gleicher MAC zusammen) und höchstens 10.000 Frames je Karte, die ältesten 1.000 fallen auf einmal heraus, die Nummern bleiben (Java behält alles) |
|
||||
| `src/util/equal.ts` | — | neu (Ersatz für Swifts `Equatable`) |
|
||||
| `src/util/swiftString.ts` | — | neu: Swift-String-Verhalten, das der übertragene Code stillschweigend voraussetzt (Trimmen, Kleinschreibung je Skalar, kanonische Gleichheit, Sortierung nach Skalarwerten, Base64 wie Foundation); am Swift-Orakel gemessen, Leerraum und Steuerzeichen über ganz Unicode |
|
||||
| `src/model/virtualFileSystem.ts`, `src/model/defaultImages.ts` | `State/TopologyVirtualFileSystem.swift` | übertragen; Schlüssel in NFC statt Swifts kanonischer Wörterbuch-Gleichheit; Steuerzeichen nach Unicode Cc+Cf, ohne Foundations Tabellenfehler in Ebene 14; seit F3.4a die Einträge eines Ordners auch in der Reihenfolge ihres Entstehens, wie Javas Dateibaum (`entriesInCreationOrder`) |
|
||||
| `src/model/virtualFileSystem.ts`, `src/model/defaultImages.ts` | `State/TopologyVirtualFileSystem.swift` | übertragen; Schlüssel in NFC statt Swifts kanonischer Wörterbuch-Gleichheit; Steuerzeichen nach Unicode Cc+Cf, ohne Foundations Tabellenfehler in Ebene 14; seit F3.4a die Einträge eines Ordners auch in der Reihenfolge ihres Entstehens, wie Javas Dateibaum (`entriesInCreationOrder`); seit F3.5b `renameInPlace`: ein Eintrag behält beim Umbenennen seine Stelle, auch bei reiner Änderung der Groß-/Kleinschreibung, wie Javas Knoten (`renameItem` bleibt Swifts Verschieben) |
|
||||
| `test/virtualFileSystem.test.ts`, `test/vfs.*.json` | `TopologyProjectPersistenceTests.swift`, `TopologyEditorReducerTests.swift` (drei Dateisystem-Tests, als Fälle) | übertragen und erweitert: 11 Fälle, Ergebnis aus dem Swift-Kern |
|
||||
| `test/strings.test.ts`, `test/strings.*.json` | — | neu: Fälle und ganze Zeichenmengen, Ergebnis aus dem Swift-Kern |
|
||||
| `test/engine.test.ts` | `TopologyNetworkRuntimeEngineTests.swift` (21 von 72 Tests) | übertragen; `nextInt`- und `nextDouble`-Referenzwerte an OpenJDK 21 gemessen; die DHCP-Client-Tests seit F2.3b nach Javas Verhalten (neun, davon zwei benannte Java-Fehler, die der Kern nicht übernimmt); seit F2.8a sechs Tests für den Mitschnitt (über Neustart, Leeren, Obergrenze, verlorene Frames, Switches) |
|
||||
@@ -59,6 +60,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
|
||||
| `test/emailJava.test.ts`, `test/scenarios/mail-lan.scenario.json`, `test/scenarios/email-java-*.java.json` | — | neu (F3.3a): vier Abläufe mit dem E-Mail-Server gegen Java-Filius (`EmailRun`): SMTP, SMTP mit Fehlern und Benachrichtigung, POP3 zweimal angemeldet, POP3-Anmeldefehler; je Karte Frames, Zeiten, Tabellen, die Fensterzeilen von Server und Client und die Konten am Ende (das Datum der Benachrichtigung als Platzhalter); seit F3.5a auch die Kontendatei am Ende, wie Java die Konten schreibt (und wo Javas hinterlassene Datei hinterherhinkt) |
|
||||
| `test/emailClientJava.test.ts`, `test/scenarios/email-client-java-*.java.json` | — | neu (F3.3b): sechs Abläufe mit dem E-Mail-Programm und zwei Servern gegen Java-Filius (`EmailClientRun`): senden mit Weiterleitung, abholen, Server per Name und Benachrichtigungen von beiden Servern, falsches Passwort, kein Server, unbekannter Servername; je Karte Frames, Zeiten, Tabellen, die Meldungen von Programm und Servern, Posteingang, Gesendete und Konten am Ende; Javas 100-ms-Blick kostet je Verbindung (und je DNS-Antwort, auf die es so wartet) eine Periode Toleranz; ein benannter Java-Fehler (nur ein SYN); bei `byName` die Startnummern ohne die Million (Thread-Wettlauf in Java) |
|
||||
| `test/emailClient.test.ts` | — | neu (F3.3b): was das Fenster ablehnt, Bcc, der SMTP-Port des Kontos, eine ungültige Kontoadresse, eine Verbindung, die beim Warten endet, eine Mail ohne Absender aus dem Modell, die Ordner im Schnappschuss |
|
||||
| `test/filePrograms.test.ts`, `test/file-programs.cases.txt`, `test/file-programs.{de,en}.java.json` | — | neu (F3.5b): sechs Fälle der Dateiprogramme gegen Java-Filius (`FileCases`), je Schritt Rückgabe und alle Dateien, Deutsch und Englisch, benannte Java-Fehler (`JAVA_BUGS`); dazu Dateitypen, Ordnerliste, Bildbetrachter, leerer Ordnername |
|
||||
| `test/terminal.test.ts`, `test/terminal.cases.txt`, `test/terminal.{de,en}.java.json` | — | neu (F3.4a): 16 Fälle der Befehlszeile gegen Java-Filius (`TerminalCases`), je Zeile die Handlung des Fensters, was das Programm sagt, Arbeitsordner und alle Dateien, Deutsch und Englisch; Javas Größe von Textdateien überall als Java-Fehler korrigiert, dazu acht Fälle mit benannten Java-Fehlern (`JAVA_BUGS`); ein Bild bleibt beim Kopieren ein Bild; jeder Text der Tabelle ist wörtlich Javas |
|
||||
| `test/terminalJava.test.ts`, `test/scenarios/terminal-net.scenario.json`, `test/scenarios/terminal-java-*.java.json` | — | neu (F3.4b): zwölf Abläufe der Netzbefehle gegen Java-Filius (`TerminalRun`) auf einem LAN mit DNS-Server, Router und Rechner dahinter: `ping` im LAN, über den Router, ins Leere, hinter dem Router unerreichbar, ohne Route am Router, Broadcast, eigene Adresse, Fehler, Strg+C; `host` und `nslookup`, auch ohne erreichbaren DNS-Server; `arp`; `netstat` mit TCP-Verbindung und DNS-Server; je Karte die Frames samt Javas Tabellen, je Rechner Texte und Laufmarken mit Zeit; benannte Java-Fehler (`JAVA_BUGS`); die Reihenfolge der Antworten auf einen Broadcast ist ein Wettlauf von Javas Threads (je Sekunde als Menge verglichen); seit F3.4c neun Abläufe mit `traceroute` (LAN, über den Router, unerreichbar, ohne Route, ins Leere, eigene Adresse, Fehler, Strg+C) und `tcpdump` auf zwei Rechnern; ein Wächter verwirft Aufnahmen, in denen Java sein Aufwecken verpasst hat; dazu zehn Einzelfälle (traceroute über 20 Sprünge, Strg+C vor dem Start, fremde Echo-Antworten, gerundeter Verlust, Broadcast bei anderer Maske, zufälliger Identifier und sein Weiterzählen, gleich lange Routen) |
|
||||
| `test/apps.test.ts` | — | neu (F3.1): wie Threads aufwachen (nach dem Ereignis, nach einer Pause hinter den anderen, ein Weckruf nur für sein Warten, Reihenfolge des Beginns, kein Warten auf schon Geltendes), und was die Java-Abläufe nicht zeigen: belegter Port, Name über DNS und ohne DNS, keine Route, unbestätigtes Senden, Neustart, eine Nachricht vor dem Schließen |
|
||||
|
||||
@@ -26,6 +26,7 @@ export * from './runtime/apps/routerPages.js';
|
||||
export * from './runtime/apps/firewallSettings.js';
|
||||
export * from './runtime/apps/emailServer.js';
|
||||
export * from './runtime/apps/emailClient.js';
|
||||
export * from './runtime/apps/filePrograms.js';
|
||||
export * from './runtime/apps/terminal.js';
|
||||
export * from './runtime/apps/terminalTexts.js';
|
||||
export * from './runtime/email.js';
|
||||
|
||||
@@ -343,6 +343,32 @@ export class VirtualFileSystem {
|
||||
return this.existingPath(destination);
|
||||
}
|
||||
|
||||
/**
|
||||
* Java's rename (Datei.setName, DefaultMutableTreeNode.setUserObject): the
|
||||
* entry keeps its place among its folder's entries, also when only its
|
||||
* case changes; another entry of the folder may not have the name yet.
|
||||
* Returns the new path.
|
||||
*/
|
||||
renameInPlace(rawPath: string, newName: string): string {
|
||||
if (newName.includes('/') || newName.includes('\\')) fail('invalidPathComponent', newName);
|
||||
const sourcePath = this.existingPath(rawPath);
|
||||
if (sourcePath === '/') fail('cannotMutateRoot');
|
||||
const destination = normalizedAbsolutePath(join(parentPath(sourcePath), newName));
|
||||
if (entryName(destination) !== newName) fail('invalidPathComponent', newName);
|
||||
const taken = this.existingPathIfPresent(destination);
|
||||
if (taken !== undefined && !swiftEqual(taken, sourcePath)) fail('itemAlreadyExists', destination);
|
||||
this.transaction((candidate) => {
|
||||
const store: Store = new Map();
|
||||
for (const [key, entry] of candidate.store) {
|
||||
const inside = entry.path === sourcePath || entry.path.startsWith(`${sourcePath}/`);
|
||||
const path = inside ? destination + entry.path.slice(sourcePath.length) : entry.path;
|
||||
store.set(inside ? keyOf(path) : key, { path, content: entry.content });
|
||||
}
|
||||
candidate.store = store;
|
||||
});
|
||||
return destination;
|
||||
}
|
||||
|
||||
deleteItem(rawPath: string, recursive = false): void {
|
||||
const path = this.existingPath(rawPath);
|
||||
if (path === '/') fail('cannotMutateRoot');
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// What FILIUS' file explorer, text editor and image viewer do to a host's
|
||||
// files (filius.software.lokal.FileExplorer, TextEditor, ImageViewer; most of
|
||||
// it in their windows, GUIApplicationFileExplorerWindow,
|
||||
// GUIApplicationTextEditorWindow, GUIApplicationImageViewerWindow): the
|
||||
// operations behind their buttons, on the node's file system, for the
|
||||
// editor's windows to call. The files are the project's own, so what they
|
||||
// change is in a project saved afterwards.
|
||||
//
|
||||
// Departures from Java (filePrograms.test.ts names each):
|
||||
// - a new folder's name that comes to no name (".." at the root) makes no
|
||||
// folder (Java makes one without a name);
|
||||
// - a paste where the folder has the name already is refused (Java adds a
|
||||
// second entry of the same name);
|
||||
// - a rename to a name with "/" is refused (Java makes a file whose name
|
||||
// holds the separator), as one to a name with blanks at its ends (the
|
||||
// file system's rule), and one that changes only the case is done (Java
|
||||
// finds the entry itself and refuses);
|
||||
// - "save as" onto a folder's name, and an import onto it, is refused (Java
|
||||
// throws);
|
||||
// - a text file's size is its length in characters (as the command line's).
|
||||
|
||||
import type { VirtualFileContent, VirtualFileSystem } from '../../model/virtualFileSystem.js';
|
||||
import { entryName, parentPath } from '../../model/virtualFileSystem.js';
|
||||
import type { PageLanguage } from './routerPages.js';
|
||||
import { evaluatedPath } from './terminal.js';
|
||||
|
||||
/** FILIUS' config/filetypes.txt: a file's type by its extension. */
|
||||
const FILE_TYPES: Readonly<Record<string, 'text' | 'sound' | 'image'>> = {
|
||||
txt: 'text',
|
||||
cfg: 'text',
|
||||
bat: 'text',
|
||||
sh: 'text',
|
||||
html: 'text',
|
||||
htm: 'text',
|
||||
ini: 'text',
|
||||
php: 'text',
|
||||
xml: 'text',
|
||||
mp3: 'sound',
|
||||
mp2: 'sound',
|
||||
wav: 'sound',
|
||||
jpeg: 'image',
|
||||
jpg: 'image',
|
||||
bmp: 'image',
|
||||
tiff: 'image',
|
||||
png: 'image',
|
||||
};
|
||||
|
||||
/** An image's media type by its extension. */
|
||||
const IMAGE_MEDIA_TYPES: Readonly<Record<string, string>> = { jpeg: 'image/jpeg', jpg: 'image/jpeg', bmp: 'image/bmp', tiff: 'image/tiff', png: 'image/png' };
|
||||
|
||||
/** FileExplorer.getFileType: the type of the last extension; a name without one is text, an unknown one has none. */
|
||||
export function javaFileType(name: string): 'text' | 'sound' | 'image' | undefined {
|
||||
const parts = name
|
||||
.toLowerCase()
|
||||
.split('.')
|
||||
.filter((part) => part !== '');
|
||||
if (parts.length < 2) return 'text';
|
||||
return Object.hasOwn(FILE_TYPES, parts.at(-1)!) ? FILE_TYPES[parts.at(-1)!] : undefined;
|
||||
}
|
||||
|
||||
/** FileExplorer.addFile: the largest file it imports, in bytes. */
|
||||
export const IMPORT_MAXIMUM_BYTES = 150_000;
|
||||
|
||||
/** The explorer's messages (the import's, the paste's refusal), as Java's message bundle gives them. */
|
||||
export const FILE_EXPLORER_TEXTS: Readonly<Record<PageLanguage, Readonly<Record<'imported' | 'notImported' | 'tooLarge' | 'invalidTarget', string>>>> = {
|
||||
de: {
|
||||
imported: 'Datei erfolgreich importiert!',
|
||||
notImported: 'Datei konnte nicht importiert werden',
|
||||
tooLarge: 'Datei existiert nicht oder ist zu groß (> 150KB)',
|
||||
invalidTarget: 'Unzulässiger Zielordner!',
|
||||
},
|
||||
en: {
|
||||
imported: 'File successfully imported!',
|
||||
notImported: 'File could not be imported.',
|
||||
tooLarge: 'File does not exist or is too large (> 150KB)',
|
||||
invalidTarget: 'Invalid target folder!',
|
||||
},
|
||||
};
|
||||
|
||||
/** An entry of a folder as the explorer lists it (ordnerInhaltAnzeigen), in the order it was made. */
|
||||
export interface FolderEntry {
|
||||
readonly kind: 'file' | 'folder';
|
||||
readonly name: string;
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
/** What the explorer copied or cut, to paste. */
|
||||
export interface Clipboard {
|
||||
readonly path: string;
|
||||
readonly cut: boolean;
|
||||
}
|
||||
|
||||
function found(files: VirtualFileSystem, path: string): { readonly path: string; readonly content: VirtualFileContent } | undefined {
|
||||
try {
|
||||
return files.entry(path);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const childPath = (folder: string, name: string) => `${folder === '/' ? '' : folder}/${name}`;
|
||||
|
||||
/** The folder's entries, files and folders, in the order they were made; none when it is no folder. */
|
||||
export function folderEntries(files: VirtualFileSystem, folder: string): FolderEntry[] {
|
||||
if (found(files, folder)?.content.kind !== 'directory') return [];
|
||||
return files.entriesInCreationOrder(folder).map((entry) => ({ kind: entry.content.kind === 'directory' ? 'folder' : 'file', name: entryName(entry.path), path: entry.path }));
|
||||
}
|
||||
|
||||
/**
|
||||
* "New folder" (Dateisystem.erstelleVerzeichnis): the name taken as a path
|
||||
* from the folder ("a/b" makes b in a); nothing when the name is empty or
|
||||
* there already. False when its folder is not there, or the name comes to
|
||||
* no name.
|
||||
*/
|
||||
export function makeFolder(files: VirtualFileSystem, folder: string, name: string): boolean {
|
||||
if (name === '') return false;
|
||||
const path = evaluatedPath(name.startsWith('/') ? name : `${folder}/${name}`);
|
||||
if (path === '/') return false;
|
||||
if (found(files, parentPath(path))?.content.kind !== 'directory') return false;
|
||||
if (found(files, path) === undefined) files.createDirectory(path);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** "Delete": the entry of the folder, a folder with all in it; false when it is not there. */
|
||||
export function deleteEntry(files: VirtualFileSystem, folder: string, name: string): boolean {
|
||||
const entry = found(files, childPath(folder, name));
|
||||
if (entry === undefined || entry.path === '/') return false;
|
||||
files.deleteItem(entry.path, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Paste": a copy of the copied or cut entry, with all in it, added to the
|
||||
* folder (at the end of its entries); a cut one removed from where it was.
|
||||
* Not into the entry itself or below it (invalidTarget), not where the
|
||||
* folder has an entry of that name (nameTaken; a cut entry pasted into its
|
||||
* own folder moves to the end).
|
||||
*/
|
||||
export function pasteEntry(files: VirtualFileSystem, clipboard: Clipboard, folder: string): 'pasted' | 'invalidTarget' | 'nameTaken' | 'missing' {
|
||||
const source = found(files, clipboard.path);
|
||||
const target = found(files, folder);
|
||||
if (source === undefined || target?.content.kind !== 'directory') return 'missing';
|
||||
const lower = (path: string) => path.toLowerCase();
|
||||
if (lower(target.path) === lower(source.path) || lower(target.path).startsWith(`${lower(source.path)}/`)) return 'invalidTarget';
|
||||
const destination = childPath(target.path, entryName(source.path));
|
||||
const taken = found(files, destination);
|
||||
if (taken !== undefined && !(clipboard.cut && taken.path === source.path)) return 'nameTaken';
|
||||
// The entry and all below it in preorder, each folder's entries in their order (Java's deep copy keeps it).
|
||||
const below = (path: string): { path: string; content: VirtualFileContent }[] =>
|
||||
files.entriesInCreationOrder(path).flatMap((entry) => [entry, ...(entry.content.kind === 'directory' ? below(entry.path) : [])]);
|
||||
const subtree = [source, ...(source.content.kind === 'directory' ? below(source.path) : [])].map((entry) => ({
|
||||
path: destination + entry.path.slice(source.path.length),
|
||||
content: entry.content,
|
||||
}));
|
||||
if (clipboard.cut) files.deleteItem(source.path, true);
|
||||
for (const entry of subtree) write(files, entry.path, entry.content);
|
||||
return 'pasted';
|
||||
}
|
||||
|
||||
/**
|
||||
* "Rename": a name that is not blank and that no other entry of the folder
|
||||
* has yet; the entry keeps its place. False otherwise, and for a name with
|
||||
* "/".
|
||||
*/
|
||||
export function renameEntry(files: VirtualFileSystem, folder: string, name: string, newName: string): boolean {
|
||||
const entry = found(files, childPath(folder, name));
|
||||
if (entry === undefined || entry.path === '/') return false;
|
||||
// The file system refuses an empty name, one with "/", one around blanks and one another entry has.
|
||||
try {
|
||||
files.renameInPlace(entry.path, newName);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Writes the content at the path, a file over a file of that name. */
|
||||
function write(files: VirtualFileSystem, path: string, content: VirtualFileContent): void {
|
||||
if (content.kind === 'directory') files.createDirectory(path);
|
||||
else if (content.kind === 'text') files.writeTextFile(path, content.text);
|
||||
else if (content.kind === 'image') files.writeImageFile(path, content.data, content.mediaType);
|
||||
else if (content.mediaType === undefined) files.writeBinaryFile(path, content.data);
|
||||
else files.writeBinaryFile(path, content.data, content.mediaType);
|
||||
}
|
||||
|
||||
/** A file's path in the folder for a name: the entry of that name in any case, kept as it is, else a new one. */
|
||||
function fileTarget(files: VirtualFileSystem, folder: string, name: string): { readonly path: string; readonly isFolder: boolean } | undefined {
|
||||
if (found(files, folder)?.content.kind !== 'directory') return undefined;
|
||||
const existing = found(files, childPath(folder, name));
|
||||
return existing === undefined ? { path: childPath(found(files, folder)!.path, name), isFolder: false } : { path: existing.path, isFolder: existing.content.kind === 'directory' };
|
||||
}
|
||||
|
||||
/**
|
||||
* "Import" (FileExplorer.addFile): a local file into the folder, under the
|
||||
* new name ("noName" when empty), its type by the local file's name. A text
|
||||
* file line by line, each line ended with CR LF; an image as an image; any
|
||||
* other as its bytes. Over one of that name its content (and kind), the
|
||||
* name kept as it is.
|
||||
*/
|
||||
export function importFile(files: VirtualFileSystem, folder: string, newName: string, localName: string, bytes: Uint8Array): 'imported' | 'tooLarge' | 'notImported' {
|
||||
if (bytes.length > IMPORT_MAXIMUM_BYTES) return 'tooLarge';
|
||||
const target = fileTarget(files, folder, newName === '' ? 'noName' : newName);
|
||||
if (target === undefined || target.isFolder) return 'notImported';
|
||||
const type = javaFileType(localName);
|
||||
if (type === 'text') {
|
||||
// BufferedReader.readLine: a line ends at LF, CR or CR LF; the last one may end without.
|
||||
const lines = new TextDecoder().decode(bytes).split(/\r\n|\r|\n/);
|
||||
if (lines.at(-1) === '') lines.pop();
|
||||
files.writeTextFile(target.path, lines.map((line) => `${line}\r\n`).join(''));
|
||||
} else if (type === 'image') {
|
||||
files.writeImageFile(target.path, bytes, IMAGE_MEDIA_TYPES[localName.toLowerCase().split('.').at(-1)!]!);
|
||||
} else {
|
||||
files.writeBinaryFile(target.path, bytes);
|
||||
}
|
||||
return 'imported';
|
||||
}
|
||||
|
||||
/** The editor's "save": the open file's text. */
|
||||
export function saveText(files: VirtualFileSystem, path: string, text: string): void {
|
||||
files.writeTextFile(path, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's "save as" (Dateisystem.speicherDatei): the text as a file of
|
||||
* the folder, over one of that name in any case (its name kept as it is).
|
||||
*/
|
||||
export function saveTextAs(files: VirtualFileSystem, folder: string, name: string, text: string): 'saved' | 'folderMissing' | 'isFolder' {
|
||||
const target = fileTarget(files, folder, name);
|
||||
if (target === undefined) return 'folderMissing';
|
||||
if (target.isFolder) return 'isFolder';
|
||||
files.writeTextFile(target.path, text);
|
||||
return 'saved';
|
||||
}
|
||||
|
||||
/** The image viewer's "open": an image file's bytes and media type; none for any other file. */
|
||||
export function imageFile(files: VirtualFileSystem, path: string): { readonly data: Uint8Array; readonly mediaType: string } | undefined {
|
||||
const entry = found(files, path);
|
||||
return entry?.content.kind === 'image' ? { data: entry.content.data, mediaType: entry.content.mediaType } : undefined;
|
||||
}
|
||||
@@ -94,7 +94,7 @@ const USAGE: Record<string, string> = {
|
||||
* Dateisystem.evaluatePathString for an absolute path: "." and empty parts
|
||||
* left out, ".." one up (at the root it stays there).
|
||||
*/
|
||||
function evaluatedPath(path: string): string {
|
||||
export function evaluatedPath(path: string): string {
|
||||
const parts: string[] = [];
|
||||
for (const part of path.split('/')) {
|
||||
if (part === '..') parts.pop();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# FileCases: what FILIUS' file explorer, text editor and image viewer do to a
|
||||
# host's files, as their windows call FILIUS (tools/java-oracle/src/FileCases.java).
|
||||
# Each case starts from TerminalCases' files. A step: an operation and its
|
||||
# fields, separated by tabs; text in Java-style unicode escapes.
|
||||
case folders
|
||||
mkdir / neu
|
||||
mkdir / neu
|
||||
mkdir / NEU
|
||||
mkdir /
|
||||
mkdir /Dokumente Unter
|
||||
mkdir / leer/innen
|
||||
mkdir / fehlt/innen
|
||||
mkdir / ..
|
||||
mkdir /Dokumente ../oben
|
||||
mkdir /Dokumente /absolut
|
||||
case delete
|
||||
delete / notizen.txt
|
||||
delete / DOKUMENTE
|
||||
delete / gibtsnicht
|
||||
case paste
|
||||
copy / notizen.txt
|
||||
paste /Dokumente
|
||||
paste /Dokumente
|
||||
cut / kurz.txt
|
||||
paste /leer
|
||||
paste /leer
|
||||
copy / Dokumente
|
||||
paste /Dokumente/Alt
|
||||
paste /Dokumente
|
||||
paste /leer
|
||||
import /Dokumente/Alt tief.txt quelle.txt tief
|
||||
copy / Dokumente
|
||||
paste /leer/Dokumente
|
||||
cut / bild.png
|
||||
paste /
|
||||
case rename
|
||||
rename / notizen.txt notiz.txt
|
||||
rename / notiz.txt KURZ.TXT
|
||||
rename / notiz.txt
|
||||
rename / Dokumente Papiere
|
||||
rename / notiz.txt NOTIZ.txt
|
||||
rename / notiz.txt a/b
|
||||
rename / kurz.txt rand
|
||||
case import
|
||||
import / neu.txt quelle.txt Zeile 1\u000aZeile 2
|
||||
import / quelle.txt ohne Namen
|
||||
import / zeilen.txt quelle.txt A\u000d\u000aB\u000dC\u000a\u000aD
|
||||
import / NOTIZEN.TXT quelle.txt neu
|
||||
import / daten.bin quelle.bin @binary
|
||||
import / foto.png bild.png @image
|
||||
import /Dokumente foto bild.png @image
|
||||
import / gross.txt gross.txt @bytes:150001
|
||||
import / genau.txt genau.txt @bytes:150000
|
||||
import / leer.txt leer.txt
|
||||
import / Dokumente quelle.txt in den Ordner
|
||||
import / Umlaut.txt quelle.txt Gr\u00fc\u00dfe
|
||||
case editor
|
||||
save /notizen.txt Neuer Text
|
||||
saveas / neu.txt Hallo\u000aWelt
|
||||
saveas / NOTIZEN.TXT ersetzt
|
||||
saveas / bild.png Text ins Bild
|
||||
saveas / Dokumente in den Ordner
|
||||
saveas /fehlt x.txt y
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,241 @@
|
||||
// The file programs (apps/filePrograms.ts) against Java FILIUS' own:
|
||||
// tools/java-oracle's FileCases did what the file explorer's and the text
|
||||
// editor's windows do to a host's files, with the steps of
|
||||
// file-programs.cases.txt, from TerminalCases' files
|
||||
// (file-programs.<de|en>.java.json). After each step: what it gave back and
|
||||
// every file must be Java's, in both languages, except where Java errs.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
deleteEntry,
|
||||
FILE_EXPLORER_TEXTS,
|
||||
folderEntries,
|
||||
imageFile,
|
||||
importFile,
|
||||
javaBase64,
|
||||
javaBase64Decode,
|
||||
javaFileType,
|
||||
makeFolder,
|
||||
pasteEntry,
|
||||
renameEntry,
|
||||
saveText,
|
||||
saveTextAs,
|
||||
VirtualFileSystem,
|
||||
type Clipboard,
|
||||
type PageLanguage,
|
||||
} from '../src/index.js';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** A file as FileCases writes it: path, and for a file its type, content and size. */
|
||||
type JavaNode = [string] | [string, string | null, string, number];
|
||||
|
||||
interface JavaStep {
|
||||
step: string[];
|
||||
returned: string | null;
|
||||
files: JavaNode[];
|
||||
}
|
||||
|
||||
interface JavaCase {
|
||||
name: string;
|
||||
start: JavaNode[];
|
||||
steps: JavaStep[];
|
||||
}
|
||||
|
||||
interface JavaRun {
|
||||
cases: JavaCase[];
|
||||
texts: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Java's file types as the engine's kinds: an image, its bytes (unknown or sound), or text. */
|
||||
const kindOf = (type: string | null) => (type === 'png' || type === 'image' ? 'image' : type === null || type === 'sound' ? 'binary' : 'text');
|
||||
|
||||
/** Java's files with the engine's kinds, a text file's size its characters (Datei.holeGroesse decodes Base64). */
|
||||
const asKinds = (nodes: JavaNode[]): JavaNode[] =>
|
||||
nodes.map((node): JavaNode => (node.length === 1 ? node : [node[0], kindOf(node[1]), node[2], kindOf(node[1]) === 'text' ? node[2].length : node[3]]));
|
||||
|
||||
function startFiles(start: JavaNode[]): VirtualFileSystem {
|
||||
const files = new VirtualFileSystem();
|
||||
for (const node of start) {
|
||||
if (node.length === 1) files.createDirectory(node[0]);
|
||||
else if (kindOf(node[1]) === 'image') files.writeImageFile(node[0], javaBase64Decode(node[2])!, 'image/png');
|
||||
else files.writeTextFile(node[0], node[2]);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** The engine's files as FileCases writes Java's: preorder, each folder's entries in their order. */
|
||||
function engineFiles(files: VirtualFileSystem, folder = '/'): JavaNode[] {
|
||||
return files.entriesInCreationOrder(folder).flatMap((entry): JavaNode[] => {
|
||||
const content = entry.content;
|
||||
if (content.kind === 'directory') return [[entry.path], ...engineFiles(files, entry.path)];
|
||||
if (content.kind === 'text') return [[entry.path, 'text', content.text, content.text.length]];
|
||||
return [[entry.path, content.kind, javaBase64(content.data), content.data.length]];
|
||||
});
|
||||
}
|
||||
|
||||
/** A local file's bytes as FileCases makes them. */
|
||||
function localBytes(spec: string): Uint8Array {
|
||||
if (spec === '@image') return javaBase64Decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==')!;
|
||||
if (spec === '@binary') return Uint8Array.from({ length: 256 }, (_, index) => index);
|
||||
if (spec.startsWith('@bytes:')) return new Uint8Array(Number(spec.slice('@bytes:'.length))).fill(0x61);
|
||||
return new TextEncoder().encode(spec);
|
||||
}
|
||||
|
||||
/** Plays a case's steps on the engine's files, returning what each gave back as FileCases writes Java's. */
|
||||
function play(javaCase: JavaCase, language: PageLanguage): JavaStep[] {
|
||||
const files = startFiles(javaCase.start);
|
||||
const texts = FILE_EXPLORER_TEXTS[language];
|
||||
let clipboard: Clipboard | undefined;
|
||||
const child = (folder: string, name: string) => `${folder === '/' ? '' : folder}/${name}`;
|
||||
return javaCase.steps.map(({ step }) => {
|
||||
const [operation, folder, name = '', third = '', fourth = ''] = step as [string, string, string?, string?, string?];
|
||||
let returned: string | null = null;
|
||||
switch (operation) {
|
||||
case 'mkdir':
|
||||
returned = name === '' ? null : String(makeFolder(files, folder, name));
|
||||
break;
|
||||
case 'delete':
|
||||
returned = deleteEntry(files, folder, name) ? null : 'false';
|
||||
break;
|
||||
case 'copy':
|
||||
case 'cut':
|
||||
clipboard = { path: child(folder, name), cut: operation === 'cut' };
|
||||
break;
|
||||
case 'paste': {
|
||||
// The explorer offers paste only with something to paste.
|
||||
if (clipboard === undefined) break;
|
||||
const result = pasteEntry(files, clipboard, folder);
|
||||
if (result === 'invalidTarget') returned = 'fileexplorer_msg19';
|
||||
else if (result !== 'pasted') returned = result;
|
||||
if (result === 'pasted' && clipboard.cut) clipboard = undefined;
|
||||
break;
|
||||
}
|
||||
case 'rename':
|
||||
renameEntry(files, folder, name, third);
|
||||
break;
|
||||
case 'import': {
|
||||
const result = importFile(files, folder, name, third, localBytes(fourth));
|
||||
returned = result === 'imported' ? texts.imported : result === 'tooLarge' ? `${texts.notImported}\n${texts.tooLarge}` : texts.notImported;
|
||||
break;
|
||||
}
|
||||
case 'save':
|
||||
saveText(files, folder, name);
|
||||
break;
|
||||
case 'saveas': {
|
||||
const result = saveTextAs(files, folder, name, third);
|
||||
returned = result === 'saved' ? 'true' : result === 'folderMissing' ? 'false' : result;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`unknown step ${operation}`);
|
||||
}
|
||||
return { step, returned, files: engineFiles(files) };
|
||||
});
|
||||
}
|
||||
|
||||
type Fix = (steps: JavaStep[], texts: Record<string, string>) => JavaStep[];
|
||||
|
||||
/** The steps from `index` on with their files changed. */
|
||||
const filesFrom = (steps: JavaStep[], index: number, change: (files: JavaNode[]) => JavaNode[]) =>
|
||||
steps.map((step, at) => (at >= index ? { ...step, files: change(step.files) } : step));
|
||||
const returnedAt = (steps: JavaStep[], index: number, returned: string | null) => steps.map((step, at) => (at === index ? { ...step, returned } : step));
|
||||
|
||||
/** A folder's file list without a second entry of the same path (Java pasted one). */
|
||||
const withoutRepeats = (files: JavaNode[]) => files.filter((node, index) => files.findIndex((each) => each[0] === node[0]) === index);
|
||||
|
||||
/** Where Java errs, the case as Java means it (the engine's departures, see filePrograms.ts). */
|
||||
const JAVA_BUGS: Record<string, Fix> = {
|
||||
// ".." at the root comes to no name: Java makes a folder without one.
|
||||
folders: (steps) => filesFrom(returnedAt(steps, 7, 'false'), 7, (files) => files.filter((node) => node[0] !== '/')),
|
||||
// An entry that is not there: Java throws (the window offers only those there).
|
||||
delete: (steps) => returnedAt(steps, 2, 'false'),
|
||||
// A second paste of the same name: Java adds a second entry; a paste with nothing cut: the window offers none.
|
||||
paste: (steps) => filesFrom(returnedAt(returnedAt(steps, 2, 'nameTaken'), 5, null), 2, withoutRepeats),
|
||||
// A rename that changes only the case is done (Java refuses); one to a name with "/", or with blanks at its ends
|
||||
// (the file system's rule), is refused (Java makes it).
|
||||
rename: (steps) =>
|
||||
filesFrom(steps, 4, (files) =>
|
||||
files.map((node): JavaNode => {
|
||||
if (node[0] === '/notiz.txt' || node[0] === '/a/b') return [`/NOTIZ.txt`, ...node.slice(1)] as JavaNode;
|
||||
return node[0] === '/ rand ' ? (['/kurz.txt', ...node.slice(1)] as JavaNode) : node;
|
||||
}),
|
||||
),
|
||||
// An import onto a folder's name: Java throws.
|
||||
import: (steps, texts) => returnedAt(steps, 10, texts.sw_fileexplorer_msg1!),
|
||||
// "Save as" onto a folder's name: Java throws.
|
||||
editor: (steps) => returnedAt(steps, 4, 'isFolder'),
|
||||
};
|
||||
|
||||
function javaRun(language: PageLanguage): JavaRun {
|
||||
return JSON.parse(readFileSync(join(here, `file-programs.${language}.java.json`), 'utf8')) as JavaRun;
|
||||
}
|
||||
|
||||
describe('the file programs as Java FILIUS does it', () => {
|
||||
for (const language of ['de', 'en'] as const) {
|
||||
const run = javaRun(language);
|
||||
|
||||
it(`has Java's messages (${language})`, () => {
|
||||
const texts = FILE_EXPLORER_TEXTS[language];
|
||||
expect([texts.notImported, texts.imported, texts.tooLarge, texts.invalidTarget]).toEqual(
|
||||
['sw_fileexplorer_msg1', 'sw_fileexplorer_msg2', 'sw_fileexplorer_msg3', 'fileexplorer_msg19'].map((key) => run.texts[key]),
|
||||
);
|
||||
});
|
||||
|
||||
it(`corrects Java only where it errs (${language})`, () => {
|
||||
for (const [name, fix] of Object.entries(JAVA_BUGS)) {
|
||||
const steps = run.cases.find((each) => each.name === name)!.steps;
|
||||
expect(JSON.stringify(fix(steps, run.texts)), name).not.toBe(JSON.stringify(steps));
|
||||
}
|
||||
});
|
||||
|
||||
for (const javaCase of run.cases) {
|
||||
it(`${javaCase.name} (${language})`, () => {
|
||||
const measured = javaCase.steps.map((step) => ({ ...step, files: asKinds(step.files) }));
|
||||
const expected = JAVA_BUGS[javaCase.name]?.(measured, run.texts) ?? measured;
|
||||
const actual = play({ ...javaCase, start: javaCase.start }, language);
|
||||
for (const [index, step] of expected.entries()) {
|
||||
expect(actual[index], `step ${index}: ${JSON.stringify(step.step)}`).toEqual(step);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('the file programs beyond the cases', () => {
|
||||
it("knows FILIUS' file types by the last extension, in any case", () => {
|
||||
expect(['a.TXT', 'b.tar.png', 'Song.mp3', 'ohne', '.versteckt', 'x.bin'].map(javaFileType)).toEqual(['text', 'image', 'sound', 'text', 'text', undefined]);
|
||||
});
|
||||
|
||||
it('lists a folder in the order its entries were made, and nothing for a file', () => {
|
||||
const files = new VirtualFileSystem();
|
||||
files.writeTextFile('/b.txt', 'b');
|
||||
files.createDirectory('/a');
|
||||
expect(folderEntries(files, '/')).toEqual([
|
||||
{ kind: 'file', name: 'b.txt', path: '/b.txt' },
|
||||
{ kind: 'folder', name: 'a', path: '/a' },
|
||||
]);
|
||||
expect(folderEntries(files, '/b.txt')).toEqual([]);
|
||||
});
|
||||
|
||||
it('makes no folder of an empty name (the explorer asks for none)', () => {
|
||||
const files = new VirtualFileSystem();
|
||||
files.createDirectory('/Dokumente');
|
||||
expect(makeFolder(files, '/Dokumente', '')).toBe(false);
|
||||
expect(files.allEntries()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('shows an image file, and nothing for any other', () => {
|
||||
const files = new VirtualFileSystem();
|
||||
files.writeImageFile('/bild.png', new Uint8Array([1, 2]), 'image/png');
|
||||
files.writeTextFile('/text.txt', 'x');
|
||||
files.writeBinaryFile('/daten.bin', new Uint8Array([3]));
|
||||
expect(imageFile(files, '/daten.bin')).toBeUndefined();
|
||||
expect(imageFile(files, '/BILD.png')).toEqual({ data: new Uint8Array([1, 2]), mediaType: 'image/png' });
|
||||
expect(imageFile(files, '/text.txt')).toBeUndefined();
|
||||
expect(imageFile(files, '/fehlt.png')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -88,3 +88,5 @@ packages/core/src/runtime/apps/emailServer.ts::pop3mitarbeiter
|
||||
packages/core/src/runtime/apps/emailClient.ts::smtpclient
|
||||
# arpsend: a command only FILIUS' terminal has (send an ARP reply); named as Java names it.
|
||||
packages/core/src/runtime/apps/::arpsend
|
||||
# sw_fileexplorer_msg1: the key of a message in FILIUS' bundle, read from Java's recording as Java names it.
|
||||
packages/core/test/filePrograms.test.ts::fileexplorer
|
||||
|
||||
@@ -870,6 +870,40 @@ Gemessen (02.10.2026):
|
||||
ebenso eine unter Last (andere Sitzungen auf dem Rechner: die erste
|
||||
ARP-Runde dauert dann knapp 300 statt 225 ms).
|
||||
|
||||
## Datei-Explorer, Texteditor, Bildbetrachter (F3.5b)
|
||||
|
||||
`FileCases` tut, was die Fenster dieser Programme mit den Dateien eines
|
||||
Rechners tun (`GUIApplicationFileExplorerWindow`,
|
||||
`GUIApplicationTextEditorWindow`; der Bildbetrachter liest nur), Schritt für
|
||||
Schritt aus `file-programs.cases.txt`, ohne laufendes Netz, ab den Dateien
|
||||
von `TerminalCases`, in beiden Sprachen. Geschrieben werden je Schritt die
|
||||
Rückgabe (Meldung, Ausnahme) und alle Dateien, dazu die Meldungen des
|
||||
Explorers.
|
||||
|
||||
Gemessen (02.10.2026):
|
||||
|
||||
* Neuer Ordner (`erstelleVerzeichnis`): der Name ist ein Pfad (`a/b` legt b
|
||||
in a an, `/x` unter der Wurzel, `../x` darüber); ein vorhandener Name
|
||||
ändert nichts; `..` an der Wurzel legt einen Ordner ohne Namen an.
|
||||
* Einfügen: eine tiefe Kopie ans Ende des Ordners; ein zweites Einfügen
|
||||
desselben Namens legt eine zweite Datei gleichen Namens an; nicht in das
|
||||
Kopierte selbst oder darunter (`fileexplorer_msg19`); Ausschneiden und in
|
||||
denselben Ordner einfügen schiebt den Eintrag ans Ende.
|
||||
* Umbenennen: der Eintrag behält seine Stelle; ein leerer oder vorhandener
|
||||
Name ändert nichts, auch eine reine Änderung der Groß-/Kleinschreibung
|
||||
(Java findet den Eintrag selbst); `a/b` wird eine Datei mit `/` im Namen;
|
||||
Leerzeichen am Rand bleiben.
|
||||
* Import (`FileExplorer.addFile`): der Typ nach dem Namen der lokalen Datei
|
||||
(`config/filetypes.txt`; ohne Endung Text, unbekannte Endung kein Typ);
|
||||
Text Zeile für Zeile, jede mit CR LF (auch die letzte), UTF-8; anderes als
|
||||
Base64; über 150 000 Byte abgelehnt; ohne Namen `noName`; auf einen
|
||||
vorhandenen Namen Inhalt und Typ ersetzt, Schreibweise bleibt; auf einen
|
||||
Ordnernamen eine Ausnahme.
|
||||
* Texteditor: „Speichern“ ersetzt den Inhalt der offenen Datei, „Speichern
|
||||
unter“ (`speicherDatei`) legt eine Datei vom Typ `text` an oder ersetzt
|
||||
Inhalt und Typ einer gleichnamigen (auch eines Bildes); auf einen
|
||||
Ordnernamen eine Ausnahme, in einem fehlenden Ordner nichts.
|
||||
|
||||
## Befehlszeile, traceroute und tcpdump (F3.4c)
|
||||
|
||||
Weitere Abläufe von `TerminalRun` auf `terminal-net`. Gemessen (02.10.2026):
|
||||
|
||||
@@ -21,7 +21,7 @@ if [ ! -f "$upstream/target/filius.jar" ]; then
|
||||
fi
|
||||
classpath="$upstream/target/filius.jar:$upstream/target/lib/*"
|
||||
mkdir -p "$here/.build"
|
||||
javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java" "$here/src/EmailClientRun.java" "$here/src/TerminalCases.java" "$here/src/TerminalRun.java"
|
||||
javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java" "$here/src/EmailClientRun.java" "$here/src/TerminalCases.java" "$here/src/TerminalRun.java" "$here/src/FileCases.java"
|
||||
# FILIUS writes its settings below $HOME; keep them out of the real one.
|
||||
mkdir -p "$here/.build/home"
|
||||
# Removed first: a crashed run must not leave the previous result standing.
|
||||
@@ -286,6 +286,16 @@ for language in de en; do
|
||||
test -s "$output"
|
||||
done
|
||||
|
||||
# What the file explorer and the text editor do to a host's files, without a
|
||||
# running network, in both languages (FileCases; packages/core/test/filePrograms.test.ts).
|
||||
for language in de en; do
|
||||
output="$root/packages/core/test/file-programs.$language.java.json"
|
||||
rm -f "$output"
|
||||
HOME="$here/.build/home" xvfb-run -a java -cp "$classpath:$here/.build" FileCases "$language" \
|
||||
"$root/packages/core/test/scenarios/router-web.scenario.json" "$root/packages/core/test/file-programs.cases.txt" "$output" 2>&1 | grep -v 'logback\|^$' || true
|
||||
test -s "$output"
|
||||
done
|
||||
|
||||
# The command line's network commands on terminal-net, typed at set times
|
||||
# (TerminalRun; packages/core/test/terminalJava.test.ts), in the German locale.
|
||||
for run in pingLan:8000 pingRouted:13000 pingNobody:49000 pingUnreachable:45000 pingBroadcast:27000 pingSelf:17000 \
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// Feeds what FILIUS' file explorer, text editor and image viewer do to a
|
||||
// host's files, as their windows call FILIUS (GUIApplicationFileExplorerWindow,
|
||||
// GUIApplicationTextEditorWindow; the image viewer only reads), without a
|
||||
// running network, and writes after each step what it gave back (a message,
|
||||
// an exception) and every file and directory of the host, and the explorer's
|
||||
// messages as Java loads them: the reference for the core's file programs
|
||||
// (packages/core/src/runtime/apps/filePrograms.ts).
|
||||
// Each case starts on host 10.0.0.2 of a scenario with TerminalCases' files.
|
||||
// The cases file is ASCII, with Java-style unicode escapes; a step is an
|
||||
// operation and its fields, separated by tabs:
|
||||
//
|
||||
// mkdir <folder> <name> the explorer's "new folder" (an empty name: nothing)
|
||||
// delete <folder> <name> the explorer's "delete" of an entry of the folder
|
||||
// copy|cut <folder> <name> the explorer's clipboard
|
||||
// paste <folder> the explorer's "paste" into the folder
|
||||
// rename <folder> <name> <new> the explorer's "rename"
|
||||
// import <folder> <new> <local name> <content>
|
||||
// the explorer's import of a local file (FileExplorer.addFile);
|
||||
// content @image (TerminalCases' image), @binary (bytes 0 to 255),
|
||||
// @bytes:N (N times 'a'), else the text
|
||||
// save <path> <text> the editor's "save" of the open file
|
||||
// saveas <folder> <name> <text> the editor's "save as"
|
||||
//
|
||||
// Usage: java -cp filius.jar:lib/*:classes FileCases <de|en> <scenario.json> <cases txt> <output json>
|
||||
|
||||
import filius.rahmenprogramm.Base64;
|
||||
import filius.rahmenprogramm.Information;
|
||||
import filius.software.lokal.FileExplorer;
|
||||
import filius.software.system.Datei;
|
||||
import filius.software.system.Dateisystem;
|
||||
import filius.software.system.InternetKnotenBetriebssystem;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.ObjectInputStream;
|
||||
import java.io.ObjectOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import javax.swing.tree.DefaultMutableTreeNode;
|
||||
|
||||
public class FileCases {
|
||||
static DefaultMutableTreeNode clipboard;
|
||||
static boolean cut;
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
Information.getInformation().setLocale(args[0].equals("en") ? new Locale("en", "GB") : Locale.GERMANY);
|
||||
ScenarioNetwork network = ScenarioNetwork.read(args[1]);
|
||||
InternetKnotenBetriebssystem system = network.system(TerminalCases.HOST);
|
||||
system.installApp("filius.software.lokal.FileExplorer", false);
|
||||
FileExplorer explorer = (FileExplorer) system.holeSoftware("filius.software.lokal.FileExplorer");
|
||||
explorer.setSystemSoftware(system);
|
||||
Dateisystem files = system.getDateisystem();
|
||||
Path local = Files.createTempDirectory("filecases");
|
||||
List<Object> cases = new ArrayList<>();
|
||||
List<Object> steps = null;
|
||||
for (String line : Files.readAllLines(Path.of(args[2]), StandardCharsets.US_ASCII)) {
|
||||
if (line.startsWith("#")) continue;
|
||||
if (line.startsWith("case ")) {
|
||||
Map<String, Object> current = new LinkedHashMap<>();
|
||||
steps = new ArrayList<>();
|
||||
current.put("name", line.substring("case ".length()));
|
||||
current.put("steps", steps);
|
||||
cases.add(current);
|
||||
TerminalCases.files(files);
|
||||
clipboard = null;
|
||||
current.put("start", TerminalCases.tree(files));
|
||||
continue;
|
||||
}
|
||||
String[] fields = line.split("\t", -1);
|
||||
for (int index = 0; index < fields.length; index++) fields[index] = IpCheckCases.unescape(fields[index]);
|
||||
Map<String, Object> step = new LinkedHashMap<>();
|
||||
step.put("step", Arrays.asList(fields));
|
||||
Object returned;
|
||||
try {
|
||||
returned = run(fields, files, explorer, local);
|
||||
} catch (Exception error) {
|
||||
returned = "!" + error.getClass().getSimpleName();
|
||||
}
|
||||
step.put("returned", returned == null ? null : returned.toString());
|
||||
step.put("files", TerminalCases.tree(files));
|
||||
steps.add(step);
|
||||
}
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("cases", cases);
|
||||
// The messages the explorer shows (its import's, and its paste's refusal), as Java loads them.
|
||||
Map<String, Object> texts = new LinkedHashMap<>();
|
||||
for (String key : new String[] { "sw_fileexplorer_msg1", "sw_fileexplorer_msg2", "sw_fileexplorer_msg3", "fileexplorer_msg19" }) {
|
||||
texts.put(key, explorer.messages.getString(key));
|
||||
}
|
||||
out.put("texts", texts);
|
||||
try (FileOutputStream stream = new FileOutputStream(args[3])) {
|
||||
stream.write((FlsOracle.Json.write(out, "") + "\n").getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
System.out.println("file cases (" + args[0] + "): " + cases.size());
|
||||
System.exit(0);
|
||||
}
|
||||
|
||||
static Object run(String[] fields, Dateisystem files, FileExplorer explorer, Path local) throws Exception {
|
||||
DefaultMutableTreeNode folder = files.verzeichnisKnoten(fields[1]);
|
||||
switch (fields[0]) {
|
||||
case "mkdir":
|
||||
// "Neuer Ordner": the name asked for, nothing when it is empty.
|
||||
if (fields[2].equals("")) return null;
|
||||
return files.erstelleVerzeichnis(folder, fields[2]);
|
||||
case "delete":
|
||||
// "Loeschen": the selected entry, found by its name, removed from the folder.
|
||||
folder.remove(Dateisystem.verzeichnisKnoten(folder, fields[2]));
|
||||
return null;
|
||||
case "copy":
|
||||
case "cut":
|
||||
clipboard = Dateisystem.verzeichnisKnoten(folder, fields[2]);
|
||||
cut = fields[0].equals("cut");
|
||||
return null;
|
||||
case "paste":
|
||||
// "Einfuegen": not into the copied entry itself or below it; a deep copy added, a cut one removed.
|
||||
if (clipboard.isNodeDescendant(folder)) return "fileexplorer_msg19";
|
||||
folder.add(deepCopy(clipboard));
|
||||
if (cut) {
|
||||
((DefaultMutableTreeNode) clipboard.getParent()).remove(clipboard);
|
||||
clipboard = null;
|
||||
}
|
||||
return null;
|
||||
case "rename": {
|
||||
// "Umbenennen": a name that is not blank and not there yet in the folder.
|
||||
String name = fields[3];
|
||||
if (name.trim().isEmpty() || files.dateiVorhanden(folder, name)) return null;
|
||||
DefaultMutableTreeNode node = Dateisystem.verzeichnisKnoten(folder, fields[2]);
|
||||
if (node.getUserObject() instanceof Datei) ((Datei) node.getUserObject()).setName(name);
|
||||
else node.setUserObject(name);
|
||||
return null;
|
||||
}
|
||||
case "import": {
|
||||
Path file = local.resolve(fields[3]);
|
||||
Files.write(file, content(fields[4]));
|
||||
return explorer.addFile(local.toString() + "/", fields[3], folder, fields[2]);
|
||||
}
|
||||
case "save":
|
||||
// "Speichern": the open file's content.
|
||||
files.holeDatei(fields[1]).setDateiInhalt(fields[2]);
|
||||
return null;
|
||||
case "saveas":
|
||||
// "Speichern unter": a new text file (texteditor_msg8) stored in the folder chosen.
|
||||
return files.speicherDatei(fields[1], new Datei(fields[2], explorer.messages.getString("texteditor_msg8"), fields[3]));
|
||||
default:
|
||||
throw new IllegalArgumentException("unknown step " + fields[0]);
|
||||
}
|
||||
}
|
||||
|
||||
static byte[] content(String spec) throws Exception {
|
||||
if (spec.equals("@image")) return Base64.decode(TerminalCases.IMAGE);
|
||||
if (spec.equals("@binary")) {
|
||||
byte[] bytes = new byte[256];
|
||||
for (int index = 0; index < bytes.length; index++) bytes[index] = (byte) index;
|
||||
return bytes;
|
||||
}
|
||||
if (spec.startsWith("@bytes:")) {
|
||||
byte[] bytes = new byte[Integer.parseInt(spec.substring("@bytes:".length()))];
|
||||
Arrays.fill(bytes, (byte) 'a');
|
||||
return bytes;
|
||||
}
|
||||
return spec.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/** GUIApplicationFileExplorerWindow.tiefesKopieren: the node with all below it, serialized and read back. */
|
||||
static DefaultMutableTreeNode deepCopy(DefaultMutableTreeNode original) throws Exception {
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
try (ObjectOutputStream out = new ObjectOutputStream(buffer)) {
|
||||
out.writeObject(original);
|
||||
}
|
||||
try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(buffer.toByteArray()))) {
|
||||
return (DefaultMutableTreeNode) in.readObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user