feat: v0.1.0 - geolocation capture, calendar, search, Starlight docs site

- Automatic browser geolocation capture on event creation
- Reverse geocoding via Nominatim API for place names
- Full-text search with SQLite FTS5
- Calendar view for browsing past entries
- DateNavigator component for day navigation
- SearchModal with Ctrl+K shortcut
- QuickAddWidget with Ctrl+J shortcut
- Starlight documentation site with GitHub Pages deployment
- Multiple AI provider support (Groq, OpenAI, Anthropic, Ollama, LM Studio)
- Multi-user registration support

BREAKING: Events now include latitude/longitude/placeName fields
This commit is contained in:
lotherk
2026-03-27 02:27:55 +00:00
parent deaf496a7d
commit 0bdd71a4ed
67 changed files with 15201 additions and 355 deletions

View File

@@ -1,27 +1,51 @@
# Database connection (SQLite, PostgreSQL, or MySQL)
# =============================================================================
# DearDiary Configuration
# =============================================================================
# -----------------------------------------------------------------------------
# Database (SQLite, PostgreSQL, or MySQL)
# -----------------------------------------------------------------------------
DATABASE_URL="file:./data/deardiary.db"
# Media storage directory
MEDIA_DIR="./data/media"
# Example PostgreSQL:
# DATABASE_URL="postgresql://postgres:password@db:5432/deardiary"
# JWT secret for authentication tokens (REQUIRED in production)
JWT_SECRET="change-this-to-a-random-string-in-production"
# Example MySQL:
# DATABASE_URL="mysql://root:password@localhost:3306/deardiary"
# -----------------------------------------------------------------------------
# Application
# -----------------------------------------------------------------------------
# App name displayed in UI
APP_NAME="DearDiary"
# App version
VERSION="0.1.0"
# Server port
PORT="3000"
# CORS origin (use specific domain in production)
# -----------------------------------------------------------------------------
# Security
# -----------------------------------------------------------------------------
# JWT secret for authentication tokens (REQUIRED in production)
JWT_SECRET="change-this-to-a-random-string-in-production"
# CORS origin (use specific domain in production, e.g., "https://yourapp.com")
CORS_ORIGIN="*"
# Default user (auto-created on startup if doesn't exist)
# -----------------------------------------------------------------------------
# User Management
# -----------------------------------------------------------------------------
# Enable/disable user registration ("true" or "false")
REGISTRATION_ENABLED="false"
# Default admin user (auto-created on startup if doesn't exist)
DEFAULT_USER_EMAIL="admin@localhost"
DEFAULT_USER_PASSWORD="changeme123"
# Default journal prompt (strict anti-hallucination)
# JOURNAL_PROMPT="You are a factual diary summarizer. Your ONLY job is to summarize the entries provided to you - nothing more.\n\nCRITICAL RULES:\n1. ONLY use information explicitly stated in the entries below\n2. NEVER invent, assume, or hallucinate any detail not in the entries\n3. NEVER add activities, emotions, weather, or context not directly mentioned\n4. If something is unclear in the entries, simply state what IS clear\n5. Keep the summary grounded and factual - no embellishment\n6. Do not write in an overly creative or story-telling style\n7. Only reference what the user explicitly recorded"
# Example PostgreSQL connection:
# DATABASE_URL="postgresql://postgres:password@db:5432/deardiary"
# Example MySQL connection:
# DATABASE_URL="mysql://root:password@localhost:3306/deardiary"
# -----------------------------------------------------------------------------
# Storage
# -----------------------------------------------------------------------------
# Media storage directory
MEDIA_DIR="./data/media"

View File

