import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as fs from 'fs/promises'; import * as path from 'path'; import axios from 'axios'; import FormData from 'form-data'; import { LibreOfficeConvertResponse, LibreOfficeHealthResponse, } from './libreoffice.interface'; import { I18nService } from '../i18n/i18n.service'; @Injectable() export class LibreOfficeService implements OnModuleInit { private readonly logger = new Logger(LibreOfficeService.name); private baseUrl: string; constructor( private configService: ConfigService, private i18nService: I18nService, ) {} onModuleInit() { const libreofficeUrl = this.configService.get('LIBREOFFICE_URL'); if (!libreofficeUrl) { throw new Error(this.i18nService.getMessage('libreofficeUrlRequired')); } this.baseUrl = libreofficeUrl; this.logger.log( `LibreOffice service initialized with base URL: ${this.baseUrl}`, ); } /** * Check LibreOffice service health status */ async healthCheck(): Promise { try { const response = await axios.get( `${this.baseUrl}/health`, { timeout: 5000 }, ); return response.data.status === 'healthy'; } catch (error) { this.logger.error('LibreOffice health check failed', error.message); return false; } } /** * Convert document to PDF * @param filePath Path of file to convert * @returns PDF file path */ async convertToPDF(filePath: string): Promise { const fileName = path.basename(filePath); const ext = path.extname(fileName).toLowerCase(); // Return original path directly if PDF if (ext === '.pdf') { this.logger.log(`File is already PDF: ${filePath}`); return filePath; } // Check if file exists try { await fs.access(filePath); } catch { throw new Error(`File does not exist: ${filePath}`); } // Generate output PDF path const dir = path.dirname(filePath); const baseName = path.basename(filePath, ext); const targetPdfPath = path.join(dir, `${baseName}.pdf`); // Return directly if PDF already exists try { await fs.access(targetPdfPath); this.logger.log(`PDF already exists: ${targetPdfPath}`); return targetPdfPath; } catch { // Need to convert as PDF does not exist } // Load file const fileBuffer = await fs.readFile(filePath); // Build FormData const formData = new FormData(); formData.append('file', fileBuffer, fileName); this.logger.log(`Converting ${fileName} to PDF...`); // Conversion retry count const maxRetries = 3; let lastError: any; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { // Call LibreOffice service const response = await axios.post(`${this.baseUrl}/convert`, formData, { headers: formData.getHeaders(), timeout: 300000, // 5 minute timeout responseType: 'stream', // Receive file stream maxRedirects: 5, // Max redirects }); // Write stream to output file const writer = (await import('fs')).createWriteStream(targetPdfPath); response.data.pipe(writer); return new Promise((resolve, reject) => { writer.on('finish', () => { this.logger.log( `Conversion completed: ${fileName} -> ${targetPdfPath}`, ); resolve(targetPdfPath); }); writer.on('error', (err) => { this.logger.error(`Error writing PDF file: ${err.message}`); reject(err); }); }); } catch (error) { this.logger.error( `Attempt ${attempt} failed for ${fileName}: ${error.message}`, ); lastError = error; // Wait and retry on socket hang up or connection error if ( error.code === 'ECONNRESET' || error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' || error.message.includes('socket hang up') ) { if (attempt < maxRetries) { const delay = 2000 * attempt; // Increasing delay this.logger.log(`Waiting ${delay}ms before retry...`); await new Promise((resolve) => setTimeout(resolve, delay)); } } else { // Do not retry other errors break; } } } // Detailed error handling if all retries fail if (lastError.response) { try { const stream = lastError.response.data; const chunks: any[] = []; for await (const chunk of stream) { chunks.push(chunk); } const errorText = Buffer.concat(chunks).toString('utf8'); this.logger.error(`LibreOffice service error details: ${errorText}`); let detail = errorText; try { const errorJson = JSON.parse(errorText); detail = errorJson.detail || errorText; } catch { // ignore JSON parse error } if (lastError.response.status === 504) { throw new Error('Conversion timed out. The file may be too large.'); } throw new Error(`Conversion failed: ${detail}`); } catch (streamError) { this.logger.error('Error reading error stream:', streamError); throw new Error(`Conversion failed: ${lastError.message}`); } } this.logger.error( `Conversion failed for ${fileName} after ${maxRetries} attempts:`, lastError.message, ); if (lastError.code === 'ECONNREFUSED') { throw new Error( 'LibreOffice service is not running. Please check the service status.', ); } if ( lastError.code === 'ECONNRESET' || lastError.message.includes('socket hang up') ) { throw new Error( 'Connection to LibreOffice service was reset. The service may be unstable.', ); } throw new Error(`Conversion failed: ${lastError.message}`); } /** * Batch convert files */ async batchConvert(filePaths: string[]): Promise { const results: string[] = []; for (const filePath of filePaths) { try { const pdfPath = await this.convertToPDF(filePath); results.push(pdfPath); } catch (error) { this.logger.error(`Failed to convert ${filePath}: ${error.message}`); throw error; } } return results; } /** * Get service version information */ async getVersion(): Promise { try { const response = await axios.get(`${this.baseUrl}/version`); return response.data; } catch (error) { this.logger.error('Failed to get version', error.message); return null; } } }