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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ export default defineConfig({
resolve: {
alias: [
{ find: '@', replacement: resolve(__dirname, 'src/renderer') },
{
find: '@dripnex/tables',
replacement: resolve(__dirname, '../../packages/tables/src/index.ts'),
},
// highlight@1.2.3 nests common@1.5.0; language/markdown nest 1.5.2.
// Two NodeProp identities → HighlightStyle.style(undefined) →
// "tags is not iterable". Pin every import to the desktop copy.
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"@dripnex/storage-core": "workspace:*",
"@dripnex/storage-sqlite": "workspace:*",
"@dripnex/sync-core": "workspace:*",
"@dripnex/tables": "workspace:*",
"@dripnex/wikilinks": "workspace:*",
"@playwright/test": "^1.49.1",
"@types/better-sqlite3": "^7.6.12",
Expand Down
75 changes: 66 additions & 9 deletions apps/desktop/src/main/handlers/dataHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,22 @@ import {
type NoteStatus,
} from '@dripnex/core';
import { defineIpcHandler } from '../ipc/registry.js';
import { htmlToPdfBuffer, printHtml } from '../services/printNote.js';
import type { SQLiteNoteRepository, Database } from './types.js';

function safeExportName(suggestedName: string): string {
let safeName =
suggestedName
.normalize('NFC')
// eslint-disable-next-line no-control-regex
.replace(/[/\\:*?"<>|\x00-\x1f.]/g, '')
.substring(0, 80)
.trim() || 'note';
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com\d|lpt\d)$/i;
if (WINDOWS_RESERVED.test(safeName)) safeName = `_${safeName}`;
return safeName;
}

export interface DataHandlerDeps {
dataPaths: DataPaths;
noteRepository: SQLiteNoteRepository;
Expand Down Expand Up @@ -164,6 +178,8 @@ export function registerDataHandlers(deps: DataHandlerDeps): void {
updatedAt: note.metadata.updatedAt,
tags: [...note.metadata.tags],
wordCount: note.metadata.wordCount,
taskCount: note.metadata.taskCount,
checkedTaskCount: note.metadata.checkedTaskCount,
archivedAt: note.metadata.archivedAt,
notebookId: note.notebookId,
isArchived: note.metadata.archivedAt !== null,
Expand All @@ -190,15 +206,7 @@ export function registerDataHandlers(deps: DataHandlerDeps): void {
channel: 'data:exportNote',
args: z.tuple([z.string().max(1024 * 1024), z.string().max(512)]),
handler: async (content, suggestedName) => {
let safeName =
suggestedName
.normalize('NFC')
// eslint-disable-next-line no-control-regex
.replace(/[/\\:*?"<>|\x00-\x1f.]/g, '')
.substring(0, 80)
.trim() || 'note';
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com\d|lpt\d)$/i;
if (WINDOWS_RESERVED.test(safeName)) safeName = `_${safeName}`;
const safeName = safeExportName(suggestedName);
const { filePath, canceled } = await dialog.showSaveDialog({
title: 'Export Note',
defaultPath: join(app.getPath('documents'), `${safeName}.md`),
Expand All @@ -222,6 +230,55 @@ export function registerDataHandlers(deps: DataHandlerDeps): void {
},
});

defineIpcHandler({
channel: 'data:exportFile',
args: z.tuple([
z.string().max(5 * 1024 * 1024),
z.string().max(512),
z.enum(['md', 'html', 'pdf']),
]),
handler: async (content, suggestedName, kind) => {
const safeName = safeExportName(suggestedName);
const filters =
kind === 'pdf'
? [{ name: 'PDF', extensions: ['pdf'] }]
: kind === 'html'
? [{ name: 'HTML', extensions: ['html'] }]
: [{ name: 'Markdown', extensions: ['md'] }];
const { filePath, canceled } = await dialog.showSaveDialog({
title: 'Export Note',
defaultPath: join(app.getPath('documents'), `${safeName}.${kind}`),
buttonLabel: 'Export',
filters,
});

if (canceled || !filePath) {
return { success: false, error: 'Export cancelled' };
}

try {
if (kind === 'pdf') {
const pdf = await htmlToPdfBuffer(content);
await writeFile(filePath, pdf);
} else {
await writeFile(filePath, content, 'utf-8');
}
return { success: true, path: filePath };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to write file',
};
}
},
});

defineIpcHandler({
channel: 'note:printHtml',
args: z.tuple([z.string().max(5 * 1024 * 1024)]),
handler: html => printHtml(html),
});

defineIpcHandler({
channel: 'data:import',
args: z.tuple([]),
Expand Down
131 changes: 127 additions & 4 deletions apps/desktop/src/main/handlers/localServerHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,38 @@
* to the renderer (settings UI).
*/

import { dirname } from 'path';
import { dirname, join } from 'path';
import { app } from 'electron';
import { z } from 'zod';
import { createNoteId, createNoteOperation, updateNoteOperation } from '@dripnex/core';
import {
createNoteId,
createNotebook,
createNotebookId,
createNoteOperation,
deleteNoteOperation,
renameNotebook,
setNotebookIcon,
trashNoteOperation,
updateNoteOperation,
} from '@dripnex/core';
import {
ChangeLog,
LocalServer,
getOrCreateApiToken,
type LocalServerHandlers,
} from '../services/localServer.js';
import { resolveMcpLaunch } from '../services/mcpLaunch.js';
import { writeMcpWritesConfig } from '../services/mcpWrites.js';
import { defineIpcHandler } from '../ipc/registry.js';
import type { SQLiteNoteRepository, DataPaths } from './types.js';
import type { SQLiteNoteRepository, SQLiteNotebookRepository, DataPaths } from './types.js';

// ============================================================================
// Types
// ============================================================================

export interface LocalServerHandlerDeps {
noteRepository: SQLiteNoteRepository;
notebookRepository: SQLiteNotebookRepository;
dataPaths: DataPaths;
noteToSnapshot: (note: {
id: string;
Expand All @@ -50,6 +62,8 @@ export interface LocalServerHandlerDeps {
updatedAt: string;
tags: string[];
wordCount: number;
taskCount?: number;
checkedTaskCount?: number;
archivedAt: string | null;
isArchived: boolean;
isPinned: boolean;
Expand All @@ -63,14 +77,17 @@ export interface LocalServerHandlerDeps {
// ============================================================================

const server = new LocalServer();
const changeLog = new ChangeLog();
let apiToken: string | null = null;

// ============================================================================
// Registration
// ============================================================================

export function registerLocalServerHandlers(deps: LocalServerHandlerDeps): void {
const { noteRepository: repo, dataPaths, noteToSnapshot } = deps;
const { noteRepository: repo, notebookRepository, dataPaths, noteToSnapshot } = deps;
changeLog.attach(join(dataPaths.root, 'changes.json'));
void changeLog.load();

// Build handler callbacks that bridge HTTP requests to the note repository
const handlers: LocalServerHandlers = {
Expand Down Expand Up @@ -99,13 +116,16 @@ export function registerLocalServerHandlers(deps: LocalServerHandlerDeps): void
updatedAt: snap.updatedAt,
tags: snap.tags,
wordCount: snap.wordCount,
taskCount: snap.taskCount,
checkedTaskCount: snap.checkedTaskCount,
isPinned: snap.isPinned,
};
},

async createNote(input) {
const result = await createNoteOperation(input, repo);
if (result.ok) {
changeLog.record('note', result.data.id);
return { ok: true, data: { id: result.data.id } };
}
return { ok: false, error: result.error };
Expand All @@ -114,6 +134,7 @@ export function registerLocalServerHandlers(deps: LocalServerHandlerDeps): void
async updateNote(id, content) {
const noteId = createNoteId(id);
const result = await updateNoteOperation({ id: noteId, content }, repo);
if (result.ok) changeLog.record('note', id);
return { ok: result.ok, error: result.ok ? undefined : result.error };
},

Expand All @@ -134,6 +155,108 @@ export function registerLocalServerHandlers(deps: LocalServerHandlerDeps): void
getAppVersion() {
return app.getVersion();
},

async listNotebooks() {
const notebooks = await notebookRepository.getAll();
return notebooks.map(nb => ({
id: nb.id,
name: nb.name,
parentId: nb.parentId,
icon: nb.icon,
}));
},

async listTags() {
return repo.listTags();
},

async deleteNote(id, permanent) {
const noteId = createNoteId(id);
const result = permanent
? await deleteNoteOperation({ id: noteId }, repo)
: await trashNoteOperation({ id: noteId }, repo);
if (result.ok) changeLog.record('note', id, true);
return { ok: result.ok, error: result.ok ? undefined : result.error };
},

async createNotebook(input) {
try {
let parentDepth = 0;
if (input.parentId) {
const parent = await notebookRepository.get(createNotebookId(input.parentId));
if (parent) parentDepth = parent.depth;
}
const nextOrder = await notebookRepository.getNextOrder(
input.parentId ? createNotebookId(input.parentId) : null
);
const notebook = createNotebook({
name: input.name,
parentId: input.parentId ? createNotebookId(input.parentId) : null,
parentDepth,
order: nextOrder,
});
await notebookRepository.save(notebook);
changeLog.record('book', notebook.id);
return { ok: true, data: { id: notebook.id } };
} catch (err) {
return { ok: false, error: err };
}
},

async deleteNotebook(id) {
try {
await notebookRepository.delete(createNotebookId(id));
changeLog.record('book', id, true);
return { ok: true };
} catch (err) {
return { ok: false, error: err };
}
},

async updateNotebook(id, patch) {
try {
const notebook = await notebookRepository.get(createNotebookId(id));
if (!notebook) return { ok: false, error: 'not found' };
let next = notebook;
if (typeof patch.name === 'string' && patch.name.trim()) {
next = renameNotebook(next, patch.name);
}
if (patch.icon !== undefined) {
next = setNotebookIcon(next, patch.icon);
}
await notebookRepository.save(next);
changeLog.record('book', id);
return { ok: true };
} catch (err) {
return { ok: false, error: err };
}
},

async putTag(name, patch) {
try {
const current = name.trim();
if (!current) return { ok: false, error: 'empty name' };
if (patch.color !== undefined) {
repo.setTagColor(current, patch.color);
} else if (!patch.newName) {
repo.setTagColor(current, null);
}
if (patch.newName && patch.newName.trim() && patch.newName.trim() !== current) {
const renamed = repo.renameTag(current, patch.newName);
if (!renamed.ok) return { ok: false, error: renamed.error };
changeLog.record('tag', patch.newName.trim().toLowerCase());
} else {
changeLog.record('tag', current.toLowerCase());
}
return { ok: true };
} catch (err) {
return { ok: false, error: err };
}
},

getChanges(since) {
return changeLog.since(since);
},
};

defineIpcHandler({
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src/main/handlers/notebookHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
createNotebook,
createTemplatesNotebook,
renameNotebook,
setNotebookIcon,
moveNotebook,
INBOX_NOTEBOOK_ID,
TEMPLATES_NOTEBOOK_ID,
Expand All @@ -35,6 +36,7 @@ export function registerNotebookHandlers(deps: NotebookHandlerDeps): void {
order: number;
createdAt: string;
updatedAt: string;
icon: string | null;
}) => ({
id: nb.id,
name: nb.name,
Expand All @@ -43,6 +45,7 @@ export function registerNotebookHandlers(deps: NotebookHandlerDeps): void {
order: nb.order,
createdAt: nb.createdAt,
updatedAt: nb.updatedAt,
icon: nb.icon,
});

defineIpcHandler({
Expand Down Expand Up @@ -140,6 +143,20 @@ export function registerNotebookHandlers(deps: NotebookHandlerDeps): void {
},
});

defineIpcHandler({
channel: 'notebooks:setIcon',
args: z.tuple([IdSchema, z.string().min(1).max(64).nullable()]),
handler: async (id, icon) => {
const notebook = await repo.get(createNotebookId(id));
if (!notebook) {
throw new Error('Notebook not found');
}
const updated = setNotebookIcon(notebook, icon);
await repo.save(updated);
return serialize(updated);
},
});

defineIpcHandler({
channel: 'notebooks:move',
args: z.tuple([IdSchema, IdSchema.nullable()]),
Expand Down
Loading
Loading