@@ -1,6 +1,6 @@
{
"name": "deardiary-backend",
"version": "0.0.1",
"version": "0.0.3",
"type": "module",
"scripts": {
"dev": "bun --watch src/index.ts",

View File

@@ -42,6 +42,9 @@ model Event {
content String
mediaPath String?
metadata String?
latitude Float?
longitude Float?
placeName String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -55,6 +58,7 @@ model Journal {
id String @id @default(uuid())
userId String
date String
title String?
content String
eventCount Int
generatedAt DateTime @default(now())
@@ -78,6 +82,7 @@ model Task {
request String?
response String?
error String?
title String?
createdAt DateTime @default(now())
completedAt DateTime?
@@ -94,7 +99,7 @@ model Settings {
aiApiKey String?
aiModel String @default("llama-3.3-70b-versatile")
aiBaseUrl String?
journalPrompt String @default("You are a factual diary summarizer. Your ONLY job is to summarize the entries provided to you - nothing more.\n\nCRITICAL RULES:\n1. ONLY use information explicitly stated in the entries below\n2. NEVER invent, assume, or hallucinate any detail not in the entries\n3. NEVER add activities, emotions, weather, or context not directly mentioned\n4. If something is unclear in the entries, simply state what IS clear\n5. Keep the summary grounded and factual - no embellishment\n6. Do not write in an overly creative or story-telling style\n7. Only reference what the user explicitly recorded\n\nStructure:\n- Start with what was recorded (meetings, tasks, activities)\n- Note any explicit feelings or observations mentioned\n- Keep it concise and factual\n- If there are gaps in the day, acknowledge only what was recorded")
journalPrompt String?
language String @default("en")
timezone String @default("UTC")
providerSettings String?

View File

@@ -114,6 +114,17 @@ app.post('/api/v1/auth/login', async (c) => {
return c.json({ data: { token, userId: user.id }, error: null });
});
app.get('/api/v1/server-info', async (c) => {
return c.json({
data: {
version: envVars.VERSION || '0.1.0',
registrationEnabled: envVars.REGISTRATION_ENABLED !== 'false',
appName: envVars.APP_NAME || 'DearDiary',
},
error: null
});
});
app.post('/api/v1/auth/api-key', async (c) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
@@ -156,17 +167,22 @@ app.get('/api/v1/days', async (c) => {
const journals = await prisma.journal.findMany({
where: { userId },
select: { date: true, generatedAt: true },
select: { date: true, title: true, generatedAt: true, content: true },
});
const journalMap = new Map(journals.map(j => [j.date, j]));
const result = days.map(day => ({
date: day.date,
eventCount: day._count.id,
hasJournal: journalMap.has(day.date),
journalGeneratedAt: journalMap.get(day.date)?.generatedAt,
}));
const result = days.map(day => {
const journal = journalMap.get(day.date);
return {
date: day.date,
eventCount: day._count.id,
hasJournal: !!journal,
journalTitle: journal?.title,
journalGeneratedAt: journal?.generatedAt,
journalExcerpt: journal?.content?.substring(0, 250),
};
});
return c.json({ data: result, error: null });
});
@@ -203,80 +219,78 @@ app.delete('/api/v1/days/:date', async (c) => {
return c.json({ data: { deleted: true }, error: null });
});
// Delete journal only (keeps events)
app.delete('/api/v1/journal/:date', async (c) => {
app.get('/api/v1/search', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const { date } = c.req.param();
const journal = await prisma.journal.findFirst({ where: { userId, date } });
if (!journal) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Journal not found' } }, 404);
const query = c.req.query('q') || '';
if (query.length < 2) {
return c.json({ data: { journals: [], events: [] }, error: null });
}
await prisma.journal.delete({ where: { id: journal.id } });
return c.json({ data: { deleted: true }, error: null });
});
// Events routes
app.post('/api/v1/events', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const { date, type, content, metadata } = await c.req.json();
if (!date || !type || !content) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'date, type, and content are required' } }, 400);
try {
const safeQuery = query.replace(/['"]/g, '');
const searchTerm = safeQuery.split(/\s+/).filter(t => t.length > 0).map(t => `${t}*`).join(' OR ') || `${safeQuery}*`;
let journalResults: Array<{ userId: string; date: string; title: string; content: string }> = [];
let eventResults: Array<{ userId: string; date: string; type: string; content: string }> = [];
try {
journalResults = await prisma.$queryRaw<Array<{ userId: string; date: string; title: string; content: string }>>`
SELECT userId, date, title, content
FROM journal_fts
WHERE userId = ${userId} AND journal_fts MATCH ${searchTerm}
ORDER BY rank
LIMIT 20
`;
} catch (e) {
console.error('Journal FTS search error:', e);
const journals = await prisma.journal.findMany({
where: { userId, OR: [
{ title: { contains: safeQuery } },
{ content: { contains: safeQuery } }
]},
take: 20,
});
journalResults = journals.map(j => ({ userId: j.userId, date: j.date, title: j.title || '', content: j.content }));
}
try {
eventResults = await prisma.$queryRaw<Array<{ userId: string; date: string; type: string; content: string }>>`
SELECT userId, date, type, content
FROM event_fts
WHERE userId = ${userId} AND event_fts MATCH ${searchTerm}
ORDER BY rank
LIMIT 20
`;
} catch (e) {
console.error('Event FTS search error:', e);
const events = await prisma.event.findMany({
where: { userId, content: { contains: safeQuery } },
take: 20,
});
eventResults = events.map(e => ({ userId: e.userId, date: e.date, type: e.type, content: e.content }));
}
return c.json({
data: {
journals: journalResults.map(j => ({
date: j.date,
title: j.title,
excerpt: j.content.substring(0, 200),
})),
events: eventResults.map(e => ({
date: e.date,
type: e.type,
content: e.content,
})),
},
error: null
});
} catch (err) {
console.error('Search error:', err);
return c.json({ data: { journals: [], events: [] }, error: null });
}
const validTypes = ['event'];
if (!validTypes.includes(type)) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: `type must be one of: ${validTypes.join(', ')}` } }, 400);
}
const event = await prisma.event.create({
data: { userId, date, type, content, metadata: metadata ? JSON.stringify(metadata) : null },
});
return c.json({ data: event, error: null }, 201);
});
app.get('/api/v1/events/:id', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const { id } = c.req.param();
const event = await prisma.event.findFirst({ where: { id, userId } });
if (!event) return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Event not found' } }, 404);
return c.json({ data: event, error: null });
});
app.put('/api/v1/events/:id', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const { id } = c.req.param();
const { content, metadata } = await c.req.json();
const existing = await prisma.event.findFirst({ where: { id, userId } });
if (!existing) return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Event not found' } }, 404);
const journal = await prisma.journal.findFirst({ where: { userId, date: existing.date } });
if (journal) {
return c.json({ data: null, error: { code: 'EVENT_IMMUTABLE', message: 'Cannot edit event: journal already generated. Delete the journal first.' } }, 400);
}
const event = await prisma.event.update({
where: { id },
data: {
content: content ?? existing.content,
metadata: metadata !== undefined ? JSON.stringify(metadata) : existing.metadata,
},
});
return c.json({ data: event, error: null });
});
app.delete('/api/v1/events/:id', async (c) => {
@@ -302,6 +316,8 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const { date } = c.req.param();
const body = await c.req.json().catch(() => ({}));
const additionalInstructions = body.instructions || '';
const [events, settings] = await Promise.all([
prisma.event.findMany({ where: { userId, date }, orderBy: { createdAt: 'asc' } }),
@@ -324,10 +340,14 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
// Build events text
const eventsText = events.map(event => {
let text = `[EVENT] ${event.createdAt.toISOString()}\n${event.content}`;
if (event.placeName) {
text += `\nLocation: ${event.placeName}`;
} else if (event.latitude && event.longitude) {
text += `\nLocation: ${event.latitude}, ${event.longitude}`;
}
if (event.metadata) {
try {
const meta = JSON.parse(event.metadata);
if (meta.location) text += `\nLocation: ${meta.location.lat}, ${meta.location.lng}`;
if (meta.duration) text += `\nDuration: ${meta.duration}s`;
} catch {}
}
@@ -356,22 +376,57 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
if (previousJournals.length > 0) {
previousJournalsText = `PREVIOUS DIARIES:\n${previousJournals.map(j =>
`[${j.date}]\n${j.content}`
`[${j.date}]\n${j.title ? `Title: ${j.title}\n` : ''}${j.content}`
).join('\n\n')}\n\n`;
}
}
// Build prompts: 1. system prompt, 2. previous journals, 3. today's events
const systemPrompt = settings?.journalPrompt || 'You are a thoughtful journal writer.';
const userPrompt = `${previousJournalsText}EVENTS FROM TODAY (${date}):\n${eventsText}\n\nWrite a thoughtful, reflective journal entry based on the events above.`;
const jsonInstruction = `IMPORTANT: Return ONLY valid JSON in this exact format, nothing else:
{"title": "A short, descriptive title for this day (max 50 characters)", "content": "Your diary entry text here..."}
Do not include any text before or after the JSON. Do not use markdown code blocks.`;
// Build system prompt from user's settings + JSON instruction
const defaultPrompt = `You are a factual diary summarizer. Your ONLY job is to summarize the entries provided to you - nothing more.
CRITICAL RULES:
1. ONLY use information explicitly stated in the entries below
2. NEVER invent, assume, or hallucinate any detail not in the entries
3. NEVER add activities, emotions, weather, or context not directly mentioned
4. If something is unclear in the entries, simply state what IS clear
5. Keep the summary grounded and factual - no embellishment
6. Do not write in an overly creative or story-telling style
7. Only reference what the user explicitly recorded
8. NEVER write any preamble, meta-commentary, or statements about how you are writing
9. NEVER include any closing remarks, sign-offs, or follow-up offers`;
const userInstructions = settings?.journalPrompt;
const systemPromptWithJson = userInstructions
? `${defaultPrompt}\n\n${jsonInstruction}\n\nCUSTOM USER INSTRUCTIONS:\n${userInstructions}`
: `${defaultPrompt}\n\n${jsonInstruction}`;
let userPrompt = `${previousJournalsText}EVENTS FROM TODAY (${date}):\n${eventsText}`;
if (additionalInstructions) {
userPrompt = `${userPrompt}\n\nADDITIONAL USER INSTRUCTIONS:\n${additionalInstructions}`;
}
console.log(`[Journal Generate] Date: ${date}, Context days: ${contextDays}, Events: ${events.length}`);
// Create placeholder journal and task
const placeholderJournal = await prisma.journal.create({
data: { userId, date, content: 'Generating...', eventCount: events.length },
// Check if journal already exists for this date
const existingJournal = await prisma.journal.findUnique({
where: { userId_date: { userId, date } },
});
// Use upsert to handle both create and regenerate cases
const placeholderJournal = await prisma.journal.upsert({
where: { userId_date: { userId, date } },
create: { userId, date, content: 'Generating...', eventCount: events.length },
update: { content: 'Generating...', eventCount: events.length, generatedAt: new Date() },
});
const model = providerConfig.model || settings?.aiModel;
const task = await prisma.task.create({
data: {
userId,
@@ -379,23 +434,16 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
type: 'journal_generate',
status: 'pending',
provider,
model: settings?.aiModel,
model: model,
prompt: `${systemPrompt}\n\n---\n\n${userPrompt}`,
request: '',
response: '',
},
});
// Update journal with taskId
await prisma.journal.update({
where: { id: placeholderJournal.id },
data: { id: placeholderJournal.id },
});
try {
console.log(`[Journal Generate] Using provider: ${provider}`);
const model = providerConfig.model || settings?.aiModel;
console.log(`[Journal Generate] Using model: ${model}`);
const baseUrl = providerConfig.baseUrl || settings?.aiBaseUrl;
const aiProvider = createAIProvider({
@@ -407,13 +455,13 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
console.log(`[Journal Generate] AI Provider created: ${aiProvider.provider}`);
const result = await aiProvider.generate(userPrompt, systemPrompt);
const result = await aiProvider.generate(userPrompt, systemPromptWithJson, { jsonMode: true });
if (!result.content) {
throw new Error('No content generated from AI');
}
console.log(`[Journal Generate] Success! Content length: ${result.content.length}`);
console.log(`[Journal Generate] Success! Content length: ${result.content.length}, Title: ${result.title || 'N/A'}`);
// Update task with success - store full request and response JSON
await prisma.task.update({
@@ -422,14 +470,19 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
status: 'completed',
request: JSON.stringify(result.request, null, 2),
response: JSON.stringify(result.response, null, 2),
title: result.title,
completedAt: new Date(),
},
});
// Update journal with content
// Update journal with content and title
const journal = await prisma.journal.update({
where: { id: placeholderJournal.id },
data: { content: result.content, generatedAt: new Date() },
data: {
content: result.content,
title: result.title,
generatedAt: new Date()
},
});
return c.json({ data: { journal, task }, error: null });
@@ -448,8 +501,16 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
},
});
// Delete placeholder journal
await prisma.journal.delete({ where: { id: placeholderJournal.id } });
// Only delete journal if it was newly created (not an existing one)
if (!existingJournal) {
await prisma.journal.delete({ where: { id: placeholderJournal.id } });
} else {
// Restore the existing content
await prisma.journal.update({
where: { id: placeholderJournal.id },
data: { content: existingJournal.content },
});
}
return c.json({ data: null, error: { code: 'AI_ERROR', message: `Failed to generate journal: ${errorMessage}` } }, 500);
}
@@ -469,9 +530,10 @@ app.get('/api/v1/journal/:date/tasks', async (c) => {
const tasks = await prisma.task.findMany({
where: { journalId: journal.id },
orderBy: { createdAt: 'desc' },
include: { journal: { select: { title: true } } },
});
return c.json({ data: tasks, error: null });
return c.json({ data: tasks.map(t => ({ ...t, journalTitle: t.journal?.title || null })), error: null });
});
app.get('/api/v1/tasks/:id', async (c) => {
@@ -502,6 +564,30 @@ app.get('/api/v1/journal/:date', async (c) => {
return c.json({ data: journal, error: null });
});
app.get('/api/v1/journals', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const page = parseInt(c.req.query('page') || '1');
const limit = parseInt(c.req.query('limit') || '10');
const offset = (page - 1) * limit;
const [journals, total] = await Promise.all([
prisma.journal.findMany({
where: { userId },
orderBy: { date: 'desc' },
skip: offset,
take: limit,
}),
prisma.journal.count({ where: { userId } }),
]);
return c.json({
data: { journals, total, page, limit, totalPages: Math.ceil(total / limit) },
error: null,
});
});
// Settings routes
app.get('/api/v1/settings', async (c) => {
const userId = await getUserId(c);
@@ -512,6 +598,11 @@ app.get('/api/v1/settings', async (c) => {
settings = await prisma.settings.create({ data: { userId } });
}
const oldDefaultLength = 400;
if (settings.journalPrompt && settings.journalPrompt.length > oldDefaultLength) {
settings.journalPrompt = null;
}
return c.json({ data: settings, error: null });
});
@@ -527,7 +618,7 @@ app.put('/api/v1/settings', async (c) => {
if (aiApiKey !== undefined) data.aiApiKey = aiApiKey;
if (aiModel !== undefined) data.aiModel = aiModel;
if (aiBaseUrl !== undefined) data.aiBaseUrl = aiBaseUrl;
if (journalPrompt !== undefined) data.journalPrompt = journalPrompt;
if (journalPrompt !== undefined) data.journalPrompt = journalPrompt === '' ? null : journalPrompt;
if (language !== undefined) data.language = language;
if (providerSettings !== undefined) data.providerSettings = JSON.stringify(providerSettings);
if (journalContextDays !== undefined) data.journalContextDays = journalContextDays;
@@ -589,12 +680,489 @@ app.post('/api/v1/ai/test', async (c) => {
}
});
const DEARDIARY_VERSION = '0.1.0';
const MIN_IMPORT_VERSION = '0.0.3';
interface ExportData {
version: string;
exportedAt: string;
settings: {
aiProvider: string;
aiApiKey?: string;
aiModel?: string;
aiBaseUrl?: string;
journalPrompt?: string;
language?: string;
timezone?: string;
providerSettings?: string;
journalContextDays?: number;
};
events: Array<{
id: string;
date: string;
type: string;
content: string;
mediaPath?: string;
metadata?: string;
latitude?: number;
longitude?: number;
placeName?: string;
createdAt: string;
}>;
journals: Array<{
id: string;
date: string;
title?: string;
content: string;
eventCount: number;
generatedAt: string;
}>;
tasks: Array<{
id: string;
journalId: string;
date: string;
type: string;
status: string;
provider: string;
model?: string;
prompt?: string;
request?: string;
response?: string;
error?: string;
title?: string;
createdAt: string;
completedAt?: string;
}>;
}
app.get('/api/v1/export', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const [settings, events, journals, tasks] = await Promise.all([
prisma.settings.findUnique({ where: { userId } }),
prisma.event.findMany({ where: { userId }, orderBy: { createdAt: 'asc' } }),
prisma.journal.findMany({ where: { userId }, orderBy: { date: 'asc' } }),
prisma.task.findMany({ where: { userId }, orderBy: { createdAt: 'asc' } }),
]);
const journalMap = new Map(journals.map(j => [j.id, j]));
const exportData: ExportData = {
version: DEARDIARY_VERSION,
exportedAt: new Date().toISOString(),
settings: {
aiProvider: settings?.aiProvider || 'groq',
aiApiKey: settings?.aiApiKey,
aiModel: settings?.aiModel,
aiBaseUrl: settings?.aiBaseUrl,
journalPrompt: settings?.journalPrompt,
language: settings?.language,
timezone: settings?.timezone,
providerSettings: settings?.providerSettings,
journalContextDays: settings?.journalContextDays,
},
events: events.map(e => ({
id: e.id,
date: e.date,
type: e.type,
content: e.content,
mediaPath: e.mediaPath || undefined,
metadata: e.metadata || undefined,
latitude: e.latitude ?? undefined,
longitude: e.longitude ?? undefined,
placeName: e.placeName ?? undefined,
createdAt: e.createdAt.toISOString(),
})),
journals: journals.map(j => ({
id: j.id,
date: j.date,
title: j.title || undefined,
content: j.content,
eventCount: j.eventCount,
generatedAt: j.generatedAt.toISOString(),
})),
tasks: tasks.map(t => {
const journal = journalMap.get(t.journalId);
return {
id: t.id,
journalId: t.journalId,
date: journal?.date || '',
type: t.type,
status: t.status,
provider: t.provider,
model: t.model || undefined,
prompt: t.prompt || undefined,
request: t.request || undefined,
response: t.response || undefined,
error: t.error || undefined,
title: t.title || undefined,
createdAt: t.createdAt.toISOString(),
completedAt: t.completedAt?.toISOString() || undefined,
};
}),
};
return c.json({ data: exportData, error: null });
});
app.post('/api/v1/import', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
let importData: ExportData;
try {
importData = await c.req.json();
} catch {
return c.json({ data: null, error: { code: 'INVALID_JSON', message: 'Invalid JSON body' } }, 400);
}
if (!importData.version || !importData.events || !importData.journals) {
return c.json({ data: null, error: { code: 'INVALID_FORMAT', message: 'Invalid export format: missing required fields' } }, 400);
}
const versionParts = importData.version.split('.');
const minParts = MIN_IMPORT_VERSION.split('.');
const versionNum = parseInt(versionParts[0]) * 10000 + parseInt(versionParts[1]) * 100 + parseInt(versionParts[2] || '0');
const minNum = parseInt(minParts[0]) * 10000 + parseInt(minParts[1]) * 100 + parseInt(minParts[2]);
const compatible = versionNum >= minNum;
if (!compatible) {
return c.json({
data: {
compatible: false,
importVersion: importData.version,
currentVersion: DEARDIARY_VERSION,
warning: `Import version ${importData.version} is older than minimum supported version ${MIN_IMPORT_VERSION}. Import may fail or lose data.`,
},
error: { code: 'VERSION_INCOMPATIBLE', message: `Import version ${importData.version} is not compatible with current version ${DEARDIARY_VERSION}` }
}, 400);
}
try {
let importedEvents = 0;
let importedJournals = 0;
let importedTasks = 0;
let skippedEvents = 0;
let skippedJournals = 0;
const idMapping = {
events: new Map<string, string>(),
journals: new Map<string, string>(),
};
for (const event of importData.events) {
const existing = await prisma.event.findFirst({
where: { userId, date: event.date, content: event.content, createdAt: new Date(event.createdAt) }
});
if (existing) {
skippedEvents++;
idMapping.events.set(event.id, existing.id);
} else {
const created = await prisma.event.create({
data: {
userId,
date: event.date,
type: event.type,
content: event.content,
mediaPath: event.mediaPath,
metadata: event.metadata,
latitude: event.latitude,
longitude: event.longitude,
placeName: event.placeName,
createdAt: new Date(event.createdAt),
}
});
idMapping.events.set(event.id, created.id);
importedEvents++;
}
}
for (const journal of importData.journals) {
const existing = await prisma.journal.findFirst({
where: { userId, date: journal.date }
});
if (existing) {
skippedJournals++;
} else {
await prisma.journal.create({
data: {
userId,
date: journal.date,
title: journal.title,
content: journal.content,
eventCount: journal.eventCount,
generatedAt: new Date(journal.generatedAt),
}
});
importedJournals++;
}
}
const journalDateMap = new Map<string, string>();
const journals = await prisma.journal.findMany({ where: { userId }, select: { id: true, date: true } });
for (const j of journals) {
journalDateMap.set(j.date, j.id);
}
for (const task of importData.tasks) {
const newJournalId = journalDateMap.get(task.date);
if (!newJournalId) continue;
const existing = await prisma.task.findFirst({
where: {
userId,
journalId: newJournalId,
provider: task.provider,
createdAt: new Date(task.createdAt),
}
});
if (!existing) {
await prisma.task.create({
data: {
userId,
journalId: newJournalId,
type: task.type,
status: task.status,
provider: task.provider,
model: task.model,
prompt: task.prompt,
request: task.request,
response: task.response,
error: task.error,
title: task.title,
createdAt: new Date(task.createdAt),
completedAt: task.completedAt ? new Date(task.completedAt) : null,
}
});
importedTasks++;
}
}
if (importData.settings) {
await prisma.settings.update({
where: { userId },
data: {
aiProvider: importData.settings.aiProvider || 'groq',
aiApiKey: importData.settings.aiApiKey,
aiModel: importData.settings.aiModel,
aiBaseUrl: importData.settings.aiBaseUrl,
journalPrompt: importData.settings.journalPrompt,
language: importData.settings.language,
timezone: importData.settings.timezone,
providerSettings: importData.settings.providerSettings,
journalContextDays: importData.settings.journalContextDays,
},
}).catch(() => {});
}
return c.json({
data: {
compatible: true,
importedEvents,
importedJournals,
importedTasks,
skippedEvents,
skippedJournals,
totalEvents: importData.events.length,
totalJournals: importData.journals.length,
totalTasks: importData.tasks.length,
},
error: null
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Import failed';
return c.json({ data: null, error: { code: 'IMPORT_FAILED', message } }, 500);
}
});
app.delete('/api/v1/account', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
try {
await prisma.user.delete({
where: { id: userId },
});
api.clearApiKey();
return c.json({ data: { deleted: true }, error: null });
} catch (err) {
const message = err instanceof Error ? err.message : 'Delete failed';
return c.json({ data: null, error: { code: 'DELETE_FAILED', message } }, 500);
}
});
app.post('/api/v1/account/reset', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
try {
await prisma.event.deleteMany({ where: { userId } });
await prisma.task.deleteMany({ where: { userId } });
await prisma.journal.deleteMany({ where: { userId } });
await prisma.settings.update({
where: { userId },
data: {
aiProvider: 'groq',
aiApiKey: null,
aiModel: 'llama-3.3-70b-versatile',
aiBaseUrl: null,
journalPrompt: null,
language: 'en',
timezone: 'UTC',
providerSettings: null,
journalContextDays: 10,
},
});
return c.json({ data: { reset: true }, error: null });
} catch (err) {
const message = err instanceof Error ? err.message : 'Reset failed';
return c.json({ data: null, error: { code: 'RESET_FAILED', message } }, 500);
}
});
app.post('/api/v1/account/password', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const body = await c.req.json();
const { currentPassword, newPassword } = body;
if (!currentPassword || !newPassword) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'currentPassword and newPassword are required' } }, 400);
}
if (newPassword.length < 6) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'New password must be at least 6 characters' } }, 400);
}
try {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'User not found' } }, 404);
}
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!valid) {
return c.json({ data: null, error: { code: 'INVALID_PASSWORD', message: 'Current password is incorrect' } }, 400);
}
const newHash = await bcrypt.hash(newPassword, 12);
await prisma.user.update({
where: { id: userId },
data: { passwordHash: newHash },
});
return c.json({ data: { changed: true }, error: null });
} catch (err) {
const message = err instanceof Error ? err.message : 'Password change failed';
return c.json({ data: null, error: { code: 'CHANGE_FAILED', message } }, 500);
}
});
app.post('/api/v1/auth/register', async (c) => {
const registrationEnabled = envVars.REGISTRATION_ENABLED !== 'false';
if (!registrationEnabled) {
return c.json({ data: null, error: { code: 'REGISTRATION_DISABLED', message: 'Registration is currently disabled' } }, 403);
}
const body = await c.req.json();
const { email, password } = body;
if (!email || !password) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'email and password are required' } }, 400);
}
if (password.length < 6) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Password must be at least 6 characters' } }, 400);
}
try {
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return c.json({ data: null, error: { code: 'EMAIL_EXISTS', message: 'Email already registered' } }, 400);
}
const passwordHash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: {
email,
passwordHash,
settings: { create: {} },
},
});
const apiKey = randomBytes(32).toString('hex');
const keyHash = createHash('sha256').update(apiKey).digest('hex');
await prisma.apiKey.create({
data: { userId: user.id, keyHash, name: 'Default' },
});
return c.json({ data: { apiKey, userId: user.id }, error: null }, 201);
} catch (err) {
const message = err instanceof Error ? err.message : 'Registration failed';
return c.json({ data: null, error: { code: 'REGISTRATION_FAILED', message } }, 500);
}
});
app.notFound((c) => c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Not found' } }, 404));
app.onError((err, c) => {
console.error('Unhandled error:', err);
return c.json({ data: null, error: { code: 'INTERNAL_ERROR', message: 'Internal server error' } }, 500);
});
async function setupFTS() {
try {
await prisma.$executeRaw`CREATE VIRTUAL TABLE IF NOT EXISTS journal_fts USING fts5(
userId UNINDEXED,
date UNINDEXED,
title,
content,
tokenize='porter unicode61'
)`;
await prisma.$executeRaw`CREATE VIRTUAL TABLE IF NOT EXISTS event_fts USING fts5(
userId UNINDEXED,
date UNINDEXED,
type UNINDEXED,
content,
tokenize='porter unicode61'
)`;
console.log('FTS tables ready');
} catch (err) {
console.error('FTS setup error:', err);
}
}
async function rebuildFTS() {
try {
const journals = await prisma.journal.findMany();
await prisma.$executeRaw`DELETE FROM journal_fts`;
for (const j of journals) {
await prisma.$executeRaw`INSERT INTO journal_fts(userId, date, title, content) VALUES (${j.userId}, ${j.date}, ${j.title || ''}, ${j.content})`;
}
const events = await prisma.event.findMany();
await prisma.$executeRaw`DELETE FROM event_fts`;
for (const e of events) {
await prisma.$executeRaw`INSERT INTO event_fts(userId, date, type, content) VALUES (${e.userId}, ${e.date}, ${e.type}, ${e.content})`;
}
console.log('FTS indexes rebuilt');
} catch (err) {
console.error('FTS rebuild error:', err);
}
}
async function createDefaultUser() {
const defaultEmail = envVars.DEFAULT_USER_EMAIL;
const defaultPassword = envVars.DEFAULT_USER_PASSWORD;
@@ -647,7 +1215,11 @@ async function createDefaultUser() {
const port = parseInt(envVars.PORT || '3000', 10);
console.log(`Starting DearDiary API on port ${port}`);
createDefaultUser().then(() => {
setupFTS().then(() => {
return rebuildFTS();
}).then(() => {
return createDefaultUser();
}).then(() => {
console.log('Server ready');
});

View File

@@ -9,7 +9,7 @@ eventsRoutes.post('/', async (c) => {
const mediaDir = c.env.MEDIA_DIR || './data/media';
const body = await c.req.json();
const { date, type, content, metadata } = body;
const { date, type, content, metadata, latitude, longitude, placeName } = body;
if (!date || !type || !content) {
return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'date, type, and content are required' } }, 400);
@@ -27,9 +27,18 @@ eventsRoutes.post('/', async (c) => {
type,
content,
metadata: metadata ? JSON.stringify(metadata) : null,
latitude: latitude ?? null,
longitude: longitude ?? null,
placeName: placeName ?? null,
},
});
try {
await prisma.$executeRaw`INSERT INTO event_fts(userId, date, type, content) VALUES (${userId}, ${date}, ${type}, ${content})`;
} catch (e) {
console.error('FTS index error:', e);
}
return c.json({ data: event, error: null }, 201);
});
@@ -78,6 +87,13 @@ eventsRoutes.put('/:id', async (c) => {
},
});
try {
await prisma.$executeRaw`DELETE FROM event_fts WHERE rowid = (SELECT rowid FROM event_fts WHERE userId = ${userId} AND date = ${existing.date} AND content = ${existing.content} LIMIT 1)`;
await prisma.$executeRaw`INSERT INTO event_fts(userId, date, type, content) VALUES (${userId}, ${event.date}, ${event.type}, ${event.content})`;
} catch (e) {
console.error('FTS index error:', e);
}
return c.json({ data: event, error: null });
});
@@ -129,15 +145,16 @@ eventsRoutes.post('/:id/photo', async (c) => {
const fileName = `${id}.${ext}`;
const userMediaDir = `${mediaDir}/${userId}/${event.date}`;
const filePath = `${userMediaDir}/${fileName}`;
const mediaUrl = `/media/${userId}/${event.date}/${fileName}`;
await Bun.write(filePath, file);
await prisma.event.update({
where: { id },
data: { mediaPath: filePath },
data: { mediaPath: mediaUrl },
});
return c.json({ data: { mediaPath: filePath }, error: null }, 201);
return c.json({ data: { mediaPath: mediaUrl }, error: null }, 201);
});
eventsRoutes.post('/:id/voice', async (c) => {
@@ -164,13 +181,14 @@ eventsRoutes.post('/:id/voice', async (c) => {
const fileName = `${id}.webm`;
const userMediaDir = `${mediaDir}/${userId}/${event.date}`;
const filePath = `${userMediaDir}/${fileName}`;
const mediaUrl = `/media/${userId}/${event.date}/${fileName}`;
await Bun.write(filePath, file);
await prisma.event.update({
where: { id },
data: { mediaPath: filePath },
data: { mediaPath: mediaUrl },
});
return c.json({ data: { mediaPath: filePath }, error: null }, 201);
return c.json({ data: { mediaPath: mediaUrl }, error: null }, 201);
});

View File

@@ -12,8 +12,8 @@ export class AnthropicProvider implements AIProvider {
this.baseUrl = config.baseUrl || 'https://api.anthropic.com/v1';
}
async generate(prompt: string, systemPrompt?: string): Promise<AIProviderResult> {
const requestBody = {
async generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise<AIProviderResult> {
const requestBody: Record<string, unknown> = {
model: this.model,
max_tokens: 2000,
system: systemPrompt,
@@ -22,6 +22,10 @@ export class AnthropicProvider implements AIProvider {
],
};
if (options?.jsonMode) {
requestBody.output = { format: { type: 'json_object' } };
}
const response = await fetch(`${this.baseUrl}/messages`, {
method: 'POST',
headers: {
@@ -39,10 +43,22 @@ export class AnthropicProvider implements AIProvider {
throw new Error(`Anthropic API error: ${response.status} ${JSON.stringify(responseData)}`);
}
const content = responseData.content?.[0]?.text || '';
let content = responseData.content?.[0]?.text || '';
let title: string | undefined;
if (options?.jsonMode) {
try {
const parsed = JSON.parse(content);
title = parsed.title;
content = parsed.content || content;
} catch {
// If JSON parsing fails, use content as-is
}
}
return {
content,
title,
request: requestBody,
response: responseData,
};

View File

@@ -12,7 +12,7 @@ export class GroqProvider implements AIProvider {
this.baseUrl = config.baseUrl || 'https://api.groq.com/openai/v1';
}
async generate(prompt: string, systemPrompt?: string): Promise<AIProviderResult> {
async generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise<AIProviderResult> {
const messages: Array<{ role: string; content: string }> = [];
if (systemPrompt) {
@@ -21,13 +21,17 @@ export class GroqProvider implements AIProvider {
messages.push({ role: 'user', content: prompt });
const requestBody = {
const requestBody: Record<string, unknown> = {
model: this.model,
messages,
temperature: 0.7,
max_tokens: 2000,
};
if (options?.jsonMode) {
requestBody.response_format = { type: 'json_object' };
}
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
@@ -44,10 +48,22 @@ export class GroqProvider implements AIProvider {
}
const responseData = JSON.parse(responseText);
const content = responseData.choices?.[0]?.message?.content || '';
let content = responseData.choices?.[0]?.message?.content || '';
let title: string | undefined;
if (options?.jsonMode) {
try {
const parsed = JSON.parse(content);
title = parsed.title;
content = parsed.content || content;
} catch {
// If JSON parsing fails, use content as-is
}
}
return {
content,
title,
request: requestBody,
response: responseData,
};

View File

@@ -10,7 +10,7 @@ export class LMStudioProvider implements AIProvider {
this.model = config.model || 'local-model';
}
async generate(prompt: string, systemPrompt?: string): Promise<AIProviderResult> {
async generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise<AIProviderResult> {
const messages: Array<{ role: string; content: string }> = [];
if (systemPrompt) {
@@ -40,10 +40,22 @@ export class LMStudioProvider implements AIProvider {
throw new Error(`LM Studio API error: ${response.status} ${JSON.stringify(responseData)}`);
}
const content = responseData.choices?.[0]?.message?.content || '';
let content = responseData.choices?.[0]?.message?.content || '';
let title: string | undefined;
if (options?.jsonMode) {
try {
const parsed = JSON.parse(content);
title = parsed.title;
content = parsed.content || content;
} catch {
// If JSON parsing fails, use content as-is
}
}
return {
content,
title,
request: requestBody,
response: responseData,
};

View File

@@ -10,7 +10,7 @@ export class OllamaProvider implements AIProvider {
this.model = config.model || 'llama3.2';
}
async generate(prompt: string, systemPrompt?: string): Promise<AIProviderResult> {
async generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise<AIProviderResult> {
const requestBody = {
model: this.model,
stream: false,
@@ -34,10 +34,22 @@ export class OllamaProvider implements AIProvider {
throw new Error(`Ollama API error: ${response.status} ${JSON.stringify(responseData)}`);
}
const content = responseData.message?.content || '';
let content = responseData.message?.content || '';
let title: string | undefined;
if (options?.jsonMode) {
try {
const parsed = JSON.parse(content);
title = parsed.title;
content = parsed.content || content;
} catch {
// If JSON parsing fails, use content as-is
}
}
return {
content,
title,
request: requestBody,
response: responseData,
};

View File

@@ -12,7 +12,7 @@ export class OpenAIProvider implements AIProvider {
this.baseUrl = config.baseUrl || 'https://api.openai.com/v1';
}
async generate(prompt: string, systemPrompt?: string): Promise<AIProviderResult> {
async generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise<AIProviderResult> {
const messages: Array<{ role: string; content: string }> = [];
if (systemPrompt) {
@@ -21,13 +21,17 @@ export class OpenAIProvider implements AIProvider {
messages.push({ role: 'user', content: prompt });
const requestBody = {
const requestBody: Record<string, unknown> = {
model: this.model,
messages,
temperature: 0.7,
max_tokens: 2000,
};
if (options?.jsonMode) {
requestBody.response_format = { type: 'json_object' };
}
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
@@ -43,10 +47,22 @@ export class OpenAIProvider implements AIProvider {
throw new Error(`OpenAI API error: ${response.status} ${JSON.stringify(responseData)}`);
}
const content = responseData.choices?.[0]?.message?.content || '';
let content = responseData.choices?.[0]?.message?.content || '';
let title: string | undefined;
if (options?.jsonMode) {
try {
const parsed = JSON.parse(content);
title = parsed.title;
content = parsed.content || content;
} catch {
// If JSON parsing fails, use content as-is
}
}
return {
content,
title,
request: requestBody,
response: responseData,
};

View File

@@ -1,12 +1,13 @@
export interface AIProviderResult {
content: string;
title?: string;
request: Record<string, unknown>;
response: Record<string, unknown>;
}
export interface AIProvider {
provider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio' | 'groq';
generate(prompt: string, systemPrompt?: string): Promise<AIProviderResult>;
generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise<AIProviderResult>;
validate?(): Promise<boolean>;
}