Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions packages/server/src/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/* eslint-disable eslint-plugin-import/no-nodejs-modules */
import { readFile, readdir, writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import type { Context } from "hono";

const DATA_DIR = join(process.cwd(), "data");
const DEFAULT_LIMIT = 5;
const NOT_FOUND_INDEX = -1;
const START_INDEX_OFFSET = 1;
const STATUS_BAD_REQUEST = 400;
const STATUS_INTERNAL_ERROR = 500;

// Helper to get notes sorted by date (descending)
const getNotes = async () => {
try {
const files = await readdir(DATA_DIR);
const notes = files
.filter((file) => /^\d{4}-\d{2}-\d{2}\.md$/.test(file))
.map((file) => ({
date: basename(file, ".md"),
filename: file,
}));
// eslint-disable-next-line eslint-plugin-unicorn/no-array-sort
return [...notes].sort((a, b) => b.date.localeCompare(a.date));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The [...notes] creates a shallow copy before sorting. Since notes is a new array created within this function's scope, creating another copy is redundant. You can directly sort the notes array to simplify the code.

Suggested change
return [...notes].sort((a, b) => b.date.localeCompare(a.date));
return notes.sort((a, b) => b.date.localeCompare(a.date));

} catch (error) {
console.error("Error reading notes directory:", error);
return [];
}
};

// eslint-disable-next-line max-statements
const getNotesHandler = async (c: Context) => {
const limit = Number(c.req.query("limit")) || DEFAULT_LIMIT;
const cursor = c.req.query("cursor");

const allNotes = await getNotes();

let startIndex = 0;
// eslint-disable-next-line no-undefined
if (cursor !== undefined && cursor !== "") {
const cursorIndex = allNotes.findIndex((note) => note.date === cursor);
if (cursorIndex !== NOT_FOUND_INDEX) {
startIndex = cursorIndex + START_INDEX_OFFSET;
}
}

const slicedNotes = allNotes.slice(startIndex, startIndex + limit);

const notesWithContent = await Promise.all(
slicedNotes.map(async (note) => {
const content = await readFile(join(DATA_DIR, note.filename), "utf8");
return {
date: note.date,
content,
};
})
);

// eslint-disable-next-line eslint-plugin-unicorn/prefer-at, no-undefined, no-magic-numbers, eslint-plugin-unicorn/prefer-ternary, no-ternary
const nextCursor = slicedNotes.length > 0 ? slicedNotes[slicedNotes.length - 1].date : undefined;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This line can be simplified by using the .at() method with optional chaining, which is more modern and readable. This would also allow you to remove the long eslint-disable comment on the preceding line.

Suggested change
const nextCursor = slicedNotes.length > 0 ? slicedNotes[slicedNotes.length - 1].date : undefined;
const nextCursor = slicedNotes.at(-1)?.date;


return c.json({
notes: notesWithContent,
nextCursor,
hasMore: startIndex + limit < allNotes.length,
});
};

const getNoteHandler = async (c: Context) => {
const date = c.req.param("date");
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return c.json({ error: "Invalid date format" }, STATUS_BAD_REQUEST);
}

const filepath = join(DATA_DIR, `${date}.md`);
try {
const content = await readFile(filepath, "utf8");
return c.json({ date, content });
} catch (error: unknown) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
if ((error as any).code === "ENOENT") {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The type assertion (error as any) is unsafe and bypasses TypeScript's type checking. You can use a type guard or a more specific check to safely access the code property.

Suggested change
if ((error as any).code === "ENOENT") {
if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {

return c.json({ date, content: "" });
}
return c.json({ error: "Failed to read note" }, STATUS_INTERNAL_ERROR);
}
};

// eslint-disable-next-line max-statements
const saveNoteHandler = async (c: Context) => {
const date = c.req.param("date");
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return c.json({ error: "Invalid date format" }, STATUS_BAD_REQUEST);
}

// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const body: { content?: string } = await c.req.json();
const content = body.content ?? "";

const filepath = join(DATA_DIR, `${date}.md`);
try {
await writeFile(filepath, content, "utf8");
return c.json({ success: true, date, content });
} catch (error) {
console.error("Error writing note:", error);
return c.json({ error: "Failed to save note" }, STATUS_INTERNAL_ERROR);
}
};

