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
10 changes: 10 additions & 0 deletions packages/backend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import {
updateNote,
} from "./note";
import { getCurrentProjectId } from "./project";
import {
createReminder,
deleteReminder,
dismissReminder,
getReminders,
} from "./reminder";

export {
getTree,
Expand All @@ -26,4 +32,8 @@ export {
getLegacyNotes,
migrateNote,
getFileContent,
getReminders,
createReminder,
deleteReminder,
dismissReminder,
};
139 changes: 139 additions & 0 deletions packages/backend/src/api/reminder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { randomUUID } from "crypto";

import type { SDK } from "caido:plugin";
import type { Reminder, Result } from "shared";
import { error, ok } from "shared";

import {
createReminderSchema,
deleteReminderSchema,
dismissReminderSchema,
} from "../schemas/reminder";
import { ensureProjectDirectory } from "../utils/fileSystem";
import { readRemindersFile, writeRemindersFile } from "../utils/reminderFile";

/**
* Get all reminders for the current project
*/
export async function getReminders(sdk: SDK): Promise<Result<Reminder[]>> {
try {
const projectIDResult = await ensureProjectDirectory(sdk);
if (projectIDResult.kind === "Error") {
return error(projectIDResult.error);
}

const reminders = readRemindersFile(projectIDResult.value);
return ok(reminders);
} catch (err) {
sdk.console.error(`Error getting reminders: ${err}`);
return error(err instanceof Error ? err.message : String(err));
}
}

/**
* Create a new reminder
*/
export async function createReminder(
sdk: SDK,
notePath: string,
context: string,
reminderAt: string,
): Promise<Result<Reminder>> {
try {
createReminderSchema.parse({ notePath, context, reminderAt });

const parsedDate = new Date(reminderAt);
if (isNaN(parsedDate.getTime())) {
return error("Invalid reminder date");
}
const normalizedReminderAt = parsedDate.toISOString();

const projectIDResult = await ensureProjectDirectory(sdk);
if (projectIDResult.kind === "Error") {
return error(projectIDResult.error);
}

const projectID = projectIDResult.value;
const reminder: Reminder = {
id: randomUUID(),
notePath,
context,
reminderAt: normalizedReminderAt,
createdAt: new Date().toISOString(),
Comment on lines +43 to +62
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Validate reminderAt as a real timestamp before persisting it.

This currently accepts any non-empty string, so bad values can be written and then parsed with new Date(reminderAt) in the frontend. The result is Invalid Date output or reminders that never transition to due/missed. Parse and normalize the timestamp here before writing the file.

💡 Suggested change
   try {
     createReminderSchema.parse({ notePath, context, reminderAt });
+    const parsedReminderAt = new Date(reminderAt);
+    if (Number.isNaN(parsedReminderAt.getTime())) {
+      return error("Invalid reminderAt");
+    }
@@
     const reminder: Reminder = {
       id: randomUUID(),
       notePath,
       context,
-      reminderAt,
+      reminderAt: parsedReminderAt.toISOString(),
       createdAt: new Date().toISOString(),
       triggered: false,
       dismissed: false,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/backend/src/api/reminder.ts` around lines 43 - 56, The code accepts
reminderAt as any non-empty string and persists it, which can produce invalid
dates; instead, after createReminderSchema.parse(...) and before constructing
the Reminder object in this handler, parse reminderAt into a Date, verify
!isNaN(date.getTime()), normalize it to an ISO timestamp (e.g.
date.toISOString()) and use that normalized value in the Reminder.reminderAt
field; if parsing fails return error(...) (similar to the existing project error
path) so invalid timestamps are rejected before calling ensureProjectDirectory /
writing the file.

triggered: false,
dismissed: false,
};

const reminders = readRemindersFile(projectID);
reminders.push(reminder);
writeRemindersFile(projectID, reminders);

return ok(reminder);
} catch (err) {
sdk.console.error(`Error creating reminder: ${err}`);
return error(err instanceof Error ? err.message : String(err));
}
}

/**
* Delete a reminder by ID
*/
export async function deleteReminder(
sdk: SDK,
reminderId: string,
): Promise<Result<boolean>> {
try {
deleteReminderSchema.parse({ reminderId });

const projectIDResult = await ensureProjectDirectory(sdk);
if (projectIDResult.kind === "Error") {
return error(projectIDResult.error);
}

const projectID = projectIDResult.value;
const reminders = readRemindersFile(projectID);
const filtered = reminders.filter((r) => r.id !== reminderId);

if (filtered.length === reminders.length) {
return error(`Reminder not found: ${reminderId}`);
}

writeRemindersFile(projectID, filtered);
return ok(true);
} catch (err) {
sdk.console.error(`Error deleting reminder: ${err}`);
return error(err instanceof Error ? err.message : String(err));
}
}

/**
* Dismiss a reminder (mark as acknowledged by user)
*/
export async function dismissReminder(
sdk: SDK,
reminderId: string,
): Promise<Result<boolean>> {
try {
dismissReminderSchema.parse({ reminderId });

const projectIDResult = await ensureProjectDirectory(sdk);
if (projectIDResult.kind === "Error") {
return error(projectIDResult.error);
}

const projectID = projectIDResult.value;
const reminders = readRemindersFile(projectID);
const target = reminders.find((r) => r.id === reminderId);

if (!target) {
return error(`Reminder not found: ${reminderId}`);
}

target.dismissed = true;
writeRemindersFile(projectID, reminders);
return ok(true);
} catch (err) {
sdk.console.error(`Error dismissing reminder: ${err}`);
return error(err instanceof Error ? err.message : String(err));
}
}
18 changes: 18 additions & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,29 @@ import type { DefineAPI, SDK } from "caido:plugin";
import {
createFolder,
createNote,
createReminder,
deleteFolder,
deleteNote,
deleteReminder,
dismissReminder,
getCurrentProjectId,
getFileContent,
getLegacyNotes,
getNote,
getReminders,
getTree,
migrateNote,
moveItem,
searchNotes,
updateNote,
} from "./api";
import { type BackendEvents } from "./types/events";
import { startReminderTimer } from "./utils/reminderTimer";

export type { BackendEvents } from "./types/events";

let stopReminderTimer: (() => void) | undefined;

export type API = DefineAPI<{
getTree: typeof getTree;
getNote: typeof getNote;
Expand All @@ -33,6 +40,10 @@ export type API = DefineAPI<{
getLegacyNotes: typeof getLegacyNotes;
migrateNote: typeof migrateNote;
getFileContent: typeof getFileContent;
getReminders: typeof getReminders;
createReminder: typeof createReminder;
deleteReminder: typeof deleteReminder;
dismissReminder: typeof dismissReminder;
}>;

export function init(sdk: SDK<API, BackendEvents>) {
Expand All @@ -49,10 +60,17 @@ export function init(sdk: SDK<API, BackendEvents>) {
sdk.api.register("getLegacyNotes", getLegacyNotes);
sdk.api.register("migrateNote", migrateNote);
sdk.api.register("getFileContent", getFileContent);
sdk.api.register("getReminders", getReminders);
sdk.api.register("createReminder", createReminder);
sdk.api.register("deleteReminder", deleteReminder);
sdk.api.register("dismissReminder", dismissReminder);

sdk.events.onProjectChange((sdk, project) => {
sdk.api.send("notes++:projectChange", project?.getId());
});

stopReminderTimer?.();
stopReminderTimer = startReminderTimer(sdk);

sdk.console.log("Notes++ backend initialized successfully");
}
15 changes: 15 additions & 0 deletions packages/backend/src/schemas/reminder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { z } from "zod";

export const createReminderSchema = z.object({
notePath: z.string().min(1),
context: z.string(),
reminderAt: z.string().datetime({ offset: true }),
});

export const deleteReminderSchema = z.object({
reminderId: z.string().min(1),
});

export const dismissReminderSchema = z.object({
reminderId: z.string().min(1),
});
2 changes: 2 additions & 0 deletions packages/backend/src/types/events.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { DefineEvents } from "caido:plugin";
import type { Reminder } from "shared";

export type BackendEvents = DefineEvents<{
"notes++:projectChange": (projectId: string) => void;
"notes++:reminderDue": (reminder: Reminder) => void;
}>;
62 changes: 62 additions & 0 deletions packages/backend/src/utils/reminderFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import * as fs from "fs";
import path from "path";

import type { Reminder } from "shared";
import { z } from "zod";

import {
createDirectory,
directoryExists,
fileExists,
toSystemPath,
} from "./fileSystem";
import { getNoteRootPath } from "./paths";

const REMINDERS_FILENAME = "reminders.json";

const ReminderSchema = z.object({
id: z.string(),
notePath: z.string(),
context: z.string(),
reminderAt: z.string(),
createdAt: z.string(),
triggered: z.boolean(),
dismissed: z.boolean(),
});

const RemindersArraySchema = z.array(ReminderSchema);

export function getRemindersFilePath(projectID: string): string {
return path.join(getNoteRootPath(projectID), REMINDERS_FILENAME);
}

export function readRemindersFile(projectID: string): Reminder[] {
const filePath = getRemindersFilePath(projectID);

if (!fileExists(filePath)) {
return [];
}

try {
const raw = fs.readFileSync(toSystemPath(filePath), "utf8");
const parsed = JSON.parse(raw);
const result = RemindersArraySchema.safeParse(parsed);
return result.success ? (result.data as Reminder[]) : [];
} catch {
return [];
}
}

export function writeRemindersFile(
projectID: string,
reminders: Reminder[],
): void {
const filePath = getRemindersFilePath(projectID);
const dirPath = path.dirname(filePath);

if (!directoryExists(dirPath)) {
createDirectory(dirPath);
}

fs.writeFileSync(toSystemPath(filePath), JSON.stringify(reminders, null, 2));
}
46 changes: 46 additions & 0 deletions packages/backend/src/utils/reminderTimer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { SDK } from "caido:plugin";

import type { API } from "../index";
import type { BackendEvents } from "../types/events";

import { readRemindersFile, writeRemindersFile } from "./reminderFile";

const CHECK_INTERVAL_MS = 30_000;

/** Periodically checks for due reminders and dispatches notification events. */
export function startReminderTimer(sdk: SDK<API, BackendEvents>): () => void {
async function checkReminders() {
try {
const project = await sdk.projects.getCurrent();
const projectID = project?.getId();

if (!projectID) return;

const reminders = readRemindersFile(projectID);
const now = new Date();
let changed = false;

for (const reminder of reminders) {
if (reminder.triggered || reminder.dismissed) continue;

const dueDate = new Date(reminder.reminderAt);
if (dueDate <= now) {
reminder.triggered = true;
changed = true;
sdk.api.send("notes++:reminderDue", reminder);
}
}

if (changed) {
writeRemindersFile(projectID, reminders);
}
} catch (err) {
sdk.console.error(`Error checking reminders: ${err}`);
}
}

checkReminders();
const intervalId = setInterval(checkReminders, CHECK_INTERVAL_MS);

return () => clearInterval(intervalId);
}
Loading
Loading