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>
This commit is contained in:
Haritabh 2025-09-10 16:13:33 +05:30 committed by GitHub
parent 674092f1b7
commit a128ec7972
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 2980 additions and 10 deletions

View file

@ -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"
}
}

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 -22.5 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
<g>
<polygon fill="#00832D" points="144.822496 105.321856 169.778926 133.848796 203.341343 155.294133 209.178931 105.50196 203.341343 56.8331137 169.136495 75.6715889">
</polygon>
<path d="M0.000557021739,150.659712 L0.000557021739,193.089915 C0.000557021739,202.77838 7.86384724,210.643527 17.5541688,210.643527 L59.9843714,210.643527 L68.7704609,178.585069 L59.9843714,150.659712 L30.8744153,141.873623 L0.000557021739,150.659712 Z" fill="#0066DA">
</path>
<polygon fill="#E94235" points="59.9838143 9.9475983e-14 0 59.9838143 30.875715 68.7494798 59.9838143 59.9838143 68.6102243 32.4390893">
</polygon>
<polygon fill="#2684FC" points="0.000557021739 150.679394 59.9843714 150.679394 59.9843714 59.9832573 0.000557021739 59.9832573">
</polygon>
<path d="M241.658683,25.3977775 L203.341157,56.8342278 L203.341157,155.29339 L241.818362,186.852385 C247.577967,191.364261 256.003849,187.251584 256.003849,179.930462 L256.003849,32.1785888 C256.003849,24.7757699 247.377439,20.6835169 241.658683,25.3977775" fill="#00AC47">
</path>
<path d="M144.822496,105.321856 L144.822496,150.659712 L59.9843714,150.659712 L59.9843714,210.643527 L185.787731,210.643527 C195.478053,210.643527 203.341343,202.77838 203.341343,193.089915 L203.341343,155.294133 L144.822496,105.321856 Z" fill="#00AC47">
</path>
<path d="M185.787731,0 L59.9843714,0 L59.9843714,59.9838143 L144.822496,59.9838143 L144.822496,105.32167 L203.341343,56.832928 L203.341343,17.5536117 C203.341343,7.86329022 195.478053,0 185.787731,0" fill="#FFBA00">
</path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" preserveAspectRatio="xMidYMid" viewBox="0 0 256 256"><defs><linearGradient id="zoom__a" x1="23.666%" x2="76.334%" y1="95.6118%" y2="4.3882%"><stop offset=".00006%" stop-color="#0845BF"/><stop offset="19.11%" stop-color="#0950DE"/><stop offset="38.23%" stop-color="#0B59F6"/><stop offset="50%" stop-color="#0B5CFF"/><stop offset="67.32%" stop-color="#0E5EFE"/><stop offset="77.74%" stop-color="#1665FC"/><stop offset="86.33%" stop-color="#246FF9"/><stop offset="93.88%" stop-color="#387FF4"/><stop offset="100%" stop-color="#4F90EE"/></linearGradient></defs><path fill="url(#zoom__a)" d="M256 128c0 13.568-1.024 27.136-3.328 40.192-6.912 43.264-41.216 77.568-84.48 84.48C155.136 254.976 141.568 256 128 256c-13.568 0-27.136-1.024-40.192-3.328-43.264-6.912-77.568-41.216-84.48-84.48C1.024 155.136 0 141.568 0 128c0-13.568 1.024-27.136 3.328-40.192 6.912-43.264 41.216-77.568 84.48-84.48C100.864 1.024 114.432 0 128 0c13.568 0 27.136 1.024 40.192 3.328 43.264 6.912 77.568 41.216 84.48 84.48C254.976 100.864 256 114.432 256 128Z"/><path fill="#FFF" d="M204.032 207.872H75.008c-8.448 0-16.64-4.608-20.48-12.032-4.608-8.704-2.816-19.2 4.096-26.112l89.856-89.856H83.968c-17.664 0-32-14.336-32-32h118.784c8.448 0 16.64 4.608 20.48 12.032 4.608 8.704 2.816 19.2-4.096 26.112l-89.6 90.112h74.496c17.664 0 32 14.08 32 31.744Z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -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);
});

View file

