Skip to content
Merged
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
572 changes: 512 additions & 60 deletions package-lock.json

Large diffs are not rendered by default.

38 changes: 36 additions & 2 deletions scripts/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,45 @@ import { build } from "esbuild";
import { readFileSync, mkdirSync, cpSync, existsSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { spawnSync } from "child_process";

const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, "..");
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));

/**
* Compute the version string to embed.
* Uses `git describe` so dev builds get a suffix like "0.11.0-dev.3+gabc1234".
* Falls back to package.json version if git is unavailable.
*/
function computeVersion() {
try {
const result = spawnSync("git", ["describe", "--tags", "--always"], {
cwd: root,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) return pkg.version;
const raw = result.stdout.toString().trim();

// Exact tag: v0.11.0 → "0.11.0"
const tagMatch = raw.match(/^v?(\d+\.\d+\.\d+)$/);
if (tagMatch) return tagMatch[1];

// Tag + commits: v0.11.0-3-gabc1234 → "0.11.0-dev.3+gabc1234"
const devMatch = raw.match(/^v?(\d+\.\d+\.\d+)-(\d+)-g([a-f0-9]+)$/);
if (devMatch) return `${devMatch[1]}-dev.${devMatch[2]}+g${devMatch[3]}`;

// No tag, just a hash
if (/^[a-f0-9]+$/.test(raw)) return `0.0.0+g${raw}`;

return pkg.version;
} catch {
return pkg.version;
}
}

const version = computeVersion();

// All npm dependencies stay external — they live in node_modules at runtime.
const external = Object.keys({
...pkg.dependencies,
Expand All @@ -28,7 +62,7 @@ await build({
external,
// Inject the version at build time so version.ts works without package.json at runtime.
define: {
__PACKAGE_VERSION__: JSON.stringify(pkg.version),
__PACKAGE_VERSION__: JSON.stringify(version),
},
});

Expand All @@ -43,4 +77,4 @@ const webDistDest = join(root, "dist/app/dist");
mkdirSync(webDistDest, { recursive: true });
cpSync(webDistSrc, webDistDest, { recursive: true });

console.log(`Built dist/main.js (v${pkg.version})`);
console.log(`Built dist/main.js (v${version})`);
4 changes: 4 additions & 0 deletions src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ export async function createFolder(path: string): Promise<void> {
});
}

export async function deleteFolder(path: string): Promise<void> {
await request(`/notes/folder?path=${encodeURIComponent(path)}`, { method: "DELETE" });
}

// --- Logs ---

export async function listJournals(prefix?: string): Promise<ListEntry[]> {
Expand Down
25 changes: 24 additions & 1 deletion src/core/notes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdir, unlink, readdir, stat, readFile, writeFile } from "fs/promises";
import { mkdir, unlink, readdir, stat, readFile, writeFile, rm } from "fs/promises";
import { existsSync, readdirSync } from "fs";
import { dirname, join, basename } from "path";
import matter from "gray-matter";
Expand Down Expand Up @@ -178,6 +178,29 @@ export async function createFolder(logicalPath: string): Promise<string> {
return logicalPath;
}

/** Delete a folder and all its contents. */
export async function deleteFolder(logicalPath: string): Promise<void> {
if (!logicalPath.startsWith("notes/") && !logicalPath.startsWith("logs/")) {
throw new Error(`Can only delete folders under notes/ or logs/. Got: ${logicalPath}`);
}

const home = getHome();
const dirPath = join(home, logicalPath);

try {
const s = await stat(dirPath);
if (!s.isDirectory()) {
throw new Error(`Not a folder: ${logicalPath}`);
}
} catch (err: any) {
if (err.code === "ENOENT") throw new Error(`Folder not found: ${logicalPath}`);
throw err;
}

await rm(dirPath, { recursive: true, force: true });
await updateIndex();
}

export async function deleteNote(logicalPath: string): Promise<void> {
const filePath = resolvePath(logicalPath);

Expand Down
5 changes: 5 additions & 0 deletions src/core/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ export async function createFolder(path: string): Promise<void> {
await directNotes.createFolder(path);
}

export async function deleteFolder(path: string): Promise<void> {
if (useServer()) return client.deleteFolder(path);
return directNotes.deleteFolder(path);
}

// --- Logs ---

export async function listJournals(prefix?: string): Promise<ListEntry[]> {
Expand Down
13 changes: 13 additions & 0 deletions src/web/api/notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ensureHome, resolvePath } from "../../core/config.ts";
import {
createNote,
createFolder,
deleteFolder,
getNote,
updateNote,
deleteNote,
Expand Down Expand Up @@ -151,6 +152,18 @@ notesApi.post("/import", async (c) => {
}
});

// Delete a folder
notesApi.delete("/folder", async (c) => {
const path = c.req.query("path");
if (!path) return c.json({ error: "path is required" }, 400);
try {
await deleteFolder(path);
return c.json({ ok: true });
} catch (err: any) {
return c.json({ error: err.message }, 404);
}
});

// Delete a note
notesApi.delete("/", async (c) => {
const path = c.req.query("path");
Expand Down
9 changes: 9 additions & 0 deletions src/web/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ export default function App() {
setSidebarOpen(false);
}

function handleNoteSelectEdit(note: NoteResult) {
setCurrentNote(note);
setViewMode("edit");
setShowSearch(false);
setSidebarOpen(false);
}

function handleNoteSaved() {
setSidebarRefresh((n) => n + 1);
}
Expand Down Expand Up @@ -102,6 +109,7 @@ export default function App() {
>
<Sidebar
onSelect={handleNoteSelect}
onSelectEdit={handleNoteSelectEdit}
refreshTrigger={sidebarRefresh()}
onNewNote={handleNoteSaved}
currentPath={currentPath}
Expand All @@ -118,6 +126,7 @@ export default function App() {
<div class="shrink-0">
<Sidebar
onSelect={handleNoteSelect}
onSelectEdit={handleNoteSelectEdit}
refreshTrigger={sidebarRefresh()}
onNewNote={handleNoteSaved}
currentPath={currentPath}
Expand Down
93 changes: 62 additions & 31 deletions src/web/app/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { notes, logs, contextApi, type ListEntry, type NoteResult } from "../lib

interface Props {
onSelect: (note: NoteResult) => void;
onSelectEdit?: (note: NoteResult) => void;
refreshTrigger: number;
onNewNote: () => void;
currentPath: () => string | undefined;
Expand Down Expand Up @@ -157,10 +158,12 @@ export default function Sidebar(props: Props) {

async function handleDelete(entry: ListEntry) {
setContextMenu(null);
const label = entry.type === "log" ? "journal" : "note";
const label = entry.type === "directory" ? "folder and all its contents" : entry.type === "log" ? "journal" : "note";
if (!confirm(`Delete this ${label}? This cannot be undone.`)) return;
try {
if (entry.type === "log") {
if (entry.type === "directory") {
await notes.deleteFolder(entry.path);
} else if (entry.type === "log") {
await logs.deleteJournal(entry.path);
} else {
await notes.delete(entry.path);
Expand Down Expand Up @@ -465,46 +468,74 @@ export default function Sidebar(props: Props) {

{/* Right-click context menu */}
<Show when={contextMenu()}>
{(menu) => (
<div
class="fixed z-50 rounded shadow-lg border py-1 text-sm"
style={{
left: `${menu().x}px`,
top: `${menu().y}px`,
"background-color": "var(--color-bg-surface)",
"border-color": "var(--color-border)",
"min-width": "120px",
}}
onClick={(e) => e.stopPropagation()}
>
<Show when={menu().entry.type !== "directory"}>
{(menu) => {
const entry = () => menu().entry;
const isTopLevel = () => entry().path === "notes" || entry().path === "logs";
const isDirectory = () => entry().type === "directory";
const isNote = () => entry().type === "note";
return (
<div
class="fixed z-50 rounded shadow-lg border py-1 text-sm"
style={{
left: `${menu().x}px`,
top: `${menu().y}px`,
"background-color": "var(--color-bg-surface)",
"border-color": "var(--color-border)",
"min-width": "120px",
}}
onClick={(e) => e.stopPropagation()}
>
{/* Open — always shown */}
<button
class="w-full text-left px-3 py-1.5 cursor-pointer"
style={{ color: "var(--color-text-primary)" }}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--color-bg-hover)")}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "transparent")}
onClick={async () => {
const { path, type } = entry();
setContextMenu(null);
const note = await notes.get(menu().entry.path);
props.onSelect(note);
if (type === "directory") {
navigateInto(path);
} else {
const note = await notes.get(path);
props.onSelect(note);
}
}}
>
Open
</button>
</Show>
<Show when={menu().entry.type !== "directory"}>
<button
class="w-full text-left px-3 py-1.5 cursor-pointer"
style={{ color: "#ef4444" }}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--color-bg-hover)")}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "transparent")}
onClick={() => handleDelete(menu().entry)}
>
Delete
</button>
</Show>
</div>
)}
{/* Edit — notes only (not logs, not directories) */}
<Show when={isNote() && !props.readOnly}>
<button
class="w-full text-left px-3 py-1.5 cursor-pointer"
style={{ color: "var(--color-text-primary)" }}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--color-bg-hover)")}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "transparent")}
onClick={async () => {
const path = entry().path;
setContextMenu(null);
const note = await notes.get(path);
props.onSelectEdit?.(note);
}}
>
Edit
</button>
</Show>
{/* Delete — non-top-level entries only */}
<Show when={!isTopLevel() && !props.readOnly}>
<button
class="w-full text-left px-3 py-1.5 cursor-pointer"
style={{ color: "#ef4444" }}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--color-bg-hover)")}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "transparent")}
onClick={() => handleDelete(entry())}
>
Delete
</button>
</Show>
</div>
);
}}
</Show>
</aside>
);
Expand Down
5 changes: 5 additions & 0 deletions src/web/app/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ export const notes = {
method: "POST",
body: JSON.stringify({ path }),
}),

deleteFolder: (path: string) =>
request<{ ok: boolean }>(`/notes/folder?path=${encodeURIComponent(path)}`, {
method: "DELETE",
}),
};

// Logs API
Expand Down
Loading
Loading