From 10db25e54f779685bd39133bf08f32a9e3e4d1c9 Mon Sep 17 00:00:00 2001 From: Admir Kadriu Date: Sun, 15 Mar 2026 00:58:25 +0100 Subject: [PATCH] fix: preserve folder structure when pushing files to Overleaf `olcli push` previously uploaded all files to the project root, ignoring subdirectory structure (e.g. files in `figures/` or `sections/` would end up flattened at root level). This was caused by `uploadFile()` stripping the directory portion of the file path and always uploading to the root folder ID. The fix fetches the project's folder tree via Socket.IO before uploading, then resolves each file's directory to the correct Overleaf folder ID. Missing folders are created automatically via the folder API. --- src/cli.ts | 11 ++- src/client.ts | 182 ++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 186 insertions(+), 7 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index e91c4ea..72baecc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -780,6 +780,15 @@ program } } + // Fetch folder tree once so uploads go into correct subfolders + spinner.text = 'Resolving folder structure...'; + let folderTree = await client.getFolderTreeFromSocket(projectId!); + if (!folderTree) { + // Fallback: build minimal tree with just root + const resolvedRootId = rootFolderId || await client.getRootFolderId(projectId!); + folderTree = { '': resolvedRootId }; + } + spinner.text = `Uploading ${filesToUpload.length} file(s)...`; let uploaded = 0; @@ -789,7 +798,7 @@ program for (const file of filesToUpload) { try { const content = readFileSync(file.path); - await client.uploadFile(projectId!, rootFolderId || null, file.relativePath, content); + await client.uploadFile(projectId!, rootFolderId || null, file.relativePath, content, folderTree); uploaded++; spinner.text = `Uploading... (${uploaded}/${filesToUpload.length})`; } catch (error: any) { diff --git a/src/client.ts b/src/client.ts index d539aef..a78b888 100644 --- a/src/client.ts +++ b/src/client.ts @@ -472,6 +472,37 @@ export class OverleafClient { } } + /** + * Extract full folder tree from a Socket.IO joinProjectResponse packet. + * Returns a map of folder path -> folder ID, e.g. { '': rootId, 'figures': figuresId } + */ + private extractFolderTreeFromSocketPacket(packet: string): Record | null { + if (!packet.startsWith('5:::')) return null; + + try { + const payload = JSON.parse(packet.slice(4)); + if (payload?.name !== 'joinProjectResponse') return null; + + const rootFolder = payload?.args?.[0]?.project?.rootFolder?.[0]; + if (!rootFolder?._id) return null; + + const folderMap: Record = {}; + + function walkFolders(folder: any, currentPath: string): void { + folderMap[currentPath] = folder._id; + for (const sub of folder.folders || []) { + const subPath = currentPath ? `${currentPath}/${sub.name}` : sub.name; + walkFolders(sub, subPath); + } + } + + walkFolders(rootFolder, ''); + return folderMap; + } catch { + return null; + } + } + /** * main problem to resolve root folder ID from Overleaf's collaboration join payload * authoritative for projects where ObjectID arithmetic does not apply @@ -567,6 +598,136 @@ export class OverleafClient { return null; } + /** + * Get full folder tree for a project via Socket.IO. + * Returns a map of folder path -> folder ID, e.g. { '': rootId, 'figures': figuresId } + */ + async getFolderTreeFromSocket(projectId: string): Promise | null> { + let sid: string | null = null; + + try { + const handshakeUrl = `${BASE_URL}/socket.io/1/?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; + const handshakeResponse = await this.fetchWithTimeout(handshakeUrl, { + headers: { + 'Cookie': this.getCookieHeader(), + 'User-Agent': USER_AGENT + } + }, 5000); + + if (!handshakeResponse.ok) return null; + this.applySetCookieHeaders(handshakeResponse.headers); + + const handshakeBody = (await handshakeResponse.text()).trim(); + sid = handshakeBody.split(':')[0]; + if (!sid) return null; + + const buildPollUrl = () => + `${BASE_URL}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; + + for (let attempt = 0; attempt < 3; attempt++) { + const pollResponse = await this.fetchWithTimeout(buildPollUrl(), { + headers: { + 'Cookie': this.getCookieHeader(), + 'User-Agent': USER_AGENT + } + }, 5000); + + if (!pollResponse.ok) return null; + this.applySetCookieHeaders(pollResponse.headers); + + const payload = await pollResponse.text(); + const packets = this.decodeSocketIoPayload(payload); + + for (const packet of packets) { + const folderTree = this.extractFolderTreeFromSocketPacket(packet); + if (folderTree) return folderTree; + + if (packet.startsWith('2::')) { + const heartbeatResponse = await this.fetchWithTimeout(buildPollUrl(), { + method: 'POST', + headers: { + 'Cookie': this.getCookieHeader(), + 'User-Agent': USER_AGENT, + 'Content-Type': 'text/plain;charset=UTF-8' + }, + body: '2::' + }, 5000); + this.applySetCookieHeaders(heartbeatResponse.headers); + } + } + } + } catch { + // Fall back + } finally { + if (sid) { + try { + const disconnectUrl = + `${BASE_URL}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; + await this.fetchWithTimeout(disconnectUrl, { + method: 'POST', + headers: { + 'Cookie': this.getCookieHeader(), + 'User-Agent': USER_AGENT, + 'Content-Type': 'text/plain;charset=UTF-8' + }, + body: '0::' + }, 5000); + } catch { + // Ignore cleanup failures. + } + } + } + + return null; + } + + /** + * Resolve a folder path to a folder ID, creating missing folders as needed. + * folderTree is a map of path -> ID (fetched once per push session). + * folderPath is e.g. 'figures' or 'a/b/c'. + */ + async resolveFolderId( + projectId: string, + folderTree: Record, + folderPath: string + ): Promise { + if (!folderPath || folderPath === '') return folderTree['']; + if (folderTree[folderPath]) return folderTree[folderPath]; + + // Create each missing segment + const segments = folderPath.split('/'); + let currentPath = ''; + + for (const segment of segments) { + const parentPath = currentPath; + currentPath = currentPath ? `${currentPath}/${segment}` : segment; + + if (folderTree[currentPath]) continue; + + const parentId = folderTree[parentPath]; + if (!parentId) throw new Error(`Cannot resolve parent folder for: ${currentPath}`); + + try { + const newId = await this.createFolder(projectId, parentId, segment); + folderTree[currentPath] = newId; + } catch (e: any) { + if (e.message === 'Folder already exists') { + // Folder exists but we don't have its ID - re-fetch tree + const freshTree = await this.getFolderTreeFromSocket(projectId); + if (freshTree?.[currentPath]) { + folderTree[currentPath] = freshTree[currentPath]; + } else { + throw new Error(`Folder '${currentPath}' exists but could not resolve its ID`); + } + } else { + throw e; + } + } + } + + return folderTree[folderPath]; + } + /** * Get root folder ID for a project (tries multiple methods) */ @@ -655,20 +816,29 @@ export class OverleafClient { } /** - * Upload a file to a project + * Upload a file to a project. + * If folderTree is provided and fileName contains a path (e.g. 'figures/img.png'), + * the file will be uploaded into the correct subfolder, creating it if needed. */ async uploadFile( projectId: string, folderId: string | null, fileName: string, - content: Buffer + content: Buffer, + folderTree?: Record ): Promise<{ success: boolean; entityId?: string; entityType?: string }> { - // If no folder ID provided, get the root folder - let targetFolderId = folderId || await this.getRootFolderId(projectId); - - // Extract just the filename without path (PR #73 fix) + // Extract just the filename without path const baseName = fileName.split('/').pop() || fileName; + // Resolve target folder: if fileName has a directory part and we have a folderTree, use it + const dirPart = fileName.includes('/') ? fileName.split('/').slice(0, -1).join('/') : ''; + let targetFolderId: string; + if (dirPart && folderTree) { + targetFolderId = await this.resolveFolderId(projectId, folderTree, dirPart); + } else { + targetFolderId = folderId || await this.getRootFolderId(projectId); + } + // Determine MIME type const ext = baseName.split('.').pop()?.toLowerCase() || ''; const mimeTypes: Record = {