Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat: Matrix integration, AI assistant, offline sync (#64, #63, #54)' (#146) from feat/matrix-ai-pwa into main

scott e7526791 cb20a04f

+1280
+194
src/lib/ai-assistant.ts
··· 1 + /** 2 + * AI Assistant — document AI actions via Aperture gateway. 3 + * 4 + * Pure logic module: prompt building, action definitions, response parsing. 5 + * API calls to Aperture handled by the integration layer. 6 + */ 7 + 8 + export type AIAction = 'summarize' | 'rewrite' | 'expand' | 'translate' | 'fix-grammar' | 'simplify' | 'make-formal' | 'make-casual'; 9 + 10 + export interface AIRequest { 11 + action: AIAction; 12 + content: string; 13 + /** Target language (for translate action) */ 14 + targetLanguage?: string; 15 + /** Additional context or instructions */ 16 + context?: string; 17 + /** Max tokens for response */ 18 + maxTokens: number; 19 + } 20 + 21 + export interface AIResponse { 22 + success: boolean; 23 + result: string; 24 + /** Tokens used */ 25 + tokensUsed: number; 26 + /** Action that was performed */ 27 + action: AIAction; 28 + } 29 + 30 + export interface AIConfig { 31 + /** Aperture gateway URL */ 32 + gatewayUrl: string; 33 + /** Model to use */ 34 + model: string; 35 + /** Default max tokens */ 36 + defaultMaxTokens: number; 37 + /** Available actions for the current context */ 38 + enabledActions: AIAction[]; 39 + } 40 + 41 + /** 42 + * Create default AI config. 43 + */ 44 + export function createAIConfig(gatewayUrl: string): AIConfig { 45 + return { 46 + gatewayUrl, 47 + model: 'claude-sonnet-4-20250514', 48 + defaultMaxTokens: 1024, 49 + enabledActions: ['summarize', 'rewrite', 'expand', 'translate', 'fix-grammar', 'simplify', 'make-formal', 'make-casual'], 50 + }; 51 + } 52 + 53 + /** 54 + * Build the system prompt for an AI action. 55 + */ 56 + export function buildSystemPrompt(action: AIAction, targetLanguage?: string): string { 57 + switch (action) { 58 + case 'summarize': 59 + return 'You are a helpful assistant. Summarize the following text concisely, preserving the key points. Return only the summary.'; 60 + case 'rewrite': 61 + return 'You are a helpful assistant. Rewrite the following text to improve clarity and flow while preserving the meaning. Return only the rewritten text.'; 62 + case 'expand': 63 + return 'You are a helpful assistant. Expand the following text with more detail, examples, and explanation. Return only the expanded text.'; 64 + case 'translate': 65 + return `You are a helpful translator. Translate the following text to ${targetLanguage || 'English'}. Return only the translation.`; 66 + case 'fix-grammar': 67 + return 'You are a helpful editor. Fix any grammar, spelling, and punctuation errors in the following text. Return only the corrected text.'; 68 + case 'simplify': 69 + return 'You are a helpful assistant. Simplify the following text to make it easier to understand, using shorter sentences and simpler words. Return only the simplified text.'; 70 + case 'make-formal': 71 + return 'You are a helpful assistant. Rewrite the following text in a formal, professional tone. Return only the rewritten text.'; 72 + case 'make-casual': 73 + return 'You are a helpful assistant. Rewrite the following text in a casual, friendly tone. Return only the rewritten text.'; 74 + } 75 + } 76 + 77 + /** 78 + * Build an AI request. 79 + */ 80 + export function buildRequest( 81 + action: AIAction, 82 + content: string, 83 + options: { targetLanguage?: string; context?: string; maxTokens?: number } = {}, 84 + ): AIRequest { 85 + return { 86 + action, 87 + content, 88 + targetLanguage: options.targetLanguage, 89 + context: options.context, 90 + maxTokens: options.maxTokens ?? 1024, 91 + }; 92 + } 93 + 94 + /** 95 + * Build the API payload for Aperture. 96 + */ 97 + export function buildApiPayload( 98 + request: AIRequest, 99 + config: AIConfig, 100 + ): { model: string; max_tokens: number; system: string; messages: Array<{ role: string; content: string }> } { 101 + const system = buildSystemPrompt(request.action, request.targetLanguage); 102 + const userContent = request.context 103 + ? `Context: ${request.context}\n\nText:\n${request.content}` 104 + : request.content; 105 + 106 + return { 107 + model: config.model, 108 + max_tokens: request.maxTokens, 109 + system, 110 + messages: [{ role: 'user', content: userContent }], 111 + }; 112 + } 113 + 114 + /** 115 + * Parse an AI response from the API. 116 + */ 117 + export function parseApiResponse( 118 + apiResponse: { content?: Array<{ text?: string }>; usage?: { output_tokens?: number } }, 119 + action: AIAction, 120 + ): AIResponse { 121 + const text = apiResponse.content?.[0]?.text ?? ''; 122 + const tokens = apiResponse.usage?.output_tokens ?? 0; 123 + 124 + return { 125 + success: text.length > 0, 126 + result: text, 127 + tokensUsed: tokens, 128 + action, 129 + }; 130 + } 131 + 132 + /** 133 + * Get available AI actions with labels. 134 + */ 135 + export function getAIActions(): Array<{ action: AIAction; label: string; icon: string }> { 136 + return [ 137 + { action: 'summarize', label: 'Summarize', icon: 'compress' }, 138 + { action: 'rewrite', label: 'Rewrite', icon: 'edit' }, 139 + { action: 'expand', label: 'Expand', icon: 'unfold' }, 140 + { action: 'translate', label: 'Translate', icon: 'globe' }, 141 + { action: 'fix-grammar', label: 'Fix Grammar', icon: 'check' }, 142 + { action: 'simplify', label: 'Simplify', icon: 'minimize' }, 143 + { action: 'make-formal', label: 'Make Formal', icon: 'briefcase' }, 144 + { action: 'make-casual', label: 'Make Casual', icon: 'smile' }, 145 + ]; 146 + } 147 + 148 + /** 149 + * Get common translation languages. 150 + */ 151 + export function getTranslationLanguages(): Array<{ code: string; name: string }> { 152 + return [ 153 + { code: 'en', name: 'English' }, 154 + { code: 'es', name: 'Spanish' }, 155 + { code: 'fr', name: 'French' }, 156 + { code: 'de', name: 'German' }, 157 + { code: 'it', name: 'Italian' }, 158 + { code: 'pt', name: 'Portuguese' }, 159 + { code: 'ja', name: 'Japanese' }, 160 + { code: 'ko', name: 'Korean' }, 161 + { code: 'zh', name: 'Chinese' }, 162 + { code: 'ar', name: 'Arabic' }, 163 + ]; 164 + } 165 + 166 + /** 167 + * Estimate token count (rough: ~4 chars per token). 168 + */ 169 + export function estimateTokens(text: string): number { 170 + return Math.ceil(text.length / 4); 171 + } 172 + 173 + /** 174 + * Check if content is within the token limit. 175 + */ 176 + export function isWithinLimit(content: string, maxInputTokens = 4096): boolean { 177 + return estimateTokens(content) <= maxInputTokens; 178 + } 179 + 180 + /** 181 + * Truncate content to fit within token limit. 182 + */ 183 + export function truncateToLimit(content: string, maxInputTokens = 4096): string { 184 + const maxChars = maxInputTokens * 4; 185 + if (content.length <= maxChars) return content; 186 + return content.slice(0, maxChars - 3) + '...'; 187 + } 188 + 189 + /** 190 + * Check if an action is enabled in the config. 191 + */ 192 + export function isActionEnabled(config: AIConfig, action: AIAction): boolean { 193 + return config.enabledActions.includes(action); 194 + }
+175
src/lib/matrix-integration.ts
··· 1 + /** 2 + * Matrix/Owl Integration — share docs to Matrix, receive notifications. 3 + * 4 + * Pure logic module: message formatting, link generation, notification config. 5 + * Matrix client API calls handled by the integration layer. 6 + */ 7 + 8 + export interface MatrixShareConfig { 9 + roomId: string; 10 + docId: string; 11 + docType: 'docs' | 'sheets' | 'slides'; 12 + docTitle: string; 13 + /** Base URL of the crypt instance */ 14 + baseUrl: string; 15 + /** Optional message to include */ 16 + message: string; 17 + } 18 + 19 + export interface NotificationConfig { 20 + /** Matrix room to send notifications to */ 21 + roomId: string; 22 + /** Events to notify on */ 23 + events: NotificationEvent[]; 24 + /** Mention @room on urgent events */ 25 + mentionRoom: boolean; 26 + } 27 + 28 + export type NotificationEvent = 'comment' | 'mention' | 'edit' | 'share' | 'delete'; 29 + 30 + export interface MatrixMessage { 31 + msgtype: 'm.text'; 32 + body: string; 33 + format: 'org.matrix.custom.html'; 34 + formatted_body: string; 35 + } 36 + 37 + export interface ThreadSummaryRequest { 38 + roomId: string; 39 + threadId: string; 40 + docTitle: string; 41 + } 42 + 43 + /** 44 + * Generate a share link for a document. 45 + */ 46 + export function generateShareLink(baseUrl: string, docType: string, docId: string): string { 47 + return `${baseUrl}/${docType}/${docId}`; 48 + } 49 + 50 + /** 51 + * Format a share message for Matrix. 52 + */ 53 + export function formatShareMessage(config: MatrixShareConfig): MatrixMessage { 54 + const link = generateShareLink(config.baseUrl, config.docType, config.docId); 55 + const typeLabel = config.docType === 'docs' ? 'document' : config.docType === 'sheets' ? 'spreadsheet' : 'presentation'; 56 + const prefix = config.message ? `${config.message}\n\n` : ''; 57 + 58 + const body = `${prefix}Shared ${typeLabel}: ${config.docTitle}\n${link}`; 59 + const formatted_body = `${prefix ? `<p>${escapeHtml(config.message)}</p>` : ''}<p>Shared ${typeLabel}: <a href="${escapeHtml(link)}">${escapeHtml(config.docTitle)}</a></p>`; 60 + 61 + return { 62 + msgtype: 'm.text', 63 + body, 64 + format: 'org.matrix.custom.html', 65 + formatted_body, 66 + }; 67 + } 68 + 69 + /** 70 + * Format a notification message. 71 + */ 72 + export function formatNotification( 73 + event: NotificationEvent, 74 + userName: string, 75 + docTitle: string, 76 + docLink: string, 77 + mentionRoom = false, 78 + ): MatrixMessage { 79 + const actions: Record<NotificationEvent, string> = { 80 + comment: 'commented on', 81 + mention: 'mentioned you in', 82 + edit: 'edited', 83 + share: 'shared', 84 + delete: 'deleted', 85 + }; 86 + 87 + const action = actions[event] || 'updated'; 88 + const roomMention = mentionRoom ? '@room ' : ''; 89 + const body = `${roomMention}${userName} ${action} "${docTitle}"\n${docLink}`; 90 + const formatted_body = `${roomMention}<strong>${escapeHtml(userName)}</strong> ${action} "<a href="${escapeHtml(docLink)}">${escapeHtml(docTitle)}</a>"`; 91 + 92 + return { 93 + msgtype: 'm.text', 94 + body, 95 + format: 'org.matrix.custom.html', 96 + formatted_body, 97 + }; 98 + } 99 + 100 + /** 101 + * Create a default notification config. 102 + */ 103 + export function createNotificationConfig(roomId: string): NotificationConfig { 104 + return { 105 + roomId, 106 + events: ['comment', 'mention'], 107 + mentionRoom: false, 108 + }; 109 + } 110 + 111 + /** 112 + * Check if a notification should be sent for an event. 113 + */ 114 + export function shouldNotify(config: NotificationConfig, event: NotificationEvent): boolean { 115 + return config.events.includes(event); 116 + } 117 + 118 + /** 119 + * Add an event to notification config. 120 + */ 121 + export function addNotificationEvent(config: NotificationConfig, event: NotificationEvent): NotificationConfig { 122 + if (config.events.includes(event)) return config; 123 + return { ...config, events: [...config.events, event] }; 124 + } 125 + 126 + /** 127 + * Remove an event from notification config. 128 + */ 129 + export function removeNotificationEvent(config: NotificationConfig, event: NotificationEvent): NotificationConfig { 130 + return { ...config, events: config.events.filter(e => e !== event) }; 131 + } 132 + 133 + /** 134 + * Format a thread summary request message (sent to Owl). 135 + */ 136 + export function formatThreadSummaryRequest(request: ThreadSummaryRequest): MatrixMessage { 137 + const body = `@owl Please summarize this thread and create a document titled "${request.docTitle}"`; 138 + const formatted_body = `<a href="https://matrix.to/#/@owl:matrix.lobster-hake.ts.net">@owl</a> Please summarize this thread and create a document titled "<strong>${escapeHtml(request.docTitle)}</strong>"`; 139 + 140 + return { 141 + msgtype: 'm.text', 142 + body, 143 + format: 'org.matrix.custom.html', 144 + formatted_body, 145 + }; 146 + } 147 + 148 + /** 149 + * Get all available notification events. 150 + */ 151 + export function getNotificationEvents(): Array<{ event: NotificationEvent; label: string }> { 152 + return [ 153 + { event: 'comment', label: 'New comments' }, 154 + { event: 'mention', label: 'Mentions' }, 155 + { event: 'edit', label: 'Document edits' }, 156 + { event: 'share', label: 'Document shared' }, 157 + { event: 'delete', label: 'Document deleted' }, 158 + ]; 159 + } 160 + 161 + /** 162 + * Validate a Matrix room ID format. 163 + */ 164 + export function isValidRoomId(roomId: string): boolean { 165 + return /^![a-zA-Z0-9._=\-/]+:[a-zA-Z0-9.\-]+$/.test(roomId); 166 + } 167 + 168 + /** Escape HTML for safe insertion */ 169 + function escapeHtml(str: string): string { 170 + return str 171 + .replace(/&/g, '&amp;') 172 + .replace(/</g, '&lt;') 173 + .replace(/>/g, '&gt;') 174 + .replace(/"/g, '&quot;'); 175 + }
+282
src/lib/offline-sync.ts
··· 1 + /** 2 + * PWA Offline-First — IndexedDB cache, sync queue, install prompt logic. 3 + * 4 + * Pure logic module: sync queue management, cache metadata, conflict resolution. 5 + * IndexedDB and Service Worker APIs handled by the platform layer. 6 + */ 7 + 8 + export type SyncStatus = 'pending' | 'syncing' | 'synced' | 'conflict' | 'error'; 9 + 10 + export interface CachedDocument { 11 + docId: string; 12 + docType: 'docs' | 'sheets'; 13 + title: string; 14 + /** Encrypted content (stored as-is) */ 15 + encryptedData: string; 16 + /** Last sync timestamp */ 17 + lastSynced: number; 18 + /** Local modification timestamp */ 19 + lastModified: number; 20 + /** Server version at time of cache */ 21 + serverVersion: number; 22 + syncStatus: SyncStatus; 23 + } 24 + 25 + export interface SyncQueueItem { 26 + id: string; 27 + docId: string; 28 + operation: 'create' | 'update' | 'delete'; 29 + /** Encrypted payload */ 30 + payload: string; 31 + /** When the operation was queued */ 32 + queuedAt: number; 33 + /** Number of sync attempts */ 34 + attempts: number; 35 + /** Last error message */ 36 + lastError: string | null; 37 + } 38 + 39 + export interface OfflineState { 40 + isOnline: boolean; 41 + /** Cached documents */ 42 + documents: Map<string, CachedDocument>; 43 + /** Pending sync queue */ 44 + syncQueue: SyncQueueItem[]; 45 + /** Last successful sync */ 46 + lastSync: number; 47 + /** Whether a sync is in progress */ 48 + syncing: boolean; 49 + } 50 + 51 + export interface ConflictResolution { 52 + docId: string; 53 + strategy: 'local' | 'remote' | 'merge'; 54 + resolvedData: string; 55 + } 56 + 57 + let _queueCounter = 0; 58 + 59 + /** 60 + * Create initial offline state. 61 + */ 62 + export function createOfflineState(): OfflineState { 63 + return { 64 + isOnline: true, 65 + documents: new Map(), 66 + syncQueue: [], 67 + lastSync: 0, 68 + syncing: false, 69 + }; 70 + } 71 + 72 + /** 73 + * Set online status. 74 + */ 75 + export function setOnline(state: OfflineState, online: boolean): OfflineState { 76 + return { ...state, isOnline: online }; 77 + } 78 + 79 + /** 80 + * Cache a document locally. 81 + */ 82 + export function cacheDocument( 83 + state: OfflineState, 84 + docId: string, 85 + docType: 'docs' | 'sheets', 86 + title: string, 87 + encryptedData: string, 88 + serverVersion: number, 89 + ): OfflineState { 90 + const now = Date.now(); 91 + const doc: CachedDocument = { 92 + docId, 93 + docType, 94 + title, 95 + encryptedData, 96 + lastSynced: now, 97 + lastModified: now, 98 + serverVersion, 99 + syncStatus: 'synced', 100 + }; 101 + const documents = new Map(state.documents); 102 + documents.set(docId, doc); 103 + return { ...state, documents }; 104 + } 105 + 106 + /** 107 + * Mark a cached document as locally modified. 108 + */ 109 + export function markModified(state: OfflineState, docId: string, encryptedData: string): OfflineState { 110 + const doc = state.documents.get(docId); 111 + if (!doc) return state; 112 + const documents = new Map(state.documents); 113 + documents.set(docId, { 114 + ...doc, 115 + encryptedData, 116 + lastModified: Date.now(), 117 + syncStatus: 'pending', 118 + }); 119 + return { ...state, documents }; 120 + } 121 + 122 + /** 123 + * Remove a cached document. 124 + */ 125 + export function removeCachedDocument(state: OfflineState, docId: string): OfflineState { 126 + const documents = new Map(state.documents); 127 + documents.delete(docId); 128 + return { ...state, documents }; 129 + } 130 + 131 + /** 132 + * Queue a sync operation. 133 + */ 134 + export function queueSync( 135 + state: OfflineState, 136 + docId: string, 137 + operation: SyncQueueItem['operation'], 138 + payload: string, 139 + ): OfflineState { 140 + const item: SyncQueueItem = { 141 + id: `sync-${Date.now()}-${++_queueCounter}`, 142 + docId, 143 + operation, 144 + payload, 145 + queuedAt: Date.now(), 146 + attempts: 0, 147 + lastError: null, 148 + }; 149 + return { ...state, syncQueue: [...state.syncQueue, item] }; 150 + } 151 + 152 + /** 153 + * Remove a completed item from the sync queue. 154 + */ 155 + export function dequeueSync(state: OfflineState, itemId: string): OfflineState { 156 + return { ...state, syncQueue: state.syncQueue.filter(i => i.id !== itemId) }; 157 + } 158 + 159 + /** 160 + * Record a failed sync attempt. 161 + */ 162 + export function recordSyncFailure(state: OfflineState, itemId: string, error: string): OfflineState { 163 + return { 164 + ...state, 165 + syncQueue: state.syncQueue.map(i => 166 + i.id === itemId ? { ...i, attempts: i.attempts + 1, lastError: error } : i, 167 + ), 168 + }; 169 + } 170 + 171 + /** 172 + * Get the next item to sync (oldest first). 173 + */ 174 + export function nextSyncItem(state: OfflineState): SyncQueueItem | null { 175 + return state.syncQueue.length > 0 ? state.syncQueue[0] : null; 176 + } 177 + 178 + /** 179 + * Check if a document needs syncing. 180 + */ 181 + export function needsSync(doc: CachedDocument): boolean { 182 + return doc.syncStatus === 'pending' || doc.syncStatus === 'conflict'; 183 + } 184 + 185 + /** 186 + * Get all documents that need syncing. 187 + */ 188 + export function pendingDocuments(state: OfflineState): CachedDocument[] { 189 + return Array.from(state.documents.values()).filter(needsSync); 190 + } 191 + 192 + /** 193 + * Detect a conflict (local and server both modified). 194 + */ 195 + export function detectConflict(doc: CachedDocument, serverVersion: number): boolean { 196 + return doc.syncStatus === 'pending' && serverVersion > doc.serverVersion; 197 + } 198 + 199 + /** 200 + * Mark a document as conflicted. 201 + */ 202 + export function markConflict(state: OfflineState, docId: string): OfflineState { 203 + const doc = state.documents.get(docId); 204 + if (!doc) return state; 205 + const documents = new Map(state.documents); 206 + documents.set(docId, { ...doc, syncStatus: 'conflict' }); 207 + return { ...state, documents }; 208 + } 209 + 210 + /** 211 + * Resolve a conflict. 212 + */ 213 + export function resolveConflict( 214 + state: OfflineState, 215 + resolution: ConflictResolution, 216 + ): OfflineState { 217 + const doc = state.documents.get(resolution.docId); 218 + if (!doc) return state; 219 + const documents = new Map(state.documents); 220 + documents.set(resolution.docId, { 221 + ...doc, 222 + encryptedData: resolution.resolvedData, 223 + syncStatus: 'pending', 224 + lastModified: Date.now(), 225 + }); 226 + return { ...state, documents }; 227 + } 228 + 229 + /** 230 + * Mark sync as complete for a document. 231 + */ 232 + export function markSynced( 233 + state: OfflineState, 234 + docId: string, 235 + serverVersion: number, 236 + ): OfflineState { 237 + const doc = state.documents.get(docId); 238 + if (!doc) return state; 239 + const documents = new Map(state.documents); 240 + documents.set(docId, { 241 + ...doc, 242 + lastSynced: Date.now(), 243 + serverVersion, 244 + syncStatus: 'synced', 245 + }); 246 + return { ...state, documents }; 247 + } 248 + 249 + /** 250 + * Get sync queue length. 251 + */ 252 + export function queueLength(state: OfflineState): number { 253 + return state.syncQueue.length; 254 + } 255 + 256 + /** 257 + * Get count of cached documents. 258 + */ 259 + export function cachedDocCount(state: OfflineState): number { 260 + return state.documents.size; 261 + } 262 + 263 + /** 264 + * Check if there are any pending operations. 265 + */ 266 + export function hasPendingWork(state: OfflineState): boolean { 267 + return state.syncQueue.length > 0 || pendingDocuments(state).length > 0; 268 + } 269 + 270 + /** 271 + * Get items that have failed too many times (> maxAttempts). 272 + */ 273 + export function failedItems(state: OfflineState, maxAttempts = 3): SyncQueueItem[] { 274 + return state.syncQueue.filter(i => i.attempts >= maxAttempts); 275 + } 276 + 277 + /** 278 + * Purge failed items from the queue. 279 + */ 280 + export function purgeFailedItems(state: OfflineState, maxAttempts = 3): OfflineState { 281 + return { ...state, syncQueue: state.syncQueue.filter(i => i.attempts < maxAttempts) }; 282 + }
+199
tests/ai-assistant.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createAIConfig, 4 + buildSystemPrompt, 5 + buildRequest, 6 + buildApiPayload, 7 + parseApiResponse, 8 + getAIActions, 9 + getTranslationLanguages, 10 + estimateTokens, 11 + isWithinLimit, 12 + truncateToLimit, 13 + isActionEnabled, 14 + } from '../src/lib/ai-assistant'; 15 + 16 + describe('ai-assistant', () => { 17 + describe('createAIConfig', () => { 18 + it('creates config with gateway URL', () => { 19 + const config = createAIConfig('http://ai'); 20 + expect(config.gatewayUrl).toBe('http://ai'); 21 + expect(config.model).toBeTruthy(); 22 + expect(config.defaultMaxTokens).toBe(1024); 23 + expect(config.enabledActions).toHaveLength(8); 24 + }); 25 + }); 26 + 27 + describe('buildSystemPrompt', () => { 28 + it('builds summarize prompt', () => { 29 + const prompt = buildSystemPrompt('summarize'); 30 + expect(prompt).toContain('Summarize'); 31 + }); 32 + 33 + it('builds rewrite prompt', () => { 34 + expect(buildSystemPrompt('rewrite')).toContain('Rewrite'); 35 + }); 36 + 37 + it('builds expand prompt', () => { 38 + expect(buildSystemPrompt('expand')).toContain('Expand'); 39 + }); 40 + 41 + it('builds translate prompt with language', () => { 42 + const prompt = buildSystemPrompt('translate', 'Spanish'); 43 + expect(prompt).toContain('Spanish'); 44 + }); 45 + 46 + it('defaults to English for translate', () => { 47 + const prompt = buildSystemPrompt('translate'); 48 + expect(prompt).toContain('English'); 49 + }); 50 + 51 + it('builds fix-grammar prompt', () => { 52 + expect(buildSystemPrompt('fix-grammar')).toContain('grammar'); 53 + }); 54 + 55 + it('builds simplify prompt', () => { 56 + expect(buildSystemPrompt('simplify')).toContain('Simplify'); 57 + }); 58 + 59 + it('builds make-formal prompt', () => { 60 + expect(buildSystemPrompt('make-formal')).toContain('formal'); 61 + }); 62 + 63 + it('builds make-casual prompt', () => { 64 + expect(buildSystemPrompt('make-casual')).toContain('casual'); 65 + }); 66 + }); 67 + 68 + describe('buildRequest', () => { 69 + it('builds a basic request', () => { 70 + const req = buildRequest('summarize', 'Hello world'); 71 + expect(req.action).toBe('summarize'); 72 + expect(req.content).toBe('Hello world'); 73 + expect(req.maxTokens).toBe(1024); 74 + }); 75 + 76 + it('accepts options', () => { 77 + const req = buildRequest('translate', 'Hello', { targetLanguage: 'French', maxTokens: 512 }); 78 + expect(req.targetLanguage).toBe('French'); 79 + expect(req.maxTokens).toBe(512); 80 + }); 81 + }); 82 + 83 + describe('buildApiPayload', () => { 84 + it('builds Aperture-compatible payload', () => { 85 + const config = createAIConfig('http://ai'); 86 + const request = buildRequest('summarize', 'Long text here'); 87 + const payload = buildApiPayload(request, config); 88 + expect(payload.model).toBeTruthy(); 89 + expect(payload.max_tokens).toBe(1024); 90 + expect(payload.system).toContain('Summarize'); 91 + expect(payload.messages).toHaveLength(1); 92 + expect(payload.messages[0].role).toBe('user'); 93 + expect(payload.messages[0].content).toBe('Long text here'); 94 + }); 95 + 96 + it('includes context when provided', () => { 97 + const config = createAIConfig('http://ai'); 98 + const request = buildRequest('rewrite', 'Fix this', { context: 'Technical document' }); 99 + const payload = buildApiPayload(request, config); 100 + expect(payload.messages[0].content).toContain('Context: Technical document'); 101 + expect(payload.messages[0].content).toContain('Fix this'); 102 + }); 103 + }); 104 + 105 + describe('parseApiResponse', () => { 106 + it('parses successful response', () => { 107 + const result = parseApiResponse({ 108 + content: [{ text: 'Summary here' }], 109 + usage: { output_tokens: 50 }, 110 + }, 'summarize'); 111 + expect(result.success).toBe(true); 112 + expect(result.result).toBe('Summary here'); 113 + expect(result.tokensUsed).toBe(50); 114 + expect(result.action).toBe('summarize'); 115 + }); 116 + 117 + it('handles empty response', () => { 118 + const result = parseApiResponse({ content: [], usage: {} }, 'rewrite'); 119 + expect(result.success).toBe(false); 120 + expect(result.result).toBe(''); 121 + }); 122 + 123 + it('handles missing fields gracefully', () => { 124 + const result = parseApiResponse({}, 'expand'); 125 + expect(result.success).toBe(false); 126 + expect(result.tokensUsed).toBe(0); 127 + }); 128 + }); 129 + 130 + describe('getAIActions', () => { 131 + it('returns 8 actions', () => { 132 + expect(getAIActions()).toHaveLength(8); 133 + }); 134 + 135 + it('all have labels and icons', () => { 136 + for (const action of getAIActions()) { 137 + expect(action.label).toBeTruthy(); 138 + expect(action.icon).toBeTruthy(); 139 + } 140 + }); 141 + }); 142 + 143 + describe('getTranslationLanguages', () => { 144 + it('returns 10 languages', () => { 145 + expect(getTranslationLanguages()).toHaveLength(10); 146 + }); 147 + 148 + it('includes English', () => { 149 + expect(getTranslationLanguages().some(l => l.code === 'en')).toBe(true); 150 + }); 151 + }); 152 + 153 + describe('estimateTokens', () => { 154 + it('estimates ~4 chars per token', () => { 155 + expect(estimateTokens('abcd')).toBe(1); 156 + expect(estimateTokens('12345678')).toBe(2); 157 + }); 158 + 159 + it('rounds up', () => { 160 + expect(estimateTokens('abc')).toBe(1); 161 + }); 162 + }); 163 + 164 + describe('isWithinLimit', () => { 165 + it('returns true for short text', () => { 166 + expect(isWithinLimit('hello')).toBe(true); 167 + }); 168 + 169 + it('returns false for text exceeding limit', () => { 170 + const longText = 'a'.repeat(20000); 171 + expect(isWithinLimit(longText, 1000)).toBe(false); 172 + }); 173 + }); 174 + 175 + describe('truncateToLimit', () => { 176 + it('returns text unchanged when within limit', () => { 177 + expect(truncateToLimit('hello', 100)).toBe('hello'); 178 + }); 179 + 180 + it('truncates and adds ellipsis', () => { 181 + const long = 'a'.repeat(500); 182 + const truncated = truncateToLimit(long, 100); 183 + expect(truncated.length).toBeLessThanOrEqual(400); 184 + expect(truncated).toMatch(/\.\.\.$/); 185 + }); 186 + }); 187 + 188 + describe('isActionEnabled', () => { 189 + it('returns true for enabled actions', () => { 190 + const config = createAIConfig('http://ai'); 191 + expect(isActionEnabled(config, 'summarize')).toBe(true); 192 + }); 193 + 194 + it('returns false for disabled actions', () => { 195 + const config = { ...createAIConfig('http://ai'), enabledActions: ['summarize' as const] }; 196 + expect(isActionEnabled(config, 'rewrite')).toBe(false); 197 + }); 198 + }); 199 + });
+183
tests/matrix-integration.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + generateShareLink, 4 + formatShareMessage, 5 + formatNotification, 6 + createNotificationConfig, 7 + shouldNotify, 8 + addNotificationEvent, 9 + removeNotificationEvent, 10 + formatThreadSummaryRequest, 11 + getNotificationEvents, 12 + isValidRoomId, 13 + } from '../src/lib/matrix-integration'; 14 + 15 + describe('matrix-integration', () => { 16 + describe('generateShareLink', () => { 17 + it('generates a share link', () => { 18 + expect(generateShareLink('https://tools.example.com', 'docs', 'abc')).toBe('https://tools.example.com/docs/abc'); 19 + }); 20 + }); 21 + 22 + describe('formatShareMessage', () => { 23 + it('formats a share message with HTML', () => { 24 + const msg = formatShareMessage({ 25 + roomId: '!room:matrix.org', 26 + docId: 'doc-1', 27 + docType: 'docs', 28 + docTitle: 'Meeting Notes', 29 + baseUrl: 'https://tools.example.com', 30 + message: '', 31 + }); 32 + expect(msg.msgtype).toBe('m.text'); 33 + expect(msg.body).toContain('Meeting Notes'); 34 + expect(msg.body).toContain('document'); 35 + expect(msg.formatted_body).toContain('<a href='); 36 + }); 37 + 38 + it('includes custom message', () => { 39 + const msg = formatShareMessage({ 40 + roomId: '!room:matrix.org', 41 + docId: 'doc-1', 42 + docType: 'sheets', 43 + docTitle: 'Budget', 44 + baseUrl: 'https://tools.example.com', 45 + message: 'Please review this', 46 + }); 47 + expect(msg.body).toContain('Please review this'); 48 + expect(msg.body).toContain('spreadsheet'); 49 + expect(msg.formatted_body).toContain('Please review this'); 50 + }); 51 + 52 + it('labels slides as presentation', () => { 53 + const msg = formatShareMessage({ 54 + roomId: '!room:matrix.org', 55 + docId: 's-1', 56 + docType: 'slides', 57 + docTitle: 'Pitch Deck', 58 + baseUrl: 'https://x.com', 59 + message: '', 60 + }); 61 + expect(msg.body).toContain('presentation'); 62 + }); 63 + 64 + it('escapes HTML in title', () => { 65 + const msg = formatShareMessage({ 66 + roomId: '!room:matrix.org', 67 + docId: 'doc-1', 68 + docType: 'docs', 69 + docTitle: '<script>alert("xss")</script>', 70 + baseUrl: 'https://x.com', 71 + message: '', 72 + }); 73 + expect(msg.formatted_body).not.toContain('<script>'); 74 + expect(msg.formatted_body).toContain('&lt;script&gt;'); 75 + }); 76 + }); 77 + 78 + describe('formatNotification', () => { 79 + it('formats a comment notification', () => { 80 + const msg = formatNotification('comment', 'Alice', 'Budget', 'https://x.com/sheets/1'); 81 + expect(msg.body).toContain('Alice'); 82 + expect(msg.body).toContain('commented on'); 83 + expect(msg.body).toContain('Budget'); 84 + }); 85 + 86 + it('formats a mention notification', () => { 87 + const msg = formatNotification('mention', 'Bob', 'Notes', 'https://x.com/docs/1'); 88 + expect(msg.body).toContain('mentioned you in'); 89 + }); 90 + 91 + it('includes @room when mentionRoom is true', () => { 92 + const msg = formatNotification('delete', 'Admin', 'Old Doc', 'https://x.com/docs/1', true); 93 + expect(msg.body).toContain('@room'); 94 + }); 95 + 96 + it('does not include @room by default', () => { 97 + const msg = formatNotification('edit', 'Alice', 'Doc', 'https://x.com/docs/1'); 98 + expect(msg.body).not.toContain('@room'); 99 + }); 100 + }); 101 + 102 + describe('createNotificationConfig', () => { 103 + it('creates config with default events', () => { 104 + const config = createNotificationConfig('!room:matrix.org'); 105 + expect(config.roomId).toBe('!room:matrix.org'); 106 + expect(config.events).toContain('comment'); 107 + expect(config.events).toContain('mention'); 108 + expect(config.mentionRoom).toBe(false); 109 + }); 110 + }); 111 + 112 + describe('shouldNotify', () => { 113 + it('returns true for subscribed events', () => { 114 + const config = createNotificationConfig('!room:matrix.org'); 115 + expect(shouldNotify(config, 'comment')).toBe(true); 116 + expect(shouldNotify(config, 'mention')).toBe(true); 117 + }); 118 + 119 + it('returns false for unsubscribed events', () => { 120 + const config = createNotificationConfig('!room:matrix.org'); 121 + expect(shouldNotify(config, 'edit')).toBe(false); 122 + expect(shouldNotify(config, 'delete')).toBe(false); 123 + }); 124 + }); 125 + 126 + describe('addNotificationEvent / removeNotificationEvent', () => { 127 + it('adds a new event', () => { 128 + const config = createNotificationConfig('!room:matrix.org'); 129 + const updated = addNotificationEvent(config, 'edit'); 130 + expect(updated.events).toContain('edit'); 131 + }); 132 + 133 + it('does not duplicate existing event', () => { 134 + const config = createNotificationConfig('!room:matrix.org'); 135 + const updated = addNotificationEvent(config, 'comment'); 136 + expect(updated.events.filter(e => e === 'comment')).toHaveLength(1); 137 + }); 138 + 139 + it('removes an event', () => { 140 + const config = createNotificationConfig('!room:matrix.org'); 141 + const updated = removeNotificationEvent(config, 'comment'); 142 + expect(updated.events).not.toContain('comment'); 143 + }); 144 + }); 145 + 146 + describe('formatThreadSummaryRequest', () => { 147 + it('formats Owl request', () => { 148 + const msg = formatThreadSummaryRequest({ 149 + roomId: '!room:matrix.org', 150 + threadId: '$thread123', 151 + docTitle: 'Sprint Planning', 152 + }); 153 + expect(msg.body).toContain('@owl'); 154 + expect(msg.body).toContain('Sprint Planning'); 155 + expect(msg.formatted_body).toContain('matrix.to'); 156 + }); 157 + }); 158 + 159 + describe('getNotificationEvents', () => { 160 + it('returns 5 event types', () => { 161 + expect(getNotificationEvents()).toHaveLength(5); 162 + }); 163 + 164 + it('all have labels', () => { 165 + for (const e of getNotificationEvents()) { 166 + expect(e.label).toBeTruthy(); 167 + } 168 + }); 169 + }); 170 + 171 + describe('isValidRoomId', () => { 172 + it('validates correct room IDs', () => { 173 + expect(isValidRoomId('!abc123:matrix.org')).toBe(true); 174 + expect(isValidRoomId('!room_name:server.example.com')).toBe(true); 175 + }); 176 + 177 + it('rejects invalid room IDs', () => { 178 + expect(isValidRoomId('abc')).toBe(false); 179 + expect(isValidRoomId('#room:matrix.org')).toBe(false); 180 + expect(isValidRoomId('')).toBe(false); 181 + }); 182 + }); 183 + });
+247
tests/offline-sync.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createOfflineState, 4 + setOnline, 5 + cacheDocument, 6 + markModified, 7 + removeCachedDocument, 8 + queueSync, 9 + dequeueSync, 10 + recordSyncFailure, 11 + nextSyncItem, 12 + needsSync, 13 + pendingDocuments, 14 + detectConflict, 15 + markConflict, 16 + resolveConflict, 17 + markSynced, 18 + queueLength, 19 + cachedDocCount, 20 + hasPendingWork, 21 + failedItems, 22 + purgeFailedItems, 23 + } from '../src/lib/offline-sync'; 24 + 25 + describe('offline-sync', () => { 26 + describe('createOfflineState', () => { 27 + it('creates initial state', () => { 28 + const state = createOfflineState(); 29 + expect(state.isOnline).toBe(true); 30 + expect(state.documents.size).toBe(0); 31 + expect(state.syncQueue).toHaveLength(0); 32 + expect(state.lastSync).toBe(0); 33 + expect(state.syncing).toBe(false); 34 + }); 35 + }); 36 + 37 + describe('setOnline', () => { 38 + it('sets online status', () => { 39 + const state = createOfflineState(); 40 + expect(setOnline(state, false).isOnline).toBe(false); 41 + expect(setOnline(state, true).isOnline).toBe(true); 42 + }); 43 + }); 44 + 45 + describe('cacheDocument', () => { 46 + it('caches a document', () => { 47 + let state = createOfflineState(); 48 + state = cacheDocument(state, 'doc-1', 'docs', 'Notes', 'encrypted-data', 1); 49 + expect(cachedDocCount(state)).toBe(1); 50 + const doc = state.documents.get('doc-1')!; 51 + expect(doc.title).toBe('Notes'); 52 + expect(doc.syncStatus).toBe('synced'); 53 + expect(doc.serverVersion).toBe(1); 54 + }); 55 + 56 + it('overwrites existing cache', () => { 57 + let state = createOfflineState(); 58 + state = cacheDocument(state, 'doc-1', 'docs', 'V1', 'data1', 1); 59 + state = cacheDocument(state, 'doc-1', 'docs', 'V2', 'data2', 2); 60 + expect(cachedDocCount(state)).toBe(1); 61 + expect(state.documents.get('doc-1')!.title).toBe('V2'); 62 + }); 63 + }); 64 + 65 + describe('markModified', () => { 66 + it('marks document as pending sync', () => { 67 + let state = createOfflineState(); 68 + state = cacheDocument(state, 'doc-1', 'docs', 'Notes', 'old', 1); 69 + state = markModified(state, 'doc-1', 'new-data'); 70 + const doc = state.documents.get('doc-1')!; 71 + expect(doc.syncStatus).toBe('pending'); 72 + expect(doc.encryptedData).toBe('new-data'); 73 + }); 74 + 75 + it('returns unchanged for missing doc', () => { 76 + const state = createOfflineState(); 77 + expect(markModified(state, 'fake', 'data')).toBe(state); 78 + }); 79 + }); 80 + 81 + describe('removeCachedDocument', () => { 82 + it('removes a document from cache', () => { 83 + let state = createOfflineState(); 84 + state = cacheDocument(state, 'doc-1', 'docs', 'X', 'data', 1); 85 + state = removeCachedDocument(state, 'doc-1'); 86 + expect(cachedDocCount(state)).toBe(0); 87 + }); 88 + }); 89 + 90 + describe('sync queue', () => { 91 + it('queues a sync operation', () => { 92 + let state = createOfflineState(); 93 + state = queueSync(state, 'doc-1', 'update', 'payload'); 94 + expect(queueLength(state)).toBe(1); 95 + expect(state.syncQueue[0].docId).toBe('doc-1'); 96 + expect(state.syncQueue[0].operation).toBe('update'); 97 + expect(state.syncQueue[0].attempts).toBe(0); 98 + }); 99 + 100 + it('dequeues a completed item', () => { 101 + let state = createOfflineState(); 102 + state = queueSync(state, 'doc-1', 'update', 'payload'); 103 + const itemId = state.syncQueue[0].id; 104 + state = dequeueSync(state, itemId); 105 + expect(queueLength(state)).toBe(0); 106 + }); 107 + 108 + it('records sync failure', () => { 109 + let state = createOfflineState(); 110 + state = queueSync(state, 'doc-1', 'update', 'payload'); 111 + const itemId = state.syncQueue[0].id; 112 + state = recordSyncFailure(state, itemId, 'Network error'); 113 + expect(state.syncQueue[0].attempts).toBe(1); 114 + expect(state.syncQueue[0].lastError).toBe('Network error'); 115 + }); 116 + 117 + it('nextSyncItem returns oldest', () => { 118 + let state = createOfflineState(); 119 + state = queueSync(state, 'doc-1', 'update', 'p1'); 120 + state = queueSync(state, 'doc-2', 'create', 'p2'); 121 + expect(nextSyncItem(state)!.docId).toBe('doc-1'); 122 + }); 123 + 124 + it('nextSyncItem returns null when empty', () => { 125 + expect(nextSyncItem(createOfflineState())).toBeNull(); 126 + }); 127 + }); 128 + 129 + describe('needsSync', () => { 130 + it('returns true for pending', () => { 131 + const doc = { docId: 'x', docType: 'docs' as const, title: '', encryptedData: '', lastSynced: 0, lastModified: 1, serverVersion: 1, syncStatus: 'pending' as const }; 132 + expect(needsSync(doc)).toBe(true); 133 + }); 134 + 135 + it('returns true for conflict', () => { 136 + const doc = { docId: 'x', docType: 'docs' as const, title: '', encryptedData: '', lastSynced: 0, lastModified: 1, serverVersion: 1, syncStatus: 'conflict' as const }; 137 + expect(needsSync(doc)).toBe(true); 138 + }); 139 + 140 + it('returns false for synced', () => { 141 + const doc = { docId: 'x', docType: 'docs' as const, title: '', encryptedData: '', lastSynced: 0, lastModified: 0, serverVersion: 1, syncStatus: 'synced' as const }; 142 + expect(needsSync(doc)).toBe(false); 143 + }); 144 + }); 145 + 146 + describe('pendingDocuments', () => { 147 + it('returns only pending/conflict docs', () => { 148 + let state = createOfflineState(); 149 + state = cacheDocument(state, 'doc-1', 'docs', 'A', 'data', 1); 150 + state = cacheDocument(state, 'doc-2', 'docs', 'B', 'data', 1); 151 + state = markModified(state, 'doc-1', 'new-data'); 152 + expect(pendingDocuments(state)).toHaveLength(1); 153 + }); 154 + }); 155 + 156 + describe('conflict detection and resolution', () => { 157 + it('detects conflict when server has newer version', () => { 158 + const doc = { docId: 'x', docType: 'docs' as const, title: '', encryptedData: '', lastSynced: 0, lastModified: 1, serverVersion: 1, syncStatus: 'pending' as const }; 159 + expect(detectConflict(doc, 2)).toBe(true); 160 + }); 161 + 162 + it('no conflict when server version matches', () => { 163 + const doc = { docId: 'x', docType: 'docs' as const, title: '', encryptedData: '', lastSynced: 0, lastModified: 1, serverVersion: 1, syncStatus: 'pending' as const }; 164 + expect(detectConflict(doc, 1)).toBe(false); 165 + }); 166 + 167 + it('marks document as conflicted', () => { 168 + let state = createOfflineState(); 169 + state = cacheDocument(state, 'doc-1', 'docs', 'X', 'data', 1); 170 + state = markConflict(state, 'doc-1'); 171 + expect(state.documents.get('doc-1')!.syncStatus).toBe('conflict'); 172 + }); 173 + 174 + it('resolves conflict with new data', () => { 175 + let state = createOfflineState(); 176 + state = cacheDocument(state, 'doc-1', 'docs', 'X', 'old', 1); 177 + state = markConflict(state, 'doc-1'); 178 + state = resolveConflict(state, { docId: 'doc-1', strategy: 'local', resolvedData: 'resolved' }); 179 + const doc = state.documents.get('doc-1')!; 180 + expect(doc.encryptedData).toBe('resolved'); 181 + expect(doc.syncStatus).toBe('pending'); 182 + }); 183 + }); 184 + 185 + describe('markSynced', () => { 186 + it('marks a document as synced with new version', () => { 187 + let state = createOfflineState(); 188 + state = cacheDocument(state, 'doc-1', 'docs', 'X', 'data', 1); 189 + state = markModified(state, 'doc-1', 'new'); 190 + state = markSynced(state, 'doc-1', 2); 191 + const doc = state.documents.get('doc-1')!; 192 + expect(doc.syncStatus).toBe('synced'); 193 + expect(doc.serverVersion).toBe(2); 194 + }); 195 + }); 196 + 197 + describe('hasPendingWork', () => { 198 + it('returns false for clean state', () => { 199 + expect(hasPendingWork(createOfflineState())).toBe(false); 200 + }); 201 + 202 + it('returns true with queued items', () => { 203 + let state = createOfflineState(); 204 + state = queueSync(state, 'doc-1', 'update', 'p'); 205 + expect(hasPendingWork(state)).toBe(true); 206 + }); 207 + 208 + it('returns true with pending documents', () => { 209 + let state = createOfflineState(); 210 + state = cacheDocument(state, 'doc-1', 'docs', 'X', 'data', 1); 211 + state = markModified(state, 'doc-1', 'new'); 212 + expect(hasPendingWork(state)).toBe(true); 213 + }); 214 + }); 215 + 216 + describe('failedItems / purgeFailedItems', () => { 217 + it('identifies failed items', () => { 218 + let state = createOfflineState(); 219 + state = queueSync(state, 'doc-1', 'update', 'p'); 220 + const itemId = state.syncQueue[0].id; 221 + state = recordSyncFailure(state, itemId, 'err'); 222 + state = recordSyncFailure(state, itemId, 'err'); 223 + state = recordSyncFailure(state, itemId, 'err'); 224 + expect(failedItems(state, 3)).toHaveLength(1); 225 + }); 226 + 227 + it('purges failed items', () => { 228 + let state = createOfflineState(); 229 + state = queueSync(state, 'doc-1', 'update', 'p'); 230 + const itemId = state.syncQueue[0].id; 231 + state = recordSyncFailure(state, itemId, 'err'); 232 + state = recordSyncFailure(state, itemId, 'err'); 233 + state = recordSyncFailure(state, itemId, 'err'); 234 + state = purgeFailedItems(state, 3); 235 + expect(queueLength(state)).toBe(0); 236 + }); 237 + 238 + it('keeps items under failure threshold', () => { 239 + let state = createOfflineState(); 240 + state = queueSync(state, 'doc-1', 'update', 'p'); 241 + const itemId = state.syncQueue[0].id; 242 + state = recordSyncFailure(state, itemId, 'err'); 243 + state = purgeFailedItems(state, 3); 244 + expect(queueLength(state)).toBe(1); 245 + }); 246 + }); 247 + });