diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md index 572e9a6f..2d9816d0 100644 --- a/apps/x/ANALYTICS.md +++ b/apps/x/ANALYTICS.md @@ -16,6 +16,8 @@ ## Event catalog +All PostHog events include `app_version` automatically. Main-process events add it in `packages/core/src/analytics/posthog.ts`; renderer events get it from the `analytics:bootstrap` IPC payload and an initialization-time `before_send` hook. + ### `llm_usage` Emitted whenever ai-sdk returns token usage (one event per LLM call, not per run). @@ -101,6 +103,7 @@ Persistent across sessions for the same user. Set via `posthog.people.set` or as | `email` | main on identify | From `/v1/me`; powers PostHog cohort match + integrations | | `plan`, `status` | main on identify | Subscription state | | `api_url` | both processes (init + identify) | Distinguishes prod / staging / custom — assign meaning in PostHog dashboard. `https://api.x.rowboatlabs.com` = production | +| `app_version` | both processes (init + identify) | Electron app version; also included automatically on every event | | `signed_in` | renderer | `true` while rowboat OAuth is connected | | `{provider}_connected` | renderer | One of `gmail`, `calendar`, `slack`, `rowboat` | | `total_notes` | renderer (init) | Workspace size signal | diff --git a/apps/x/apps/main/bundle.mjs b/apps/x/apps/main/bundle.mjs index 9ae77e0e..976e8db3 100644 --- a/apps/x/apps/main/bundle.mjs +++ b/apps/x/apps/main/bundle.mjs @@ -10,11 +10,13 @@ */ import * as esbuild from 'esbuild'; +import { readFile } from 'node:fs/promises'; // In CommonJS, import.meta.url doesn't exist. We need to polyfill it. // The banner defines __import_meta_url at the top of the bundle, // and we use define to replace all import.meta.url references with it. const cjsBanner = `var __import_meta_url = require('url').pathToFileURL(__filename).href;`; +const pkg = JSON.parse(await readFile(new URL('./package.json', import.meta.url), 'utf8')); await esbuild.build({ entryPoints: ['./dist/main.js'], @@ -36,6 +38,7 @@ await esbuild.build({ // Empty strings disable analytics gracefully. 'process.env.POSTHOG_KEY': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_KEY ?? ''), 'process.env.POSTHOG_HOST': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com'), + 'process.env.ROWBOAT_APP_VERSION': JSON.stringify(pkg.version ?? ''), }, }); diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 2f5730ce..ec2803aa 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1,4 +1,4 @@ -import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer } from 'electron'; +import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer, app } from 'electron'; import { ipc } from '@x/shared'; import path from 'node:path'; import os from 'node:os'; @@ -455,6 +455,7 @@ export function setupIpcHandlers() { return { installationId: getInstallationId(), apiUrl: API_URL, + appVersion: app.getVersion(), }; }, 'workspace:getRoot': async () => { diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index a193b3f1..67876189 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "@eigenpal/docx-editor-react": "^1.0.3", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", @@ -46,6 +47,15 @@ "motion": "^12.23.26", "nanoid": "^5.1.6", "posthog-js": "^1.332.0", + "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.2", + "prosemirror-history": "^1.5.0", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.7", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.41.8", "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 5c072b2a..57a03727 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -18,6 +18,7 @@ import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/ba import { ImageFileViewer } from '@/components/image-file-viewer'; import { VideoFileViewer } from '@/components/video-file-viewer'; import { AudioFileViewer } from '@/components/audio-file-viewer'; +import { DocxFileViewer } from '@/components/docx-file-viewer'; import { PersistentViewerCache } from '@/components/persistent-viewer-cache'; import { UnsupportedFileViewer } from '@/components/unsupported-file-viewer'; import { getViewerType, isCacheableViewerPath } from '@/lib/file-types'; @@ -5506,7 +5507,11 @@ function App() { remove: knowledgeActions.remove, copyPath: knowledgeActions.copyPath, revealInFileManager: knowledgeActions.revealInFileManager, + createNote: knowledgeActions.createNote, + createFolder: knowledgeActions.createFolder, + onOpenInNewTab: knowledgeActions.onOpenInNewTab, }} + onNavigate={(path) => { void navigateToView({ type: 'workspace', path: path === WORKSPACE_ROOT ? undefined : path }) }} onOpenNote={(path) => navigateToFile(path)} onCreateWorkspace={async (name) => { await knowledgeActions.createWorkspace(name) }} /> @@ -5718,6 +5723,10 @@ function App() {
+ ) : selectedPath && getViewerType(selectedPath) === 'docx' ? ( +
+ +
) : (
diff --git a/apps/x/apps/renderer/src/components/docx-file-viewer.tsx b/apps/x/apps/renderer/src/components/docx-file-viewer.tsx new file mode 100644 index 00000000..415ae4a0 --- /dev/null +++ b/apps/x/apps/renderer/src/components/docx-file-viewer.tsx @@ -0,0 +1,196 @@ +import { Suspense, lazy, useEffect, useRef, useState } from 'react' +import { ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react' +import type { DocxEditorRef } from '@eigenpal/docx-editor-react' + +// The editor (and its CSS) is heavy and only needed when a .docx is open, so it +// loads in its own chunk the first time a Word document is viewed. +const LazyDocxEditor = lazy(async () => { + const [mod] = await Promise.all([ + import('@eigenpal/docx-editor-react'), + import('@eigenpal/docx-editor-react/styles.css'), + ]) + return { default: mod.DocxEditor } +}) + +interface DocxFileViewerProps { + path: string +} + +type LoadState = 'loading' | 'ready' | 'error' +type SaveState = 'idle' | 'saving' | 'saved' | 'error' + +const SAVE_DEBOUNCE_MS = 800 +// onChange fires for the editor's own load-time normalization. Ignore changes +// until shortly after the document settles so opening a file never rewrites it. +const ARM_DELAY_MS = 500 + +function base64ToArrayBuffer(base64: string): ArrayBuffer { + const binary = atob(base64) + const len = binary.length + const bytes = new Uint8Array(len) + for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i) + return bytes.buffer +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + let binary = '' + const chunk = 0x8000 + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)) + } + return btoa(binary) +} + +function baseName(path: string): string { + const segs = path.split('/') + return segs[segs.length - 1] || path +} + +export function DocxFileViewer({ path }: DocxFileViewerProps) { + const [loadState, setLoadState] = useState('loading') + const [buffer, setBuffer] = useState(null) + const [saveState, setSaveState] = useState('idle') + + const editorRef = useRef(null) + const saveTimerRef = useRef | null>(null) + const armTimerRef = useRef | null>(null) + const armedRef = useRef(false) + const dirtyRef = useRef(false) + const savingRef = useRef(false) + + // Load the .docx bytes whenever the path changes. + useEffect(() => { + let cancelled = false + setLoadState('loading') + setBuffer(null) + setSaveState('idle') + armedRef.current = false + dirtyRef.current = false + savingRef.current = false + + ;(async () => { + try { + const result = await window.ipc.invoke('workspace:readFile', { path, encoding: 'base64' }) + if (cancelled) return + setBuffer(base64ToArrayBuffer(result.data)) + setLoadState('ready') + if (armTimerRef.current) clearTimeout(armTimerRef.current) + armTimerRef.current = setTimeout(() => { armedRef.current = true }, ARM_DELAY_MS) + } catch (err) { + console.error('Failed to load docx:', err) + if (!cancelled) setLoadState('error') + } + })() + + return () => { + cancelled = true + if (armTimerRef.current) clearTimeout(armTimerRef.current) + } + }, [path]) + + // Serialize the current document and write it back to disk. + const persist = async () => { + const editor = editorRef.current + if (!editor || savingRef.current) return + savingRef.current = true + dirtyRef.current = false + setSaveState('saving') + try { + const out = await editor.save() + if (out) { + await window.ipc.invoke('workspace:writeFile', { + path, + data: arrayBufferToBase64(out), + opts: { encoding: 'base64' }, + }) + } + setSaveState('saved') + } catch (err) { + console.error('Failed to save docx:', err) + dirtyRef.current = true + setSaveState('error') + } finally { + savingRef.current = false + // A change landed while we were saving — flush it. + if (dirtyRef.current) scheduleSave() + } + } + + const scheduleSave = () => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current) + saveTimerRef.current = setTimeout(() => { void persist() }, SAVE_DEBOUNCE_MS) + } + + const handleChange = () => { + if (!armedRef.current) return + dirtyRef.current = true + scheduleSave() + } + + // Flush a pending save when navigating away or unmounting. + useEffect(() => { + return () => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current) + if (dirtyRef.current) void persist() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [path]) + + if (loadState === 'error') { + return ( +
+ +

Cannot open this document

+

The file may be corrupted or not a valid Word document.

+ +
+ ) + } + + if (loadState === 'loading' || !buffer) { + return ( +
+ +

Loading document…

+
+ ) + } + + return ( +
+ + +

Loading editor…

+
+ } + > + { console.error('docx editor error:', err) }} + className="flex-1 min-h-0" + /> + + {saveState !== 'idle' && ( +
+ {saveState === 'saving' ? 'Saving…' : saveState === 'saved' ? 'Saved' : 'Save failed'} +
+ )} +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index deed545c..dea0561e 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -69,6 +69,31 @@ function snippet(text?: string): string { return (text || '').replace(/\s+/g, ' ').trim().slice(0, 180) } +function isReplyQuoteBoundary(lines: string[], index: number): boolean { + const line = lines[index]?.trim() || '' + if (/^On\b.+\bwrote:\s*$/i.test(line)) return true + if (/^-{2,}\s*(Original Message|Forwarded message)\s*-{2,}$/i.test(line)) return true + if (/^From:\s+\S/i.test(line)) { + const next = lines.slice(index + 1, index + 6).map((value) => value.trim()) + return next.some((value) => /^(Sent|Date):\s+\S/i.test(value)) + && next.some((value) => /^To:\s+\S/i.test(value)) + && next.some((value) => /^Subject:\s+\S/i.test(value)) + } + return false +} + +function stripQuotedReplyText(text: string): string { + const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n') + const boundary = lines.findIndex((line, index) => { + if (isReplyQuoteBoundary(lines, index)) return true + return index > 0 + && line.trim().startsWith('>') + && (lines[index - 1]?.trim() === '' || lines[index - 1]?.trim().startsWith('>')) + }) + const visible = boundary >= 0 ? lines.slice(0, boundary) : lines + return visible.join('\n').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim() +} + function getInitial(from?: string): string { return (extractName(from)[0] || '?').toUpperCase() } @@ -692,7 +717,7 @@ function ComposeBox({ const initialContent = useMemo(() => { if (mode === 'forward') return buildForwardedContent(thread) // Gmail-side draft (user's own work) wins over the AI-generated draft. - const source = thread.gmail_draft || thread.draft_response + const source = stripQuotedReplyText(thread.gmail_draft || thread.draft_response || '') if (!source) return '' return source .split(/\n{2,}/) @@ -1048,8 +1073,7 @@ function ThreadDetail({ const MAX_KEPT_OPEN = 5 const PAGE_SIZE = 25 -const SECTIONS = ['important', 'other'] as const -type InboxSection = (typeof SECTIONS)[number] +type InboxSection = 'important' | 'other' interface SectionState { threads: GmailThread[] diff --git a/apps/x/apps/renderer/src/components/workspace-view.tsx b/apps/x/apps/renderer/src/components/workspace-view.tsx index 6cbd1075..6923ac1c 100644 --- a/apps/x/apps/renderer/src/components/workspace-view.tsx +++ b/apps/x/apps/renderer/src/components/workspace-view.tsx @@ -1,7 +1,8 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { ChevronRight, Copy, + ExternalLink, File as FileIcon, FilePlus, Folder as FolderIcon, @@ -53,12 +54,18 @@ type WorkspaceActions = { remove: (path: string) => Promise copyPath: (path: string) => void revealInFileManager: (path: string, isDir: boolean) => void + createNote: (parentPath?: string) => void + createFolder: (parentPath?: string) => Promise + onOpenInNewTab?: (path: string) => void } type WorkspaceViewProps = { tree: TreeNode[] initialPath?: string | null actions: WorkspaceActions + // Folder currently being browsed. Controlled by the app so drill-down + // participates in the global back/forward history. + onNavigate: (path: string) => void onOpenNote: (path: string) => void onCreateWorkspace: (name: string) => Promise } @@ -71,6 +78,12 @@ function getFileManagerName(): string { return 'File Manager' } +function fileExtensionLabel(name: string): string { + const dot = name.lastIndexOf('.') + if (dot <= 0 || dot === name.length - 1) return 'File' + return `${name.slice(dot + 1).toUpperCase()} file` +} + function findNode(nodes: TreeNode[] | undefined, path: string): TreeNode | null { if (!nodes) return null for (const node of nodes) { @@ -113,8 +126,8 @@ function readFileAsBase64(file: File): Promise { }) } -export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreateWorkspace }: WorkspaceViewProps) { - const [currentPath, setCurrentPath] = useState(initialPath || WORKSPACE_ROOT) +export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNote, onCreateWorkspace }: WorkspaceViewProps) { + const currentPath = initialPath || WORKSPACE_ROOT const [addOpen, setAddOpen] = useState(false) const [newName, setNewName] = useState('') const [creating, setCreating] = useState(false) @@ -127,10 +140,6 @@ export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreate const filesInputRef = useRef(null) const folderInputRef = useRef(null) - useEffect(() => { - if (initialPath) setCurrentPath(initialPath) - }, [initialPath]) - const isRoot = currentPath === WORKSPACE_ROOT const fileManagerName = getFileManagerName() @@ -160,12 +169,12 @@ export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreate (item: TreeNode) => { if (renameTarget) return if (item.kind === 'dir') { - setCurrentPath(item.path) + onNavigate(item.path) } else { onOpenNote(item.path) } }, - [onOpenNote, renameTarget], + [onNavigate, onOpenNote, renameTarget], ) const beginRename = useCallback((item: TreeNode) => { @@ -295,7 +304,7 @@ export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreate
- {isRoot ? ( - - ) : ( - - - - - - filesInputRef.current?.click()}> - - Add files… - - folderInputRef.current?.click()}> - - Add folder… - - - - )} + {isRoot ? ( + + ) : ( + + + + + + filesInputRef.current?.click()}> + + Add files… + + folderInputRef.current?.click()}> + + Add folder… + + + + )} + {item.name} )} - {item.kind === 'dir' && !isRenaming && ( -
- {childCount} {childCount === 1 ? 'item' : 'items'} + {!isRenaming && ( +
+ {item.kind === 'dir' + ? `${childCount} ${childCount === 1 ? 'item' : 'items'}` + : fileExtensionLabel(item.name)}
)}
) + const isDir = item.kind === 'dir' return ( {card} - - beginRename(item)}> - - Rename - + e.preventDefault()}> + {isDir && ( + <> + actions.createNote(item.path)}> + + New Note + + void actions.createFolder(item.path)}> + + New Folder + + + + )} + {!isDir && actions.onOpenInNewTab && ( + <> + actions.onOpenInNewTab!(item.path)}> + + Open in new tab + + + + )} { actions.copyPath(item.path); toast('Path copied', 'success') }}> Copy Path - actions.revealInFileManager(item.path, item.kind === 'dir')}> + actions.revealInFileManager(item.path, isDir)}> - Show in {fileManagerName} + Open in {fileManagerName} + beginRename(item)}> + + Rename + void handleDelete(item)}> Delete diff --git a/apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts b/apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts index 82220782..5bc5cec0 100644 --- a/apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts +++ b/apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react' import posthog from 'posthog-js' +import { identifyUser, resetAnalyticsIdentity } from '@/lib/analytics' /** * Identifies the user in PostHog when signed into Rowboat, @@ -17,7 +18,7 @@ export function useAnalyticsIdentity() { // Identify if Rowboat account is connected const rowboat = config.rowboat if (rowboat?.connected && rowboat?.userId) { - posthog.identify(rowboat.userId) + identifyUser(rowboat.userId) } // Set provider connection flags @@ -69,7 +70,7 @@ export function useAnalyticsIdentity() { // Rowboat sign-in if (event.success) { if (event.userId) { - posthog.identify(event.userId) + identifyUser(event.userId) } posthog.people.set({ signed_in: true, rowboat_connected: true }) posthog.capture('user_signed_in') @@ -80,7 +81,7 @@ export function useAnalyticsIdentity() { // future events on this device don't get attributed to the prior user. posthog.people.set({ signed_in: false, rowboat_connected: false }) posthog.capture('user_signed_out') - posthog.reset() + resetAnalyticsIdentity() }) return cleanup diff --git a/apps/x/apps/renderer/src/lib/analytics.ts b/apps/x/apps/renderer/src/lib/analytics.ts index 672ea0c3..de837bab 100644 --- a/apps/x/apps/renderer/src/lib/analytics.ts +++ b/apps/x/apps/renderer/src/lib/analytics.ts @@ -1,5 +1,42 @@ import posthog from 'posthog-js' +let appVersion: string | undefined +let apiUrl: string | undefined + +function appVersionProperties(): Record { + return appVersion ? { app_version: appVersion } : {} +} + +export function configureAnalyticsContext(props: { appVersion?: string; apiUrl?: string }) { + appVersion = props.appVersion?.trim() || undefined + apiUrl = props.apiUrl?.trim() || undefined + + const eventProperties = appVersionProperties() + if (Object.keys(eventProperties).length > 0) { + posthog.register(eventProperties) + } + + const personProperties = { + ...(apiUrl ? { api_url: apiUrl } : {}), + ...eventProperties, + } + if (Object.keys(personProperties).length > 0) { + posthog.people.set(personProperties) + } +} + +export function identifyUser(userId: string, properties?: Record) { + posthog.identify(userId, { + ...properties, + ...appVersionProperties(), + }) +} + +export function resetAnalyticsIdentity() { + posthog.reset() + configureAnalyticsContext({ appVersion, apiUrl }) +} + export function chatSessionCreated(runId: string) { posthog.capture('chat_session_created', { run_id: runId }) } diff --git a/apps/x/apps/renderer/src/lib/file-types.ts b/apps/x/apps/renderer/src/lib/file-types.ts index d4477f7a..c293ac6f 100644 --- a/apps/x/apps/renderer/src/lib/file-types.ts +++ b/apps/x/apps/renderer/src/lib/file-types.ts @@ -6,7 +6,7 @@ * also uses it to decide what to keep mounted. */ -export type ViewerType = 'html' | 'image' | 'video' | 'audio' | 'pdf' +export type ViewerType = 'html' | 'image' | 'video' | 'audio' | 'pdf' | 'docx' const VIEWER_BY_EXT: Record = { html: 'html', @@ -31,6 +31,7 @@ const VIEWER_BY_EXT: Record = { flac: 'audio', aac: 'audio', pdf: 'pdf', + docx: 'docx', } function extensionOf(path: string): string { diff --git a/apps/x/apps/renderer/src/main.tsx b/apps/x/apps/renderer/src/main.tsx index fedc029c..7999061d 100644 --- a/apps/x/apps/renderer/src/main.tsx +++ b/apps/x/apps/renderer/src/main.tsx @@ -2,9 +2,10 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' -import posthog from 'posthog-js' import { PostHogProvider } from 'posthog-js/react' +import type { CaptureResult } from 'posthog-js' import { ThemeProvider } from '@/contexts/theme-context' +import { configureAnalyticsContext } from './lib/analytics' // Fetch the stable installation ID from main so renderer + main share one // PostHog distinct_id. Falls back to PostHog's auto-generated anonymous ID @@ -12,19 +13,36 @@ import { ThemeProvider } from '@/contexts/theme-context' async function bootstrap() { let installationId: string | undefined let apiUrl: string | undefined + let appVersion: string | undefined try { const result = await window.ipc.invoke('analytics:bootstrap', null) installationId = result.installationId apiUrl = result.apiUrl + appVersion = result.appVersion } catch (err) { console.error('[Analytics] Failed to bootstrap from main:', err) } + configureAnalyticsContext({ apiUrl, appVersion }) + const options = { api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST, - defaults: '2025-11-30', + defaults: '2025-11-30' as const, ...(installationId ? { bootstrap: { distinctID: installationId } } : {}), - } as const + before_send: (event: CaptureResult | null) => { + if (!event) return event + if (appVersion) { + event.properties = { + ...event.properties, + app_version: appVersion, + } + } + return event + }, + loaded: () => { + configureAnalyticsContext({ apiUrl, appVersion }) + }, + } createRoot(document.getElementById('root')!).render( @@ -36,11 +54,7 @@ async function bootstrap() { , ) - // Tag the active person record with api_url so anonymous users are also - // segmentable by environment. - if (apiUrl) { - posthog.people.set({ api_url: apiUrl }) - } + // The loaded callback applies api_url/app_version once PostHog has initialized. } bootstrap() diff --git a/apps/x/packages/core/src/analytics/posthog.ts b/apps/x/packages/core/src/analytics/posthog.ts index 156194d9..d3d1e55c 100644 --- a/apps/x/packages/core/src/analytics/posthog.ts +++ b/apps/x/packages/core/src/analytics/posthog.ts @@ -6,6 +6,7 @@ import { API_URL } from '../config/env.js'; // In dev/tsc, fall back to process.env so local runs work too. const POSTHOG_KEY = process.env.POSTHOG_KEY ?? process.env.VITE_PUBLIC_POSTHOG_KEY ?? ''; const POSTHOG_HOST = process.env.POSTHOG_HOST ?? process.env.VITE_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com'; +const APP_VERSION = (process.env.ROWBOAT_APP_VERSION ?? process.env.npm_package_version ?? '').trim(); let client: PostHog | null = null; let initAttempted = false; @@ -29,7 +30,7 @@ function getClient(): PostHog | null { // distinguishes prod / staging / custom — meaning is assigned in PostHog). client.identify({ distinctId: getInstallationId(), - properties: { api_url: API_URL }, + properties: { api_url: API_URL, ...appVersionProperties() }, }); } catch (err) { console.error('[Analytics] Failed to init PostHog:', err); @@ -42,6 +43,10 @@ function activeDistinctId(): string { return identifiedUserId ?? getInstallationId(); } +function appVersionProperties(): Record { + return APP_VERSION ? { app_version: APP_VERSION } : {}; +} + export function capture(event: string, properties?: Record): void { const ph = getClient(); if (!ph) return; @@ -49,7 +54,10 @@ export function capture(event: string, properties?: Record): vo ph.capture({ distinctId: activeDistinctId(), event, - properties, + properties: { + ...properties, + ...appVersionProperties(), + }, }); } catch (err) { console.error('[Analytics] capture failed:', err); @@ -68,6 +76,7 @@ export function identify(userId: string, properties?: Record): properties: { ...properties, api_url: API_URL, + ...appVersionProperties(), }, }); identifiedUserId = userId; diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.test.ts b/apps/x/packages/core/src/knowledge/sync_gmail.test.ts new file mode 100644 index 00000000..5da55bbc --- /dev/null +++ b/apps/x/packages/core/src/knowledge/sync_gmail.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { + sanitizeReplyBodyForGmailReply, + stripGmailQuotedReplyHtml, + stripGmailQuotedReplyText, +} from './sync_gmail.js'; + +describe('Gmail reply body sanitization', () => { + it('strips Gmail quote attribution and older quoted text from plain text replies', () => { + const body = [ + 'Sounds good, thanks. I will send it over today.', + '', + 'On Thu, 28 May 2026 at 23:45, PRAKHAR wrote:', + '> Can you share the final file?', + '> Thanks', + ].join('\n'); + + expect(stripGmailQuotedReplyText(body)).toBe('Sounds good, thanks. I will send it over today.'); + }); + + it('strips Gmail quote blocks from html replies', () => { + const html = [ + '

Sounds good, thanks.

', + '
', + '
On Thu, 28 May 2026 at 23:45, PRAKHAR wrote:
', + '
Older thread text
', + '
', + ].join(''); + + expect(stripGmailQuotedReplyHtml(html)).toBe('

Sounds good, thanks.

'); + }); + + it('regenerates html from clean text if only the text boundary is detected', () => { + const result = sanitizeReplyBodyForGmailReply( + '

Sounds good, thanks.

Older thread text

', + 'Sounds good, thanks.\n\nOn Thu, 28 May 2026 at 23:45, PRAKHAR wrote:\nOlder thread text', + ); + + expect(result.bodyText).toBe('Sounds good, thanks.'); + expect(result.bodyHtml).toBe('

Sounds good, thanks.

'); + }); +}); diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index 6b131a5d..77055f37 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -35,7 +35,7 @@ const nhm = new NodeHtmlMarkdown(); // previously cached snapshots (e.g. attachment / recipient parsing fixes). The // short-circuit in buildAndCacheSnapshot only reuses a cache whose version matches, // so stale entries are transparently rebuilt on the next sync. -const SNAPSHOT_PARSER_VERSION = 2; +const SNAPSHOT_PARSER_VERSION = 3; interface SnapshotCacheEntry { historyId: string; @@ -405,6 +405,112 @@ function normalizeBody(body: string): string { return body.replace(/\r\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim(); } +function isGmailQuoteAttribution(line: string): boolean { + const trimmed = line.trim(); + return /^On\b.+\bwrote:\s*$/i.test(trimmed); +} + +function isOriginalMessageBoundary(line: string): boolean { + return /^-{2,}\s*Original Message\s*-{2,}$/i.test(line.trim()); +} + +function isForwardedMessageBoundary(line: string): boolean { + return /^-{2,}\s*Forwarded message\s*-{2,}$/i.test(line.trim()); +} + +function isOutlookHeaderBoundary(lines: string[], index: number): boolean { + if (!/^From:\s+\S/i.test(lines[index]?.trim() || '')) return false; + const next = lines.slice(index + 1, index + 6).map((line) => line.trim()); + return next.some((line) => /^(Sent|Date):\s+\S/i.test(line)) + && next.some((line) => /^To:\s+\S/i.test(line)) + && next.some((line) => /^Subject:\s+\S/i.test(line)); +} + +function findQuotedReplyBoundary(lines: string[]): number { + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i] || ''; + if ( + isGmailQuoteAttribution(line) + || isOriginalMessageBoundary(line) + || isForwardedMessageBoundary(line) + || isOutlookHeaderBoundary(lines, i) + ) { + return i; + } + + // Gmail plain text drafts often carry older messages as a quoted block. + // Treat a trailing blockquote as history, but avoid stripping an inline + // quote the user is actively writing at the top of the reply. + if (i > 0 && line.trim().startsWith('>') && (lines[i - 1]?.trim() === '' || lines[i - 1]?.trim().startsWith('>'))) { + return i; + } + } + return -1; +} + +export function stripGmailQuotedReplyText(text: string): string { + const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const lines = normalized.split('\n'); + const boundary = findQuotedReplyBoundary(lines); + const visible = boundary >= 0 ? lines.slice(0, boundary) : lines; + return visible + .join('\n') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +function htmlQuoteBoundaryIndex(html: string): number { + const candidates: number[] = []; + const patterns = [ + /<[^>]+\bclass\s*=\s*["'][^"']*\bgmail_(?:quote|attr)\b[^"']*["'][^>]*>/i, + /]*(?:type\s*=\s*["']cite["']|class\s*=\s*["'][^"']*\bgmail_quote\b[^"']*["'])[^>]*>/i, + /<(p|div|li)\b[^>]*>\s*(?:<(?:span|b|strong|i|em)\b[^>]*>\s*)*On\b[\s\S]{0,800}?\bwrote:\s*(?:\s*)?(?:<\/(?:span|b|strong|i|em)>\s*)*<\/\1>/i, + /<(p|div|li)\b[^>]*>\s*-{2,}\s*(?:Original Message|Forwarded message)\s*-{2,}\s*<\/\1>/i, + ]; + + for (const pattern of patterns) { + const match = pattern.exec(html); + if (match?.index !== undefined) candidates.push(match.index); + } + + return candidates.length > 0 ? Math.min(...candidates) : -1; +} + +export function stripGmailQuotedReplyHtml(html: string): string { + const boundary = htmlQuoteBoundaryIndex(html); + const visible = boundary >= 0 ? html.slice(0, boundary) : html; + return visible.trim(); +} + +function textToHtml(text: string): string { + return text + .split(/\n{2,}/) + .map((para) => `

${escapeHtml(para).replace(/\n/g, '
')}

`) + .join(''); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function sanitizeReplyBodyForGmailReply(bodyHtml: string, bodyText: string): { bodyHtml: string; bodyText: string } { + const cleanText = stripGmailQuotedReplyText(bodyText); + const cleanHtml = stripGmailQuotedReplyHtml(bodyHtml); + const textWasStripped = cleanText !== bodyText.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim(); + const htmlWasStripped = cleanHtml !== bodyHtml.trim(); + + return { + bodyText: cleanText, + bodyHtml: textWasStripped && !htmlWasStripped ? textToHtml(cleanText) : cleanHtml, + }; +} + function headerValue(headers: gmail.Schema$MessagePartHeader[] | undefined, name: string): string | undefined { return headers?.find(h => h.name?.toLowerCase() === name.toLowerCase())?.value || undefined; } @@ -636,9 +742,13 @@ async function buildAndCacheSnapshot( const sentMessages = parsed.filter((m) => !m.isDraft); const draftMessages = parsed.filter((m) => m.isDraft); - const visibleMessages = sentMessages.map(({ isDraft: _isDraft, ...rest }) => rest); + const visibleMessages = sentMessages.map((msg) => { + const rest: Partial = { ...msg }; + delete rest.isDraft; + return rest as Omit; + }); const latestDraftBody = draftMessages.length > 0 - ? draftMessages[draftMessages.length - 1]!.body.trim() + ? stripGmailQuotedReplyText(draftMessages[draftMessages.length - 1]!.body) : ''; if (visibleMessages.length === 0) return null; @@ -674,7 +784,10 @@ async function buildAndCacheSnapshot( const classification = await classifyThread(snapshot, userEmail, { skipDraft }); snapshot.importance = classification.importance; if (classification.summary) snapshot.summary = classification.summary; - if (classification.draftResponse) snapshot.draft_response = classification.draftResponse; + if (classification.draftResponse) { + const draftResponse = stripGmailQuotedReplyText(classification.draftResponse); + if (draftResponse) snapshot.draft_response = draftResponse; + } } catch (err) { console.warn(`[Gmail] classify failed for ${threadId}:`, err); } @@ -947,16 +1060,20 @@ async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: str // If the state file holds a last_sync timestamp (e.g. left over from a // prior Composio sync, or from a previous successful native sync that // we're falling back to after a history.list 404), use that as the - // floor instead of the default lookback. Carries forward Composio's - // last_sync on first migration so we don't refetch the last 7 days. + // floor — but never reach back further than lookbackDays. This caps the + // window at "1 week at most": if last_sync is within the lookback window + // we resume from it (a smaller window), otherwise we clamp to lookbackDays + // ago. Mail older than the cap that arrived during a long offline gap is + // intentionally skipped rather than backfilled. const state = loadState(stateFile); + const lookbackFloor = new Date(); + lookbackFloor.setDate(lookbackFloor.getDate() - lookbackDays); let pastDate: Date; - if (state.last_sync) { + if (state.last_sync && new Date(state.last_sync) > lookbackFloor) { pastDate = new Date(state.last_sync); console.log(`Performing full sync from last_sync=${state.last_sync}...`); } else { - pastDate = new Date(); - pastDate.setDate(pastDate.getDate() - lookbackDays); + pastDate = lookbackFloor; console.log(`Performing full sync of last ${lookbackDays} days...`); } @@ -1222,12 +1339,22 @@ async function performSync() { // this runs once, the cache directory is populated and we fall back to // partial-sync on subsequent calls. const cacheMissing = !fs.existsSync(CACHE_DIR) || fs.readdirSync(CACHE_DIR).length === 0; + // partialSync replays *every* messageAdded since the stored historyId, + // regardless of date — so after a long offline gap a still-valid + // historyId would pull the entire gap (e.g. 3 weeks). To honor the + // "1 week at most" cap, bypass it when last_sync is older than the + // lookback window and run a (date-clamped) fullSync instead. + const gapMs = state.last_sync ? Date.now() - new Date(state.last_sync).getTime() : 0; + const gapTooLarge = gapMs > LOOKBACK_DAYS * 24 * 60 * 60 * 1000; if (!state.historyId) { console.log("No history ID found, starting full sync..."); await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS); } else if (cacheMissing) { console.log("History ID present but inbox cache empty — running full sync to backfill snapshots..."); await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS); + } else if (gapTooLarge) { + console.log(`Last sync older than ${LOOKBACK_DAYS} days — running full sync clamped to the lookback window instead of partial sync...`); + await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS); } else { console.log("History ID found, starting partial sync..."); await partialSync(auth, state.historyId, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS); @@ -1330,6 +1457,10 @@ export async function sendThreadReply(opts: SendReplyOptions): Promise=3.25.76 <5' + '@eigenpal/docx-editor-agents@1.0.3': + resolution: {integrity: sha512-Bk/J9/PBnMCOxb6w4cHQiCTuN/1C4FtZM9evC9EXXcLP13yFMdqoEqsYs+Lh3HyaRRAaCZTrkfgOZyTqqyjtwQ==} + peerDependencies: + '@ai-sdk/vue': ^2.0.0 + ai: ^5.0.0 || ^6.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@ai-sdk/vue': + optional: true + ai: + optional: true + react: + optional: true + vue: + optional: true + + '@eigenpal/docx-editor-core@1.0.3': + resolution: {integrity: sha512-etpupuln9ZlHLW4DgS7877WBdMEChsAG0D1bEZLjF70isYbyxrd2ARWax745P7XMm4GqqkAfByzxE2GGWQmJaA==} + hasBin: true + peerDependencies: + prosemirror-commands: ^1.5.2 + prosemirror-dropcursor: ^1.8.2 + prosemirror-history: ^1.4.0 + prosemirror-keymap: ^1.2.2 + prosemirror-model: ^1.19.4 + prosemirror-state: ^1.4.3 + prosemirror-tables: ^1.8.5 + prosemirror-transform: ^1.12.0 + prosemirror-view: ^1.32.7 + + '@eigenpal/docx-editor-i18n@1.0.3': + resolution: {integrity: sha512-zwz/S+duPOnzg/kh4bs28T3UqI8mKMzHdmFgbWgMxwtTfUkAxaUAnAVbuZgrysl1aD2scv4Hfy4EgOZcFy9NnA==} + + '@eigenpal/docx-editor-react@1.0.3': + resolution: {integrity: sha512-KupDVHo6KC4KUs48bM1pMYFFbDJqkW8XyIhgsnLx+BWk2yOPU4bx2HfWB6H+JEVROA1h1AmhTAyE39gk75wg5w==} + peerDependencies: + prosemirror-commands: ^1.5.2 + prosemirror-dropcursor: ^1.8.2 + prosemirror-history: ^1.4.0 + prosemirror-keymap: ^1.2.2 + prosemirror-model: ^1.19.4 + prosemirror-state: ^1.4.3 + prosemirror-tables: ^1.8.5 + prosemirror-transform: ^1.12.0 + prosemirror-view: ^1.41.6 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + '@electron-forge/cli@7.11.1': resolution: {integrity: sha512-pk8AoLsr7t7LBAt0cFD06XFA6uxtPdvtLx06xeal7O9o7GHGCbj29WGwFoJ8Br/ENM0Ho868S3PrAn1PtBXt5g==} engines: {node: '>= 16.4.0'} @@ -1488,30 +1578,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-arm64-musl@0.1.80': resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-x64-gnu@0.1.80': resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-x64-musl@0.1.80': resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/canvas-win32-x64-msvc@0.1.80': resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==} @@ -2634,56 +2729,67 @@ packages: resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.54.0': resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.54.0': resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.54.0': resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.54.0': resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.54.0': resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.54.0': resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.54.0': resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.54.0': resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.54.0': resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.54.0': resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.54.0': resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} @@ -3002,24 +3108,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -3155,6 +3265,12 @@ packages: peerDependencies: '@tiptap/extension-list': 3.22.5 + '@tiptap/extension-list@3.22.4': + resolution: {integrity: sha512-Xe8UFvvHmyp/c/TJsFwlwU9CWACYbBirNsluJ3U1+H8BTu1wqdrT/AXR5uIXeyCl5kiWKgX5q71eHWbYFOrqrg==} + peerDependencies: + '@tiptap/core': 3.22.4 + '@tiptap/pm': 3.22.4 + '@tiptap/extension-list@3.22.5': resolution: {integrity: sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==} peerDependencies: @@ -3207,6 +3323,12 @@ packages: peerDependencies: '@tiptap/core': 3.22.5 + '@tiptap/extensions@3.22.4': + resolution: {integrity: sha512-fOe8VptJvLPs32bNdUYo8SRyljwqKNQVXWW056VoXIc5en/59OdJlJQVeHI0jRRciH3MtrqODi/gfJR0VHNZ8A==} + peerDependencies: + '@tiptap/core': 3.22.4 + '@tiptap/pm': 3.22.4 + '@tiptap/extensions@3.22.5': resolution: {integrity: sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==} peerDependencies: @@ -3663,6 +3785,10 @@ packages: engines: {node: '>=10.0.0'} deprecated: this version has critical issues, please update to the latest version + '@xmldom/xmldom@0.9.10': + resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} + engines: {node: '>=14.6'} + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -4472,6 +4598,10 @@ packages: dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} + docxtemplater@3.68.7: + resolution: {integrity: sha512-FwgeAKqY2vc9eVm2V2XGg8bq25B0OQjtSDITGi9zNnvu5GbtR4WvGjM5QNld/ALB6ZbsSuHskBPK9SvPpKhsbA==} + engines: {node: '>=0.10'} + dom-serializer@0.2.2: resolution: {integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==} @@ -5655,24 +5785,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -6386,6 +6520,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + papaparse@5.5.3: resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} @@ -6499,6 +6636,9 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + pizzip@3.2.0: + resolution: {integrity: sha512-X4NPNICxCfIK8VYhF6wbksn81vTiziyLbvKuORVAmolvnUzl1A1xmz9DAWKxPRq9lZg84pJOOAMq3OE61bD8IQ==} + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -6605,8 +6745,8 @@ packages: prosemirror-markdown@1.13.2: resolution: {integrity: sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==} - prosemirror-model@1.25.4: - resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==} + prosemirror-model@1.25.7: + resolution: {integrity: sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug==} prosemirror-schema-list@1.5.1: resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} @@ -6617,11 +6757,11 @@ packages: prosemirror-tables@1.8.5: resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} - prosemirror-transform@1.10.5: - resolution: {integrity: sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==} + prosemirror-transform@1.12.0: + resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} - prosemirror-view@1.41.4: - resolution: {integrity: sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==} + prosemirror-view@1.41.8: + resolution: {integrity: sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==} protobufjs@7.5.4: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} @@ -6979,6 +7119,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + scheduler@0.25.0-rc-603e6108-20241029: resolution: {integrity: sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==} @@ -7800,6 +7944,10 @@ packages: engines: {node: '>=0.8'} hasBin: true + xml-js@1.6.11: + resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} + hasBin: true + xmlbuilder2@2.1.2: resolution: {integrity: sha512-PI710tmtVlQ5VmwzbRTuhmVhKnj9pM8Si+iOZCV2g2SNo3gCrpzR2Ka9wNzZtqfD+mnP+xkrqoNy0sjKZqP4Dg==} engines: {node: '>=8.0'} @@ -8588,6 +8736,61 @@ snapshots: dependencies: zod: 4.2.1 + '@eigenpal/docx-editor-agents@1.0.3(ai@5.0.117(zod@4.2.1))(react@19.2.3)': + dependencies: + docxtemplater: 3.68.7 + jszip: 3.10.1 + pizzip: 3.2.0 + xml-js: 1.6.11 + optionalDependencies: + ai: 5.0.117(zod@4.2.1) + react: 19.2.3 + + '@eigenpal/docx-editor-core@1.0.3(prosemirror-commands@1.7.1)(prosemirror-dropcursor@1.8.2)(prosemirror-history@1.5.0)(prosemirror-keymap@1.2.3)(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-tables@1.8.5)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8)': + dependencies: + docxtemplater: 3.68.7 + jszip: 3.10.1 + pizzip: 3.2.0 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-history: 1.5.0 + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.7 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + xml-js: 1.6.11 + + '@eigenpal/docx-editor-i18n@1.0.3': {} + + '@eigenpal/docx-editor-react@1.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(ai@5.0.117(zod@4.2.1))(prosemirror-commands@1.7.1)(prosemirror-dropcursor@1.8.2)(prosemirror-history@1.5.0)(prosemirror-keymap@1.2.3)(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-tables@1.8.5)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@eigenpal/docx-editor-agents': 1.0.3(ai@5.0.117(zod@4.2.1))(react@19.2.3) + '@eigenpal/docx-editor-core': 1.0.3(prosemirror-commands@1.7.1)(prosemirror-dropcursor@1.8.2)(prosemirror-history@1.5.0)(prosemirror-keymap@1.2.3)(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-tables@1.8.5)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8) + '@eigenpal/docx-editor-i18n': 1.0.3 + '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + clsx: 2.1.1 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-history: 1.5.0 + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.7 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + sonner: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + optionalDependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - '@ai-sdk/vue' + - '@types/react' + - '@types/react-dom' + - ai + - vue + '@electron-forge/cli@7.11.1(encoding@0.1.13)(esbuild@0.24.2)': dependencies: '@electron-forge/core': 7.11.1(encoding@0.1.13)(esbuild@0.24.2) @@ -11264,6 +11467,11 @@ snapshots: dependencies: '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': + dependencies: + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/pm': 3.22.4 + '@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': dependencies: '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) @@ -11277,9 +11485,9 @@ snapshots: dependencies: '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/extension-placeholder@3.22.4(@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': + '@tiptap/extension-placeholder@3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extensions': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) '@tiptap/extension-strike@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': dependencies: @@ -11290,13 +11498,13 @@ snapshots: '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) '@tiptap/pm': 3.22.4 - '@tiptap/extension-task-item@3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': + '@tiptap/extension-task-item@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) - '@tiptap/extension-task-list@3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': + '@tiptap/extension-task-list@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) '@tiptap/extension-text@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': dependencies: @@ -11306,6 +11514,11 @@ snapshots: dependencies: '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': + dependencies: + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/pm': 3.22.4 + '@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': dependencies: '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) @@ -11319,12 +11532,12 @@ snapshots: prosemirror-gapcursor: 1.4.0 prosemirror-history: 1.5.0 prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.7 prosemirror-schema-list: 1.5.1 prosemirror-state: 1.4.4 prosemirror-tables: 1.8.5 - prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 '@tiptap/react@3.22.4(@floating-ui/dom@1.7.4)(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: @@ -11939,6 +12152,8 @@ snapshots: '@xmldom/xmldom@0.8.11': {} + '@xmldom/xmldom@0.9.10': {} + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -12763,6 +12978,10 @@ snapshots: minimatch: 3.1.2 p-limit: 3.1.0 + docxtemplater@3.68.7: + dependencies: + '@xmldom/xmldom': 0.9.10 + dom-serializer@0.2.2: dependencies: domelementtype: 2.3.0 @@ -15227,6 +15446,8 @@ snapshots: pako@1.0.11: {} + pako@2.1.0: {} + papaparse@5.5.3: {} parent-module@1.0.1: @@ -15324,6 +15545,10 @@ snapshots: pify@4.0.1: {} + pizzip@3.2.0: + dependencies: + pako: 2.1.0 + pkce-challenge@5.0.1: {} pkg-types@1.3.1: @@ -15412,32 +15637,32 @@ snapshots: prosemirror-changeset@2.3.1: dependencies: - prosemirror-transform: 1.10.5 + prosemirror-transform: 1.12.0 prosemirror-commands@1.7.1: dependencies: - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.7 prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 + prosemirror-transform: 1.12.0 prosemirror-dropcursor@1.8.2: dependencies: prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 prosemirror-gapcursor@1.4.0: dependencies: prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.7 prosemirror-state: 1.4.4 - prosemirror-view: 1.41.4 + prosemirror-view: 1.41.8 prosemirror-history@1.5.0: dependencies: prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 rope-sequence: 1.3.4 prosemirror-keymap@1.2.3: @@ -15449,41 +15674,41 @@ snapshots: dependencies: '@types/markdown-it': 14.1.2 markdown-it: 14.1.0 - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.7 - prosemirror-model@1.25.4: + prosemirror-model@1.25.7: dependencies: orderedmap: 2.1.1 prosemirror-schema-list@1.5.1: dependencies: - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.7 prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 + prosemirror-transform: 1.12.0 prosemirror-state@1.4.4: dependencies: - prosemirror-model: 1.25.4 - prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.4 + prosemirror-model: 1.25.7 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 prosemirror-tables@1.8.5: dependencies: prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.7 prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 - prosemirror-transform@1.10.5: + prosemirror-transform@1.12.0: dependencies: - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.7 - prosemirror-view@1.41.4: + prosemirror-view@1.41.8: dependencies: - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.7 prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 + prosemirror-transform: 1.12.0 protobufjs@7.5.4: dependencies: @@ -15986,6 +16211,8 @@ snapshots: safer-buffer@2.1.2: {} + sax@1.6.0: {} + scheduler@0.25.0-rc-603e6108-20241029: {} scheduler@0.27.0: {} @@ -16884,6 +17111,10 @@ snapshots: wmf: 1.0.2 word: 0.3.0 + xml-js@1.6.11: + dependencies: + sax: 1.6.0 + xmlbuilder2@2.1.2: dependencies: '@oozcitak/dom': 1.15.5