@ -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`);

View file

@ -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": {}
}
}

View file

@ -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
}
]
}

View file

@ -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<NewNote, "id" | "createdAt" | "updatedAt" | "lastAccessedAt">,
) {
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<Omit<Note, "id" | "createdAt" | "docName">>,
) {
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<Uint8Array[]> {
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<number[]> {
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<YjsUpdate[]> {
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<void> {
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,
});
});
}

View file

@ -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;

View file

@ -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;
}
});
}
}

View file

@ -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();

View file

@ -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);

View file

@ -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",

View file

@ -0,0 +1,17 @@
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View file

@ -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 (
<div
onClick={() => 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 */}
<div className="flex-shrink-0 mt-0.5">
{note.icon ? (
<span className="text-lg">{note.icon}</span>
) : (
<FileText className="w-5 h-5 text-muted-foreground" />
)}
</div>
{/* Note Content */}
<div className="flex-1 min-w-0">
{/* Note Name */}
<div className="font-medium text-foreground text-sm leading-tight">
{note.title}
</div>
{/* Date and Meeting Info */}
<div className="flex items-center gap-1.5 mt-1 text-xs text-muted-foreground">
<span>{formatDate(note.updatedAt)}</span>
{note.meetingEvent && (
<>
<span className="w-1 h-1 bg-muted-foreground rounded-full"></span>
<div className="flex items-center gap-1">
<Calendar
className="w-3 h-3"
style={{ color: note.meetingEvent.calendarColor }}
/>
<span className="">{note.meetingEvent.title}</span>
</div>
</>
)}
</div>
</div>
</div>
);
}

View file

@ -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<string | null>(null);
// Refs
const ydocRef = useRef<Y.Doc | null>(null);
const textRef = useRef<Y.Text | null>(null);
const noteRef = useRef<typeof note>(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 (
<div className="flex flex-col items-center justify-center h-full gap-4">
<FileTextIcon className="h-12 w-12 text-muted-foreground" />
<p className="text-muted-foreground">Note not found</p>
<Button
variant="outline"
onClick={() => {
if (onBack) {
onBack();
} else {
navigate({ to: "/settings/notes" });
}
}}
>
Back to notes
</Button>
</div>
);
}
const lastEditDate = note ? new Date(note.updatedAt) : new Date();
// Use the presentational component
return (
<Note
noteId={noteId}
noteTitle={noteTitle}
noteBody={noteBody}
noteEmoji={noteIcon}
isLoading={isLoading}
isSyncing={isSyncing}
lastEditDate={lastEditDate}
onTitleChange={handleTitleChange}
onBodyChange={handleContentChange}
onDelete={handleDelete}
onEmojiChange={handleEmojiChange}
onBack={onBack}
isDeleting={deleteMutation.isPending}
/>
);
}

View file

@ -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<Array<InvitedUser>>([]);
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 (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<TooltipProvider>
<div className="">
{/* Note Content */}
<div className="mt-0 space-y-2">
{/* Note Title with Emoji Picker */}
<div className="flex items-center">
{/* Emoji Picker */}
<Popover open={showEmojiPicker} onOpenChange={setShowEmojiPicker}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-12 w-12 p-0 hover:bg-muted/50"
>
{noteEmoji ? (
<span className="text-2xl">{noteEmoji}</span>
) : (
<FileTextIcon className="!h-6 !w-6 text-muted-foreground" />
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div>
{noteEmoji && (
<div className="flex justify-end p-2 border-b">
<Button
variant="ghost"
size="sm"
onClick={handleEmojiRemove}
className="text-xs"
>
Remove emoji
</Button>
</div>
)}
<EmojiPicker
onEmojiClick={handleEmojiSelect}
autoFocusSearch={false}
theme={Theme.DARK}
lazyLoadEmojis={false}
height={400}
width={400}
/>
</div>
</PopoverContent>
</Popover>
{/* Note Title Input */}
<Input
value={noteTitle}
onChange={(e) => 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}
/>
</div>
{/* Top Bar */}
<div className="flex items-center justify-start pl-4 pb-0.5 bg-card">
{/* Right side - Actions */}
<div className="flex items-center ">
{/* Last edited date */}
<span className="text-sm text-muted-foreground">
Edited{" "}
{lastEditDate.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year:
lastEditDate.getFullYear() !== new Date().getFullYear()
? "numeric"
: undefined,
})}
</span>
{/* {console.log("[v0] Note ID:", noteId)} */}
{/* <Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled
className="gap-1 mx-1.5"
>
<Sparkles className="h-4 w-4" />
AI Notes
</Button>
</TooltipTrigger>
<TooltipContent>AI Notes coming soon</TooltipContent>
</Tooltip> */}
{/* Shared users avatars */}
{/* <div className="flex -space-x-2">
{sharedUsers.map((user) => (
<Tooltip key={user.id}>
<TooltipTrigger asChild>
<Avatar className="h-8 w-8 border-2 border-background cursor-pointer">
<AvatarImage
className="object-cover"
src={user.avatar || "/placeholder.svg"}
alt={user.name}
/>
<AvatarFallback className="text-xs">
{user.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent>
<div className="text-center">
<p className="font-medium">{user.name}</p>
<p className="text-xs text-muted-foreground">
{user.email}
</p>
</div>
</TooltipContent>
</Tooltip>
))}
</div> */}
{/* Star the note */}
{/* <Button
size="sm"
variant="ghost"
className="gap-2"
onClick={() => setStarred(!starred)}
>
<Star
fill={starred ? "orange" : "none"}
stroke={starred ? "orange" : "currentColor"}
className="h-4 w-4"
/>
</Button> */}
{/* Share button with popover */}
{/* <Popover>
<PopoverTrigger asChild>
<Button size="sm" variant="ghost">
<Share className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end">
<div className="space-y-4">
{showConfirmation ? (
<div className="flex items-center gap-2 p-3 bg-green-50 dark:bg-green-950/20 rounded-lg border border-green-200 dark:border-green-800">
<Check className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="text-sm text-green-700 dark:text-green-300">
Invitation sent successfully!
</span>
</div>
) : (
<>
<div>
<h4 className="font-medium text-sm mb-2">
Share this note
</h4>
<p className="text-xs text-muted-foreground mb-3">
Invite others to collaborate on this note
</p>
</div>
<div className="space-y-3">
<Input
placeholder="Enter email address"
value={shareEmail}
onChange={(e) => setShareEmail(e.target.value)}
/>
<Select
value={accessLevel}
onValueChange={setAccessLevel}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="full-access">
Full access
</SelectItem>
<SelectItem value="can-write">
Can write
</SelectItem>
<SelectItem value="can-read">Can read</SelectItem>
</SelectContent>
</Select>
<Button
onClick={handleShare}
className="w-full"
size="sm"
>
Send invitation
</Button>
</div>
</>
)}
{allUsers.length > 0 && (
<div className="pt-3 border-t border-border">
<h5 className="text-xs font-medium mb-2 text-muted-foreground">
People with access
</h5>
<div className="space-y-2">
{allUsers.map((user) => (
<div
key={user.id}
className="flex items-center gap-2"
>
<Avatar className="h-6 w-6">
<AvatarImage
src={user.avatar || "/placeholder.svg"}
alt={user.name}
/>
<AvatarFallback className="text-xs">
{user.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<span className="text-xs">{user.name}</span>
{user.status === "invited" && (
<span className="text-xs text-muted-foreground ml-1">
(invited)
</span>
)}
</div>
<span className="text-xs text-muted-foreground capitalize">
{user.access.replace("-", " ")}
</span>
</div>
))}
</div>
</div>
)}
</div>
</PopoverContent>
</Popover> */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" disabled>
<Mic className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Transcription coming soon</TooltipContent>
</Tooltip>
{/* More actions dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* <DropdownMenuItem className="gap-2">
<Copy className="h-4 w-4" />
Copy
</DropdownMenuItem>
<DropdownMenuItem className="gap-2">
<FileText className="h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem className="gap-2">
<FolderOpen className="h-4 w-4" />
Move to
</DropdownMenuItem> */}
<AlertDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
>
<AlertDialogTrigger asChild>
<DropdownMenuItem
className="gap-2"
onSelect={(e) => e.preventDefault()}
variant="destructive"
>
<Trash2 className="h-4 w-4 text-destructive" />
Delete
</DropdownMenuItem>
</AlertDialogTrigger>
</AlertDialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Note Body */}
<Textarea
value={noteBody}
onChange={(e) => onBodyChange(e.target.value)}
placeholder="Start writing your note..."
className="min-h-[500px] resize-none border-none bg-transparent px-4 py-2 focus-visible:ring-0 text-base leading-relaxed placeholder:text-muted-foreground"
style={{
backgroundColor: "transparent",
}}
disabled={isSyncing}
/>
</div>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Note</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{noteTitle}"? This action
cannot be undone and the note will be permanently removed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteClick}
className="bg-destructive text-foreground hover:bg-destructive/90"
disabled={isDeleting}
>
{isDeleting ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Deleting...
</>
) : (
"Delete Note"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TooltipProvider>
);
}

View file

@ -0,0 +1,112 @@
import { NotebookText, Plus } from "lucide-react";
import { NoteCard } from "./note-card";
import { Button } from "@/components/ui/button";
import { api } from "@/trpc/react";
import { Skeleton } from "@/components/ui/skeleton";
import { useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
export function NotesList() {
const navigate = useNavigate();
const utils = api.useUtils();
const { data: notes, isLoading } = api.notes.getNotes.useQuery({
sortBy: "updatedAt",
sortOrder: "desc",
});
// Create note mutation
const createNoteMutation = api.notes.createNote.useMutation({
onSuccess: (newNote) => {
// Invalidate notes list to refetch
utils.notes.getNotes.invalidate();
// Navigate to the new note
navigate({
to: "/settings/notes/$noteId",
params: { noteId: String(newNote.id) },
});
toast.success("Note created");
},
onError: (error) => {
toast.error("Failed to create note: " + error.message);
},
});
const onCreateNote = () => {
createNoteMutation.mutate({
title: "Untitled Note",
initialContent: "",
});
};
const onNoteClick = (noteId: number) => {
navigate({
to: "/settings/notes/$noteId",
params: { noteId: String(noteId) },
});
};
// Convert database notes to UI format
const formattedNotes = notes || [];
// Loading state
if (isLoading) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-muted-foreground">
<NotebookText className="w-4 h-4" />
<h2 className="text-sm font-medium">Notes</h2>
</div>
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-start gap-3 py-2 px-3">
<Skeleton className="w-5 h-5 mt-0.5" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-muted-foreground">
<NotebookText className="w-4 h-4" />
<h2 className="text-sm font-medium">Notes</h2>
</div>
{formattedNotes.length > 0 && (
<div>
{formattedNotes.map((note) => (
<NoteCard key={note.id} note={note} onNoteClick={onNoteClick} />
))}
</div>
)}
{formattedNotes.length === 0 && (
<div className="border border-dashed rounded-lg p-6 text-center space-y-4">
<NotebookText className="w-8 h-8 text-muted-foreground mx-auto" />
<div className="space-y-2">
<p className="text-sm text-muted-foreground">No notes yet</p>
<p className="text-xs text-muted-foreground">
Create your first note to get started
</p>
<Button
className="mt-4"
size={"sm"}
variant={"outline"}
onClick={onCreateNote}
>
<Plus className="w-4 h-4" />
Create note
</Button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,90 @@
import { NotebookPen } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { getMeetingIcon } from "@/utils/meeting-icons";
interface UpcomingEvent {
title: string;
time: string;
url: string;
date?: string;
}
interface UpcomingEventCardProps {
event: UpcomingEvent;
onTakeNotes?: (event: UpcomingEvent) => void;
}
const UpcomingEventCard = ({ event, onTakeNotes }: UpcomingEventCardProps) => {
const handleLinkClick = () => {
if (event.url) {
// Open external link - adjust this based on your Electron setup
window.electronAPI.openExternal(event.url);
}
};
const handleTakeNotes = () => {
onTakeNotes?.(event);
};
return (
<div className="bg-transparent border-none group hover:bg-accent/60 transition-colors relative py-3 px-4">
<div className="flex items-start gap-4">
{/* Leading icon */}
<div className="flex-shrink-0 mt-0.5">
{getMeetingIcon(event.url, {
className: "w-5 h-5 text-muted-foreground",
})}
</div>
<div className="flex-1 space-y-2">
{/* Event title */}
<h3 className="text-foreground text-sm font-medium leading-tight line-clamp-1">
{event.title}
</h3>
{/* Time and meeting url */}
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<span className="whitespace-nowrap">
{event.date} {event.time}
</span>
|{" "}
{
<a
onClick={handleLinkClick}
className="text-muted-foreground text-xs line-clamp-1 hover:text-foreground cursor-pointer transition-colors"
>
{event.url}
</a>
}
</div>
</div>
{/* take notes button - visible only on hover */}
<div className="absolute top-6 right-6 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex-shrink-0">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="secondary"
onClick={handleTakeNotes}
className="h-8 w-8 p-0"
>
<NotebookPen className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Take notes</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
);
};
export default UpcomingEventCard;

View file

@ -0,0 +1,94 @@
import { Calendar, CalendarX, CalendarPlus } from "lucide-react";
import UpcomingEventCard from "./upcoming-event-card";
import { UpcomingEvent } from "../types";
import { Button } from "@/components/ui/button";
type CalendarState = "with-events" | "no-events" | "no-calendar";
// TOOD: add calendar connection and sync logic
export function UpcomingEvents() {
// Switch this variable to test different states:
// "with-events" - shows upcoming events
// "no-events" - calendar connected but no events
// "no-calendar" - no calendar connected
const calendarState: CalendarState = "with-events";
// TODO: replace mock data with actual data from backend
// Mock events data
const mockEvents: UpcomingEvent[] = [
{
title: "Product Review: Q3 Feature Planning",
date: "Tomorrow",
time: "2:00 3:00 PM",
url: "https://zoom.us/j/123456789",
},
{
title: "1:1 with Sarah - Engineering Sync",
date: "Sep 8th",
time: "10:00 10:30 AM",
url: "https://meet.google.com/abc-defg-hij",
},
];
// Determine events based on state
const upcomingEvents = calendarState === "with-events" ? mockEvents : [];
const handleTakeNotes = (event: UpcomingEvent) => {
// Handle taking notes for the event
console.log("Taking notes for:", event.title);
// TODO: navigate to a notes editor, open a modal, etc.
// You can implement your note-taking logic here
// For example: navigate to a notes editor, open a modal, etc.
};
const handleConnectCalendar = () => {
// Handle connecting calendar
console.log("Connecting calendar...");
// TODO: implement your calendar connection logic here
// You can implement your calendar connection logic here
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-muted-foreground">
<Calendar className="w-4 h-4" />
<h2 className="text-sm font-medium">Upcoming events</h2>
</div>
{calendarState === "with-events" ? (
<div className="bg-accent/40 rounded-xl overflow-clip">
{upcomingEvents.map((event, index) => (
<div key={index}>
<UpcomingEventCard event={event} onTakeNotes={handleTakeNotes} />
</div>
))}
</div>
) : calendarState === "no-events" ? (
<div className="border border-dashed rounded-lg p-6 text-center">
<CalendarX className="w-8 h-8 text-muted-foreground mx-auto mb-3" />
<p className="text-sm text-muted-foreground">No upcoming events</p>
</div>
) : (
<div className="border border-dashed rounded-lg p-6 text-center space-y-4">
<CalendarPlus className="w-8 h-8 text-muted-foreground mx-auto" />
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Connect your calendar
<br />
Connect your calendar to get started
</p>
<Button
onClick={handleConnectCalendar}
className="mt-2"
size={"sm"}
variant={"outline"}
>
<CalendarPlus className="w-4 h-4" />
Connect calendar
</Button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,13 @@
import { NotesList } from "./components/notes-list";
export default function Notes() {
return (
<div className="space-y-6 p-2">
{/* Upcoming events section */}
{/* <UpcomingEvents /> */}
{/* Recent notes section */}
<NotesList />
</div>
);
}

View file

@ -0,0 +1,18 @@
export type UpcomingEvent = {
title: string;
time: string;
url: string;
date?: string;
calendarColor?: string;
};
export interface Note {
id: number;
title: string;
icon?: string | null;
updatedAt: Date;
meetingEvent?: {
title: string;
calendarColor: string;
};
}

View file

@ -15,11 +15,14 @@ import { Route as SettingsIndexRouteImport } from './routes/settings/index'
import { Route as SettingsVocabularyRouteImport } from './routes/settings/vocabulary'
import { Route as SettingsShortcutsRouteImport } from './routes/settings/shortcuts'
import { Route as SettingsPreferencesRouteImport } from './routes/settings/preferences'
import { Route as SettingsNotesRouteImport } from './routes/settings/notes'
import { Route as SettingsHistoryRouteImport } from './routes/settings/history'
import { Route as SettingsDictationRouteImport } from './routes/settings/dictation'
import { Route as SettingsAiModelsRouteImport } from './routes/settings/ai-models'
import { Route as SettingsAdvancedRouteImport } from './routes/settings/advanced'
import { Route as SettingsAboutRouteImport } from './routes/settings/about'
import { Route as SettingsNotesIndexRouteImport } from './routes/settings/notes.index'
import { Route as SettingsNotesNoteIdRouteImport } from './routes/settings/notes.$noteId'
const SettingsRouteRoute = SettingsRouteRouteImport.update({
id: '/settings',
@ -51,6 +54,11 @@ const SettingsPreferencesRoute = SettingsPreferencesRouteImport.update({
path: '/preferences',
getParentRoute: () => SettingsRouteRoute,
} as any)
const SettingsNotesRoute = SettingsNotesRouteImport.update({
id: '/notes',
path: '/notes',
getParentRoute: () => SettingsRouteRoute,
} as any)
const SettingsHistoryRoute = SettingsHistoryRouteImport.update({
id: '/history',
path: '/history',
@ -76,6 +84,16 @@ const SettingsAboutRoute = SettingsAboutRouteImport.update({
path: '/about',
getParentRoute: () => SettingsRouteRoute,
} as any)
const SettingsNotesIndexRoute = SettingsNotesIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => SettingsNotesRoute,
} as any)
const SettingsNotesNoteIdRoute = SettingsNotesNoteIdRouteImport.update({
id: '/$noteId',
path: '/$noteId',
getParentRoute: () => SettingsNotesRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@ -85,10 +103,13 @@ export interface FileRoutesByFullPath {
'/settings/ai-models': typeof SettingsAiModelsRoute
'/settings/dictation': typeof SettingsDictationRoute
'/settings/history': typeof SettingsHistoryRoute
'/settings/notes': typeof SettingsNotesRouteWithChildren
'/settings/preferences': typeof SettingsPreferencesRoute
'/settings/shortcuts': typeof SettingsShortcutsRoute
'/settings/vocabulary': typeof SettingsVocabularyRoute
'/settings/': typeof SettingsIndexRoute
'/settings/notes/$noteId': typeof SettingsNotesNoteIdRoute
'/settings/notes/': typeof SettingsNotesIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@ -101,6 +122,8 @@ export interface FileRoutesByTo {
'/settings/shortcuts': typeof SettingsShortcutsRoute
'/settings/vocabulary': typeof SettingsVocabularyRoute
'/settings': typeof SettingsIndexRoute
'/settings/notes/$noteId': typeof SettingsNotesNoteIdRoute
'/settings/notes': typeof SettingsNotesIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@ -111,10 +134,13 @@ export interface FileRoutesById {
'/settings/ai-models': typeof SettingsAiModelsRoute
'/settings/dictation': typeof SettingsDictationRoute
'/settings/history': typeof SettingsHistoryRoute
'/settings/notes': typeof SettingsNotesRouteWithChildren
'/settings/preferences': typeof SettingsPreferencesRoute
'/settings/shortcuts': typeof SettingsShortcutsRoute
'/settings/vocabulary': typeof SettingsVocabularyRoute
'/settings/': typeof SettingsIndexRoute
'/settings/notes/$noteId': typeof SettingsNotesNoteIdRoute
'/settings/notes/': typeof SettingsNotesIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@ -126,10 +152,13 @@ export interface FileRouteTypes {
| '/settings/ai-models'
| '/settings/dictation'
| '/settings/history'
| '/settings/notes'
| '/settings/preferences'
| '/settings/shortcuts'
| '/settings/vocabulary'
| '/settings/'
| '/settings/notes/$noteId'
| '/settings/notes/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@ -142,6 +171,8 @@ export interface FileRouteTypes {
| '/settings/shortcuts'
| '/settings/vocabulary'
| '/settings'
| '/settings/notes/$noteId'
| '/settings/notes'
id:
| '__root__'
| '/'
@ -151,10 +182,13 @@ export interface FileRouteTypes {
| '/settings/ai-models'
| '/settings/dictation'
| '/settings/history'
| '/settings/notes'
| '/settings/preferences'
| '/settings/shortcuts'
| '/settings/vocabulary'
| '/settings/'
| '/settings/notes/$noteId'
| '/settings/notes/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@ -206,6 +240,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsPreferencesRouteImport
parentRoute: typeof SettingsRouteRoute
}
'/settings/notes': {
id: '/settings/notes'
path: '/notes'
fullPath: '/settings/notes'
preLoaderRoute: typeof SettingsNotesRouteImport
parentRoute: typeof SettingsRouteRoute
}
'/settings/history': {
id: '/settings/history'
path: '/history'
@ -241,15 +282,44 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsAboutRouteImport
parentRoute: typeof SettingsRouteRoute
}
'/settings/notes/': {
id: '/settings/notes/'
path: '/'
fullPath: '/settings/notes/'
preLoaderRoute: typeof SettingsNotesIndexRouteImport
parentRoute: typeof SettingsNotesRoute
}
'/settings/notes/$noteId': {
id: '/settings/notes/$noteId'
path: '/$noteId'
fullPath: '/settings/notes/$noteId'
preLoaderRoute: typeof SettingsNotesNoteIdRouteImport
parentRoute: typeof SettingsNotesRoute
}
}
}
interface SettingsNotesRouteChildren {
SettingsNotesNoteIdRoute: typeof SettingsNotesNoteIdRoute
SettingsNotesIndexRoute: typeof SettingsNotesIndexRoute
}
const SettingsNotesRouteChildren: SettingsNotesRouteChildren = {
SettingsNotesNoteIdRoute: SettingsNotesNoteIdRoute,
SettingsNotesIndexRoute: SettingsNotesIndexRoute,
}
const SettingsNotesRouteWithChildren = SettingsNotesRoute._addFileChildren(
SettingsNotesRouteChildren,
)
interface SettingsRouteRouteChildren {
SettingsAboutRoute: typeof SettingsAboutRoute
SettingsAdvancedRoute: typeof SettingsAdvancedRoute
SettingsAiModelsRoute: typeof SettingsAiModelsRoute
SettingsDictationRoute: typeof SettingsDictationRoute
SettingsHistoryRoute: typeof SettingsHistoryRoute
SettingsNotesRoute: typeof SettingsNotesRouteWithChildren
SettingsPreferencesRoute: typeof SettingsPreferencesRoute
SettingsShortcutsRoute: typeof SettingsShortcutsRoute
SettingsVocabularyRoute: typeof SettingsVocabularyRoute
@ -262,6 +332,7 @@ const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
SettingsAiModelsRoute: SettingsAiModelsRoute,
SettingsDictationRoute: SettingsDictationRoute,
SettingsHistoryRoute: SettingsHistoryRoute,
SettingsNotesRoute: SettingsNotesRouteWithChildren,
SettingsPreferencesRoute: SettingsPreferencesRoute,
SettingsShortcutsRoute: SettingsShortcutsRoute,
SettingsVocabularyRoute: SettingsVocabularyRoute,

View file

@ -0,0 +1,12 @@
import { createFileRoute } from "@tanstack/react-router";
import NotePage from "../../pages/notes/components/note-wrapper";
export const Route = createFileRoute("/settings/notes/$noteId")({
component: NotePageWrapper,
});
function NotePageWrapper() {
const { noteId } = Route.useParams();
return <NotePage noteId={noteId} />;
}

View file

@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import NotesPage from "../../pages/notes";
export const Route = createFileRoute("/settings/notes/")({
component: NotesPage,
});

View file

@ -0,0 +1,5 @@
import { createFileRoute, Outlet } from "@tanstack/react-router";
export const Route = createFileRoute("/settings/notes")({
component: () => <Outlet />,
});

View file

@ -12,6 +12,11 @@ function SettingsLayout() {
const location = useLocation();
const getSettingsPageTitle = (pathname: string): string => {
// Check for dynamic routes first
if (pathname.startsWith("/settings/notes")) {
return "Notes";
}
const routes: Record<string, string> = {
"/settings/preferences": "Preferences",
"/settings/dictation": "Dictation",

View file

@ -0,0 +1,25 @@
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number,
): ((...args: Parameters<T>) => void) & { cancel: () => void } {
let timeout: ReturnType<typeof setTimeout> | null = null;
const debounced = function (this: any, ...args: Parameters<T>) {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
func.apply(this, args);
timeout = null;
}, wait);
};
debounced.cancel = () => {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
};
return debounced;
}

View file

@ -117,11 +117,7 @@ export function UnifiedPermissionsStep({
<div className="max-w-lg w-full space-y-6">
{/* Header with logo */}
<div className="text-center space-y-4">
<img
src="assets/logo.svg"
alt="Amical"
className="w-20 h-20 mx-auto"
/>
<img src="assets/logo.svg" alt="Amical" className="w-20 h-20 mx-auto" />
<div>
<h1 className="text-2xl font-bold">Permissions Required</h1>
<p className="text-muted-foreground mt-2">

View file

@ -0,0 +1,193 @@
import * as cron from "node-cron";
import {
createNote,
getNotes,
getNoteById,
updateNote,
deleteNote,
saveYjsUpdate as saveYjsUpdateToDB,
loadYjsUpdates as loadYjsUpdatesFromDB,
getUniqueNoteIds,
getYjsUpdatesByNoteId,
replaceYjsUpdates,
} from "../db/notes";
import * as Y from "yjs";
import { logger } from "../main/logger";
export interface NoteCreateOptions {
title: string;
initialContent?: string;
icon?: string | null;
}
export interface NoteUpdateOptions {
title?: string;
transcriptionId?: number | null;
icon?: string | null;
}
class NotesService {
private static instance: NotesService;
private compactionTask: cron.ScheduledTask | null = null;
private constructor() {
// Set up cron job for daily compaction
this.setupCompactionCron();
}
public static getInstance(): NotesService {
if (!NotesService.instance) {
NotesService.instance = new NotesService();
}
return NotesService.instance;
}
async createNote(options: NoteCreateOptions) {
// Create the note in the database
const note = await createNote({
title: options.title,
icon: options.icon,
});
// Initialize yjs document with initial content if provided
if (options.initialContent) {
const ydoc = new Y.Doc();
const text = ydoc.getText("content");
text.insert(0, options.initialContent);
// Save initial content as a YJS update
const initialUpdate = Y.encodeStateAsUpdate(ydoc);
await saveYjsUpdateToDB(note.id, initialUpdate);
}
return note;
}
async getNote(id: number) {
const note = await getNoteById(id);
return note;
}
async listNotes(options?: {
limit?: number;
offset?: number;
sortBy?: "title" | "updatedAt" | "createdAt";
sortOrder?: "asc" | "desc";
search?: string;
transcriptionId?: number | null;
}) {
return await getNotes(options);
}
async updateNote(id: number, options: NoteUpdateOptions) {
return await updateNote(id, options);
}
async deleteNote(id: number) {
const note = await getNoteById(id);
if (!note) return null;
return await deleteNote(id);
}
// Save yjs update to database
async saveYjsUpdate(noteId: number, update: Uint8Array) {
await saveYjsUpdateToDB(noteId, update);
}
// Load all yjs updates for a note
async loadYjsUpdates(noteId: number): Promise<Uint8Array[]> {
return await loadYjsUpdatesFromDB(noteId);
}
// Compact all note documents
async compactAllNotes(): Promise<void> {
const startTime = Date.now();
logger.main.info("Starting yjs compaction for all notes");
try {
// Get all unique note IDs that have updates
const noteIds = await getUniqueNoteIds();
logger.main.info(`Found ${noteIds.length} notes to compact`);
let totalUpdatesBefore = 0;
let totalUpdatesAfter = 0;
for (const noteId of noteIds) {
const compactResult = await this.compactNote(noteId);
totalUpdatesBefore += compactResult.updatesBefore;
totalUpdatesAfter += compactResult.updatesAfter;
}
const duration = Date.now() - startTime;
logger.main.info(`Compaction completed in ${duration}ms`, {
notesCompacted: noteIds.length,
totalUpdatesBefore,
totalUpdatesAfter,
updatesReduced: totalUpdatesBefore - totalUpdatesAfter,
});
} catch (error) {
logger.main.error("Failed to compact notes:", error);
}
}
// Compact a specific note
async compactNote(
noteId: number,
): Promise<{ updatesBefore: number; updatesAfter: number }> {
// Get all updates for this note
const updates = await getYjsUpdatesByNoteId(noteId);
const updatesBefore = updates.length;
if (updatesBefore <= 1) {
// No need to compact if there's only one update or none
return { updatesBefore, updatesAfter: updatesBefore };
}
// Create a new Y.Doc and apply all updates
const ydoc = new Y.Doc();
for (const update of updates) {
const updateArray = new Uint8Array(update.updateData as Buffer);
Y.applyUpdate(ydoc, updateArray);
}
// Encode the current state as a single update
const stateUpdate = Y.encodeStateAsUpdate(ydoc);
// Replace all updates with the compacted one
await replaceYjsUpdates(noteId, stateUpdate);
logger.main.debug(
`Compacted note ${noteId}: ${updatesBefore} updates -> 1 update`,
);
return { updatesBefore, updatesAfter: 1 };
}
// Set up cron job for scheduled compaction
private setupCompactionCron() {
// Schedule for daily at 2 AM in production, every 5 minutes in development
const schedule =
process.env.NODE_ENV === "development" ? "*/5 * * * *" : "0 2 * * *";
this.compactionTask = cron.schedule(schedule, async () => {
logger.main.info(
`Running scheduled yjs compaction (schedule: ${schedule})`,
);
await this.compactAllNotes();
});
logger.main.info(`Yjs compaction cron job scheduled: ${schedule}`);
}
// Clean up resources
cleanup() {
// Stop the cron job
if (this.compactionTask) {
this.compactionTask.stop();
this.compactionTask = null;
}
}
}
export default NotesService;

View file

@ -413,14 +413,13 @@ export class TranscriptionService {
? completionTime - session.recordingStartedAt
: undefined;
const selectedModel =
this.modelManagerService.getSelectedModel() || "unknown";
const selectedModel = await this.modelManagerService.getSelectedModel();
const audioDurationSeconds =
session.context.sharedData.audioMetadata?.duration;
this.telemetryService.trackTranscriptionCompleted({
session_id: sessionId,
model_id: selectedModel,
model_id: selectedModel!,
model_preloaded: this.modelWasPreloaded,
total_duration_ms: totalDuration || 0,
recording_duration_ms: recordingDuration,

View file

@ -6,6 +6,7 @@ import { settingsRouter } from "./routers/settings";
import { updaterRouter } from "./routers/updater";
import { recordingRouter } from "./routers/recording";
import { widgetRouter } from "./routers/widget";
import { notesRouter } from "./routers/notes";
import { createRouter, procedure } from "./trpc";
export const router = createRouter({
@ -53,6 +54,9 @@ export const router = createRouter({
// Widget router
widget: widgetRouter,
// Notes router
notes: notesRouter,
});
export type AppRouter = typeof router;

View file

@ -0,0 +1,104 @@
import { z } from "zod";
import { createRouter, procedure } from "../trpc";
import NotesService from "../../services/notes-service";
const notesService = NotesService.getInstance();
// Input schemas
const GetNotesSchema = z.object({
limit: z.number().optional().default(50),
offset: z.number().optional().default(0),
sortBy: z
.enum(["title", "updatedAt", "createdAt"])
.optional()
.default("updatedAt"),
sortOrder: z.enum(["asc", "desc"]).optional().default("desc"),
search: z.string().optional(),
transcriptionId: z.number().nullable().optional(),
});
const CreateNoteSchema = z.object({
title: z.string().min(1),
initialContent: z.string().optional(),
icon: z.string().nullish(),
});
const UpdateNoteTitleSchema = z.object({
id: z.number(),
title: z.string().min(1),
});
const UpdateNoteIconSchema = z.object({
id: z.number(),
icon: z.string().nullish(),
});
export const notesRouter = createRouter({
// Get all notes
getNotes: procedure.input(GetNotesSchema).query(async ({ input }) => {
return await notesService.listNotes({
limit: input.limit,
offset: input.offset,
sortBy: input.sortBy,
sortOrder: input.sortOrder,
search: input.search,
transcriptionId: input.transcriptionId,
});
}),
// Get note by ID
getNoteById: procedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const note = await notesService.getNote(input.id);
if (!note) {
throw new Error("Note not found");
}
return note;
}),
// Create new note
createNote: procedure.input(CreateNoteSchema).mutation(async ({ input }) => {
return await notesService.createNote({
title: input.title,
initialContent: input.initialContent || "",
icon: input.icon,
});
}),
// Update note title
updateNoteTitle: procedure
.input(UpdateNoteTitleSchema)
.mutation(async ({ input }) => {
const updated = await notesService.updateNote(input.id, {
title: input.title,
});
if (!updated) {
throw new Error("Failed to update note");
}
return updated;
}),
updateNoteIcon: procedure
.input(UpdateNoteIconSchema)
.mutation(async ({ input }) => {
const updated = await notesService.updateNote(input.id, {
icon: input.icon,
});
if (!updated) {
throw new Error("Failed to update note");
}
return updated;
}),
// Delete note
deleteNote: procedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const deleted = await notesService.deleteNote(input.id);
if (!deleted) {
throw new Error("Note not found");
}
return { success: true };
}),
});

View file

@ -37,4 +37,10 @@ export interface ElectronAPI {
// External link handling
openExternal: (url: string) => Promise<void>;
// Notes API - Yjs synchronization only
notes: {
saveYjsUpdate: (noteId: number, update: ArrayBuffer) => Promise<void>;
loadYjsUpdates: (noteId: number) => Promise<ArrayBuffer[]>;
};
}

View file

@ -0,0 +1,98 @@
import React from "react";
import { Calendar } from "lucide-react";
interface MeetingIconProps {
className?: string;
}
// Helper function to determine meeting platform from URL
export function getMeetingPlatform(url: string): string {
const hostname = new URL(url).hostname.toLowerCase();
// Remove www. prefix if present
const cleanHostname = hostname.replace(/^www\./, "");
switch (true) {
case cleanHostname.includes("zoom.us"):
case cleanHostname.includes("zoom.com"):
return "zoom";
case cleanHostname.includes("meet.google.com"):
case cleanHostname.includes("meets.google.com"):
return "google-meet";
case cleanHostname.includes("teams.microsoft.com"):
case cleanHostname.includes("teams.live.com"):
return "microsoft-teams";
case cleanHostname.includes("discord.com"):
case cleanHostname.includes("discord.gg"):
return "discord";
case cleanHostname.includes("webex.com"):
return "webex";
case cleanHostname.includes("gotomeeting.com"):
return "gotomeeting";
case cleanHostname.includes("bluejeans.com"):
return "bluejeans";
case cleanHostname.includes("whereby.com"):
return "whereby";
default:
return "default";
}
}
// Component to render the appropriate meeting icon
export function getMeetingIcon(
url: string,
props: MeetingIconProps = {},
): React.ReactElement {
const { className = "w-4 h-4" } = props;
const platform = getMeetingPlatform(url);
switch (platform) {
case "zoom":
return <img src="/assets/zoom.svg" alt="Zoom" className={className} />;
case "google-meet":
return (
<img src="/assets/meet.svg" alt="Google Meet" className={className} />
);
case "discord":
return (
<img
src="/assets/discord-icon.svg"
alt="Discord"
className={className}
/>
);
// Add more platforms as needed when you have their icons
case "microsoft-teams":
case "webex":
case "gotomeeting":
case "bluejeans":
case "whereby":
default:
// Fallback to calendar icon for unknown platforms
return <Calendar className={className} />;
}
}
// Export the platforms for potential use elsewhere
export const SUPPORTED_PLATFORMS = {
ZOOM: "zoom",
GOOGLE_MEET: "google-meet",
MICROSOFT_TEAMS: "microsoft-teams",
DISCORD: "discord",
WEBEX: "webex",
GOTOMEETING: "gotomeeting",
BLUEJEANS: "bluejeans",
WHEREBY: "whereby",
DEFAULT: "default",
} as const;

View file

@ -0,0 +1,24 @@
{
"name": "@amical/y-libsql",
"version": "0.1.0",
"description": "LibSQL persistence provider for Yjs",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist"
},
"dependencies": {
"@libsql/client": "^0.15.9",
"yjs": "^13.6.18",
"minimatch": "10.0.3"
},
"devDependencies": {
"@types/node": "^20.14.9",
"typescript": "^5.5.2"
},
"peerDependencies": {
"yjs": "^13.6.0"
}
}

View file

@ -0,0 +1,240 @@
import * as Y from "yjs";
import { Client, createClient } from "@libsql/client";
export interface LibSQLPersistenceOptions {
url?: string;
authToken?: string;
client?: Client;
}
export class LibSQLPersistence {
private doc: Y.Doc;
private docName: string;
private client: Client;
private _synced: boolean = false;
private _destroyed: boolean = false;
private whenSynced: Promise<void>;
private _resolveSynced!: () => void;
private _storeUpdateHandler: (update: Uint8Array, origin: any) => void;
private meta: Map<string, any> = new Map();
constructor(
docName: string,
ydoc: Y.Doc,
options: LibSQLPersistenceOptions = {},
) {
this.doc = ydoc;
this.docName = docName;
// Initialize client
if (options.client) {
this.client = options.client;
} else if (options.url) {
this.client = createClient({
url: options.url,
authToken: options.authToken,
});
} else {
// Default to local file
this.client = createClient({
url: "file:local.db",
});
}
// Create promise for sync status
this.whenSynced = new Promise((resolve) => {
this._resolveSynced = resolve;
});
// Bind the update handler
this._storeUpdateHandler = this._storeUpdate.bind(this);
// Initialize the database and load existing data
this._initialize();
}
private async _initialize() {
try {
// Create tables if they don't exist
await this._createTables();
// Load existing updates
await this._loadUpdates();
// Listen for document updates
this.doc.on("update", this._storeUpdateHandler);
// Mark as synced
this._synced = true;
this._resolveSynced();
} catch (error) {
console.error("Failed to initialize LibSQLPersistence:", error);
throw error;
}
}
private async _createTables() {
// Create updates table
await this.client.execute(`
CREATE TABLE IF NOT EXISTS yjs_updates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
doc_name TEXT NOT NULL,
update_data BLOB NOT NULL,
created_at INTEGER DEFAULT (unixepoch()),
INDEX idx_doc_name (doc_name)
)
`);
// Create metadata table
await this.client.execute(`
CREATE TABLE IF NOT EXISTS yjs_metadata (
doc_name TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT,
PRIMARY KEY (doc_name, key)
)
`);
}
private async _loadUpdates() {
// Fetch all updates for this document
const result = await this.client.execute({
sql: "SELECT update_data FROM yjs_updates WHERE doc_name = ? ORDER BY id",
args: [this.docName],
});
if (result.rows.length > 0) {
// Apply updates to the document
Y.transact(
this.doc,
() => {
for (const row of result.rows) {
const updateData = row.update_data;
if (updateData instanceof ArrayBuffer) {
Y.applyUpdate(this.doc, new Uint8Array(updateData), this);
} else if (typeof updateData === "string") {
// Handle base64 encoded data
const binaryString = atob(updateData);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
Y.applyUpdate(this.doc, bytes, this);
}
}
},
this,
);
}
// Load metadata
const metaResult = await this.client.execute({
sql: "SELECT key, value FROM yjs_metadata WHERE doc_name = ?",
args: [this.docName],
});
for (const row of metaResult.rows) {
const key = row.key as string;
const value = row.value as string;
try {
this.meta.set(key, JSON.parse(value));
} catch {
this.meta.set(key, value);
}
}
}
private async _storeUpdate(update: Uint8Array, origin: any) {
// Don't store updates that originated from this provider
if (origin === this || this._destroyed) {
return;
}
try {
// Convert Uint8Array to base64 for storage
const base64Update = btoa(String.fromCharCode(...update));
await this.client.execute({
sql: "INSERT INTO yjs_updates (doc_name, update_data) VALUES (?, ?)",
args: [this.docName, base64Update],
});
} catch (error) {
console.error("Failed to store update:", error);
}
}
async set(key: string, value: any): Promise<void> {
this.meta.set(key, value);
const jsonValue = JSON.stringify(value);
await this.client.execute({
sql: `
INSERT INTO yjs_metadata (doc_name, key, value)
VALUES (?, ?, ?)
ON CONFLICT(doc_name, key)
DO UPDATE SET value = excluded.value
`,
args: [this.docName, key, jsonValue],
});
}
get(key: string): any {
return this.meta.get(key);
}
async del(key: string): Promise<void> {
this.meta.delete(key);
await this.client.execute({
sql: "DELETE FROM yjs_metadata WHERE doc_name = ? AND key = ?",
args: [this.docName, key],
});
}
async clearData(): Promise<void> {
// Clear all data for this document
await this.client.execute({
sql: "DELETE FROM yjs_updates WHERE doc_name = ?",
args: [this.docName],
});
await this.client.execute({
sql: "DELETE FROM yjs_metadata WHERE doc_name = ?",
args: [this.docName],
});
this.meta.clear();
}
async compactUpdates(): Promise<void> {
// Get the current state as a single update
const stateUpdate = Y.encodeStateAsUpdate(this.doc);
// Clear old updates
await this.client.execute({
sql: "DELETE FROM yjs_updates WHERE doc_name = ?",
args: [this.docName],
});
// Store the compacted update
const base64Update = btoa(String.fromCharCode(...stateUpdate));
await this.client.execute({
sql: "INSERT INTO yjs_updates (doc_name, update_data) VALUES (?, ?)",
args: [this.docName, base64Update],
});
}
destroy(): void {
if (this._destroyed) return;
this._destroyed = true;
this.doc.off("update", this._storeUpdateHandler);
}
get synced(): boolean {
return this._synced;
}
}
// Export for convenience
export { Y };

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

82
pnpm-lock.yaml generated
View file

@ -35,6 +35,9 @@ importers:
'@amical/types':
specifier: workspace:*
version: link:../../packages/types
'@amical/y-libsql':
specifier: workspace:*
version: link:../../packages/y-libsql
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@ -212,6 +215,9 @@ importers:
embla-carousel-react:
specifier: ^8.6.0
version: 8.6.0(react@19.1.1)
emoji-picker-react:
specifier: ^4.13.3
version: 4.13.3(react@19.1.1)
framer-motion:
specifier: ^12.10.5
version: 12.23.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@ -233,6 +239,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
node-cron:
specifier: ^4.2.1
version: 4.2.1
node-machine-id:
specifier: ^1.1.12
version: 1.1.12
@ -293,6 +302,9 @@ importers:
workerpool:
specifier: ^9.3.3
version: 9.3.3
yjs:
specifier: ^13.6.27
version: 13.6.27
zod:
specifier: ^3.25.24
version: 3.25.76
@ -665,6 +677,25 @@ importers:
specifier: 5.8.2
version: 5.8.2
packages/y-libsql:
dependencies:
'@libsql/client':
specifier: ^0.15.9
version: 0.15.12
minimatch:
specifier: 10.0.3
version: 10.0.3
yjs:
specifier: ^13.6.18
version: 13.6.27
devDependencies:
'@types/node':
specifier: ^20.14.9
version: 20.19.11
typescript:
specifier: ^5.5.2
version: 5.8.3
packages:
'@ai-sdk/openai@1.3.24':
@ -3996,6 +4027,9 @@ packages:
'@types/node@18.19.123':
resolution: {integrity: sha512-K7DIaHnh0mzVxreCR9qwgNxp3MH9dltPNIEddW9MYUlcKAzm+3grKNSTe2vCJHI1FaLpvpL5JGJrz1UZDKYvDg==}
'@types/node@20.19.11':
resolution: {integrity: sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==}
'@types/node@22.15.12':
resolution: {integrity: sha512-K0fpC/ZVeb8G9rm7bH7vI0KAec4XHEhBam616nVJCV51bKzJ6oA3luG4WdKoaztxe70QaNjS/xBmcDLmr4PiGw==}
@ -5477,6 +5511,12 @@ packages:
resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==}
engines: {node: '>=12'}
emoji-picker-react@4.13.3:
resolution: {integrity: sha512-aZaxCI72oUQfvZtYuQ9RaYLEwmH3GVgAr5SEeB97Y7gWL06zJ4VTuSl8rAMVY7GNmd0tf/EQ1W2SDuXTl0q9AA==}
engines: {node: '>=10'}
peerDependencies:
react: '>=16'
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@ -6057,6 +6097,9 @@ packages:
fix-dts-default-cjs-exports@1.0.1:
resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==}
flairup@1.0.0:
resolution: {integrity: sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==}
flat-cache@1.3.4:
resolution: {integrity: sha512-VwyB3Lkgacfik2vhqR4uv2rvebqmDvFu4jlN/C1RzWoJEo8I7z4Q404oiqYCkq41mni8EzQnm95emU9seckwtg==}
engines: {node: '>=0.10.0'}
@ -6903,6 +6946,9 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
isomorphic.js@0.2.5:
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
@ -7209,6 +7255,11 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
lib0@0.2.114:
resolution: {integrity: sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==}
engines: {node: '>=16'}
hasBin: true
libsql@0.5.17:
resolution: {integrity: sha512-RRlj5XQI9+Wq+/5UY8EnugSWfRmHEw4hn3DKlPrkUgZONsge1PwTtHcpStP6MSNi8ohcbsRgEHJaymA33a8cBw==}
cpu: [x64, arm64, wasm32, arm]
@ -7868,6 +7919,10 @@ packages:
node-api-version@0.2.1:
resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==}
node-cron@4.2.1:
resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
engines: {node: '>=6.0.0'}
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
@ -10180,6 +10235,10 @@ packages:
yauzl@2.10.0:
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
yjs@13.6.27:
resolution: {integrity: sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
yn@3.1.1:
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
engines: {node: '>=6'}
@ -14119,6 +14178,10 @@ snapshots:
dependencies:
undici-types: 5.26.5
'@types/node@20.19.11':
dependencies:
undici-types: 6.21.0
'@types/node@22.15.12':
dependencies:
undici-types: 6.21.0
@ -15646,6 +15709,11 @@ snapshots:
emittery@0.13.1: {}
emoji-picker-react@4.13.3(react@19.1.1):
dependencies:
flairup: 1.0.0
react: 19.1.1
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
@ -16588,6 +16656,8 @@ snapshots:
mlly: 1.7.4
rollup: 4.47.1
flairup@1.0.0: {}
flat-cache@1.3.4:
dependencies:
circular-json: 0.3.3
@ -17624,6 +17694,8 @@ snapshots:
isexe@2.0.0: {}
isomorphic.js@0.2.5: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-instrument@5.2.1:
@ -18117,6 +18189,10 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
lib0@0.2.114:
dependencies:
isomorphic.js: 0.2.5
libsql@0.5.17:
dependencies:
'@neon-rs/load': 0.0.4
@ -19029,6 +19105,8 @@ snapshots:
dependencies:
semver: 7.7.2
node-cron@4.2.1: {}
node-domexception@1.0.0: {}
node-fetch-native@1.6.7: {}
@ -21736,6 +21814,10 @@ snapshots:
buffer-crc32: 0.2.13
fd-slicer: 1.1.0
yjs@13.6.27:
dependencies:
lib0: 0.2.114
yn@3.1.1: {}
yocto-queue@0.1.0: {}