Zum Inhalt springen
aviral gupta

// 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

Ende des Moduls

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
  1. Aufwärmen · Aufgabe 1 von 7

    Aufwärmen aus Modul B3: Welche Aussagen stimmen? Wählen Sie alle zutreffenden.

    Wählen Sie alle zutreffenden aus.

  2. 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"
  3. Ü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, "");
  4. Üben · Aufgabe 4 von 7

    Das fertige Tool hat noch keine Notizen. Ordnen Sie jeder Befehlszeile zu, was sie bewirkt.

  5. Ü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 list
  6. Denksport · 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?

  7. 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.js

Ausgabe

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
  1. Hinweis 1

    title.toLowerCase().replace(/[^a-z0-9]+/g, '-') macht aus jeder Folge anderer Zeichen ein -.

  2. Hinweis 2

    Ein zweites replace(/^-+|-+$/g, '') entfernt - am Anfang und am Ende.

  3. 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.js

Prüfungen ausführen (learnrun.js muss im selben Ordner liegen):

node --test
learnrun.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
  1. Hinweis 1

    typeof null ist 'object', typeof [] ebenso, also prüfen Sie auch value === null und Array.isArray(value).

  2. Hinweis 2

    value.title.trim() === '' erkennt einen Titel nur aus Leerzeichen; prüfen Sie zuerst typeof value.title === 'string'.

  3. 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.js

Prüfungen ausführen (learnrun.js muss im selben Ordner liegen):

node --test
learnrun.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
  1. Hinweis 1

    Sortieren Sie eine Kopie, [...records].sort(order), denn sort() ändert das Array, auf dem es aufgerufen wird.

  2. 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).

  3. 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.js

Prüfungen ausführen (learnrun.js muss im selben Ordner liegen):

node --test
learnrun.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
  1. Hinweis 1

    Bauen Sie den Temp-Pfad neben der Notiz: join(dir, '.' + slug + '.json.' + process.pid + '.tmp'), dann rename(temp, join(dir, slug + '.json')).

  2. 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").

  3. 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.js

Prüfungen ausführen (learnrun.js muss im selben Ordner liegen):

node --test
learnrun.js herunterladen

Hä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 arguments

Warum, 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.

Problem melden

Etwas ist falsch oder unklar? Beschreiben Sie es kurz, dann wird es geprüft und korrigiert.

#

Mindestens 20 Zeichen.

Nur, wenn Sie eine Antwort wünschen.

Kernideen

Eine Datei pro Notiz

Das Tool legt jede Notiz in einer eigenen Datei ab, notes/<slug>.json, neben main.js. Der Slug entsteht aus dem Titel: slugify("Buy Milk!") ist buy-milk, Kleinbuchstaben und Ziffern, verbunden mit -. So ist eine Notiz leicht zu finden, und derselbe Titel ersetzt sie beim erneuten Speichern, statt eine zweite anzulegen. Buchstaben außerhalb von a-z wie ü werden zu Trennern: Grüße ergibt gr-e. saveNote legt den Ordner mit mkdir und {recursive: true} an, schreibt JSON.stringify(note, null, 2) in eine Temp-Datei im selben Ordner und benennt sie über die Notizdatei um. Ein Absturz hinterlässt so nie eine halbe Notiz. --dir lenkt das Tool auf einen anderen Ordner; die Tests nutzen das.

Auflisten heißt filtern

list liest den Ordner mit readdir und withFileTypes. Ein fehlender Ordner ist kein Fehler: ENOENT heißt noch keine Notizen. Nicht jeder Eintrag ist eine Notiz: Ein Ordner wie archive oder eine Temp-Datei wie .call-ada.json.4012.tmp nach einem Absturz wird übersprungen, denn nur Dateien mit der Endung .json zählen. Für jede Notiz liefert stat size und mtimeMs, readFile und validateNote liefern den Titel. formatListing sortiert nach Slug oder mit --newest die neueste zuerst, denn readdir verspricht keine Reihenfolge. show und delete nehmen nur Namen an, die isSlug billigt, also erreicht show ../main nie eine Datei außerhalb des Ordners. clear löscht die Notizen einzeln und dann den leeren Ordner mit rmdir.

Dieselbe Befehlsform wie in B1

run(args) liefert {out, err, code}, wie der Trinkgeldrechner aus B1.5. parseArgs mit allowPositionals liefert den Befehl und den Titel oder Slug, dazu --body, --dir und --newest. Ein Ergebnis geht mit Exit-Code 0 nach stdout. Ein Notizproblem, etwa eine fehlende Notiz (ENOENT) oder ein Titel ohne Buchstaben und Ziffern, ergibt eine Zeile auf stderr und Exit-Code 1. Falsche Bedienung, etwa ein unbekannter Befehl, eine unbekannte Option oder ein Name wie ../main, ergibt stderr und Exit-Code 2. Nur die letzten vier Zeilen berühren den echten Prozess. Die reinen Teile slugify, isSlug, validateNote und formatListing liegen in notes.js und laufen auch im Browser.

Quellen

Zuletzt geprüft am 4. Oktober 2026