Aufwärmen · Aufgabe 1 von 7
// B3.5 · ca. 45 Min. · Einstieg
Build: ein Notizen-CLI, das JSON auf der Festplatte speichert
Sie bauen einen Notizen-Befehl, der jede Notiz als eigene JSON-Datei ablegt: sicher gespeichert, geordnet aufgelistet, angezeigt, gelöscht und geleert, mit klaren Exit-Codes.
Lektion 5 von 5 in B3 Dateien und Pfade
Danach können Sie
- Eine JSON-Datei pro Notiz speichern: Slug-Namen, ein Ordner per mkdir und Schreiben über Temp-Datei plus rename
- Notizen mit readdir, stat, readFile und rm auflisten, anzeigen und löschen und Einträge überspringen, die keine Notizen sind
- Alles mit util.parseArgs, Meldungen auf stderr und den Exit-Codes 0, 1 und 2 zu einem Befehl verdrahten
Vorhersagen · Aufgabe 2 von 7
Sagen Sie es voraus, bevor Sie weiterlesen. Das fertige Tool hat noch keinen Ordner notes. Sie führen den Befehl unten aus. Welche Datei liegt danach in notes?
node main.js save "Buy Milk!" --body "two litres"Üben · Aufgabe 3 von 7
Ergänzen Sie den Ersatz, sodass jede Folge von Zeichen außer a-z und 0-9 zu einem - wird und " Buy Milk! " zu buy-milk.
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "____").replace(/^-+|-+$/g, "");const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "").replace(/^-+|-+$/g, "");Üben · Aufgabe 4 von 7
Das fertige Tool hat noch keine Notizen. Ordnen Sie jeder Befehlszeile zu, was sie bewirkt.
Üben · Aufgabe 5 von 7
Was gibt der letzte Befehl aus?
node main.js save "Buy milk" --body "two litres" node main.js save "buy MILK" --body "one litre" node main.js listDenksport · Aufgabe 6 von 7
Knobelaufgabe. Ein Lauf stürzte vor seinem rename ab und hinterließ eine halb geschriebene Temp-Datei. notes enthält nun buy-milk.json, .call-ada.json.4012.tmp und einen leeren Ordner archive. Was gibt node main.js list aus?
Anwenden · Aufgabe 7 von 7
Mini-Aufgabe. Ergänzen Sie run() um den Befehl archive <slug>: Er verschiebt notes/<slug>.json in den Ordner notes/archive, den er bei Bedarf anlegt, und gibt archived und den Slug aus. list darf archivierte Notizen nicht zeigen; prüfen Sie das und begründen Sie es. Eine fehlende Notiz gibt no note: und den Namen aus, mit Exit-Code 1. Nutzen Sie isSlug, damit archive ../main abgelehnt wird.
Prüfen Sie Ihr Ergebnis anhand dieser Liste
Selbst programmieren
Lesen Sie das ausgearbeitete Beispiel und lösen Sie dann die Übungen. Ihr Code läuft in Ihrem Browser oder auf Ihrem Computer und wird nie hochgeladen.
Ausgearbeitetes Beispiel
Dieselbe Form, ein anderes Tool: Lesezeichen
Bevor Sie das Notizen-CLI bauen, sehen Sie hier dieselbe Speicheridee im Kleinen. Jedes Lesezeichen ist eine eigene Datei, bookmarks/<name>.json. save legt den Ordner bei Bedarf an und ersetzt die Datei über Temp-Datei und rename; list behält nur .json-Dateien, sortiert, mit ihrer Größe aus stat. Die Demo beginnt mit leerem Ordner, legt einen Unterordner an, den list überspringen muss, speichert einen Namen zweimal und versucht, eine nicht vorhandene Datei zu entfernen.
main.js
import {mkdir, readdir, rename, rm, stat, writeFile} from 'node:fs/promises';
import {join} from 'node:path';
const dir = join(import.meta.dirname, 'bookmarks');
// One file per bookmark, replaced in one step.
async function save(name, url) {
await mkdir(dir, {recursive: true});
const temp = join(dir, `.${name}.json.${process.pid}.tmp`);
await writeFile(temp, JSON.stringify({url}, null, 2) + '\n');
await rename(temp, join(dir, `${name}.json`));
}
// Only .json files count, sorted, each with its size.
async function list() {
const lines = [];
for (const entry of await readdir(dir, {withFileTypes: true})) {
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
const {size} = await stat(join(dir, entry.name));
lines.push(`${entry.name} ${size} bytes`);
}
return lines.sort().join(' | ');
}
await rm(dir, {recursive: true, force: true}); // a clean start for the demo
await save('nodejs', 'https://nodejs.org');
await save('mdn', 'https://developer.mozilla.org');
await mkdir(join(dir, 'old'), {recursive: true}); // a folder, not a bookmark
console.log(await list());
await save('nodejs', 'https://nodejs.org/en/learn'); // same name: replaced
console.log(await list());
try {
await rm(join(dir, 'python.json'));
} catch (error) {
console.log('rm python.json:', error.code);
}
console.log('in bookmarks:', (await readdir(dir)).sort().join(', '));
Ausführen mit
node main.jsAusgabe
mdn.json 45 bytes | nodejs.json 34 bytes
mdn.json 45 bytes | nodejs.json 43 bytes
rm python.json: ENOENT
in bookmarks: mdn.json, nodejs.json, old- Der Ordner old existiert, aber list hat ihn übersprungen, weil er keine Datei mit der Endung .json ist.
- Das erneute Speichern von nodejs hat die Datei ersetzt: weiterhin zwei Lesezeichen, und nodejs.json wuchs von 34 auf 43 Bytes.
- rm auf eine fehlende Datei lehnt mit ENOENT ab; das Notizen-CLI macht daraus no note: und Exit-Code 1.
- Im Ordner bleibt keine Temp-Datei: Jede wurde auf ihren echten Namen umbenannt.
Übungen
Übung 1 von 4
Schritt 1: Namen aus Titeln
Schreiben Sie slugify(title): Titel in Kleinbuchstaben, jede Folge von Zeichen außer a-z und 0-9 durch ein - ersetzen, - an beiden Enden entfernen. " Buy Milk! " ergibt buy-milk. Bleibt nichts übrig, werfen Sie einen Error, der den Titel nennt. Schreiben Sie auch isSlug(text), true nur für solche Namen: buy-milk und 2026, aber nicht ../main, Buy-Milk oder a--b. Beide sind rein und laufen daher auch im Browser.
Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.
Beim ersten Ausführen lädt Ihr Browser den JavaScript-Runner herunter (bis zu 0.1 MB) und speichert ihn im Cache. Ihr Code läuft in der eigenen Engine Ihres Browsers und bleibt auf Ihrem Gerät.
Hinweise
Hinweis 1
title.toLowerCase().replace(/[^a-z0-9]+/g, '-') macht aus jeder Folge anderer Zeichen ein -.
Hinweis 2
Ein zweites replace(/^-+|-+$/g, '') entfernt - am Anfang und am Ende.
Hinweis 3
Für isSlug prüfen Sie den ganzen Text mit /^[a-z0-9]+(-[a-z0-9]+)*$/: Gruppen aus Buchstaben und Ziffern, je ein - dazwischen.
Eine Lösung zeigen
Ein möglicher Lösungsweg. Ihrer kann anders aussehen und trotzdem alle Prüfungen bestehen.
// A file-safe name from a title: small letters and digits, words joined by -.
export function slugify(title) {
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
if (slug === '') throw new Error('the title "' + title + '" has no letters or digits');
return slug;
}
// True only for a name that slugify could have made, such as buy-milk.
export function isSlug(text) {
return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(text);
}
console.log(slugify(' Buy Milk! '), isSlug('../main'));
Auf dem eigenen Computer ausführen
Installieren Sie Node.js 24 LTS oder neuer. Speichern Sie diese Dateien in einem Ordner, öffnen Sie dort ein Terminal und führen Sie die Befehle unten aus.
main.js
// A file-safe name from a title: small letters and digits, words joined by -.
export function slugify(title) {
return title.toLowerCase();
}
// True only for a name that slugify could have made, such as buy-milk.
export function isSlug(text) {
return text !== '';
}
console.log(slugify(' Buy Milk! '), isSlug('../main'));
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {isSlug, slugify} from './main.js';
test('" Buy Milk! " wird zu buy-milk', () => {
assert.equal(slugify(' Buy Milk! '), 'buy-milk', `slugify lieferte ${JSON.stringify(slugify(' Buy Milk! '))}`);
});
test('Folgen anderer Zeichen werden zu einem -', () => {
assert.equal(slugify('Ideas: 2026 -- draft'), 'ideas-2026-draft', `slugify lieferte ${JSON.stringify(slugify('Ideas: 2026 -- draft'))}`);
});
test('ein Titel ohne Buchstaben und Ziffern wirft einen Error, der ihn nennt', () => {
assert.throws(() => slugify('!!!'), {message: /!!!/}, 'slugify("!!!") sollte einen Error werfen, dessen message !!! enthält');
});
test('isSlug akzeptiert nur Slugs', () => {
for (const text of ['buy-milk', '2026', 'a1-b2']) assert.equal(isSlug(text), true, `isSlug(${JSON.stringify(text)}) sollte true sein`);
for (const text of ['../main', 'Buy-Milk', 'a--b', '-a', 'a-', '', 'a b']) assert.equal(isSlug(text), false, `isSlug(${JSON.stringify(text)}) sollte false sein`);
});
package.json
{
"type": "module"
}
package.json sagt Node.js, dass die .js-Dateien Module sind; lassen Sie die Datei im Ordner.
Programm ausführen:
node main.jsPrüfungen ausführen (learnrun.js muss im selben Ordner liegen):
node --testlearnrun.js herunterladenÜbung 2 von 4
Schritt 2: eine Notizdatei prüfen
Eine Notizdatei kann von Hand bearbeitet werden, also prüft loadNote, was JSON.parse liefert, bevor es dem Wert traut. Schreiben Sie validateNote(value): Ist value ein einfaches Objekt, weder null noch Array, mit einem title als nicht leerem String und einem body als String, geben Sie ein neues Objekt nur mit title und body zurück. Sonst werfen Sie einen Error, der sagt, was falsch ist. Die Funktion ist rein und läuft daher auch im Browser.
Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.
Beim ersten Ausführen lädt Ihr Browser den JavaScript-Runner herunter (bis zu 0.1 MB) und speichert ihn im Cache. Ihr Code läuft in der eigenen Engine Ihres Browsers und bleibt auf Ihrem Gerät.
Hinweise
Hinweis 1
typeof null ist 'object', typeof [] ebenso, also prüfen Sie auch value === null und Array.isArray(value).
Hinweis 2
value.title.trim() === '' erkennt einen Titel nur aus Leerzeichen; prüfen Sie zuerst typeof value.title === 'string'.
Hinweis 3
Bauen Sie das Ergebnis selbst, {title: value.title, body: value.body}, damit andere Eigenschaften wegfallen.
Eine Lösung zeigen
Ein möglicher Lösungsweg. Ihrer kann anders aussehen und trotzdem alle Prüfungen bestehen.
// Checks a value read from a note file; returns {title, body} or throws.
export function validateNote(value) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('a note must be an object');
if (typeof value.title !== 'string' || value.title.trim() === '') throw new Error('a note needs a title');
if (typeof value.body !== 'string') throw new Error('the body of a note must be text');
return {title: value.title, body: value.body};
}
console.log(validateNote({title: 'Buy milk', body: 'two litres', colour: 'red'}));
Auf dem eigenen Computer ausführen
Installieren Sie Node.js 24 LTS oder neuer. Speichern Sie diese Dateien in einem Ordner, öffnen Sie dort ein Terminal und führen Sie die Befehle unten aus.
main.js
// Checks a value read from a note file; returns {title, body} or throws.
export function validateNote(value) {
return value;
}
console.log(validateNote({title: 'Buy milk', body: 'two litres', colour: 'red'}));
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {validateNote} from './main.js';
test('eine gute Notiz kommt nur mit title und body zurück', () => {
const got = validateNote({title: 'Buy milk', body: 'two litres', colour: 'red'});
assert.deepEqual(got, {title: 'Buy milk', body: 'two litres'}, `validateNote lieferte ${JSON.stringify(got)}`);
});
test('ein leerer body ist in Ordnung', () => {
const got = validateNote({title: 'Call Ada', body: ''});
assert.deepEqual(got, {title: 'Call Ada', body: ''}, `validateNote lieferte ${JSON.stringify(got)}`);
});
test('null, ein Array und ein String sind keine Notizen', () => {
for (const value of [null, [], 'Buy milk']) assert.throws(() => validateNote(value), Error, `validateNote(${JSON.stringify(value)}) sollte werfen`);
});
test('ein fehlender oder leerer title wirft', () => {
for (const value of [{body: 'x'}, {title: '', body: 'x'}, {title: ' ', body: 'x'}, {title: 42, body: 'x'}]) {
assert.throws(() => validateNote(value), Error, `validateNote(${JSON.stringify(value)}) sollte werfen`);
}
});
test('ein body, der kein String ist, wirft', () => {
assert.throws(() => validateNote({title: 'Buy milk'}), Error, 'validateNote ohne body sollte werfen');
assert.throws(() => validateNote({title: 'Buy milk', body: 2}), Error, 'validateNote mit body 2 sollte werfen');
});
package.json
{
"type": "module"
}
package.json sagt Node.js, dass die .js-Dateien Module sind; lassen Sie die Datei im Ordner.
Programm ausführen:
node main.jsPrüfungen ausführen (learnrun.js muss im selben Ordner liegen):
node --testlearnrun.js herunterladenÜbung 3 von 4
Schritt 3: die Liste
Schreiben Sie formatListing(records, by = "name"). Jeder Datensatz ist {slug, title, size, mtimeMs}. Geben Sie eine Zeile pro Notiz zurück: Slug, zwei Leerzeichen, Titel, zwei Leerzeichen und die Größe als (50 bytes), jede Zeile mit Zeilenumbruch. Sortieren Sie nach Slug, wie sort() Strings sortiert, oder die neueste zuerst, wenn by "newest" ist. Keine Datensätze ergeben "no notes yet" und einen Zeilenumbruch. Ändern Sie das übergebene Array nicht. Die Funktion ist rein und läuft auch im Browser.
Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.
Beim ersten Ausführen lädt Ihr Browser den JavaScript-Runner herunter (bis zu 0.1 MB) und speichert ihn im Cache. Ihr Code läuft in der eigenen Engine Ihres Browsers und bleibt auf Ihrem Gerät.
Hinweise
Hinweis 1
Sortieren Sie eine Kopie, [...records].sort(order), denn sort() ändert das Array, auf dem es aufgerufen wird.
Hinweis 2
Für die neueste zuerst: (a, b) => b.mtimeMs - a.mtimeMs. Für Slugs: (a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0).
Hinweis 3
Bauen Sie jede Zeile mit r.slug + ' ' + r.title + ' (' + r.size + ' bytes)', verbinden Sie sie mit Zeilenumbrüchen und hängen Sie am Ende einen an.
Eine Lösung zeigen
Ein möglicher Lösungsweg. Ihrer kann anders aussehen und trotzdem alle Prüfungen bestehen.
// The output of list: one line per note, sorted by slug or newest first.
export function formatListing(records, by = 'name') {
if (records.length === 0) return 'no notes yet\n';
const order = by === 'newest' ? (a, b) => b.mtimeMs - a.mtimeMs : (a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0);
return [...records].sort(order).map((r) => r.slug + ' ' + r.title + ' (' + r.size + ' bytes)').join('\n') + '\n';
}
const records = [
{slug: 'call-ada', title: 'Call Ada', size: 49, mtimeMs: 200},
{slug: 'buy-milk', title: 'Buy milk', size: 50, mtimeMs: 100}
];
console.log(formatListing(records));
Auf dem eigenen Computer ausführen
Installieren Sie Node.js 24 LTS oder neuer. Speichern Sie diese Dateien in einem Ordner, öffnen Sie dort ein Terminal und führen Sie die Befehle unten aus.
main.js
// The output of list: one line per note, sorted by slug or newest first.
export function formatListing(records, by = 'name') {
return records.map((r) => r.slug).join('\n');
}
const records = [
{slug: 'call-ada', title: 'Call Ada', size: 49, mtimeMs: 200},
{slug: 'buy-milk', title: 'Buy milk', size: 50, mtimeMs: 100}
];
console.log(formatListing(records));
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {formatListing} from './main.js';
const records = [
{slug: 'call-ada', title: 'Call Ada', size: 49, mtimeMs: 200},
{slug: 'buy-milk', title: 'Buy milk', size: 50, mtimeMs: 100}
];
test('nach Slug sortiert, eine Zeile pro Notiz', () => {
const got = formatListing(records);
assert.equal(got, 'buy-milk Buy milk (50 bytes)\ncall-ada Call Ada (49 bytes)\n', `formatListing lieferte ${JSON.stringify(got)}`);
});
test('mit "newest" die neueste zuerst', () => {
const got = formatListing(records, 'newest');
assert.equal(got, 'call-ada Call Ada (49 bytes)\nbuy-milk Buy milk (50 bytes)\n', `formatListing lieferte ${JSON.stringify(got)}`);
});
test('keine Datensätze ergeben no notes yet', () => {
assert.equal(formatListing([]), 'no notes yet\n', `formatListing([]) lieferte ${JSON.stringify(formatListing([]))}`);
});
test('das übergebene Array behält seine Reihenfolge', () => {
const copy = [...records];
formatListing(copy);
assert.deepEqual(copy, records, 'formatListing hat die Reihenfolge des übergebenen Arrays geändert');
});
package.json
{
"type": "module"
}
package.json sagt Node.js, dass die .js-Dateien Module sind; lassen Sie die Datei im Ordner.
Programm ausführen:
node main.jsPrüfungen ausführen (learnrun.js muss im selben Ordner liegen):
node --testlearnrun.js herunterladenÜbung 4 von 4
Schritt 4: der Speicher auf der Festplatte
notes.js enthält die Schritte 1 bis 3, main.js hat run() und die Verdrahtung. Schreiben Sie den Dateisystem-Teil. saveNote legt den Ordner mit mkdir und {recursive: true} an, schreibt eingerücktes JSON plus Zeilenumbruch in eine Temp-Datei im selben Ordner und benennt sie über <slug>.json um. listNotes liefert einen Datensatz pro .json-Datei und eine leere Liste für einen fehlenden Ordner. clearNotes löscht jede Notiz, dann den leeren Ordner mit rmdir. Prüfen Sie mit node --test.
Diese Übung braucht Node.js auf Ihrem Computer (die Browser-Version kann sie nicht ausführen). Dateien und Befehle stehen unten.
Hinweise
Hinweis 1
Bauen Sie den Temp-Pfad neben der Notiz: join(dir, '.' + slug + '.json.' + process.pid + '.tmp'), dann rename(temp, join(dir, slug + '.json')).
Hinweis 2
Fangen Sie in listNotes ENOENT von readdir ab und geben Sie [] zurück; überspringen Sie Einträge mit !entry.isFile() || !entry.name.endsWith(".json").
Hinweis 3
Rufen Sie in clearNotes deleteNote für jeden Datensatz von listNotes auf, dann rmdir(dir), und ignorieren Sie ENOENT und ENOTEMPTY.
Eine Lösung zeigen
Ein möglicher Lösungsweg. Ihrer kann anders aussehen und trotzdem alle Prüfungen bestehen.
import {mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile} from 'node:fs/promises';
import {join} from 'node:path';
import {parseArgs} from 'node:util';
import {formatListing, isSlug, slugify, validateNote} from './notes.js';
const USAGE = 'Usage: node main.js <save|list|show|delete|clear> [title or slug] [--body <text>] [--newest] [--dir <folder>]';
async function loadNote(dir, slug) {
return validateNote(JSON.parse(await readFile(join(dir, slug + '.json'), 'utf8')));
}
async function deleteNote(dir, slug) {
await rm(join(dir, slug + '.json'));
}
// One file per note: <dir>/<slug>.json, replaced in one step.
async function saveNote(dir, note) {
const slug = slugify(note.title);
await mkdir(dir, {recursive: true});
const temp = join(dir, '.' + slug + '.json.' + process.pid + '.tmp');
await writeFile(temp, JSON.stringify(note, null, 2) + '\n');
await rename(temp, join(dir, slug + '.json'));
return slug;
}
// A record for every *.json file in dir; a missing dir means no notes yet.
async function listNotes(dir) {
let entries;
try {
entries = await readdir(dir, {withFileTypes: true});
} catch (error) {
if (error.code === 'ENOENT') return [];
throw error;
}
const records = [];
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
const slug = entry.name.slice(0, -'.json'.length);
const info = await stat(join(dir, entry.name));
records.push({slug, title: (await loadNote(dir, slug)).title, size: info.size, mtimeMs: info.mtimeMs});
}
return records;
}
// Deletes every note, then the folder itself if nothing else is left in it.
async function clearNotes(dir) {
for (const {slug} of await listNotes(dir)) await deleteNote(dir, slug);
try {
await rmdir(dir);
} catch (error) {
if (error.code !== 'ENOENT' && error.code !== 'ENOTEMPTY') throw error;
}
}
// What to print, and the exit code: 0 = done, 1 = a note problem, 2 = wrong usage.
async function run(args) {
let values, positionals;
try {
({values, positionals} = parseArgs({
args,
options: {
body: {type: 'string', default: ''},
dir: {type: 'string', default: join(import.meta.dirname, 'notes')},
newest: {type: 'boolean'}
},
allowPositionals: true
}));
} catch (error) {
return {out: '', err: error.message + '\n' + USAGE + '\n', code: 2};
}
const [command, name, ...rest] = positionals;
const named = name !== undefined && rest.length === 0;
const {dir} = values;
try {
if (command === 'save' && named) return {out: 'saved ' + (await saveNote(dir, {title: name, body: values.body})) + '\n', err: '', code: 0};
if (command === 'list' && name === undefined) return {out: formatListing(await listNotes(dir), values.newest ? 'newest' : 'name'), err: '', code: 0};
if (command === 'clear' && name === undefined) {
await clearNotes(dir);
return {out: 'cleared\n', err: '', code: 0};
}
if ((command === 'show' || command === 'delete') && named) {
if (!isSlug(name)) return {out: '', err: 'not a note name: ' + name + '\n', code: 2};
if (command === 'delete') {
await deleteNote(dir, name);
return {out: 'deleted ' + name + '\n', err: '', code: 0};
}
const note = await loadNote(dir, name);
return {out: note.title + '\n\n' + note.body + '\n', err: '', code: 0};
}
} catch (error) {
if (error.code === 'ENOENT') return {out: '', err: 'no note: ' + name + '\n', code: 1};
return {out: '', err: 'error: ' + error.message + '\n', code: 1};
}
return {out: '', err: USAGE + '\n', code: 2};
}
// The only lines that touch the real process.
const {out, err, code} = await run(process.argv.slice(2));
process.stdout.write(out);
process.stderr.write(err);
process.exitCode = code;
Auf dem eigenen Computer ausführen
Installieren Sie Node.js 24 LTS oder neuer. Speichern Sie diese Dateien in einem Ordner, öffnen Sie dort ein Terminal und führen Sie die Befehle unten aus.
main.js
import {mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile} from 'node:fs/promises';
import {join} from 'node:path';
import {parseArgs} from 'node:util';
import {formatListing, isSlug, slugify, validateNote} from './notes.js';
const USAGE = 'Usage: node main.js <save|list|show|delete|clear> [title or slug] [--body <text>] [--newest] [--dir <folder>]';
async function loadNote(dir, slug) {
return validateNote(JSON.parse(await readFile(join(dir, slug + '.json'), 'utf8')));
}
async function deleteNote(dir, slug) {
await rm(join(dir, slug + '.json'));
}
// One file per note: <dir>/<slug>.json, replaced in one step.
async function saveNote(dir, note) {
const slug = slugify(note.title);
await writeFile(join(dir, slug + '.json'), JSON.stringify(note));
return slug;
}
// A record for every *.json file in dir; a missing dir means no notes yet.
async function listNotes(dir) {
return [];
}
// Deletes every note, then the folder itself if nothing else is left in it.
async function clearNotes(dir) {}
// What to print, and the exit code: 0 = done, 1 = a note problem, 2 = wrong usage.
async function run(args) {
let values, positionals;
try {
({values, positionals} = parseArgs({
args,
options: {
body: {type: 'string', default: ''},
dir: {type: 'string', default: join(import.meta.dirname, 'notes')},
newest: {type: 'boolean'}
},
allowPositionals: true
}));
} catch (error) {
return {out: '', err: error.message + '\n' + USAGE + '\n', code: 2};
}
const [command, name, ...rest] = positionals;
const named = name !== undefined && rest.length === 0;
const {dir} = values;
try {
if (command === 'save' && named) return {out: 'saved ' + (await saveNote(dir, {title: name, body: values.body})) + '\n', err: '', code: 0};
if (command === 'list' && name === undefined) return {out: formatListing(await listNotes(dir), values.newest ? 'newest' : 'name'), err: '', code: 0};
if (command === 'clear' && name === undefined) {
await clearNotes(dir);
return {out: 'cleared\n', err: '', code: 0};
}
if ((command === 'show' || command === 'delete') && named) {
if (!isSlug(name)) return {out: '', err: 'not a note name: ' + name + '\n', code: 2};
if (command === 'delete') {
await deleteNote(dir, name);
return {out: 'deleted ' + name + '\n', err: '', code: 0};
}
const note = await loadNote(dir, name);
return {out: note.title + '\n\n' + note.body + '\n', err: '', code: 0};
}
} catch (error) {
if (error.code === 'ENOENT') return {out: '', err: 'no note: ' + name + '\n', code: 1};
return {out: '', err: 'error: ' + error.message + '\n', code: 1};
}
return {out: '', err: USAGE + '\n', code: 2};
}
// The only lines that touch the real process.
const {out, err, code} = await run(process.argv.slice(2));
process.stdout.write(out);
process.stderr.write(err);
process.exitCode = code;
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {runMain} from './learnrun.js';
// A folder of its own for this test run, inside the exercise folder.
const dir = 'test-notes-' + Date.now() + '-' + Math.floor(Math.random() * 1e6);
const notes = async (...args) => runMain({args: [...args, '--dir', dir]});
test('list vor dem ersten save gibt no notes yet aus', async () => {
const got = await notes('list');
assert.equal(got, 'no notes yet\n', `list gab aus: ${JSON.stringify(got)}`);
});
test('save legt den Ordner an und gibt saved buy-milk aus', async () => {
const got = await notes('save', 'Buy milk', '--body', 'two litres');
assert.equal(got, 'saved buy-milk\n', `save gab aus: ${JSON.stringify(got)}`);
});
test('list zeigt die Notiz mit der Größe ihres eingerückten JSON', async () => {
const got = await notes('list');
assert.equal(got, 'buy-milk Buy milk (50 bytes)\n', `list gab aus: ${JSON.stringify(got)}`);
});
test('eine zweite Notiz: list ist nach Slug sortiert', async () => {
await notes('save', 'Call Ada', '--body', 'ring back');
const got = await notes('list');
assert.equal(got, 'buy-milk Buy milk (50 bytes)\ncall-ada Call Ada (49 bytes)\n', `list gab aus: ${JSON.stringify(got)}`);
});
test('erneutes Speichern desselben Slugs ersetzt die Notiz', async () => {
await notes('save', 'buy MILK', '--body', 'one litre');
const got = (await notes('list')).split('\n')[0];
assert.equal(got, 'buy-milk buy MILK (50 bytes)', `die erste Zeile von list war ${JSON.stringify(got)}`);
const shown = await notes('show', 'buy-milk');
assert.equal(shown, 'buy MILK\n\none litre\n', `show gab aus: ${JSON.stringify(shown)}`);
});
test('clear entfernt alle Notizen', async () => {
const cleared = await notes('clear');
assert.equal(cleared, 'cleared\n', `clear gab aus: ${JSON.stringify(cleared)}`);
const got = await notes('list');
assert.equal(got, 'no notes yet\n', `nach clear gab list aus: ${JSON.stringify(got)}`);
});
notes.js
// Pure functions: no fs, no process, so they run anywhere.
// A file-safe name from a title: small letters and digits, words joined by -.
export function slugify(title) {
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
if (slug === '') throw new Error('the title "' + title + '" has no letters or digits');
return slug;
}
// True only for a name that slugify could have made, such as buy-milk.
export function isSlug(text) {
return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(text);
}
// Checks a value read from a note file; returns {title, body} or throws.
export function validateNote(value) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('a note must be an object');
if (typeof value.title !== 'string' || value.title.trim() === '') throw new Error('a note needs a title');
if (typeof value.body !== 'string') throw new Error('the body of a note must be text');
return {title: value.title, body: value.body};
}
// The output of list: one line per note, sorted by slug or newest first.
export function formatListing(records, by = 'name') {
if (records.length === 0) return 'no notes yet\n';
const order = by === 'newest' ? (a, b) => b.mtimeMs - a.mtimeMs : (a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0);
return [...records].sort(order).map((r) => r.slug + ' ' + r.title + ' (' + r.size + ' bytes)').join('\n') + '\n';
}
package.json
{
"type": "module"
}
package.json sagt Node.js, dass die .js-Dateien Module sind; lassen Sie die Datei im Ordner.
Programm ausführen:
node main.jsPrüfungen ausführen (learnrun.js muss im selben Ordner liegen):
node --testlearnrun.js herunterladenHäufige Fehler
stat mit dem bloßen Namen aufrufen
import {mkdir, readdir, stat, writeFile} from 'node:fs/promises';
await mkdir('notes', {recursive: true});
await writeFile('notes/buy-milk.json', '{}\n');
for (const name of await readdir('notes')) {
console.log(name, (await stat(name)).size);
}
Was Node.js ausgibt
Error: ENOENT: no such file or directory, stat 'buy-milk.json'Warum, und die Lösung
readdir liefert Namen relativ zum gelesenen Ordner, und stat löst einen bloßen Namen gegen den aktuellen Ordner auf, wo buy-milk.json nicht existiert. Fügen Sie den Ordner wieder an: stat(join("notes", name)). Dasselbe gilt für readFile und rm.
Einen Ordner auflisten, den es noch nicht gibt
import {readdir} from 'node:fs/promises';
const names = await readdir('notes');
console.log(names.length, 'notes');
Was Node.js ausgibt
Error: ENOENT: no such file or directory, scandir 'notes'Warum, und die Lösung
Vor dem ersten save gibt es keinen Ordner notes, und readdir lehnt mit ENOENT ab. Für eine Liste heißt das einfach: keine Notizen. Legen Sie try/catch um readdir, geben Sie [] zurück, wenn error.code ENOENT ist, und werfen Sie jeden anderen Fehler erneut.
allowPositionals vergessen
import {parseArgs} from 'node:util';
const {values, positionals} = parseArgs({args: ['save', 'Buy milk', '--body', 'two litres'], options: {body: {type: 'string'}}});
console.log(values, positionals);
Was Node.js ausgibt
TypeError [ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL]: Unexpected argument 'save'. This command does not take positional argumentsWarum, und die Lösung
Standardmäßig akzeptiert parseArgs nur Optionen, aber der Befehl save und der Titel sind Positionsargumente. Übergeben Sie allowPositionals: true; dann ist positionals ["save", "Buy milk"]. In run() würde der Fehler abgefangen und als falsche Bedienung mit Exit-Code 2 gemeldet.
JavaScript im Browser: die eigene Engine Ihres Browsers in einem abgeschotteten Worker. Syntaxfehler findet acorn 8.18.0, MIT. Lizenz und Quellcode
Abschlussquiz
5 Fragen, ohne Hinweise. Ab 80 % ist die Lektion abgeschlossen.
Erledigen Sie zuerst alle Aufgaben oben, um das Abschlussquiz freizuschalten.