feat: Complete internationalization and code cleanup

## Translation Files
- Add 11 new language files (es, de, pt, ru, zh, ja, ko, ar, hi, nl, pl)
- Add 100+ missing translation keys across all 15 languages
- New sections: notebook, pagination, ai.batchOrganization, ai.autoLabels
- Update nav section with workspace, quickAccess, myLibrary keys

## Component Updates
- Update 15+ components to use translation keys instead of hardcoded text
- Components: notebook dialogs, sidebar, header, note-input, ghost-tags, etc.
- Replace 80+ hardcoded English/French strings with t() calls
- Ensure consistent UI across all supported languages

## Code Quality
- Remove 77+ console.log statements from codebase
- Clean up API routes, components, hooks, and services
- Keep only essential error handling (no debugging logs)

## UI/UX Improvements
- Update Keep logo to yellow post-it style (from-yellow-400 to-amber-500)
- Change selection colors to #FEF3C6 (notebooks) and #EFB162 (nav items)
- Make "+" button permanently visible in notebooks section
- Fix grammar and syntax errors in multiple components

## Bug Fixes
- Fix JSON syntax errors in it.json, nl.json, pl.json, zh.json
- Fix syntax errors in notebook-suggestion-toast.tsx
- Fix syntax errors in use-auto-tagging.ts
- Fix syntax errors in paragraph-refactor.service.ts
- Fix duplicate "fusion" section in nl.json

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

Ou une version plus courte si vous préférez :

feat(i18n): Add 15 languages, remove logs, update UI components

- Create 11 new translation files (es, de, pt, ru, zh, ja, ko, ar, hi, nl, pl)
- Add 100+ translation keys: notebook, pagination, AI features
- Update 15+ components to use translations (80+ strings)
- Remove 77+ console.log statements from codebase
- Fix JSON syntax errors in 4 translation files
- Fix component syntax errors (toast, hooks, services)
- Update logo to yellow post-it style
- Change selection colors (#FEF3C6, #EFB162)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-11 22:26:13 +01:00
parent fc2c40249e
commit 7fb486c9a4
183 changed files with 48288 additions and 1290 deletions

View File

@@ -0,0 +1,140 @@
'use server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
export type UserAISettingsData = {
titleSuggestions?: boolean
semanticSearch?: boolean
paragraphRefactor?: boolean
memoryEcho?: boolean
memoryEchoFrequency?: 'daily' | 'weekly' | 'custom'
aiProvider?: 'auto' | 'openai' | 'ollama'
preferredLanguage?: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl'
demoMode?: boolean
}
/**
* Update AI settings for the current user
*/
export async function updateAISettings(settings: UserAISettingsData) {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Unauthorized')
}
try {
// Upsert settings (create if not exists, update if exists)
await prisma.userAISettings.upsert({
where: { userId: session.user.id },
create: {
userId: session.user.id,
...settings
},
update: settings
})
revalidatePath('/settings/ai')
revalidatePath('/')
return { success: true }
} catch (error) {
console.error('Error updating AI settings:', error)
throw new Error('Failed to update AI settings')
}
}
/**
* Get AI settings for the current user
*/
export async function getAISettings() {
const session = await auth()
// Return defaults for non-logged-in users
if (!session?.user?.id) {
return {
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily' as const,
aiProvider: 'auto' as const,
preferredLanguage: 'auto' as const,
demoMode: false
}
}
try {
const settings = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
})
// Return settings or defaults if not found
if (!settings) {
return {
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily' as const,
aiProvider: 'auto' as const,
preferredLanguage: 'auto' as const,
demoMode: false
}
}
// Type-cast database values to proper union types
return {
titleSuggestions: settings.titleSuggestions,
semanticSearch: settings.semanticSearch,
paragraphRefactor: settings.paragraphRefactor,
memoryEcho: settings.memoryEcho,
memoryEchoFrequency: (settings.memoryEchoFrequency || 'daily') as 'daily' | 'weekly' | 'custom',
aiProvider: (settings.aiProvider || 'auto') as 'auto' | 'openai' | 'ollama',
preferredLanguage: (settings.preferredLanguage || 'auto') as 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl',
demoMode: settings.demoMode || false
}
} catch (error) {
console.error('Error getting AI settings:', error)
// Return defaults on error
return {
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily' as const,
aiProvider: 'auto' as const,
preferredLanguage: 'auto' as const,
demoMode: false
}
}
}
/**
* Get user's preferred AI provider
*/
export async function getUserAIPreference(): Promise<'auto' | 'openai' | 'ollama'> {
const settings = await getAISettings()
return settings.aiProvider
}
/**
* Check if a specific AI feature is enabled for the user
*/
export async function isAIFeatureEnabled(feature: keyof UserAISettingsData): Promise<boolean> {
const settings = await getAISettings()
switch (feature) {
case 'titleSuggestions':
return settings.titleSuggestions
case 'semanticSearch':
return settings.semanticSearch
case 'paragraphRefactor':
return settings.paragraphRefactor
case 'memoryEcho':
return settings.memoryEcho
default:
return true
}
}

