Tier 2 — Intermediate

JSON Note App

Full-featured note-taking CLI — Map store, CRUD, full-text search, JSON file persistence.

🐍 Python 🟨 JavaScript

1. The Problem

Build a note-taking CLI: create, read, update, delete, and search notes. Notes have a title, body, tags, and timestamps. Data persists in a local JSON file.

Why this project? Full CRUD with a real data model, Map as the primary store (fast lookups by ID), JSON file persistence, and text search — patterns used in every database-backed application.

2. How to Think About This

Store notes in a Map keyed by ID for O(1) lookup. The store class is pure — no file I/O. Persistence is a separate layer that loads from JSON into a NoteStore on startup and saves after every mutation. Search is a simple case-insensitive includes check across title, body, and tags.

3. The Build

notes.mjs
import { readFileSync, writeFileSync, existsSync } from "fs";
import * as readline from "readline/promises";
import { stdin, stdout } from "process";

const DATA_FILE = "notes.json";

// ── NoteStore (pure — no I/O) ──────────────────────────────────────────────
export class NoteStore {
  #notes;

  constructor(notes = []) {
    this.#notes = new Map(notes.map(n => [n.id, n]));
  }

  #nextId() {
    return this.#notes.size === 0 ? 1 : Math.max(...this.#notes.keys()) + 1;
  }

  add(title, body = "", tags = []) {
    if (!title.trim()) throw new Error("Title is required");
    const id = this.#nextId();
    const note = {
      id,
      title:     title.trim(),
      body:      body.trim(),
      tags:      tags.map(t => t.trim().toLowerCase()).filter(Boolean),
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    };
    this.#notes.set(id, note);
    return note;
  }

  get(id)    { return this.#notes.get(Number(id)) ?? null; }
  delete(id) { return this.#notes.delete(Number(id)); }

  update(id, { title, body, tags } = {}) {
    const note = this.get(id);
    if (!note) return null;
    const updated = {
      ...note,
      ...(title !== undefined ? { title: title.trim() } : {}),
      ...(body  !== undefined ? { body:  body.trim()  } : {}),
      ...(tags  !== undefined ? { tags:  tags.map(t => t.trim().toLowerCase()) } : {}),
      updatedAt: new Date().toISOString(),
    };
    this.#notes.set(id, updated);
    return updated;
  }

  search(q) {
    const lq = q.toLowerCase();
    return [...this.#notes.values()].filter(n =>
      n.title.toLowerCase().includes(lq) ||
      n.body.toLowerCase().includes(lq)  ||
      n.tags.some(t => t.includes(lq))
    );
  }

  all()     { return [...this.#notes.values()]; }
  toJSON()  { return JSON.stringify([...this.#notes.values()], null, 2); }
}

// ── Persistence ─────────────────────────────────────────────────────────────
function load() {
  if (!existsSync(DATA_FILE)) return new NoteStore();
  try { return new NoteStore(JSON.parse(readFileSync(DATA_FILE, "utf-8"))); }
  catch { return new NoteStore(); }
}
function save(store) { writeFileSync(DATA_FILE, store.toJSON(), "utf-8"); }

// ── CLI ──────────────────────────────────────────────────────────────────────
const [,, cmd, ...args] = process.argv;
const store = load();

function printNote(n) {
  console.log(`
#${n.id} — ${n.title}`);
  if (n.tags.length) console.log(`Tags: ${n.tags.join(", ")}`);
  console.log(`Created: ${n.createdAt.slice(0,10)}  Updated: ${n.updatedAt.slice(0,10)}`);
  if (n.body) console.log(`
${n.body}`);
}

switch (cmd) {
  case "add": {
    // Interactive: ask for title and body if not supplied
    const rl = readline.createInterface({ input: stdin, output: stdout });
    const title = args[0] || await rl.question("Title: ");
    const body  = args[1] || await rl.question("Body (enter to skip): ");
    const tagStr = args[2] || await rl.question("Tags (comma-separated, enter to skip): ");
    rl.close();
    const tags = tagStr.split(",").map(t => t.trim()).filter(Boolean);
    const note = store.add(title, body, tags);
    save(store);
    console.log(`
Note #${note.id} created: "${note.title}"`);
    break;
  }
  case "list":
    if (!store.all().length) { console.log("No notes yet."); break; }
    store.all().forEach(n =>
      console.log(`  #${n.id}  ${n.createdAt.slice(0,10)}  ${n.title}${n.tags.length?" ["+n.tags.join(",")+"]":""}`)
    );
    break;
  case "view": {
    const note = store.get(args[0]);
    note ? printNote(note) : console.error(`Note #${args[0]} not found`);
    break;
  }
  case "delete":
    if (store.delete(args[0])) { save(store); console.log(`Note #${args[0]} deleted.`); }
    else console.error(`Note #${args[0]} not found`);
    break;
  case "search":
    if (!args[0]) { console.error("Usage: node notes.mjs search <query>"); break; }
    const results = store.search(args.join(" "));
    results.length
      ? results.forEach(n => console.log(`  #${n.id}  ${n.title}`))
      : console.log("No matching notes.");
    break;
  default:
    console.log("Commands: add [title] [body] [tags] | list | view <id> | delete <id> | search <query>");
}

4. Test & Prove

notes.test.mjs
import { NoteStore } from "./notes.mjs";

let pass=0,fail=0;
function test(desc,fn){try{fn();console.log("  ✓",desc);pass++;}catch(e){console.log("  ✗",desc,"—",e.message);fail++;}}

const s = new NoteStore();
const n1 = s.add("JavaScript", "Learn JS well", ["js","tips"]);
const n2 = s.add("Python",     "Python is great", ["python"]);

test("add assigns id 1 to first",      () => { if(n1.id!==1) throw new Error(n1.id); });
test("get returns note",               () => { if(s.get(1)?.title!=="JavaScript") throw new Error(); });
test("get returns null for missing",   () => { if(s.get(99)!==null) throw new Error(); });
test("all() returns both",             () => { if(s.all().length!==2) throw new Error(s.all().length); });
test("search by title",                () => { if(s.search("java").length!==1) throw new Error(); });
test("search by body",                 () => { if(s.search("great").length!==1) throw new Error(); });
test("search by tag",                  () => { if(s.search("tips").length!==1) throw new Error(); });
test("search no results",              () => { if(s.search("zzz").length!==0) throw new Error(); });
test("update changes body",            () => { s.update(1,{body:"Updated"}); if(s.get(1).body!=="Updated") throw new Error(); });
test("update preserves other fields",  () => { if(s.get(1).title!=="JavaScript") throw new Error(); });
test("delete removes note",            () => { s.delete(2); if(s.all().length!==1) throw new Error(); });
test("empty title throws",             () => { try{s.add("");throw new Error("no throw");}catch(e){if(e.message==="no throw")throw e;} });

console.log(\`
\${pass} passed, \${fail} failed\`);

5. The Interface

node notes.mjs add                    # interactive prompts
node notes.mjs add "Title" "Body" "js,tips"  # inline
node notes.mjs list
node notes.mjs view 1
node notes.mjs search javascript
node notes.mjs delete 1

6. Run It

node notes.mjs add
node notes.mjs list
node notes.mjs search javascript
node notes.test.mjs

🎯 Try this next

  1. Add Markdown rendering — run the note body through your markdown-to-html converter
  2. Add edit <id> — open the note in $EDITOR (use child_process.spawnSync)
  3. Add tag filtering: node notes.mjs list --tag js
  4. Export to Markdown: node notes.mjs export --format md

What you practised

Map as a primary data store (O(1) reads/writes by ID), spread for immutable updates, interactive CLI with readline for multi-field input, CRUD operations, full-text search without a library, and JSON file persistence with load-on-startup / save-on-mutation.

Reference: Set & Map · Destructuring & Spread · Async & Promises

Try It Yourself