diff --git a/.gitignore b/.gitignore index 1f372a2..3355969 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,11 @@ /assets backend_build +installers/ backend/node_modules backend/bin/cloudflared.exe +backend/bin/LibreOffice/ requirements TIS* !*.iss diff --git a/backend/src/controllers/documentController.js b/backend/src/controllers/documentController.js index 1d04b82..5a16b1d 100644 --- a/backend/src/controllers/documentController.js +++ b/backend/src/controllers/documentController.js @@ -3,6 +3,7 @@ const multer = require('multer'); const path = require('path'); const fs = require('fs'); const { createNotification } = require('./notificationController'); +const LibreOfficeService = require('../services/libreOfficeService'); // ── Helper: insert one row into activity_log ───────────────────────────────── const logActivity = (userId, action, entityType, entityId, description) => { @@ -679,6 +680,91 @@ exports.copyDocument = (req, res) => { } }; +// ============================================================ +// POST /api/documents/:id/convert-to-pdf — convert Excel to PDF +// ============================================================ +exports.convertToPdf = async (req, res) => { + const { id } = req.params; + try { + const doc = db.prepare('SELECT * FROM documents WHERE id = ?').get(id); + if (!doc) return res.status(404).json({ message: 'Document not found' }); + + const isTeacher = req.user?.role?.toLowerCase() === 'teacher'; + if (isTeacher) { + const hasAccess = db.prepare(` + SELECT 1 FROM enrollments e + JOIN teacher_sections ts ON e.section_id = ts.section_id + WHERE e.student_id = ? AND ts.teacher_id = ? + AND e.id = ( + SELECT e2.id FROM enrollments e2 + JOIN academic_years ay ON e2.academic_year_id = ay.id + WHERE e2.student_id = ? + ORDER BY ay.year_range DESC, e2.grade_level DESC, e2.id DESC LIMIT 1 + ) + `).get(doc.student_id, req.user.id, doc.student_id); + if (!hasAccess) { + return res.status(403).json({ message: 'Access denied to convert this document.' }); + } + } + + const originalPath = doc.file_path; + if (!fs.existsSync(originalPath)) { + return res.status(404).json({ message: 'Physical file not found on disk' }); + } + + const ext = path.extname(doc.file_name).toLowerCase(); + const validExcelExts = ['.xlsx', '.xls', '.csv']; + if (!validExcelExts.includes(ext)) { + return res.status(400).json({ message: 'Only Excel files (.xlsx, .xls, .csv) can be converted to PDF.' }); + } + + const dir = path.dirname(originalPath); + const baseName = path.basename(doc.file_name, ext); + + // Perform conversion with headless LibreOffice + const { pdfPath: tempPdfPath } = await LibreOfficeService.convertToPdf(originalPath, dir); + + // Determine non-colliding new filename with .pdf extension + const existingNames = db.prepare( + 'SELECT file_name FROM documents WHERE student_id = ? AND deleted_at IS NULL' + ).all(doc.student_id).map((r) => r.file_name); + + let newFileName = `${baseName}.pdf`; + let counter = 1; + while (existingNames.includes(newFileName)) { + newFileName = `${baseName} (${counter}).pdf`; + counter++; + } + + const finalPdfPath = path.join(dir, `${Date.now()}-${newFileName.replace(/[^a-zA-Z0-9.-]/g, '_')}`); + fs.renameSync(tempPdfPath, finalPdfPath); + + // Insert new PDF document row into database + const result = db.prepare(` + INSERT INTO documents (student_id, requirement_id, file_name, file_path, document_type, status, uploaded_by) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(doc.student_id, doc.requirement_id, newFileName, finalPdfPath, doc.document_type, doc.status, req.user.id); + + const newDoc = db.prepare('SELECT * FROM documents WHERE id = ?').get(result.lastInsertRowid); + + // Log activity + const student = db.prepare('SELECT first_name, last_name FROM students WHERE id = ?').get(doc.student_id); + const studentName = student ? `${student.first_name} ${student.last_name}` : `Student #${doc.student_id}`; + logActivity(req.user.id, 'CREATE', 'document', result.lastInsertRowid, + `Converted Excel document "${doc.file_name}" to PDF "${newFileName}" for ${studentName}`); + + res.status(201).json({ + id: result.lastInsertRowid, + fileName: newFileName, + document: newDoc, + message: 'Document converted to PDF successfully', + }); + } catch (error) { + console.error('ConvertToPdf Error:', error); + res.status(500).json({ message: error.message || 'Failed to convert document to PDF' }); + } +}; + // ============================================================ // POST /api/documents/bulk-delete — bulk delete documents // ============================================================ diff --git a/backend/src/routes/documents.js b/backend/src/routes/documents.js index 26d86fc..cbd774e 100644 --- a/backend/src/routes/documents.js +++ b/backend/src/routes/documents.js @@ -31,6 +31,7 @@ router.post('/bulk-print', authenticateToken, authorizeRoles('admin'), documentC router.post('/bulk-copy', authenticateToken, authorizeRoles('admin'), documentController.bulkCopy); router.get('/:id/view', authenticateToken, documentController.viewDocument); +router.post('/:id/convert-to-pdf', authenticateToken, documentController.convertToPdf); router.post('/:id/copy', authenticateToken, authorizeRoles('admin'), documentController.copyDocument); router.patch('/:id/status', authenticateToken, authorizeRoles('admin'), documentController.updateStatus); router.post('/:id/restore', authenticateToken, authorizeRoles('admin'), documentController.restoreDocument); diff --git a/backend/src/services/libreOfficeService.js b/backend/src/services/libreOfficeService.js new file mode 100644 index 0000000..85cb145 --- /dev/null +++ b/backend/src/services/libreOfficeService.js @@ -0,0 +1,157 @@ +const fs = require('fs'); +const path = require('path'); +const { execFile, execSync } = require('child_process'); +const util = require('util'); +const execFileAsync = util.promisify(execFile); + +/** + * Service for converting documents (Excel, etc.) to PDF using Headless LibreOffice. + */ +class LibreOfficeService { + /** + * Finds the path to the LibreOffice / soffice executable. + * @returns {string|null} + */ + static getExecutablePath() { + // 1. Explicit environment variable + if (process.env.LIBREOFFICE_PATH && fs.existsSync(process.env.LIBREOFFICE_PATH)) { + return process.env.LIBREOFFICE_PATH; + } + + const isWindows = process.platform === 'win32'; + + if (isWindows) { + const candidatePaths = [ + path.join(__dirname, '..', '..', 'bin', 'LibreOffice', 'program', 'soffice.exe'), + path.join(__dirname, '..', '..', 'bin', 'soffice.exe'), + 'C:\\Program Files\\LibreOffice\\program\\soffice.exe', + 'C:\\Program Files (x86)\\LibreOffice\\program\\soffice.exe', + ]; + + for (const candidate of candidatePaths) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + // Try where.exe in system PATH + try { + const output = execSync('where.exe soffice', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }); + const firstLine = output.trim().split(/\r?\n/)[0]; + if (firstLine && fs.existsSync(firstLine)) { + return firstLine; + } + } catch (_) {} + } else { + const candidatePaths = [ + '/usr/bin/soffice', + '/usr/bin/libreoffice', + '/usr/local/bin/soffice', + '/Applications/LibreOffice.app/Contents/MacOS/soffice', + ]; + + for (const candidate of candidatePaths) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + try { + const output = execSync('which soffice || which libreoffice', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }); + const firstLine = output.trim().split(/\r?\n/)[0]; + if (firstLine && fs.existsSync(firstLine)) { + return firstLine; + } + } catch (_) {} + } + + return null; + } + + /** + * Checks if LibreOffice is available on the system. + * @returns {boolean} + */ + static isAvailable() { + return this.getExecutablePath() !== null; + } + + /** + * Converts an Excel file (or compatible document) to PDF. + * Preserves all worksheets / tabs, cell formatting, colors, borders, and layouts. + * + * @param {string} inputFilePath - Absolute path to the source Excel file + * @param {string} outputDir - Directory where the converted PDF should be stored + * @returns {Promise<{ pdfPath: string, pdfFileName: string }>} + */ + static async convertToPdf(inputFilePath, outputDir) { + if (!fs.existsSync(inputFilePath)) { + throw new Error(`Source file not found at: ${inputFilePath}`); + } + + const sofficeBin = this.getExecutablePath(); + if (!sofficeBin) { + const isWindows = process.platform === 'win32'; + const installHint = isWindows + ? 'Please install LibreOffice from https://www.libreoffice.org or run "winget install TheDocumentFoundation.LibreOffice", or set LIBREOFFICE_PATH in .env.' + : 'Please install LibreOffice using "sudo apt-get install -y libreoffice" or set LIBREOFFICE_PATH in .env.'; + throw new Error(`LibreOffice (soffice) executable was not found on the server. ${installHint}`); + } + + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + const ext = path.extname(inputFilePath); + const baseName = path.basename(inputFilePath, ext); + const expectedPdfName = `${baseName}.pdf`; + const expectedPdfPath = path.join(outputDir, expectedPdfName); + + // Remove any stale pre-existing file with the same temporary output name + if (fs.existsSync(expectedPdfPath)) { + try { + fs.unlinkSync(expectedPdfPath); + } catch (_) {} + } + + // Run headless LibreOffice conversion + const args = [ + '--headless', + '--invisible', + '--nologo', + '--nodefault', + '--nofirststartwizard', + '--convert-to', + 'pdf', + '--outdir', + outputDir, + inputFilePath, + ]; + + console.log(`[LibreOfficeService] Executing conversion: "${sofficeBin}" ${args.join(' ')}`); + + try { + const { stdout, stderr } = await execFileAsync(sofficeBin, args, { + timeout: 60000, // 60s timeout + maxBuffer: 10 * 1024 * 1024, + }); + + if (stdout) console.log(`[LibreOfficeService] stdout: ${stdout.trim()}`); + if (stderr) console.warn(`[LibreOfficeService] stderr: ${stderr.trim()}`); + + if (!fs.existsSync(expectedPdfPath)) { + throw new Error(`LibreOffice finished but output PDF was not found at "${expectedPdfPath}". Output: ${stdout || stderr || 'None'}`); + } + + return { + pdfPath: expectedPdfPath, + pdfFileName: expectedPdfName, + }; + } catch (err) { + console.error('[LibreOfficeService] Conversion failed:', err); + throw new Error(`Failed to convert document to PDF: ${err.message}`); + } + } +} + +module.exports = LibreOfficeService; diff --git a/frontend/lib/domain/repositories/document_repository.dart b/frontend/lib/domain/repositories/document_repository.dart index 6d9a1d0..03a2b25 100644 --- a/frontend/lib/domain/repositories/document_repository.dart +++ b/frontend/lib/domain/repositories/document_repository.dart @@ -1,4 +1,3 @@ -import 'dart:io'; import 'dart:convert'; import 'dart:typed_data'; import 'package:dio/dio.dart'; @@ -322,6 +321,21 @@ class DocumentRepository { } } + Future convertExcelToPdf(int id) async { + try { + final options = await _getAuthOptions(); + final response = await _dio.post('/documents/$id/convert-to-pdf', options: options); + final docData = response.data['document'] as Map?; + if (docData != null) { + return DocumentModel.fromJson(docData); + } + throw Exception(response.data['message'] ?? 'Failed to convert document.'); + } on DioException catch (e) { + final msg = e.response?.data?['message'] ?? 'Failed to convert Excel to PDF.'; + throw Exception(msg); + } + } + /// Uploads in-memory bytes (e.g. a converted PDF) as a new document in the /// student's folder. Future uploadDocumentBytes({ diff --git a/frontend/lib/ui/providers/conversion_provider.dart b/frontend/lib/ui/providers/conversion_provider.dart new file mode 100644 index 0000000..be57039 --- /dev/null +++ b/frontend/lib/ui/providers/conversion_provider.dart @@ -0,0 +1,47 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../domain/entities/document_model.dart'; +import '../../domain/repositories/document_repository.dart'; +import 'archives_provider.dart'; +import 'document_provider.dart'; + +final conversionProvider = + StateNotifierProvider>((ref) { + final repository = ref.watch(documentRepositoryProvider); + return ConversionNotifier(repository, ref); +}); + +class ConversionNotifier extends StateNotifier> { + final DocumentRepository _repository; + final Ref _ref; + int? _convertingDocId; + + ConversionNotifier(this._repository, this._ref) + : super(const AsyncValue.data(null)); + + int? get convertingDocId => _convertingDocId; + bool isConverting(int docId) => + state.isLoading && _convertingDocId == docId; + + Future convertToPdf(int documentId) async { + _convertingDocId = documentId; + state = const AsyncValue.loading(); + + try { + final convertedDoc = await _repository.convertExcelToPdf(documentId); + + // Invalidate relevant providers to automatically refresh lists + _ref.invalidate(documentPageProvider); + _ref.invalidate(studentFoldersProvider); + _ref.invalidate(archiveDocumentPageProvider); + _ref.invalidate(archiveStudentFoldersProvider); + + state = const AsyncValue.data(null); + return convertedDoc; + } catch (e, st) { + state = AsyncValue.error(e, st); + rethrow; + } finally { + _convertingDocId = null; + } + } +} diff --git a/frontend/lib/ui/screens/archives/archives_screen.dart b/frontend/lib/ui/screens/archives/archives_screen.dart index 8737455..065aaf7 100644 --- a/frontend/lib/ui/screens/archives/archives_screen.dart +++ b/frontend/lib/ui/screens/archives/archives_screen.dart @@ -20,6 +20,7 @@ import '../../shared/dialogs/error_dialog.dart'; import '../../shared/inputs/custom_text_field.dart'; import '../../providers/auth_provider.dart'; import '../../providers/navigation_provider.dart'; +import '../../providers/conversion_provider.dart'; import '../documents/widgets/bulk_operations_bar.dart'; import '../documents/widgets/document_preview_modal.dart'; import '../documents/widgets/file_folder_card.dart'; @@ -512,6 +513,24 @@ class _ArchivesScreenState extends ConsumerState if (!mounted) return; showErrorDialog(context, 'Download Failed', e.toString()); } + } else if (action == 'convert_pdf') { + try { + final converted = await ref + .read(conversionProvider.notifier) + .convertToPdf(doc.id); + if (!mounted) return; + showSuccessDialog( + context, + message: 'Excel converted to PDF successfully as "${converted.fileName}".', + ); + } catch (e) { + if (!mounted) return; + showErrorDialog( + context, + 'Conversion Failed', + e.toString().replaceFirst('Exception: ', ''), + ); + } } else if (action == 'view_profile' && doc.studentId != null) { showStudentProfileModal( context, diff --git a/frontend/lib/ui/screens/documents/documents_screen.dart b/frontend/lib/ui/screens/documents/documents_screen.dart index dfaf795..04b1221 100644 --- a/frontend/lib/ui/screens/documents/documents_screen.dart +++ b/frontend/lib/ui/screens/documents/documents_screen.dart @@ -13,6 +13,7 @@ import '../../providers/auth_provider.dart'; import '../../shared/widgets/app_pagination.dart'; import '../../shared/widgets/app_error_state.dart'; import '../../providers/navigation_provider.dart'; +import '../../providers/conversion_provider.dart'; import '../../shared/dialogs/success_dialog.dart'; import '../../shared/dialogs/error_dialog.dart'; @@ -342,6 +343,24 @@ class _DocumentsScreenState extends ConsumerState if (!context.mounted) return; showErrorDialog(context, 'Download Failed', e.toString()); } + } else if (action == 'convert_pdf') { + try { + final converted = await ref + .read(conversionProvider.notifier) + .convertToPdf(documentId); + if (!mounted) return; + showSuccessDialog( + context, + message: 'Excel converted to PDF successfully as "${converted.fileName}".', + ); + } catch (e) { + if (!mounted) return; + showErrorDialog( + context, + 'Conversion Failed', + e.toString().replaceFirst('Exception: ', ''), + ); + } } } diff --git a/frontend/lib/ui/screens/documents/widgets/document_preview_modal.dart b/frontend/lib/ui/screens/documents/widgets/document_preview_modal.dart index 2e46105..eb2268f 100644 --- a/frontend/lib/ui/screens/documents/widgets/document_preview_modal.dart +++ b/frontend/lib/ui/screens/documents/widgets/document_preview_modal.dart @@ -12,6 +12,7 @@ import '../../../../core/network/api_constants.dart'; import '../../../../core/utils/download_service.dart'; import '../../../../domain/entities/document_model.dart'; import '../../../providers/document_provider.dart'; +import '../../../providers/conversion_provider.dart'; import '../../../shared/dialogs/error_dialog.dart'; import '../../../shared/dialogs/success_dialog.dart'; import 'excel_viewer_widget.dart'; @@ -252,6 +253,32 @@ class _DocumentPreviewDialogState } } + bool _isConverting = false; + + Future _convertToPdf() async { + if (widget.document == null || _isConverting) return; + setState(() => _isConverting = true); + try { + final converted = await ref + .read(conversionProvider.notifier) + .convertToPdf(widget.document!.id); + if (!mounted) return; + showSuccessDialog( + context, + message: 'Excel converted to PDF successfully as "${converted.fileName}".', + ); + } catch (e) { + if (!mounted) return; + showErrorDialog( + context, + 'Conversion Failed', + e.toString().replaceFirst('Exception: ', ''), + ); + } finally { + if (mounted) setState(() => _isConverting = false); + } + } + @override Widget build(BuildContext context) { final screenW = MediaQuery.of(context).size.width; @@ -296,6 +323,21 @@ class _DocumentPreviewDialogState ], ), actions: [ + if (_isExcel && widget.document != null) + IconButton( + icon: _isConverting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : const Icon(Icons.picture_as_pdf_outlined, color: Colors.white), + tooltip: 'Convert to PDF', + onPressed: _isConverting ? null : _convertToPdf, + ), if (_isExcel) IconButton( icon: _isOpeningExternal diff --git a/frontend/lib/ui/screens/documents/widgets/file_folder_card.dart b/frontend/lib/ui/screens/documents/widgets/file_folder_card.dart index 968d545..20155b5 100644 --- a/frontend/lib/ui/screens/documents/widgets/file_folder_card.dart +++ b/frontend/lib/ui/screens/documents/widgets/file_folder_card.dart @@ -37,10 +37,12 @@ class FileFolderCard extends StatefulWidget { class _FileFolderCardState extends State { // True on Android/iOS — long press is the context menu trigger. - // Desktop uses the ⋮ icon + right-click. - static bool get _isMobile => - defaultTargetPlatform == TargetPlatform.android || - defaultTargetPlatform == TargetPlatform.iOS; + bool get _isExcel { + final name = widget.document.fileName.toLowerCase(); + return name.endsWith('.xlsx') || + name.endsWith('.xls') || + name.endsWith('.csv'); + } IconData get _fileIcon { final name = widget.document.fileName.toLowerCase(); @@ -50,6 +52,9 @@ class _FileFolderCardState extends State { name.endsWith('.jpeg')) { return Icons.image; } + if (_isExcel) { + return Icons.table_chart; + } return Icons.insert_drive_file; } @@ -61,6 +66,9 @@ class _FileFolderCardState extends State { name.endsWith('.jpeg')) { return Colors.blueAccent; } + if (_isExcel) { + return Colors.green; + } return AppColors.primaryGreen; } @@ -87,6 +95,21 @@ class _FileFolderCardState extends State { ], ), ), + if (_isExcel) + const PopupMenuItem( + value: 'convert_pdf', + child: Row( + children: [ + Icon( + Icons.picture_as_pdf_outlined, + size: 18, + color: Colors.deepOrangeAccent, + ), + SizedBox(width: 12), + Text('Convert to PDF', style: TextStyle(fontSize: 14)), + ], + ), + ), const PopupMenuItem( value: 'queue', child: Row( @@ -180,14 +203,12 @@ class _FileFolderCardState extends State { // ════════════════════════════════════════ Widget _buildGridCard(BuildContext context) { return GestureDetector( - // Desktop: right-click opens context menu (disabled for teachers) - onSecondaryTapDown: widget.userRole == 'teacher' - ? null - : (details) => _showContextMenu(context, details.globalPosition), - // Mobile: long press opens context menu (disabled for teachers) - onLongPressStart: (widget.userRole == 'teacher' || !_isMobile) - ? null - : (details) => _showContextMenu(context, details.globalPosition), + // Desktop: right-click opens context menu + onSecondaryTapDown: (details) => + _showContextMenu(context, details.globalPosition), + // Mobile: long press opens context menu + onLongPressStart: (details) => + _showContextMenu(context, details.globalPosition), child: InkWell( onTap: widget.isMultiSelectMode ? () => widget.onSelectedChanged?.call(!widget.isSelected) @@ -262,8 +283,8 @@ class _FileFolderCardState extends State { ], ), ), - // ⋮ button only on desktop and not for teachers — on mobile, long press triggers the menu - if (!widget.isMultiSelectMode && !_isMobile && widget.userRole != 'teacher') + // ⋮ button visible on both mobile and desktop + if (!widget.isMultiSelectMode) Positioned( top: 2, right: 2, @@ -289,12 +310,10 @@ class _FileFolderCardState extends State { // ════════════════════════════════════════ Widget _buildListRow(BuildContext context) { return GestureDetector( - onSecondaryTapDown: widget.userRole == 'teacher' - ? null - : (details) => _showContextMenu(context, details.globalPosition), - onLongPressStart: (widget.userRole == 'teacher' || !_isMobile) - ? null - : (details) => _showContextMenu(context, details.globalPosition), + onSecondaryTapDown: (details) => + _showContextMenu(context, details.globalPosition), + onLongPressStart: (details) => + _showContextMenu(context, details.globalPosition), child: InkWell( onTap: widget.isMultiSelectMode ? () => widget.onSelectedChanged?.call(!widget.isSelected) @@ -378,10 +397,10 @@ class _FileFolderCardState extends State { // Status Expanded(child: _buildStatusBadge()), - // ⋮ Actions — desktop only; on mobile use long press; hidden for teachers + // ⋮ Actions — visible on desktop & mobile SizedBox( width: 40, - child: (widget.isMultiSelectMode || _isMobile || widget.userRole == 'teacher') + child: widget.isMultiSelectMode ? const SizedBox.shrink() : PopupMenuButton( icon: Icon(