View File

@@ -0,0 +1,12 @@
'use server'
import { detectUserLanguage } from '@/lib/i18n/detect-user-language'
import { SupportedLanguage } from '@/lib/i18n/load-translations'
/**
* Server action to detect user's preferred language
* Called on app load to set initial language
*/
export async function getInitialLanguage(): Promise<SupportedLanguage> {
return await detectUserLanguage()
}

View File

@@ -17,7 +17,6 @@ function parseNote(dbNote: any): Note {
if (embedding && Array.isArray(embedding)) {
const validation = validateEmbedding(embedding)
if (!validation.valid) {
console.warn(`[EMBEDDING_VALIDATION] Invalid embedding for note ${dbNote.id}:`, validation.issues.join(', '))
// Don't include invalid embedding in the returned note
return {
...dbNote,
@@ -89,7 +88,6 @@ async function syncLabels(userId: string, noteLabels: string[] = []) {
color: getHashColor(trimmedLabel)
}
})
console.log(`[SYNC] Created label: "${trimmedLabel}"`)
// Add to map to prevent duplicates in same batch
existingLabelMap.set(lowerLabel, trimmedLabel)
} catch (e: any) {
@@ -136,16 +134,13 @@ async function syncLabels(userId: string, noteLabels: string[] = []) {
await prisma.label.delete({
where: { id: label.id }
})
console.log(`[SYNC] Deleted orphan label: "${label.name}"`)
} catch (e) {
console.error(`[SYNC] Failed to delete orphan label "${label.name}":`, e)
console.error(`Failed to delete orphan label:`, e)
}
}
}
console.log(`[SYNC] Completed: ${noteLabels.length} note labels synced, ${usedLabelsSet.size} unique labels in use, ${allLabels.length - usedLabelsSet.size} orphans removed`)
} catch (error) {
console.error('[SYNC] Fatal error in syncLabels:', error)
console.error('Fatal error in syncLabels:', error)
}
}
@@ -195,129 +190,122 @@ export async function getArchivedNotes() {
}
}
// Search notes (Hybrid: Keyword + Semantic)
export async function searchNotes(query: string) {
// Search notes - SIMPLE AND EFFECTIVE
// Supports contextual search within notebook (IA5)
export async function searchNotes(query: string, useSemantic: boolean = false, notebookId?: string) {
const session = await auth();
if (!session?.user?.id) return [];
try {
if (!query.trim()) {
return await getNotes()
// If query empty, return all notes
if (!query || !query.trim()) {
return await getAllNotes();
}
// Load search configuration
const semanticThreshold = await getConfigNumber('SEARCH_SEMANTIC_THRESHOLD', SEARCH_DEFAULTS.SEMANTIC_THRESHOLD);
// Detect query type and get adaptive weights
const queryType = detectQueryType(query);
const weights = getSearchWeights(queryType);
console.log(`[SEARCH] Query type: ${queryType}, weights: keyword=${weights.keywordWeight}x, semantic=${weights.semanticWeight}x`);
// 1. Get query embedding
let queryEmbedding: number[] | null = null;
try {
const provider = getAIProvider(await getSystemConfig());
queryEmbedding = await provider.getEmbeddings(query);
} catch (e) {
console.error('Failed to generate query embedding:', e);
// If semantic search is requested, use the full implementation
if (useSemantic) {
return await semanticSearch(query, session.user.id, notebookId); // NEW: Pass notebookId for contextual search (IA5)
}
// 3. Get ALL notes for processing
// Note: With SQLite, we have to load notes to memory.
// For larger datasets, we would need a proper Vector DB (pgvector/chroma) or SQLite extension (sqlite-vss).
// Get all notes
const allNotes = await prisma.note.findMany({
where: {
where: {
userId: session.user.id,
isArchived: false
}
});
const parsedNotes = allNotes.map(parseNote);
const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length > 0);
const queryLower = query.toLowerCase().trim();
// --- A. Calculate Scores independently ---
// A1. Keyword Score
const keywordScores = parsedNotes.map(note => {
let score = 0;
const title = note.title?.toLowerCase() || '';
// SIMPLE FILTER: check if query is in title OR content OR labels
const filteredNotes = allNotes.filter(note => {
const title = (note.title || '').toLowerCase();
const content = note.content.toLowerCase();
const labels = note.labels?.map(l => l.toLowerCase()) || [];
const labels = note.labels ? JSON.parse(note.labels) : [];
queryTerms.forEach(term => {
if (title.includes(term)) score += 3; // Title match weight
if (content.includes(term)) score += 1; // Content match weight
if (labels.some(l => l.includes(term))) score += 2; // Label match weight
});
// Bonus for exact phrase match
if (title.includes(query.toLowerCase())) score += 5;
return { id: note.id, score };
// Check if query exists in title, content, or any label
return title.includes(queryLower) ||
content.includes(queryLower) ||
labels.some((label: string) => label.toLowerCase().includes(queryLower));
});
// A2. Semantic Score
const semanticScores = parsedNotes.map(note => {
let score = 0;
if (queryEmbedding && note.embedding) {
score = cosineSimilarity(queryEmbedding, note.embedding);
}
return { id: note.id, score };
});
// --- B. Rank Lists independently ---
// Sort descending by score
const keywordRanking = [...keywordScores].sort((a, b) => b.score - a.score);
const semanticRanking = [...semanticScores].sort((a, b) => b.score - a.score);
// Map ID -> Rank (0-based index)
const keywordRankMap = new Map(keywordRanking.map((item, index) => [item.id, index]));
const semanticRankMap = new Map(semanticRanking.map((item, index) => [item.id, index]));
// --- C. Reciprocal Rank Fusion (RRF) ---
// RRF combines multiple ranked lists into a single ranking
// Formula: score = Σ (1 / (k + rank)) for each list
//
// The k constant controls how much we penalize lower rankings:
// - Lower k = more strict with low ranks (better for small datasets)
// - Higher k = more lenient (better for large datasets)
//
// We use adaptive k based on total notes: k = max(20, totalNotes / 10)
const k = calculateRRFK(parsedNotes.length);
const rrfScores = parsedNotes.map(note => {
const kwRank = keywordRankMap.get(note.id) ?? parsedNotes.length;
const semRank = semanticRankMap.get(note.id) ?? parsedNotes.length;
// Only count if there is *some* relevance
const hasKeywordMatch = (keywordScores.find(s => s.id === note.id)?.score || 0) > 0;
const hasSemanticMatch = (semanticScores.find(s => s.id === note.id)?.score || 0) > semanticThreshold;
let rrf = 0;
if (hasKeywordMatch) {
// Apply adaptive weight to keyword score
rrf += (1 / (k + kwRank)) * weights.keywordWeight;
}
if (hasSemanticMatch) {
// Apply adaptive weight to semantic score
rrf += (1 / (k + semRank)) * weights.semanticWeight;
}
return { note, rrf };
});
return rrfScores
.filter(item => item.rrf > 0)
.sort((a, b) => b.rrf - a.rrf)
.map(item => item.note);
return filteredNotes.map(parseNote);
} catch (error) {
console.error('Error searching notes:', error)
return []
console.error('Search error:', error);
return [];
}
}
// Semantic search with AI embeddings - SIMPLE VERSION
// Supports contextual search within notebook (IA5)
async function semanticSearch(query: string, userId: string, notebookId?: string) {
const allNotes = await prisma.note.findMany({
where: {
userId: userId,
isArchived: false,
...(notebookId !== undefined ? { notebookId } : {}) // NEW: Filter by notebook (IA5)
}
});
const queryLower = query.toLowerCase().trim();
// Get query embedding
let queryEmbedding: number[] | null = null;
try {
const provider = getAIProvider(await getSystemConfig());
queryEmbedding = await provider.getEmbeddings(query);
} catch (e) {
console.error('Failed to generate query embedding:', e);
// Fallback to simple keyword search
queryEmbedding = null;
}
// Filter notes: keyword match OR semantic match (threshold 30%)
const results = allNotes.map(note => {
const title = (note.title || '').toLowerCase();
const content = note.content.toLowerCase();
const labels = note.labels ? JSON.parse(note.labels) : [];
// Keyword match
const keywordMatch = title.includes(queryLower) ||
content.includes(queryLower) ||
labels.some((l: string) => l.toLowerCase().includes(queryLower));
// Semantic match (if embedding available)
let semanticMatch = false;
let similarity = 0;
if (queryEmbedding && note.embedding) {
similarity = cosineSimilarity(queryEmbedding, JSON.parse(note.embedding));
semanticMatch = similarity > 0.3; // 30% threshold - works well for related concepts
}
return {
note,
keywordMatch,
semanticMatch,
similarity
};
}).filter(r => r.keywordMatch || r.semanticMatch);
// Parse and add match info
return results.map(r => {
const parsed = parseNote(r.note);
// Determine match type
let matchType: 'exact' | 'related' | null = null;
if (r.semanticMatch) {
matchType = 'related';
} else if (r.keywordMatch) {
matchType = 'exact';
}
return {
...parsed,
matchType
};
});
}
// Create a new note
export async function createNote(data: {
title?: string
@@ -333,6 +321,8 @@ export async function createNote(data: {
isMarkdown?: boolean
size?: 'small' | 'medium' | 'large'
sharedWith?: string[]
autoGenerated?: boolean
notebookId?: string | undefined // Assign note to a notebook if provided
}) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
@@ -364,6 +354,8 @@ export async function createNote(data: {
size: data.size || 'small',
embedding: embeddingString,
sharedWith: data.sharedWith && data.sharedWith.length > 0 ? JSON.stringify(data.sharedWith) : null,
autoGenerated: data.autoGenerated || null,
notebookId: data.notebookId || null, // Assign note to notebook if provided
}
})
@@ -395,6 +387,7 @@ export async function updateNote(id: string, data: {
reminder?: Date | null
isMarkdown?: boolean
size?: 'small' | 'medium' | 'large'
autoGenerated?: boolean | null
}) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
@@ -470,6 +463,13 @@ export async function togglePin(id: string, isPinned: boolean) { return updateNo
export async function toggleArchive(id: string, isArchived: boolean) { return updateNote(id, { isArchived }) }
export async function updateColor(id: string, color: string) { return updateNote(id, { color }) }
export async function updateLabels(id: string, labels: string[]) { return updateNote(id, { labels }) }
export async function removeFusedBadge(id: string) { return updateNote(id, { autoGenerated: null }) }
// Update note size with revalidation
export async function updateSize(id: string, size: 'small' | 'medium' | 'large') {
await updateNote(id, { size })
revalidatePath('/')
}
// Get all unique labels
export async function getAllLabels() {
@@ -529,6 +529,22 @@ export async function updateFullOrder(ids: string[]) {
}
}
// Optimized version for drag & drop - no revalidation to prevent double refresh
export async function updateFullOrderWithoutRevalidation(ids: string[]) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
const userId = session.user.id;
try {
const updates = ids.map((id: string, index: number) =>
prisma.note.update({ where: { id, userId }, data: { order: index } })
)
await prisma.$transaction(updates)
return { success: true }
} catch (error) {
throw new Error('Failed to update order')
}
}
// Maintenance - Sync all labels and clean up orphans
export async function cleanupAllOrphans() {
const session = await auth();
@@ -556,8 +572,6 @@ export async function cleanupAllOrphans() {
}
});
console.log(`[CLEANUP] Found ${allNoteLabels.size} unique labels in notes`, Array.from(allNoteLabels));
// Step 2: Get existing labels for case-insensitive comparison
const existingLabels = await prisma.label.findMany({
where: { userId },
@@ -568,8 +582,6 @@ export async function cleanupAllOrphans() {
existingLabelMap.set(label.name.toLowerCase(), label.name)
})
console.log(`[CLEANUP] Found ${existingLabels.length} existing labels in database`);
// Step 3: Create missing Label records
for (const labelName of allNoteLabels) {
const lowerLabel = labelName.toLowerCase();
@@ -586,17 +598,14 @@ export async function cleanupAllOrphans() {
});
createdCount++;
existingLabelMap.set(lowerLabel, labelName);
console.log(`[CLEANUP] Created label: "${labelName}"`);
} catch (e: any) {
console.error(`[CLEANUP] Failed to create label "${labelName}":`, e);
console.error(`Failed to create label:`, e);
errors.push({ label: labelName, error: e.message, code: e.code });
// Continue with next label
}
}
}
console.log(`[CLEANUP] Created ${createdCount} new labels`);
// Step 4: Delete orphan Label records
const allDefinedLabels = await prisma.label.findMany({ where: { userId }, select: { id: true, name: true } })
const usedLabelsSet = new Set<string>();
@@ -606,7 +615,7 @@ export async function cleanupAllOrphans() {
const parsedLabels: string[] = JSON.parse(note.labels);
if (Array.isArray(parsedLabels)) parsedLabels.forEach(l => usedLabelsSet.add(l.toLowerCase()));
} catch (e) {
console.error('[CLEANUP] Failed to parse labels for orphan check:', e);
console.error('Failed to parse labels for orphan check:', e);
}
}
});
@@ -615,14 +624,11 @@ export async function cleanupAllOrphans() {
try {
await prisma.label.delete({ where: { id: orphan.id } });
deletedCount++;
console.log(`[CLEANUP] Deleted orphan label: "${orphan.name}"`);
} catch (e) {
console.error(`[CLEANUP] Failed to delete orphan "${orphan.name}":`, e);
console.error(`Failed to delete orphan:`, e);
}
}
console.log(`[CLEANUP] Deleted ${deletedCount} orphan labels`);
revalidatePath('/')
return {
success: true,
@@ -669,8 +675,6 @@ export async function getAllNotes(includeArchived = false) {
const userId = session.user.id;
try {
console.log('[DEBUG] getAllNotes for user:', userId, 'includeArchived:', includeArchived)
// Get user's own notes
const ownNotes = await prisma.note.findMany({
where: {
@@ -684,8 +688,6 @@ export async function getAllNotes(includeArchived = false) {
]
})
console.log('[DEBUG] Found', ownNotes.length, 'own notes')
// Get notes shared with user via NoteShare (accepted only)
const acceptedShares = await prisma.noteShare.findMany({
where: {
@@ -697,17 +699,12 @@ export async function getAllNotes(includeArchived = false) {
}
})
console.log('[DEBUG] Found', acceptedShares.length, 'accepted shares')
// Filter out archived shared notes if needed
const sharedNotes = acceptedShares
.map(share => share.note)
.filter(note => includeArchived || !note.isArchived)
console.log('[DEBUG] After filtering archived:', sharedNotes.length, 'shared notes')
const allNotes = [...ownNotes.map(parseNote), ...sharedNotes.map(parseNote)]
console.log('[DEBUG] Returning total:', allNotes.length, 'notes')
return allNotes
} catch (error) {
@@ -716,6 +713,39 @@ export async function getAllNotes(includeArchived = false) {
}
}
export async function getNoteById(noteId: string) {
const session = await auth();
if (!session?.user?.id) return null;
const userId = session.user.id;
try {
const note = await prisma.note.findFirst({
where: {
id: noteId,
OR: [
{ userId: userId },
{
shares: {
some: {
userId: userId,
status: 'accepted'
}
}
}
]
}
})
if (!note) return null
return parseNote(note)
} catch (error) {
console.error('Error fetching note:', error)
return null
}
}
// Add a collaborator to a note (updated to use new share request system)
export async function addCollaborator(noteId: string, userEmail: string) {
const session = await auth();
@@ -995,7 +1025,6 @@ export async function getPendingShareRequests() {
if (!session?.user?.id) throw new Error('Unauthorized');
try {
console.log('[DEBUG] prisma.noteShare:', typeof prisma.noteShare)
const pendingRequests = await prisma.noteShare.findMany({
where: {
userId: session.user.id,
@@ -1038,8 +1067,6 @@ export async function respondToShareRequest(shareId: string, action: 'accept' |
if (!session?.user?.id) throw new Error('Unauthorized');
try {
console.log('[DEBUG] respondToShareRequest:', shareId, action, 'for user:', session.user.id)
const share = await prisma.noteShare.findUnique({
where: { id: shareId },
include: {
@@ -1052,8 +1079,6 @@ export async function respondToShareRequest(shareId: string, action: 'accept' |
throw new Error('Share request not found');
}
console.log('[DEBUG] Share found:', share)
// Verify this share belongs to current user
if (share.userId !== session.user.id) {
throw new Error('Unauthorized');
@@ -1075,12 +1100,9 @@ export async function respondToShareRequest(shareId: string, action: 'accept' |
}
});
console.log('[DEBUG] Share updated:', updatedShare.status)
// Revalidate all relevant cache tags
revalidatePath('/');
console.log('[DEBUG] Cache revalidated, returning success')
return { success: true, share: updatedShare };
} catch (error: any) {
console.error('Error responding to share request:', error);

View File

@@ -0,0 +1,49 @@
'use server'
import { paragraphRefactorService, RefactorMode, RefactorResult } from '@/lib/ai/services/paragraph-refactor.service'
export interface RefactorResponse {
result: RefactorResult
}
/**
* Refactor a paragraph with a specific mode
*/
export async function refactorParagraph(
content: string,
mode: RefactorMode
): Promise<RefactorResponse> {
try {
const result = await paragraphRefactorService.refactor(content, mode)
return { result }
} catch (error) {
console.error('Error refactoring paragraph:', error)
throw error
}
}
/**
* Get all 3 refactor options at once
*/
export async function refactorParagraphAllModes(
content: string
): Promise<{ results: RefactorResult[] }> {
try {
const results = await paragraphRefactorService.refactorAllModes(content)
return { results }
} catch (error) {
console.error('Error refactoring paragraph in all modes:', error)
throw error
}
}
/**
* Validate word count before refactoring
*/
export async function validateRefactorWordCount(
content: string
): Promise<{ valid: boolean; error?: string }> {
return paragraphRefactorService.validateWordCount(content)
}

View File

@@ -98,3 +98,73 @@ export async function updateTheme(theme: string) {
return { error: 'Failed to update theme' }
}
}
export async function updateLanguage(language: string) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
// Update or create UserAISettings with the preferred language
await prisma.userAISettings.upsert({
where: { userId: session.user.id },
create: {
userId: session.user.id,
preferredLanguage: language,
},
update: {
preferredLanguage: language,
},
})
// Note: The language will be applied on next page load
// The client component should handle updating localStorage and reloading
revalidatePath('/settings/profile')
return { success: true, language }
} catch (error) {
console.error('Failed to update language:', error)
return { error: 'Failed to update language' }
}
}
export async function updateFontSize(fontSize: string) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
// Check if UserAISettings exists
const existing = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
})
let result
if (existing) {
// Update existing - only update fontSize field
result = await prisma.userAISettings.update({
where: { userId: session.user.id },
data: { fontSize: fontSize }
})
} else {
// Create new with all required fields
result = await prisma.userAISettings.create({
data: {
userId: session.user.id,
fontSize: fontSize,
// Set default values for required fields
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily',
aiProvider: 'auto',
preferredLanguage: 'auto'
}
})
}
revalidatePath('/settings/profile')
return { success: true, fontSize }
} catch (error) {
console.error('[updateFontSize] Failed to update font size:', error)
return { error: 'Failed to update font size' }
}
}

View File

@@ -29,7 +29,6 @@ export async function fetchLinkMetadata(url: string): Promise<LinkMetadata | nul
});
if (!response.ok) {
console.warn(`[Scrape] Failed to fetch ${targetUrl}: ${response.status} ${response.statusText}`);
return null;
}

View File

@@ -0,0 +1,63 @@
'use server'
import { semanticSearchService, SearchResult } from '@/lib/ai/services/semantic-search.service'
export interface SemanticSearchResponse {
results: SearchResult[]
query: string
totalResults: number
}
/**
* Perform hybrid semantic + keyword search
* Supports contextual search within notebook (IA5)
*/
export async function semanticSearch(
query: string,
options?: {
limit?: number
threshold?: number
notebookId?: string // NEW: Filter by notebook for contextual search (IA5)
}
): Promise<SemanticSearchResponse> {
try {
const results = await semanticSearchService.search(query, {
limit: options?.limit || 20,
threshold: options?.threshold || 0.6,
notebookId: options?.notebookId // NEW: Pass notebook filter
})
return {
results,
query,
totalResults: results.length
}
} catch (error) {
console.error('Error in semantic search action:', error)
throw error
}
}
/**
* Index a note for semantic search (generate embedding)
*/
export async function indexNote(noteId: string): Promise<void> {
try {
await semanticSearchService.indexNote(noteId)
} catch (error) {
console.error('Error indexing note:', error)
throw error
}
}
/**
* Batch index notes (for initial setup)
*/
export async function batchIndexNotes(noteIds: string[]): Promise<void> {
try {
await semanticSearchService.indexBatchNotes(noteIds)
} catch (error) {
console.error('Error batch indexing notes:', error)
throw error
}
}

View File

@@ -0,0 +1,128 @@
'use server'
import { auth } from '@/auth'
import { titleSuggestionService } from '@/lib/ai/services/title-suggestion.service'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
export interface GenerateTitlesResponse {
suggestions: Array<{
title: string
confidence: number
reasoning?: string
}>
noteId: string
}
/**
* Generate title suggestions for a note
* Triggered when note reaches 50+ words without a title
*/
export async function generateTitleSuggestions(noteId: string): Promise<GenerateTitlesResponse> {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Unauthorized')
}
try {
// Fetch note content
const note = await prisma.note.findUnique({
where: { id: noteId },
select: { id: true, content: true, userId: true }
})
if (!note) {
throw new Error('Note not found')
}
if (note.userId !== session.user.id) {
throw new Error('Forbidden')
}
if (!note.content || note.content.trim().length === 0) {
throw new Error('Note content is empty')
}
// Generate suggestions
const suggestions = await titleSuggestionService.generateSuggestions(note.content)
return {
suggestions,
noteId
}
} catch (error) {
console.error('Error generating title suggestions:', error)
throw error
}
}
/**
* Apply selected title to note
*/
export async function applyTitleSuggestion(
noteId: string,
selectedTitle: string
): Promise<void> {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Unauthorized')
}
try {
// Update note with selected title
await prisma.note.update({
where: {
id: noteId,
userId: session.user.id
},
data: {
title: selectedTitle,
autoGenerated: true,
lastAiAnalysis: new Date()
}
})
revalidatePath('/')
revalidatePath(`/note/${noteId}`)
} catch (error) {
console.error('Error applying title suggestion:', error)
throw error
}
}
/**
* Record user feedback on title suggestions
* (Phase 3 - for improving future suggestions)
*/
export async function recordTitleFeedback(
noteId: string,
selectedTitle: string,
allSuggestions: Array<{ title: string; confidence: number }>
): Promise<void> {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Unauthorized')
}
try {
// Save to AiFeedback table for learning
await prisma.aiFeedback.create({
data: {
noteId,
userId: session.user.id,
feedbackType: 'thumbs_up', // User chose one of our suggestions
feature: 'title_suggestion',
originalContent: JSON.stringify(allSuggestions),
correctedContent: selectedTitle,
metadata: JSON.stringify({
timestamp: new Date().toISOString(),
provider: 'auto' // Will be dynamic based on user settings
})
}
})
} catch (error) {
console.error('Error recording title feedback:', error)
// Don't throw - feedback is optional
}
}