export default { getNotesHandler, getNoteHandler, saveNoteHandler };
120 changes: 59 additions & 61 deletions packages/server/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,74 +1,72 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import app from "./index";
import { writeFile, unlink, readdir } from "node:fs/promises";
/* eslint-disable eslint-plugin-import/no-nodejs-modules */
import { spawn } from "node:child_process";
import { access, rm, stat } from "node:fs/promises";
import { join } from "node:path";
import { subDays } from "date-fns";
import { beforeAll, describe, expect, it } from "vitest";

const DATA_DIR = join(process.cwd(), "data");
const DATE_PADDING = 2;
const MONTH_OFFSET = 1;
const TEST_TIMEOUT = 10_000;
const SERVER_STARTUP_TIME = 3000;
const DAYS_TO_CHECK = 3;
const INCREMENT_STEP = 1;
const EMPTY_SIZE = 0;

describe("Note API", () => {
// Helper to format date as yyyy-MM-dd
const formatDate = (date: Date) => {
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + MONTH_OFFSET).padStart(DATE_PADDING, "0");
const dd = String(date.getDate()).padStart(DATE_PADDING, "0");
return `${yyyy}-${mm}-${dd}`;
};

const verifyNoteExists = async (date: Date) => {
const filename = `${formatDate(date)}.md`;
const filepath = join(DATA_DIR, filename);

console.log(`Checking for file: ${filepath}`);

try {
await access(filepath);
const fileStat = await stat(filepath);
expect(fileStat.size).toBe(EMPTY_SIZE);
} catch (error) {
throw new Error(`File ${filename} was not created. Error: ${String(error)}`, { cause: error });
}
};

describe("Server Auto Create", () => {
// Clean up data directory before test
beforeAll(async () => {
// Clean up
try {
const files = await readdir(DATA_DIR);
for (const file of files) {
if (file.endsWith(".md")) {
await unlink(join(DATA_DIR, file));
}
}
} catch {}
await rm(DATA_DIR, { recursive: true, force: true });
} catch {
// Ignore cleanup error
}
});

afterAll(async () => {
// Clean up
try {
const files = await readdir(DATA_DIR);
for (const file of files) {
if (file.endsWith(".md")) {
await unlink(join(DATA_DIR, file));
}
}
} catch {}
});
it("creates past 3 days notes when TEST_AUTO_CREATE is set", async () => {
console.log("Starting server process with TEST_AUTO_CREATE=true");
const serverProcess = spawn("bun", ["src/index.ts"], {
cwd: process.cwd(),
env: { ...process.env, TEST_AUTO_CREATE: "true", PORT: "3002" },
stdio: "inherit",
});

it("should create a note", async () => {
const res = await app.fetch(
new Request("http://localhost/api/notes/2023-01-01", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: "Hello" }),
})
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toEqual({ success: true, date: "2023-01-01", content: "Hello" });
});

it("should retrieve a note", async () => {
const res = await app.fetch(
new Request("http://localhost/api/notes/2023-01-01")
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toEqual({ date: "2023-01-01", content: "Hello" });
});
// Wait for server to start and process env
// eslint-disable-next-line eslint-plugin-promise/avoid-new
await new Promise((resolve) => { setTimeout(resolve, SERVER_STARTUP_TIME); });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using a fixed setTimeout to wait for the server to start can lead to flaky tests. The test might fail on slower machines or if the server takes longer to initialize. A more robust approach would be to poll a health check endpoint or wait for a specific log output from the server process before proceeding with the test assertions.


it("should list notes", async () => {
// Add another note
await app.fetch(
new Request("http://localhost/api/notes/2023-01-02", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: "World" }),
})
);
serverProcess.kill();

const res = await app.fetch(
new Request("http://localhost/api/notes")
const today = new Date();
// eslint-disable-next-line @typescript-eslint/promise-function-async
const checks = Array.from({ length: DAYS_TO_CHECK }, (_, i) => i + INCREMENT_STEP).map((i) =>
verifyNoteExists(subDays(today, i))
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.notes).toHaveLength(2);
expect(data.notes[0].date).toBe("2023-01-02"); // Descending
expect(data.notes[1].date).toBe("2023-01-01");
});

await Promise.all(checks);
}, TEST_TIMEOUT);
});
101 changes: 13 additions & 88 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
/* eslint-disable eslint-plugin-import/no-nodejs-modules */
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
import { join, basename, extname } from "node:path";
import handlers from "@/handlers";
import { autoCreateNotes } from "@/utils";

