From a128ec7972d3f23e81caee443d19be3c5e920d39 Mon Sep 17 00:00:00 2001 From: Haritabh <41149926+haritabh-z01@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:13:33 +0530 Subject: [PATCH] Feat/notes init (#40) * feat: notes init * Feat/notes page (#39) * wip: notes page ui added * notes page basic ui done * wip: page UI * minor cleanup * todo comments added for easy ref for backend wiring --------- Co-authored-by: amadeus-x1 <45001978+amadeus-x1@users.noreply.github.com> * feat: wire up notes --------- Co-authored-by: amadeus-x1 <45001978+amadeus-x1@users.noreply.github.com> --- apps/desktop/package.json | 4 + apps/desktop/public/assets/meet.svg | 20 + apps/desktop/public/assets/zoom.svg | 1 + apps/desktop/scripts/migrate-yjs-to-blob.ts | 63 +++ .../migrations/0002_cheerful_betty_brant.sql | 18 + .../src/db/migrations/meta/0002_snapshot.json | 491 +++++++++++++++++ .../src/db/migrations/meta/_journal.json | 7 + apps/desktop/src/db/notes.ts | 167 ++++++ apps/desktop/src/db/schema.ts | 38 ++ apps/desktop/src/main/core/event-handlers.ts | 41 ++ .../src/main/managers/service-manager.ts | 4 +- apps/desktop/src/main/preload.ts | 9 + .../main/components/settings-sidebar.tsx | 6 + .../src/renderer/main/hooks/useDebounce.ts | 17 + .../main/pages/notes/components/note-card.tsx | 81 +++ .../pages/notes/components/note-wrapper.tsx | 274 ++++++++++ .../main/pages/notes/components/note.tsx | 517 ++++++++++++++++++ .../pages/notes/components/notes-list.tsx | 112 ++++ .../notes/components/upcoming-event-card.tsx | 90 +++ .../notes/components/upcoming-events.tsx | 94 ++++ .../src/renderer/main/pages/notes/index.tsx | 13 + .../src/renderer/main/pages/notes/types.ts | 18 + .../src/renderer/main/routeTree.gen.ts | 71 +++ .../main/routes/settings/notes.$noteId.tsx | 12 + .../main/routes/settings/notes.index.tsx | 6 + .../renderer/main/routes/settings/notes.tsx | 5 + .../renderer/main/routes/settings/route.tsx | 5 + .../src/renderer/main/utils/debounce.ts | 25 + .../components/UnifiedPermissionsStep.tsx | 6 +- apps/desktop/src/services/notes-service.ts | 193 +++++++ .../src/services/transcription-service.ts | 5 +- apps/desktop/src/trpc/router.ts | 4 + apps/desktop/src/trpc/routers/notes.ts | 104 ++++ apps/desktop/src/types/electron-api.ts | 6 + apps/desktop/src/utils/meeting-icons.tsx | 98 ++++ packages/y-libsql/package.json | 24 + packages/y-libsql/src/index.ts | 240 ++++++++ packages/y-libsql/tsconfig.json | 19 + pnpm-lock.yaml | 82 +++ 39 files changed, 2980 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/public/assets/meet.svg create mode 100644 apps/desktop/public/assets/zoom.svg create mode 100644 apps/desktop/scripts/migrate-yjs-to-blob.ts create mode 100644 apps/desktop/src/db/migrations/0002_cheerful_betty_brant.sql create mode 100644 apps/desktop/src/db/migrations/meta/0002_snapshot.json create mode 100644 apps/desktop/src/db/notes.ts create mode 100644 apps/desktop/src/renderer/main/hooks/useDebounce.ts create mode 100644 apps/desktop/src/renderer/main/pages/notes/components/note-card.tsx create mode 100644 apps/desktop/src/renderer/main/pages/notes/components/note-wrapper.tsx create mode 100644 apps/desktop/src/renderer/main/pages/notes/components/note.tsx create mode 100644 apps/desktop/src/renderer/main/pages/notes/components/notes-list.tsx create mode 100644 apps/desktop/src/renderer/main/pages/notes/components/upcoming-event-card.tsx create mode 100644 apps/desktop/src/renderer/main/pages/notes/components/upcoming-events.tsx create mode 100644 apps/desktop/src/renderer/main/pages/notes/index.tsx create mode 100644 apps/desktop/src/renderer/main/pages/notes/types.ts create mode 100644 apps/desktop/src/renderer/main/routes/settings/notes.$noteId.tsx create mode 100644 apps/desktop/src/renderer/main/routes/settings/notes.index.tsx create mode 100644 apps/desktop/src/renderer/main/routes/settings/notes.tsx create mode 100644 apps/desktop/src/renderer/main/utils/debounce.ts create mode 100644 apps/desktop/src/services/notes-service.ts create mode 100644 apps/desktop/src/trpc/routers/notes.ts create mode 100644 apps/desktop/src/utils/meeting-icons.tsx create mode 100644 packages/y-libsql/package.json create mode 100644 packages/y-libsql/src/index.ts create mode 100644 packages/y-libsql/tsconfig.json diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7f4ea11..5e7a456 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -82,6 +82,7 @@ "@amical/eslint-config": "workspace:*", "@amical/smart-whisper": "workspace:*", "@amical/types": "workspace:*", + "@amical/y-libsql": "workspace:*", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", @@ -141,6 +142,7 @@ "electron-squirrel-startup": "^1.0.1", "electron-trpc-experimental": "1.0.0-alpha.1", "embla-carousel-react": "^8.6.0", + "emoji-picker-react": "^4.13.3", "framer-motion": "^12.10.5", "input-otp": "^1.4.2", "jest-worker": "^29.7.0", @@ -148,6 +150,7 @@ "libsql": "^0.5.13", "lucide-react": "^0.510.0", "next-themes": "^0.4.6", + "node-cron": "^4.2.1", "node-machine-id": "^1.1.12", "onnxruntime-node": "^1.20.1", "openai": "^4.98.0", @@ -168,6 +171,7 @@ "uuid": "^11.1.0", "vaul": "^1.1.2", "workerpool": "^9.3.3", + "yjs": "^13.6.27", "zod": "^3.25.24" } } diff --git a/apps/desktop/public/assets/meet.svg b/apps/desktop/public/assets/meet.svg new file mode 100644 index 0000000..fa436f6 --- /dev/null +++ b/apps/desktop/public/assets/meet.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/desktop/public/assets/zoom.svg b/apps/desktop/public/assets/zoom.svg new file mode 100644 index 0000000..c390d16 --- /dev/null +++ b/apps/desktop/public/assets/zoom.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/scripts/migrate-yjs-to-blob.ts b/apps/desktop/scripts/migrate-yjs-to-blob.ts new file mode 100644 index 0000000..e91853d --- /dev/null +++ b/apps/desktop/scripts/migrate-yjs-to-blob.ts @@ -0,0 +1,63 @@ +#!/usr/bin/env tsx +/** + * Migration script to convert base64 encoded yjs_updates to blob format + * Run this after applying the schema migration but before running the app + */ + +import { db } from "../src/db"; +import { yjsUpdates } from "../src/db/schema"; +import { eq } from "drizzle-orm"; + +async function migrateYjsUpdatesToBlob() { + console.log("Starting migration of yjs_updates from base64 to blob..."); + + try { + // Get all updates + const updates = await db.select().from(yjsUpdates); + console.log(`Found ${updates.length} updates to migrate`); + + let migrated = 0; + let skipped = 0; + + for (const update of updates) { + try { + // Check if data is already a Buffer or still base64 string + if (typeof update.updateData === "string") { + // Convert base64 string to Buffer + const buffer = Buffer.from(update.updateData, "base64"); + + // Update the record with the Buffer + await db + .update(yjsUpdates) + .set({ updateData: buffer }) + .where(eq(yjsUpdates.id, update.id)); + + migrated++; + } else { + // Already migrated + skipped++; + } + } catch (error) { + console.error(`Failed to migrate update ${update.id}:`, error); + } + } + + console.log(`Migration complete!`); + console.log(`- Migrated: ${migrated} updates`); + console.log(`- Skipped (already migrated): ${skipped} updates`); + } catch (error) { + console.error("Migration failed:", error); + process.exit(1); + } +} + +// Run the migration +migrateYjsUpdatesToBlob() + .then(() => { + console.log("Migration successful!"); + process.exit(0); + }) + .catch((error) => { + console.error("Migration error:", error); + process.exit(1); + }); diff --git a/apps/desktop/src/db/migrations/0002_cheerful_betty_brant.sql b/apps/desktop/src/db/migrations/0002_cheerful_betty_brant.sql new file mode 100644 index 0000000..1ced490 --- /dev/null +++ b/apps/desktop/src/db/migrations/0002_cheerful_betty_brant.sql @@ -0,0 +1,18 @@ +CREATE TABLE `notes` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `title` text NOT NULL, + `content` text DEFAULT '', + `icon` text, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL +); +--> statement-breakpoint +CREATE TABLE `yjs_updates` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `note_id` integer NOT NULL, + `update_data` blob NOT NULL, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `yjs_updates_note_id_idx` ON `yjs_updates` (`note_id`); \ No newline at end of file diff --git a/apps/desktop/src/db/migrations/meta/0002_snapshot.json b/apps/desktop/src/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..b777f4f --- /dev/null +++ b/apps/desktop/src/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,491 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "95846ee3-06b7-46d3-97d3-511cee036d5e", + "prevId": "ee7c367e-1078-491c-bcfe-5cca9efbd92f", + "tables": { + "app_settings": { + "name": "app_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "models": { + "name": "models", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "local_path": { + "name": "local_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "downloaded_at": { + "name": "downloaded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_model": { + "name": "original_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "speed": { + "name": "speed", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accuracy": { + "name": "accuracy", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "models_provider_idx": { + "name": "models_provider_idx", + "columns": ["provider"], + "isUnique": false + }, + "models_type_idx": { + "name": "models_type_idx", + "columns": ["type"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "models_provider_id_pk": { + "columns": ["provider", "id"], + "name": "models_provider_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notes": { + "name": "notes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transcriptions": { + "name": "transcriptions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'en'" + }, + "audio_file": { + "name": "audio_file", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "speech_model": { + "name": "speech_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "formatting_model": { + "name": "formatting_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta": { + "name": "meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vocabulary": { + "name": "vocabulary", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "word": { + "name": "word", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "replacement_word": { + "name": "replacement_word", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_replacement": { + "name": "is_replacement", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "date_added": { + "name": "date_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "vocabulary_word_unique": { + "name": "vocabulary_word_unique", + "columns": ["word"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "yjs_updates": { + "name": "yjs_updates", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "note_id": { + "name": "note_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_data": { + "name": "update_data", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "yjs_updates_note_id_idx": { + "name": "yjs_updates_note_id_idx", + "columns": ["note_id"], + "isUnique": false + } + }, + "foreignKeys": { + "yjs_updates_note_id_notes_id_fk": { + "name": "yjs_updates_note_id_notes_id_fk", + "tableFrom": "yjs_updates", + "tableTo": "notes", + "columnsFrom": ["note_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/desktop/src/db/migrations/meta/_journal.json b/apps/desktop/src/db/migrations/meta/_journal.json index e9cd189..2aac35c 100644 --- a/apps/desktop/src/db/migrations/meta/_journal.json +++ b/apps/desktop/src/db/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1757443963743, "tag": "0001_short_betty_ross", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1757500655155, + "tag": "0002_cheerful_betty_brant", + "breakpoints": true } ] } diff --git a/apps/desktop/src/db/notes.ts b/apps/desktop/src/db/notes.ts new file mode 100644 index 0000000..23f5e64 --- /dev/null +++ b/apps/desktop/src/db/notes.ts @@ -0,0 +1,167 @@ +import { eq, desc, asc, like, and, isNull } from "drizzle-orm"; +import { db } from "./index"; +import { + notes, + yjsUpdates, + type Note, + type NewNote, + type YjsUpdate, +} from "./schema"; + +// Create a new note +export async function createNote( + data: Omit, +) { + const now = new Date(); + + const newNote: NewNote = { + ...data, + createdAt: now, + updatedAt: now, + }; + + const result = await db.insert(notes).values(newNote).returning(); + return result[0]; +} + +// Get all notes with optional filtering and sorting +export async function getNotes( + options: { + limit?: number; + offset?: number; + sortBy?: "title" | "updatedAt" | "createdAt"; + sortOrder?: "asc" | "desc"; + search?: string; + } = {}, +) { + const { + limit = 50, + offset = 0, + sortBy = "updatedAt", + sortOrder = "desc", + search, + } = options; + + // Build query + let query = db.select().from(notes); + + // Apply filters + const conditions = []; + if (search) { + conditions.push(like(notes.title, `%${search}%`)); + } + + if (conditions.length > 0) { + query = query.where(and(...conditions)) as any; + } + + // Apply sorting + const sortColumn = notes[sortBy]; + const orderFn = sortOrder === "asc" ? asc : desc; + query = query.orderBy(orderFn(sortColumn)) as any; + + // Apply pagination + query = query.limit(limit).offset(offset) as any; + + return await query; +} + +// Get note by ID +export async function getNoteById(id: number) { + const result = await db.select().from(notes).where(eq(notes.id, id)); + return result[0] || null; +} + +// Update note +export async function updateNote( + id: number, + data: Partial>, +) { + const updateData = { + ...data, + updatedAt: new Date(), + }; + + const result = await db + .update(notes) + .set(updateData) + .where(eq(notes.id, id)) + .returning(); + + return result[0] || null; +} + +// Delete note +export async function deleteNote(id: number) { + // Delete the note (yjs updates and metadata will be cascade deleted) + const result = await db.delete(notes).where(eq(notes.id, id)).returning(); + return result[0] || null; +} + +// YJS Updates operations + +// Save a YJS update to the database +export async function saveYjsUpdate(noteId: number, update: Uint8Array) { + // Convert Uint8Array to Buffer for storage + const bufferUpdate = Buffer.from(update); + + // Insert into yjs_updates table + await db.insert(yjsUpdates).values({ + noteId, + updateData: bufferUpdate, + }); +} + +// Load all YJS updates for a note +export async function loadYjsUpdates(noteId: number): Promise { + const updates = await db + .select() + .from(yjsUpdates) + .where(eq(yjsUpdates.noteId, noteId)) + .orderBy(asc(yjsUpdates.id)); + + // Convert Buffer to Uint8Array + return updates.map((u: YjsUpdate) => { + return new Uint8Array(u.updateData as Buffer); + }); +} + +// Get all unique note IDs that have updates +export async function getUniqueNoteIds(): Promise { + const result = await db + .select({ noteId: yjsUpdates.noteId }) + .from(yjsUpdates) + .groupBy(yjsUpdates.noteId); + + return result.map((r: { noteId: number }) => r.noteId); +} + +// Get all YJS updates for a specific note +export async function getYjsUpdatesByNoteId( + noteId: number, +): Promise { + return await db + .select() + .from(yjsUpdates) + .where(eq(yjsUpdates.noteId, noteId)) + .orderBy(asc(yjsUpdates.id)); +} + +// Replace all YJS updates with a compacted one (transactional) +export async function replaceYjsUpdates( + noteId: number, + compactedUpdate: Uint8Array, +): Promise { + const bufferUpdate = Buffer.from(compactedUpdate); + + await db.transaction(async (tx) => { + // Delete all existing updates + await tx.delete(yjsUpdates).where(eq(yjsUpdates.noteId, noteId)); + + // Insert the compacted update + await tx.insert(yjsUpdates).values({ + noteId, + updateData: bufferUpdate, + }); + }); +} diff --git a/apps/desktop/src/db/schema.ts b/apps/desktop/src/db/schema.ts index ff412ad..b52422e 100644 --- a/apps/desktop/src/db/schema.ts +++ b/apps/desktop/src/db/schema.ts @@ -6,6 +6,7 @@ import { real, index, primaryKey, + blob, } from "drizzle-orm/sqlite-core"; // Transcriptions table @@ -163,6 +164,39 @@ export interface AppSettingsData { }; } +// Notes table +export const notes = sqliteTable("notes", { + id: integer("id").primaryKey({ autoIncrement: true }), + title: text("title").notNull(), + content: text("content").default(""), // Store the actual text content + icon: text("icon"), // Store the icon (emoji) associated with the note + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), +}); + +// Yjs updates table for persistence +export const yjsUpdates = sqliteTable( + "yjs_updates", + { + id: integer("id").primaryKey({ autoIncrement: true }), + noteId: integer("note_id") + .notNull() + .references(() => notes.id, { onDelete: "cascade" }), + updateData: blob("update_data", { mode: "buffer" }).notNull(), // Binary data stored as Buffer + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + }, + (table) => [ + // Index for efficient foreign key lookups + index("yjs_updates_note_id_idx").on(table.noteId), + ], +); + // Export types for TypeScript export type Transcription = typeof transcriptions.$inferSelect; export type NewTranscription = typeof transcriptions.$inferInsert; @@ -172,3 +206,7 @@ export type Model = typeof models.$inferSelect; export type NewModel = typeof models.$inferInsert; export type AppSettings = typeof appSettings.$inferSelect; export type NewAppSettings = typeof appSettings.$inferInsert; +export type Note = typeof notes.$inferSelect; +export type NewNote = typeof notes.$inferInsert; +export type YjsUpdate = typeof yjsUpdates.$inferSelect; +export type NewYjsUpdate = typeof yjsUpdates.$inferInsert; diff --git a/apps/desktop/src/main/core/event-handlers.ts b/apps/desktop/src/main/core/event-handlers.ts index 500dc66..edfa7d2 100644 --- a/apps/desktop/src/main/core/event-handlers.ts +++ b/apps/desktop/src/main/core/event-handlers.ts @@ -2,6 +2,7 @@ import { HelperEvent } from "@amical/types"; import { AppManager } from "./app-manager"; import { logger } from "../logger"; import { ipcMain, shell, systemPreferences, app } from "electron"; +import NotesService from "../../services/notes-service"; export class EventHandlers { private appManager: AppManager; @@ -14,6 +15,7 @@ export class EventHandlers { this.setupNativeBridgeEventHandlers(); this.setupGeneralIPCHandlers(); this.setupOnboardingIPCHandlers(); + this.setupNotesIPCHandlers(); // Note: Audio IPC handlers are now managed by RecordingService } @@ -100,4 +102,43 @@ export class EventHandlers { app.quit(); }); } + + private setupNotesIPCHandlers(): void { + const notesService = NotesService.getInstance(); + + // Save yjs update + ipcMain.handle( + "notes:saveYjsUpdate", + async (event, noteId: number, update: ArrayBuffer) => { + try { + // Convert ArrayBuffer to Uint8Array + const updateArray = new Uint8Array(update); + await notesService.saveYjsUpdate(noteId, updateArray); + logger.main.debug("Saved yjs update", { + noteId, + updateSize: updateArray.length, + }); + } catch (error) { + logger.main.error("Failed to save yjs update", error); + throw error; + } + }, + ); + + // Load all yjs updates for a note + ipcMain.handle("notes:loadYjsUpdates", async (event, noteId: number) => { + try { + const updates = await notesService.loadYjsUpdates(noteId); + logger.main.debug("Loaded yjs updates", { + noteId, + count: updates.length, + }); + // Convert Uint8Array[] to ArrayBuffer[] for IPC transfer + return updates.map((u) => u.buffer); + } catch (error) { + logger.main.error("Failed to load yjs updates", error); + throw error; + } + }); + } } diff --git a/apps/desktop/src/main/managers/service-manager.ts b/apps/desktop/src/main/managers/service-manager.ts index 70e8d44..0af77f7 100644 --- a/apps/desktop/src/main/managers/service-manager.ts +++ b/apps/desktop/src/main/managers/service-manager.ts @@ -121,9 +121,9 @@ export class ServiceManager { this.transcriptionService = new TranscriptionService( this.modelManagerService, - this.vadService, + this.vadService!, this.settingsService, - this.telemetryService, + this.telemetryService!, ); await this.transcriptionService.initialize(); diff --git a/apps/desktop/src/main/preload.ts b/apps/desktop/src/main/preload.ts index 5fc8380..31987e0 100644 --- a/apps/desktop/src/main/preload.ts +++ b/apps/desktop/src/main/preload.ts @@ -109,6 +109,15 @@ const api: ElectronAPI = { // External link handling openExternal: (url: string) => ipcRenderer.invoke("open-external", url), + + // Notes API - Yjs synchronization only + notes: { + saveYjsUpdate: (noteId: number, update: ArrayBuffer) => + ipcRenderer.invoke("notes:saveYjsUpdate", noteId, update), + + loadYjsUpdates: (noteId: number) => + ipcRenderer.invoke("notes:loadYjsUpdates", noteId), + }, }; contextBridge.exposeInMainWorld("electronAPI", api); diff --git a/apps/desktop/src/renderer/main/components/settings-sidebar.tsx b/apps/desktop/src/renderer/main/components/settings-sidebar.tsx index 9a889d1..141b152 100644 --- a/apps/desktop/src/renderer/main/components/settings-sidebar.tsx +++ b/apps/desktop/src/renderer/main/components/settings-sidebar.tsx @@ -9,6 +9,7 @@ import { IconBookFilled, IconKeyboard, IconAdjustments, + IconNotes, } from "@tabler/icons-react"; import { NavMain } from "@/components/nav-main"; @@ -49,6 +50,11 @@ const data = { url: "/settings/shortcuts", icon: IconKeyboard, }, + { + title: "Notes", + url: "/settings/notes", + icon: IconNotes, + }, { title: "Vocabulary", url: "/settings/vocabulary", diff --git a/apps/desktop/src/renderer/main/hooks/useDebounce.ts b/apps/desktop/src/renderer/main/hooks/useDebounce.ts new file mode 100644 index 0000000..145fab5 --- /dev/null +++ b/apps/desktop/src/renderer/main/hooks/useDebounce.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react"; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/apps/desktop/src/renderer/main/pages/notes/components/note-card.tsx b/apps/desktop/src/renderer/main/pages/notes/components/note-card.tsx new file mode 100644 index 0000000..9367dc9 --- /dev/null +++ b/apps/desktop/src/renderer/main/pages/notes/components/note-card.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { FileText, Calendar } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Note } from "../types"; + +interface RecentNoteCardProps { + note: Note; + onNoteClick: (noteId: number) => void; +} + +function formatDate(date: Date): string { + const now = new Date(); + const diffInDays = Math.floor( + (now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24), + ); + + if (diffInDays === 0) return "Today"; + if (diffInDays === 1) return "Yesterday"; + + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, + }); +} + +export function NoteCard({ note, onNoteClick }: RecentNoteCardProps) { + return ( +
onNoteClick(note.id)} + className={cn( + "flex items-start gap-3 py-2 px-3 rounded-lg transition-colors group", + "hover:bg-accent/50 hover:text-accent-foreground", + )} + tabIndex={0} + role="button" + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onNoteClick(note.id); + } + }} + > + {/* Note Icon */} +
+ {note.icon ? ( + {note.icon} + ) : ( + + )} +
+ + {/* Note Content */} +
+ {/* Note Name */} +
+ {note.title} +
+ + {/* Date and Meeting Info */} +
+ {formatDate(note.updatedAt)} + + {note.meetingEvent && ( + <> + +
+ + {note.meetingEvent.title} +
+ + )} +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/main/pages/notes/components/note-wrapper.tsx b/apps/desktop/src/renderer/main/pages/notes/components/note-wrapper.tsx new file mode 100644 index 0000000..7666faf --- /dev/null +++ b/apps/desktop/src/renderer/main/pages/notes/components/note-wrapper.tsx @@ -0,0 +1,274 @@ +import { useState, useEffect, useRef, useMemo, useCallback } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import * as Y from "yjs"; +import { api } from "@/trpc/react"; +import { toast } from "sonner"; +import { debounce } from "../../../utils/debounce"; +import Note from "./note"; +import { FileTextIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +type NotePageProps = { + noteId: string; + onBack?: () => void; +}; + +export default function NotePage({ noteId, onBack }: NotePageProps) { + const navigate = useNavigate(); + const utils = api.useUtils(); + + // State + const [noteTitle, setNoteTitle] = useState(""); + const [noteBody, setNoteBody] = useState(""); + const [isSyncing, setIsSyncing] = useState(true); + const [noteIcon, setNoteIcon] = useState(null); + + // Refs + const ydocRef = useRef(null); + const textRef = useRef(null); + const noteRef = useRef(null); + + // Fetch note data + const { data: note, isLoading } = api.notes.getNoteById.useQuery( + { id: parseInt(noteId) }, + { + enabled: !!noteId, + }, + ); + + // Update title mutation + const updateTitleMutation = api.notes.updateNoteTitle.useMutation({ + onSuccess: () => { + utils.notes.getNotes.invalidate(); + utils.notes.getNoteById.invalidate({ id: parseInt(noteId) }); + }, + }); + + // Update emoji mutation + const updateNoteIconMutation = api.notes.updateNoteIcon.useMutation({ + onSuccess: () => { + utils.notes.getNotes.invalidate(); + utils.notes.getNoteById.invalidate({ id: parseInt(noteId) }); + toast.success("Emoji updated"); + }, + onError: (error) => { + toast.error("Failed to update emoji: " + error.message); + }, + }); + + // Delete mutation + const deleteMutation = api.notes.deleteNote.useMutation({ + onSuccess: () => { + utils.notes.getNotes.invalidate(); + // Use onBack if provided, otherwise navigate + if (onBack) { + onBack(); + } else { + navigate({ to: "/settings/notes" }); + } + toast.success("Note deleted"); + }, + onError: (error) => { + toast.error("Failed to delete note: " + error.message); + }, + }); + + // Debounced title update + const debouncedUpdateTitle = useMemo( + () => + debounce((title: string) => { + const currentNote = noteRef.current; + if (currentNote && title !== currentNote.title) { + updateTitleMutation.mutate({ id: currentNote.id, title }); + } + }, 500), + [], // No dependencies - function should remain stable + ); + + // Debounced YJS update + const debouncedYjsUpdate = useMemo( + () => + debounce((newContent: string) => { + if (textRef.current && ydocRef.current) { + ydocRef.current.transact(() => { + const oldLength = textRef.current!.length; + textRef.current!.delete(0, oldLength); + textRef.current!.insert(0, newContent); + }, "user-input-debounced"); + } + }, 500), + [], + ); + + // Initialize YJS document + useEffect(() => { + if (!note) return; + + // Cancel any pending updates + debouncedYjsUpdate.cancel(); + + let mounted = true; + + const initializeYjs = async () => { + try { + // Create YJS document + const ydoc = new Y.Doc(); + const text = ydoc.getText("content"); + + // Store references + ydocRef.current = ydoc; + textRef.current = text; + + // Load existing updates from backend + try { + const updates = await window.electronAPI.notes.loadYjsUpdates( + note.id, + ); + + if (updates.length > 0) { + // Apply all updates to reconstruct the document + updates.forEach((update: ArrayBuffer) => { + Y.applyUpdate(ydoc, new Uint8Array(update)); + }); + + // Set content from the reconstructed document + const reconstructedContent = text.toString(); + setNoteBody(reconstructedContent); + } + } catch (error) { + console.error("Failed to load yjs updates:", error); + } + + setIsSyncing(false); + + // Listen for changes from YJS + const observer = () => { + if (!mounted) return; + const newContent = text.toString(); + setNoteBody(newContent); + }; + + text.observe(observer); + + // Save YJS updates to backend + ydoc.on("update", async (update: Uint8Array) => { + try { + // Convert Uint8Array to ArrayBuffer for IPC + const buffer = update.buffer.slice( + update.byteOffset, + update.byteOffset + update.byteLength, + ); + + await window.electronAPI.notes.saveYjsUpdate( + note.id, + buffer as ArrayBuffer, + ); + } catch (error) { + console.error("Failed to save yjs update:", error); + toast.error("Failed to save changes"); + } + }); + + // Cleanup + return () => { + text.unobserve(observer); + }; + } catch (error) { + console.error("Failed to initialize yjs:", error); + setIsSyncing(false); + toast.error("Failed to load note"); + } + }; + + initializeYjs(); + + return () => { + mounted = false; + debouncedYjsUpdate.cancel(); + }; + }, [note, debouncedYjsUpdate]); + + // Update note ref and set initial title and emoji + useEffect(() => { + noteRef.current = note; + if (note) { + setNoteTitle(note.title); + setNoteIcon(note.icon || null); + } + }, [note]); + + // Handle content changes + const handleContentChange = useCallback( + (newContent: string) => { + setNoteBody(newContent); + debouncedYjsUpdate(newContent); + }, + [debouncedYjsUpdate], + ); + + // Handle title change + const handleTitleChange = useCallback( + (newTitle: string) => { + setNoteTitle(newTitle); + debouncedUpdateTitle(newTitle); + }, + [debouncedUpdateTitle], + ); + + // Handle delete + const handleDelete = useCallback(() => { + deleteMutation.mutate({ id: parseInt(noteId) }); + }, [noteId, deleteMutation]); + + // Handle emoji change + const handleEmojiChange = useCallback( + (emoji: string | null) => { + setNoteIcon(emoji); + updateNoteIconMutation.mutate({ id: parseInt(noteId), icon: emoji }); + }, + [noteId, updateNoteIconMutation], + ); + + // Note not found state + if (!isLoading && !note) { + return ( +
+ +

Note not found

+ +
+ ); + } + + const lastEditDate = note ? new Date(note.updatedAt) : new Date(); + + // Use the presentational component + return ( + + ); +} diff --git a/apps/desktop/src/renderer/main/pages/notes/components/note.tsx b/apps/desktop/src/renderer/main/pages/notes/components/note.tsx new file mode 100644 index 0000000..54b51d5 --- /dev/null +++ b/apps/desktop/src/renderer/main/pages/notes/components/note.tsx @@ -0,0 +1,517 @@ +import { useState } from "react"; +import { + Sparkles, + Share, + Mic, + MoreHorizontal, + Copy, + FileText, + FolderOpen, + Trash2, + Check, + Star, + FileTextIcon, + Loader2, +} from "lucide-react"; +import EmojiPicker, { Theme } from "emoji-picker-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +type InvitedUser = { + id: number; + name: string; + avatar?: string; + email: string; + access: string; + status: "active" | "invited"; +}; + +export type NotePageUIProps = { + noteId: string; + noteTitle: string; + noteBody: string; + noteEmoji: string | null; + isLoading: boolean; + isSyncing: boolean; + lastEditDate: Date; + onTitleChange: (value: string) => void; + onBodyChange: (value: string) => void; + onDelete: () => void; + onEmojiChange: (emoji: string | null) => void; + onBack?: () => void; + isDeleting?: boolean; +}; + +export default function Note({ + noteTitle, + noteBody, + noteEmoji, + isLoading, + isSyncing, + lastEditDate, + onTitleChange, + onBodyChange, + onDelete, + onEmojiChange, + isDeleting = false, +}: NotePageUIProps) { + // Local UI state + const [shareEmail, setShareEmail] = useState(""); + const [accessLevel, setAccessLevel] = useState("can-read"); + const [showConfirmation, setShowConfirmation] = useState(false); + const [invitedUsers, setInvitedUsers] = useState>([]); + const [starred, setStarred] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showEmojiPicker, setShowEmojiPicker] = useState(false); + + // Mock shared users data + /* const sharedUsers = [ + { + id: 1, + name: "Alice Johnson", + avatar: + "https://images.unsplash.com/photo-1588516903720-8ceb67f9ef84?q=80&w=1344&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", + email: "alice@example.com", + access: "can-write", + status: "active" as const, + }, + { + id: 2, + name: "Bob Smith", + avatar: + "https://images.unsplash.com/photo-1564564321837-a57b7070ac4f?q=80&w=2676&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", + email: "bob@example.com", + access: "can-write", + status: "active" as const, + }, + { + id: 3, + name: "Carol Davis", + avatar: + "https://images.unsplash.com/photo-1560087637-bf797bc7796a?q=80&w=1287&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", + email: "carol@example.com", + access: "can-read", + status: "active" as const, + }, + ]; */ + + // TODO: implement actual share functionality + /* const handleShare = () => { + if (shareEmail) { + const newInvite = { + id: Date.now(), + name: shareEmail.split("@")[0], + email: shareEmail, + access: accessLevel, + status: "invited" as const, + }; + setInvitedUsers((prev) => [...prev, newInvite]); + setShowConfirmation(true); + setShareEmail(""); + + // Hide confirmation after 3 seconds + setTimeout(() => setShowConfirmation(false), 3000); + } + }; */ + + const handleDeleteClick = () => { + setShowDeleteDialog(false); + onDelete(); + }; + + const handleEmojiSelect = (emojiData: { emoji: string }) => { + onEmojiChange(emojiData.emoji); + setShowEmojiPicker(false); + }; + + const handleEmojiRemove = () => { + onEmojiChange(null); + }; + + /* const allUsers = [...sharedUsers, ...invitedUsers]; */ + + // Loading state + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + +
+ {/* Note Content */} +
+ {/* Note Title with Emoji Picker */} +
+ {/* Emoji Picker */} + + + + + +
+ {noteEmoji && ( +
+ +
+ )} + +
+
+
+ + {/* Note Title Input */} + onTitleChange(e.target.value)} + className="!text-4xl font-semibold border-none px-4 py-2 focus-visible:ring-0 placeholder:text-muted-foreground flex-1" + style={{ backgroundColor: "transparent" }} + placeholder="Note title..." + disabled={isSyncing} + /> +
+ + {/* Top Bar */} +
+ {/* Right side - Actions */} +
+ {/* Last edited date */} + + Edited{" "} + {lastEditDate.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: + lastEditDate.getFullYear() !== new Date().getFullYear() + ? "numeric" + : undefined, + })} + + + {/* {console.log("[v0] Note ID:", noteId)} */} + + {/* + + + + AI Notes coming soon + */} + + {/* Shared users avatars */} + {/*
+ {sharedUsers.map((user) => ( + + + + + + {user.name + .split(" ") + .map((n) => n[0]) + .join("")} + + + + +
+

{user.name}

+

+ {user.email} +

+
+
+
+ ))} +
*/} + + {/* Star the note */} + {/* */} + + {/* Share button with popover */} + {/* + + + + +
+ {showConfirmation ? ( +
+ + + Invitation sent successfully! + +
+ ) : ( + <> +
+

+ Share this note +

+

+ Invite others to collaborate on this note +

+
+ +
+ setShareEmail(e.target.value)} + /> + + + + +
+ + )} + + {allUsers.length > 0 && ( +
+
+ People with access +
+
+ {allUsers.map((user) => ( +
+ + + + {user.name + .split(" ") + .map((n) => n[0]) + .join("")} + + +
+ {user.name} + {user.status === "invited" && ( + + (invited) + + )} +
+ + {user.access.replace("-", " ")} + +
+ ))} +
+
+ )} +
+
+
*/} + + + + + + Transcription coming soon + + + {/* More actions dropdown */} + + + + + + {/* + + Copy + + + + Duplicate + + + + Move to + */} + + + e.preventDefault()} + variant="destructive" + > + + Delete + + + + + +
+
+ + {/* Note Body */} +