From b116335f9dbde4f483c0b2b8e7bfca5d321c5dfc Mon Sep 17 00:00:00 2001 From: haoyuren <13851610112@163.com> Date: Thu, 12 Mar 2026 17:52:53 -0500 Subject: Add bidirectional file sync, OT system, comments, and real-time collaboration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement full Overleaf integration with Socket.IO v0.9 real-time sync: - FileSyncBridge for bidirectional temp dir ↔ Overleaf sync via chokidar + diff-match-patch - OT state machine, transform functions, and CM6 adapter for collaborative editing - Comment system with highlights, tooltips, and review panel - Project list, file tree management, and socket-based compilation - 3-layer loop prevention (write guards, content equality, debounce) Co-Authored-By: Claude Opus 4.6 --- src/main/compilationManager.ts | 162 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 src/main/compilationManager.ts (limited to 'src/main/compilationManager.ts') diff --git a/src/main/compilationManager.ts b/src/main/compilationManager.ts new file mode 100644 index 0000000..3529345 --- /dev/null +++ b/src/main/compilationManager.ts @@ -0,0 +1,162 @@ +// Manages temp directory for Overleaf socket-mode compilation +import { join, basename } from 'path' +import { writeFile, mkdir, rm } from 'fs/promises' +import { existsSync } from 'fs' +import { spawn } from 'child_process' +import { net } from 'electron' + +export class CompilationManager { + private tmpDir: string + private projectId: string + private cookie: string + private docContents = new Map() // docPath → content + private fileRefCache = new Map() // fileRefPath → downloaded + + constructor(projectId: string, cookie: string) { + this.projectId = projectId + this.cookie = cookie + this.tmpDir = join(require('os').tmpdir(), `claudetex-${projectId}`) + } + + get dir(): string { + return this.tmpDir + } + + /** Check if a doc is already stored */ + hasDoc(relativePath: string): boolean { + return this.docContents.has(relativePath) + } + + /** Store doc content (called when docs are joined/updated) */ + setDocContent(relativePath: string, content: string) { + // Strip C1 control characters (U+0080-U+009F) — Overleaf embeds these as + // range markers for tracked changes / comments. They break pdflatex. + this.docContents.set(relativePath, content.replace(/[\u0080-\u009F]/g, '')) + } + + /** Write all doc contents to disk */ + async syncDocs(): Promise { + await mkdir(this.tmpDir, { recursive: true }) + for (const [relPath, content] of this.docContents) { + const fullPath = join(this.tmpDir, relPath) + const dir = fullPath.substring(0, fullPath.lastIndexOf('/')) + await mkdir(dir, { recursive: true }) + await writeFile(fullPath, content, 'utf-8') + } + } + + /** Download a binary file (image, .bst, etc.) from Overleaf */ + async downloadFile(fileRefId: string, relativePath: string): Promise { + if (this.fileRefCache.has(relativePath)) return + + const fullPath = join(this.tmpDir, relativePath) + const dir = fullPath.substring(0, fullPath.lastIndexOf('/')) + await mkdir(dir, { recursive: true }) + + return new Promise((resolve, reject) => { + const url = `https://www.overleaf.com/project/${this.projectId}/file/${fileRefId}` + const req = net.request(url) + req.setHeader('Cookie', this.cookie) + req.setHeader('User-Agent', 'Mozilla/5.0') + + const chunks: Buffer[] = [] + req.on('response', (res) => { + res.on('data', (chunk) => chunks.push(chunk as Buffer)) + res.on('end', async () => { + try { + const { writeFile: wf } = await import('fs/promises') + await wf(fullPath, Buffer.concat(chunks)) + this.fileRefCache.set(relativePath, true) + resolve() + } catch (e) { + reject(e) + } + }) + }) + req.on('error', reject) + req.end() + }) + } + + /** Download all binary files in the project */ + async syncBinaries(fileRefs: Array<{ id: string; path: string }>): Promise { + for (const ref of fileRefs) { + try { + await this.downloadFile(ref.id, ref.path) + } catch (e) { + console.log(`[CompilationManager] failed to download ${ref.path}:`, e) + } + } + } + + /** Run latexmk compilation */ + async compile( + mainTexRelPath: string, + onLog: (data: string) => void + ): Promise<{ success: boolean; log: string; pdfPath: string }> { + await this.syncDocs() + + const texPaths = [ + '/Library/TeX/texbin', + '/usr/local/texlive/2024/bin/universal-darwin', + '/usr/texbin', + '/opt/homebrew/bin' + ] + const envPath = texPaths.join(':') + ':' + (process.env.PATH || '') + + // Use // suffix for recursive search of ALL subdirectories in the project tree. + // This ensures .sty, .bst, .cls, images, etc. are always found regardless of nesting. + const texInputs = `${this.tmpDir}//:` + const texBase = basename(mainTexRelPath, '.tex') + const pdfPath = join(this.tmpDir, texBase + '.pdf') + + const args = [ + '-pdf', '-f', '-g', '-bibtex', '-synctex=1', + '-interaction=nonstopmode', '-file-line-error', + '-outdir=' + this.tmpDir, + mainTexRelPath + ] + console.log('[compile] cwd:', this.tmpDir) + console.log('[compile] args:', args.join(' ')) + console.log('[compile] TEXINPUTS:', texInputs) + console.log('[compile] pdfPath:', pdfPath) + console.log('[compile] docs synced:', this.docContents.size, 'files:', [...this.docContents.keys()].slice(0, 5)) + + return new Promise((resolve) => { + let log = '' + const proc = spawn('latexmk', args, { + cwd: this.tmpDir, + env: { ...process.env, PATH: envPath, TEXINPUTS: texInputs, BIBINPUTS: texInputs, BSTINPUTS: texInputs } + }) + + proc.stdout.on('data', (data) => { + const s = data.toString() + log += s + onLog(s) + }) + + proc.stderr.on('data', (data) => { + const s = data.toString() + log += s + onLog(s) + }) + + proc.on('close', (code) => { + resolve({ success: code === 0, log, pdfPath }) + }) + + proc.on('error', (err) => { + resolve({ success: false, log: log + '\n' + err.message, pdfPath }) + }) + }) + } + + /** Clean up temp directory */ + async cleanup(): Promise { + try { + if (existsSync(this.tmpDir)) { + await rm(this.tmpDir, { recursive: true }) + } + } catch { /* ignore */ } + } +} -- cgit v1.2.3