const app = new Hono();

Expand All @@ -12,103 +15,25 @@ const DATA_DIR = join(process.cwd(), "data");
// Ensure data directory exists
await mkdir(DATA_DIR, { recursive: true });

// Helper to get notes sorted by date (descending)
async function getNotes() {
try {
const files = await readdir(DATA_DIR);
const notes = files
.filter((file) => /^\d{4}-\d{2}-\d{2}\.md$/.test(file))
.map((file) => ({
date: basename(file, ".md"),
filename: file,
}))
.sort((a, b) => b.date.localeCompare(a.date));
return notes;
} catch (error) {
console.error("Error reading notes directory:", error);
return [];
}
// Auto-create notes if TEST_AUTO_CREATE is set
const CREATE_DAYS_BACK = 3;

if (process.env.TEST_AUTO_CREATE === "true") {
await autoCreateNotes(CREATE_DAYS_BACK, DATA_DIR);
}

app.get("/api/health", (c) => {
return c.json({ status: "ok" });
});

// Get list of notes with content, paginated
app.get("/api/notes", async (c) => {
const limit = Number(c.req.query("limit")) || 5;
const cursor = c.req.query("cursor"); // Date string (YYYY-MM-DD)

const allNotes = await getNotes();

let startIndex = 0;
if (cursor) {
const cursorIndex = allNotes.findIndex((note) => note.date === cursor);
if (cursorIndex !== -1) {
startIndex = cursorIndex + 1; // Start after the cursor
}
}

const slicedNotes = allNotes.slice(startIndex, startIndex + limit);

// Read content for each note
const notesWithContent = await Promise.all(
slicedNotes.map(async (note) => {
const content = await readFile(join(DATA_DIR, note.filename), "utf-8");
return {
date: note.date,
content,
};
})
);

return c.json({
notes: notesWithContent,
nextCursor: slicedNotes.length > 0 ? slicedNotes[slicedNotes.length - 1].date : null,
hasMore: startIndex + limit < allNotes.length,
});
});
app.get("/api/notes", handlers.getNotesHandler);

// Get a single note
app.get("/api/notes/:date", async (c) => {
const date = c.req.param("date");
// Basic validation
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return c.json({ error: "Invalid date format" }, 400);
}

const filepath = join(DATA_DIR, `${date}.md`);
try {
const content = await readFile(filepath, "utf-8");
return c.json({ date, content });
} catch (error: any) {
if (error.code === "ENOENT") {
// Return empty content if file doesn't exist
return c.json({ date, content: "" });
}
return c.json({ error: "Failed to read note" }, 500);
}
});
app.get("/api/notes/:date", handlers.getNoteHandler);

// Create or update a note
app.post("/api/notes/:date", async (c) => {
const date = c.req.param("date");
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return c.json({ error: "Invalid date format" }, 400);
}

const body = await c.req.json();
const content = body.content || "";

const filepath = join(DATA_DIR, `${date}.md`);
try {
await writeFile(filepath, content, "utf-8");
return c.json({ success: true, date, content });
} catch (error) {
console.error("Error writing note:", error);
return c.json({ error: "Failed to save note" }, 500);
}
});
app.post("/api/notes/:date", handlers.saveNoteHandler);

export default {
port: 3000,
Expand Down
Loading