mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
1. Source editor blank: Added ensureEditor() method with try-catch that lazily creates the CodeMirror editor when setEditorContent is called if the initial DOMContentLoaded creation failed or was skipped. Replaced inline createEditor calls in createTabElements and DOMContentLoaded with ensureEditor(). 2. Export via pandoc failing: runPandocCmd parsed the full path /bin/linux/pandoc as the command, then prepended it to args because parsed.command !== 'pandoc'. This caused pandoc to receive its own binary path as the first input file. Fix: use path.basename() to check if the command ends with 'pandoc' (or 'pandoc.exe'). Amit Haridas
4263 lines
142 KiB
JavaScript
4263 lines
142 KiB
JavaScript
const { app, BrowserWindow, Menu, dialog, ipcMain, shell } = require('electron');
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
const { exec, execFile, spawn } = require('child_process');
|
||
const WordTemplateExporter = require('./wordTemplateExporter');
|
||
const PDFOperations = require('./main/PDFOperations');
|
||
const GitOperations = require('./main/GitOperations');
|
||
|
||
// Add MiKTeX to PATH for LaTeX support
|
||
if (process.platform === 'win32') {
|
||
const miktexPath = 'C:\\Program Files\\MiKTeX\\miktex\\bin\\x64';
|
||
if (fs.existsSync(miktexPath)) {
|
||
process.env.PATH = `${miktexPath};${process.env.PATH}`;
|
||
console.log('[MAIN] Added MiKTeX to PATH:', miktexPath);
|
||
}
|
||
}
|
||
|
||
// Returns path to pandoc: bundled binary when packaged, dev bin or system fallback otherwise.
|
||
function getPandocPath() {
|
||
if (app.isPackaged) {
|
||
const ext = process.platform === 'win32' ? '.exe' : '';
|
||
return path.join(process.resourcesPath, 'bin', `pandoc${ext}`);
|
||
}
|
||
// Development: prefer locally-downloaded binary in bin/<platform>/
|
||
const devBin = path.join(
|
||
__dirname,
|
||
'..',
|
||
'bin',
|
||
process.platform,
|
||
process.platform === 'win32' ? 'pandoc.exe' : 'pandoc',
|
||
);
|
||
if (fs.existsSync(devBin)) return devBin;
|
||
return 'pandoc';
|
||
}
|
||
|
||
// Returns path to ffmpeg: asar-unpacked bundled binary when packaged, system fallback otherwise.
|
||
function getFFmpegPath() {
|
||
try {
|
||
let ffmpegPath = require('ffmpeg-static');
|
||
if (app.isPackaged) {
|
||
// ffmpeg-static is in asarUnpack — rewrite the path to the unpacked location
|
||
ffmpegPath = ffmpegPath.replace('app.asar' + path.sep, 'app.asar.unpacked' + path.sep);
|
||
}
|
||
if (fs.existsSync(ffmpegPath)) return ffmpegPath;
|
||
} catch (_) {
|
||
/* ffmpeg-static not available */
|
||
}
|
||
return process.platform === 'win32' ? 'ffmpeg' : 'ffmpeg';
|
||
}
|
||
|
||
// Check if Pandoc is available
|
||
function checkPandocAvailable() {
|
||
return new Promise((resolve) => {
|
||
const pandocPath = getPandocPath();
|
||
execFile(pandocPath, ['--version'], (error) => {
|
||
resolve(!error);
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Safe command execution using execFile (no shell injection risk)
|
||
* @param {string} command - The command to execute
|
||
* @param {string[]} args - Array of arguments
|
||
* @param {object} options - Options for execFile
|
||
* @returns {Promise<{stdout: string, stderr: string}>}
|
||
*/
|
||
function safeExecFile(command, args, options = {}) {
|
||
return new Promise((resolve, reject) => {
|
||
execFile(command, args, { maxBuffer: 10 * 1024 * 1024, ...options }, (error, stdout, stderr) => {
|
||
if (error) {
|
||
reject({ error, stdout, stderr });
|
||
} else {
|
||
resolve({ stdout, stderr });
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// File size validation
|
||
const MAX_FILE_SIZE_MB = 50;
|
||
const MAX_FILE_SIZE = MAX_FILE_SIZE_MB * 1024 * 1024;
|
||
|
||
// Sanitize error messages to strip absolute file paths
|
||
function sanitizeErrorMessage(message) {
|
||
if (typeof message !== 'string') return String(message);
|
||
// Strip absolute Windows paths, keeping only filename
|
||
return message
|
||
.replace(/[A-Z]:\\[^\s"']+\\([^\s"'\\]+)/gi, '$1')
|
||
.replace(/\/[^\s"']+\/([^\s"'/]+)/g, '$1');
|
||
}
|
||
|
||
// Rate limiter for conversions
|
||
function createRateLimiter(minIntervalMs = 2000) {
|
||
let lastCall = 0;
|
||
return function canProceed() {
|
||
const now = Date.now();
|
||
if (now - lastCall < minIntervalMs) return false;
|
||
lastCall = now;
|
||
return true;
|
||
};
|
||
}
|
||
const conversionLimiter = createRateLimiter(2000);
|
||
|
||
// ============================================
|
||
// Path Traversal Protection
|
||
// ============================================
|
||
// Define allowed base directories for file operations
|
||
function getAllowedDirectories() {
|
||
const dirs = [
|
||
app.getPath('documents'),
|
||
app.getPath('desktop'),
|
||
app.getPath('downloads'),
|
||
app.getPath('home'),
|
||
process.cwd() // Current working directory
|
||
].filter(Boolean); // Remove any undefined paths
|
||
return dirs;
|
||
}
|
||
|
||
/**
|
||
* Validates that a file path is safe and doesn't attempt path traversal
|
||
* @param {string} filePath - The path to validate
|
||
* @returns {{ valid: boolean, resolved: string, error?: string }}
|
||
*/
|
||
function validatePath(filePath) {
|
||
if (!filePath || typeof filePath !== 'string') {
|
||
return { valid: false, resolved: '', error: 'Invalid path' };
|
||
}
|
||
|
||
// Resolve to absolute path (handles .., ., symlinks)
|
||
let resolved;
|
||
try {
|
||
resolved = path.resolve(filePath);
|
||
} catch (err) {
|
||
return { valid: false, resolved: '', error: 'Invalid path format' };
|
||
}
|
||
|
||
// Normalize path separators
|
||
resolved = path.normalize(resolved);
|
||
|
||
// Check for null bytes (path injection)
|
||
if (resolved.includes('\0')) {
|
||
return { valid: false, resolved: '', error: 'Null byte in path' };
|
||
}
|
||
|
||
// Check if path exists
|
||
if (!fs.existsSync(resolved)) {
|
||
return { valid: false, resolved, error: 'Path does not exist' };
|
||
}
|
||
|
||
return { valid: true, resolved };
|
||
}
|
||
|
||
/**
|
||
* Resolves a path for operations where the target may not exist yet.
|
||
* Validates string shape and blocks obviously sensitive locations.
|
||
* @param {string} filePath
|
||
* @returns {{ valid: boolean, resolved: string, error?: string }}
|
||
*/
|
||
function resolveWritablePath(filePath) {
|
||
if (!filePath || typeof filePath !== 'string') {
|
||
return { valid: false, resolved: '', error: 'Invalid path' };
|
||
}
|
||
|
||
let resolved;
|
||
try {
|
||
resolved = path.normalize(path.resolve(filePath));
|
||
} catch (err) {
|
||
return { valid: false, resolved: '', error: 'Invalid path format' };
|
||
}
|
||
|
||
if (resolved.includes('\0')) {
|
||
return { valid: false, resolved: '', error: 'Null byte in path' };
|
||
}
|
||
|
||
if (!isPathAccessible(resolved)) {
|
||
return { valid: false, resolved, error: 'Path is not accessible' };
|
||
}
|
||
|
||
return { valid: true, resolved };
|
||
}
|
||
|
||
/**
|
||
* Checks if a resolved path is within allowed directories
|
||
* For an editor app, we allow access to all user-accessible paths
|
||
* but log any suspicious access attempts
|
||
* @param {string} resolvedPath - The resolved absolute path
|
||
* @returns {boolean}
|
||
*/
|
||
function isPathAccessible(resolvedPath) {
|
||
// Block access to sensitive system directories
|
||
const blockedPaths = [
|
||
'/etc/passwd', '/etc/shadow', '/root',
|
||
'C:\\Windows\\System32', 'C:\\Windows\\System',
|
||
'/System', '/private/etc'
|
||
];
|
||
|
||
const normalizedPath = resolvedPath.toLowerCase();
|
||
for (const blocked of blockedPaths) {
|
||
if (normalizedPath.startsWith(blocked.toLowerCase())) {
|
||
console.warn('[SECURITY] Blocked access to sensitive path:', resolvedPath);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
// Convert structured data formats to markdown code blocks
|
||
function convertDataToMarkdown(content, format) {
|
||
switch (format) {
|
||
case 'json':
|
||
return '```json\n' + content + '\n```';
|
||
case 'yaml':
|
||
case 'yml':
|
||
return '```yaml\n' + content + '\n```';
|
||
case 'xml':
|
||
return '```xml\n' + content + '\n```';
|
||
case 'toml':
|
||
return '```toml\n' + content + '\n```';
|
||
default:
|
||
return '```\n' + content + '\n```';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Run Pandoc command safely with execFile
|
||
* @param {string[]} args - Pandoc arguments array
|
||
* @param {Function} callback - Callback function (error, stdout, stderr)
|
||
*/
|
||
function runPandoc(args, callback) {
|
||
const pandocPath = getPandocPath();
|
||
execFile(pandocPath, args, { maxBuffer: 10 * 1024 * 1024 }, callback);
|
||
}
|
||
|
||
/**
|
||
* Run a Pandoc command string safely using execFile
|
||
* Parses the command string and uses execFile to prevent shell injection
|
||
* @param {string} cmdString - Full Pandoc command string (e.g., 'pandoc "input.md" -o "output.pdf"')
|
||
* @param {Function} callback - Callback function (error, stdout, stderr)
|
||
*/
|
||
function runPandocCmd(cmdString, callback) {
|
||
const parsed = parseCommand(cmdString);
|
||
// Skip 'pandoc' if it's the first element (command itself)
|
||
// Use path.basename to handle full paths like bin/linux/pandoc
|
||
const cmdBase = path.basename(parsed.command).replace(/\.exe$/i, '');
|
||
const args = cmdBase === 'pandoc' ? parsed.args : [parsed.command, ...parsed.args];
|
||
const pandocPath = getPandocPath();
|
||
execFile(pandocPath, args, { maxBuffer: 10 * 1024 * 1024 }, callback);
|
||
}
|
||
|
||
/**
|
||
* Parse a command string into command and arguments array
|
||
* This helps transition from exec() to execFile() safely
|
||
* @param {string} cmdString - Full command string
|
||
* @returns {{command: string, args: string[]}}
|
||
*/
|
||
function parseCommand(cmdString) {
|
||
// Handle quoted strings properly
|
||
const parts = [];
|
||
let current = '';
|
||
let inQuotes = false;
|
||
let quoteChar = '';
|
||
|
||
for (let i = 0; i < cmdString.length; i++) {
|
||
const char = cmdString[i];
|
||
if ((char === '"' || char === "'") && !inQuotes) {
|
||
inQuotes = true;
|
||
quoteChar = char;
|
||
} else if (char === quoteChar && inQuotes) {
|
||
inQuotes = false;
|
||
quoteChar = '';
|
||
} else if (char === ' ' && !inQuotes) {
|
||
if (current) {
|
||
parts.push(current);
|
||
current = '';
|
||
}
|
||
} else {
|
||
current += char;
|
||
}
|
||
}
|
||
if (current) {
|
||
parts.push(current);
|
||
}
|
||
|
||
return {
|
||
command: parts[0],
|
||
args: parts.slice(1)
|
||
};
|
||
}
|
||
|
||
// Simple storage implementation to replace electron-store
|
||
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
|
||
const store = {
|
||
get: (key, defaultValue) => {
|
||
try {
|
||
const data = fs.readFileSync(settingsPath, 'utf-8');
|
||
const settings = JSON.parse(data);
|
||
return settings[key] || defaultValue;
|
||
} catch {
|
||
return defaultValue;
|
||
}
|
||
},
|
||
set: (key, value) => {
|
||
let settings = {};
|
||
try {
|
||
const data = fs.readFileSync(settingsPath, 'utf-8');
|
||
settings = JSON.parse(data);
|
||
} catch {}
|
||
settings[key] = value;
|
||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
||
}
|
||
};
|
||
|
||
// Plugin settings IPC handlers
|
||
ipcMain.handle('plugin-settings:get', (_event, key) => {
|
||
return store.get(key);
|
||
});
|
||
ipcMain.handle('plugin-settings:set', (_event, { key, value }) => {
|
||
store.set(key, value);
|
||
});
|
||
ipcMain.handle('get-app-version', () => app.getVersion());
|
||
|
||
// ============================================
|
||
// SECURITY: Permission Request Handler
|
||
// ============================================
|
||
// Restrict permissions to only those explicitly needed.
|
||
// Deny: camera, microphone, geolocation, notifications, etc.
|
||
// Allow: clipboard-read, clipboard-write (user expects these)
|
||
app.on('web-contents-created', (event, contents) => {
|
||
contents.session.setPermissionRequestHandler((webContents, permission, callback) => {
|
||
const ALLOWED_PERMISSIONS = [
|
||
'clipboard-read',
|
||
'clipboard-write'
|
||
];
|
||
|
||
if (ALLOWED_PERMISSIONS.includes(permission)) {
|
||
callback(true);
|
||
} else {
|
||
// Deny all other permissions (camera, microphone, geolocation, etc.)
|
||
console.warn(`[Security] Blocked permission request: ${permission}`);
|
||
callback(false);
|
||
}
|
||
});
|
||
|
||
// Disable file download dialogs for security
|
||
// contents.session.setDownloadPath(userDownloadsPath);
|
||
});
|
||
|
||
let mainWindow;
|
||
let currentFile = null; // This will now represent the active tab's file
|
||
let pandocAvailable = null; // Cache pandoc availability check
|
||
let wordTemplatePath = null; // Path to selected Word template
|
||
let templateStartPage = 3; // Which page to start inserting content (default: page 3)
|
||
let rendererReady = false; // Track if renderer is ready to receive file data
|
||
|
||
// Header & Footer Settings
|
||
let headerFooterSettings = {
|
||
enabled: true,
|
||
header: {
|
||
left: '',
|
||
center: '',
|
||
right: '',
|
||
logo: null // Will store image file path
|
||
},
|
||
footer: {
|
||
left: '',
|
||
center: '$PAGE$ of $TOTAL$',
|
||
right: '',
|
||
logo: null
|
||
}
|
||
};
|
||
|
||
// Page Size Definitions (in twentieths of a point for Word, mm/inches for Pandoc)
|
||
const PAGE_SIZES = {
|
||
a4: {
|
||
name: 'A4',
|
||
pandoc: 'a4',
|
||
word: { width: 11906, height: 16838 }, // 210×297mm
|
||
dimensions: '210×297mm'
|
||
},
|
||
a3: {
|
||
name: 'A3',
|
||
pandoc: 'a3',
|
||
word: { width: 16838, height: 23811 }, // 297×420mm
|
||
dimensions: '297×420mm'
|
||
},
|
||
a5: {
|
||
name: 'A5',
|
||
pandoc: 'a5',
|
||
word: { width: 8391, height: 11906 }, // 148×210mm
|
||
dimensions: '148×210mm'
|
||
},
|
||
b4: {
|
||
name: 'B4',
|
||
pandoc: 'b4',
|
||
word: { width: 14170, height: 20015 }, // 250×353mm
|
||
dimensions: '250×353mm'
|
||
},
|
||
b5: {
|
||
name: 'B5',
|
||
pandoc: 'b5',
|
||
word: { width: 9979, height: 14170 }, // 176×250mm
|
||
dimensions: '176×250mm'
|
||
},
|
||
letter: {
|
||
name: 'Letter',
|
||
pandoc: 'letter',
|
||
word: { width: 12240, height: 15840 }, // 8.5×11in
|
||
dimensions: '8.5×11in'
|
||
},
|
||
legal: {
|
||
name: 'Legal',
|
||
pandoc: 'legal',
|
||
word: { width: 12240, height: 20160 }, // 8.5×14in
|
||
dimensions: '8.5×14in'
|
||
},
|
||
tabloid: {
|
||
name: 'Tabloid',
|
||
pandoc: 'tabloid',
|
||
word: { width: 15840, height: 24480 }, // 11×17in
|
||
dimensions: '11×17in'
|
||
}
|
||
};
|
||
|
||
// Default page settings
|
||
let pageSettings = {
|
||
size: 'a4',
|
||
orientation: 'portrait',
|
||
customWidth: null,
|
||
customHeight: null
|
||
};
|
||
|
||
// Handle single instance lock for Windows file association
|
||
// When a file is double-clicked and the app is already running,
|
||
// Windows tries to start a second instance. We prevent this and
|
||
// pass the file to the existing instance instead.
|
||
const gotTheLock = app.requestSingleInstanceLock();
|
||
|
||
if (!gotTheLock) {
|
||
// Another instance is already running, quit this one
|
||
app.quit();
|
||
} else {
|
||
// This is the first instance, handle second-instance events
|
||
app.on('second-instance', (event, commandLine, workingDirectory) => {
|
||
// Someone tried to run a second instance, focus our window instead
|
||
if (mainWindow) {
|
||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||
mainWindow.focus();
|
||
}
|
||
|
||
// Check if a file was passed to the second instance
|
||
// commandLine is an array like: ['PanConverter.exe', 'file.md']
|
||
console.log('[MAIN] Second instance commandLine:', JSON.stringify(commandLine));
|
||
const startIndex = app.isPackaged ? 1 : 2;
|
||
const fileArgs = commandLine.slice(startIndex);
|
||
console.log('[MAIN] Second instance file args:', fileArgs);
|
||
for (const arg of fileArgs) {
|
||
if ((arg.endsWith('.md') || arg.endsWith('.markdown'))) {
|
||
const resolvedPath = path.isAbsolute(arg) ? arg : path.resolve(workingDirectory, arg);
|
||
console.log('[MAIN] Second instance resolved path:', resolvedPath);
|
||
if (fs.existsSync(resolvedPath)) {
|
||
// Open the file in the existing instance
|
||
if (rendererReady) {
|
||
openFileFromPath(resolvedPath);
|
||
} else {
|
||
app.pendingFile = resolvedPath;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Check if pandoc is available (using execFile for consistency)
|
||
function checkPandocAvailability() {
|
||
return new Promise((resolve) => {
|
||
if (pandocAvailable !== null) {
|
||
resolve(pandocAvailable);
|
||
return;
|
||
}
|
||
|
||
execFile('pandoc', ['--version'], (error, stdout, stderr) => {
|
||
pandocAvailable = !error;
|
||
resolve(pandocAvailable);
|
||
});
|
||
});
|
||
}
|
||
|
||
function createWindow() {
|
||
mainWindow = new BrowserWindow({
|
||
width: 1200,
|
||
height: 800,
|
||
show: false,
|
||
webPreferences: {
|
||
nodeIntegration: true,
|
||
contextIsolation: false,
|
||
spellcheck: true
|
||
},
|
||
icon: path.join(__dirname, '../assets/icon.png')
|
||
});
|
||
|
||
mainWindow.loadFile(path.join(__dirname, 'index.html'));
|
||
|
||
// Show window only after content is ready — avoids blank flash
|
||
mainWindow.once('ready-to-show', () => {
|
||
mainWindow.show();
|
||
});
|
||
|
||
createMenu();
|
||
|
||
mainWindow.on('closed', () => {
|
||
mainWindow = null;
|
||
});
|
||
|
||
// Spell check context menu
|
||
mainWindow.webContents.on('context-menu', (event, params) => {
|
||
const { Menu, MenuItem } = require('electron');
|
||
const menu = new Menu();
|
||
|
||
// Add spell check suggestions
|
||
if (params.misspelledWord) {
|
||
for (const suggestion of params.dictionarySuggestions) {
|
||
menu.append(new MenuItem({
|
||
label: suggestion,
|
||
click: () => mainWindow.webContents.replaceMisspelling(suggestion)
|
||
}));
|
||
}
|
||
if (params.dictionarySuggestions.length > 0) {
|
||
menu.append(new MenuItem({ type: 'separator' }));
|
||
}
|
||
menu.append(new MenuItem({
|
||
label: 'Add to Dictionary',
|
||
click: () => mainWindow.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord)
|
||
}));
|
||
menu.append(new MenuItem({ type: 'separator' }));
|
||
}
|
||
|
||
// Standard context menu items
|
||
menu.append(new MenuItem({ role: 'cut' }));
|
||
menu.append(new MenuItem({ role: 'copy' }));
|
||
menu.append(new MenuItem({ role: 'paste' }));
|
||
menu.append(new MenuItem({ role: 'selectAll' }));
|
||
|
||
menu.popup();
|
||
});
|
||
|
||
// Wait for the page to fully load before sending file data
|
||
mainWindow.webContents.on('did-finish-load', () => {
|
||
console.log('Window finished loading');
|
||
// Don't open file here - wait for renderer-ready signal
|
||
// The renderer will send renderer-ready when TabManager is initialized
|
||
});
|
||
}
|
||
|
||
function buildRecentFilesMenu() {
|
||
const recentFiles = getRecentFiles();
|
||
|
||
if (recentFiles.length === 0) {
|
||
return [
|
||
{
|
||
label: 'No recent files',
|
||
enabled: false
|
||
}
|
||
];
|
||
}
|
||
|
||
const recentFileItems = recentFiles.map(filePath => ({
|
||
label: filePath.split(/[\\/]/).pop(), // Get filename only
|
||
click: () => {
|
||
if (fs.existsSync(filePath)) {
|
||
const stats = fs.statSync(filePath);
|
||
if (stats.size > MAX_FILE_SIZE) {
|
||
dialog.showErrorBox('File Too Large', `File exceeds the ${MAX_FILE_SIZE_MB}MB size limit.`);
|
||
return;
|
||
}
|
||
currentFile = filePath;
|
||
const content = fs.readFileSync(filePath, 'utf-8');
|
||
mainWindow.webContents.send('file-opened', { path: filePath, content });
|
||
} else {
|
||
dialog.showErrorBox('File Not Found', sanitizeErrorMessage(`The file "${filePath}" could not be found.`));
|
||
}
|
||
},
|
||
toolTip: filePath // Show full path in tooltip
|
||
}));
|
||
|
||
return [
|
||
...recentFileItems,
|
||
{ type: 'separator' },
|
||
{
|
||
label: 'Clear Recent Files',
|
||
click: () => {
|
||
mainWindow.webContents.send('clear-recent-files');
|
||
}
|
||
}
|
||
];
|
||
}
|
||
|
||
function getRecentFiles() {
|
||
try {
|
||
const recentFiles = JSON.parse(fs.readFileSync(path.join(app.getPath('userData'), 'recent-files.json'), 'utf-8'));
|
||
return recentFiles.filter(file => fs.existsSync(file));
|
||
} catch (e) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function createMenu() {
|
||
const template = [
|
||
{
|
||
label: 'File',
|
||
submenu: [
|
||
{
|
||
label: 'New',
|
||
accelerator: 'CmdOrCtrl+N',
|
||
click: () => mainWindow.webContents.send('file-new')
|
||
},
|
||
{
|
||
label: 'Open',
|
||
accelerator: 'CmdOrCtrl+O',
|
||
click: openFile
|
||
},
|
||
{
|
||
label: 'Open PDF',
|
||
accelerator: 'CmdOrCtrl+Shift+O',
|
||
click: openPdfFile
|
||
},
|
||
{
|
||
label: 'Save',
|
||
accelerator: 'CmdOrCtrl+S',
|
||
click: () => mainWindow.webContents.send('file-save')
|
||
},
|
||
{
|
||
label: 'Save As',
|
||
accelerator: 'CmdOrCtrl+Shift+S',
|
||
click: saveAsFile
|
||
},
|
||
{ type: 'separator' },
|
||
{
|
||
label: 'Print',
|
||
submenu: [
|
||
{
|
||
label: 'Print Preview',
|
||
accelerator: 'CmdOrCtrl+P',
|
||
click: () => mainWindow.webContents.send('print-preview')
|
||
},
|
||
{
|
||
label: 'Print Preview (With Styles)',
|
||
click: () => mainWindow.webContents.send('print-preview-styled')
|
||
}
|
||
]
|
||
},
|
||
{ type: 'separator' },
|
||
{
|
||
label: 'Recent Files',
|
||
submenu: buildRecentFilesMenu()
|
||
},
|
||
{ type: 'separator' },
|
||
{
|
||
label: 'New from Template',
|
||
submenu: [
|
||
{ label: 'Blog Post', click: () => mainWindow.webContents.send('load-template-menu', 'blog-post.md') },
|
||
{ label: 'Meeting Notes', click: () => mainWindow.webContents.send('load-template-menu', 'meeting-notes.md') },
|
||
{ label: 'Technical Spec', click: () => mainWindow.webContents.send('load-template-menu', 'technical-spec.md') },
|
||
{ label: 'Changelog', click: () => mainWindow.webContents.send('load-template-menu', 'changelog.md') },
|
||
{ label: 'README', click: () => mainWindow.webContents.send('load-template-menu', 'readme.md') },
|
||
{ label: 'Project Plan', click: () => mainWindow.webContents.send('load-template-menu', 'project-plan.md') },
|
||
{ label: 'API Documentation', click: () => mainWindow.webContents.send('load-template-menu', 'api-docs.md') },
|
||
{ label: 'Tutorial', click: () => mainWindow.webContents.send('load-template-menu', 'tutorial.md') },
|
||
{ label: 'Release Notes', click: () => mainWindow.webContents.send('load-template-menu', 'release-notes.md') },
|
||
{ label: 'Comparison', click: () => mainWindow.webContents.send('load-template-menu', 'comparison.md') }
|
||
]
|
||
},
|
||
{ type: 'separator' },
|
||
{
|
||
label: 'Import Document...',
|
||
accelerator: 'CmdOrCtrl+I',
|
||
click: importDocument
|
||
},
|
||
{
|
||
label: 'Export',
|
||
submenu: [
|
||
{ label: 'HTML', click: () => exportFile('html') },
|
||
{ label: 'PDF', click: () => exportFile('pdf') },
|
||
{ label: 'PDF (Enhanced)', click: () => exportPDFViaWordTemplate(), accelerator: 'Ctrl+Shift+P' },
|
||
{ label: 'DOCX', click: () => exportFile('docx') },
|
||
{ label: 'DOCX (Enhanced)', click: () => exportWordWithTemplate(), accelerator: 'Ctrl+Shift+W' },
|
||
{ label: 'LaTeX', click: () => exportFile('latex') },
|
||
{ label: 'RTF', click: () => exportFile('rtf') },
|
||
{ label: 'ODT', click: () => exportFile('odt') },
|
||
{ label: 'EPUB', click: () => exportFile('epub') },
|
||
{ type: 'separator' },
|
||
{ label: 'PowerPoint (PPTX)', click: () => exportFile('pptx') },
|
||
{ label: 'OpenDocument Presentation (ODP)', click: () => exportFile('odp') },
|
||
{ type: 'separator' },
|
||
{ label: 'CSV (Tables)', click: () => exportSpreadsheet('csv') },
|
||
{ type: 'separator' },
|
||
{ label: 'JSON (.json)', click: () => exportFile('json') },
|
||
{ label: 'YAML (.yaml)', click: () => exportFile('yaml') },
|
||
{ label: 'XML (.xml)', click: () => exportFile('xml') },
|
||
{ label: 'TOML (.toml)', click: () => exportFile('toml') },
|
||
{ type: 'separator' },
|
||
{ label: 'Reveal.js Slides (.html)', click: () => exportFile('revealjs') },
|
||
{ label: 'Beamer Slides (.pdf)', click: () => exportFile('beamer') },
|
||
{ type: 'separator' },
|
||
{ label: 'Confluence Wiki (.txt)', click: () => exportFile('confluence') },
|
||
{ label: 'MOBI E-book (.mobi)', click: () => exportFile('mobi') },
|
||
]
|
||
},
|
||
{ type: 'separator' },
|
||
{
|
||
label: 'Select Word Template...',
|
||
click: selectWordTemplate
|
||
},
|
||
{
|
||
label: 'Template Settings...',
|
||
click: showTemplateSettings
|
||
},
|
||
{
|
||
label: 'Header & Footer Settings...',
|
||
click: () => {
|
||
if (mainWindow) {
|
||
mainWindow.webContents.send('open-header-footer-dialog');
|
||
}
|
||
}
|
||
},
|
||
{ type: 'separator' },
|
||
{
|
||
label: 'Quit',
|
||
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Ctrl+Q',
|
||
click: () => app.quit()
|
||
}
|
||
]
|
||
},
|
||
{
|
||
label: 'Edit',
|
||
submenu: [
|
||
{
|
||
label: 'Undo',
|
||
accelerator: 'CmdOrCtrl+Z',
|
||
click: () => mainWindow.webContents.send('undo')
|
||
},
|
||
{
|
||
label: 'Redo',
|
||
accelerator: 'CmdOrCtrl+Shift+Z',
|
||
click: () => mainWindow.webContents.send('redo')
|
||
},
|
||
{ type: 'separator' },
|
||
{ label: 'Cut', accelerator: 'CmdOrCtrl+X', role: 'cut' },
|
||
{ label: 'Copy', accelerator: 'CmdOrCtrl+C', role: 'copy' },
|
||
{ label: 'Paste', accelerator: 'CmdOrCtrl+V', role: 'paste' },
|
||
{ label: 'Select All', accelerator: 'CmdOrCtrl+A', role: 'selectAll' },
|
||
{ type: 'separator' },
|
||
{
|
||
label: 'Find & Replace',
|
||
accelerator: 'CmdOrCtrl+F',
|
||
click: () => mainWindow.webContents.send('toggle-find')
|
||
}
|
||
]
|
||
},
|
||
{
|
||
label: 'View',
|
||
submenu: [
|
||
{
|
||
label: 'Toggle Preview',
|
||
accelerator: 'CmdOrCtrl+Shift+V',
|
||
click: () => mainWindow.webContents.send('toggle-preview')
|
||
},
|
||
{
|
||
label: 'Command Palette',
|
||
accelerator: 'CmdOrCtrl+Shift+P',
|
||
click: () => mainWindow.webContents.send('toggle-command-palette')
|
||
},
|
||
{ type: 'separator' },
|
||
{
|
||
label: 'Sidebar',
|
||
submenu: [
|
||
{ label: 'File Explorer', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'explorer') },
|
||
{ label: 'Git', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'git') },
|
||
{ label: 'Snippets', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'snippets') },
|
||
{ label: 'Templates', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'templates') }
|
||
]
|
||
},
|
||
{
|
||
label: 'Bottom Panel (REPL)',
|
||
click: () => mainWindow.webContents.send('toggle-bottom-panel')
|
||
},
|
||
{ type: 'separator' },
|
||
{
|
||
label: 'Theme',
|
||
submenu: [
|
||
// Light Themes (grouped first)
|
||
{ label: 'Atom One Light (Default)', click: () => setTheme('atomonelight') },
|
||
{ label: 'GitHub Light', click: () => setTheme('github') },
|
||
{ label: 'Light', click: () => setTheme('light') },
|
||
{ label: 'Solarized Light', click: () => setTheme('solarized') },
|
||
{ label: 'Gruvbox Light', click: () => setTheme('gruvbox-light') },
|
||
{ label: 'Ayu Light', click: () => setTheme('ayu-light') },
|
||
{ label: 'Sepia', click: () => setTheme('sepia') },
|
||
{ label: 'Paper', click: () => setTheme('paper') },
|
||
{ label: 'Rose Pine Dawn', click: () => setTheme('rosepine-dawn') },
|
||
{ label: 'Concrete Light', click: () => setTheme('concrete-light') },
|
||
{ type: 'separator' },
|
||
// Dark Themes
|
||
{ label: 'Dark', click: () => setTheme('dark') },
|
||
{ label: 'One Dark', click: () => setTheme('onedark') },
|
||
{ label: 'Dracula', click: () => setTheme('dracula') },
|
||
{ label: 'Nord', click: () => setTheme('nord') },
|
||
{ label: 'Monokai', click: () => setTheme('monokai') },
|
||
{ label: 'Material', click: () => setTheme('material') },
|
||
{ label: 'Gruvbox Dark', click: () => setTheme('gruvbox-dark') },
|
||
{ label: 'Tokyo Night', click: () => setTheme('tokyonight') },
|
||
{ label: 'Palenight', click: () => setTheme('palenight') },
|
||
{ label: 'Ayu Dark', click: () => setTheme('ayu-dark') },
|
||
{ label: 'Ayu Mirage', click: () => setTheme('ayu-mirage') },
|
||
{ label: 'Oceanic Next', click: () => setTheme('oceanic-next') },
|
||
{ label: 'Cobalt2', click: () => setTheme('cobalt2') },
|
||
{ label: 'Concrete Dark', click: () => setTheme('concrete-dark') },
|
||
{ label: 'Concrete Warm', click: () => setTheme('concrete-warm') }
|
||
]
|
||
},
|
||
{ type: 'separator' },
|
||
{
|
||
label: 'Font Size',
|
||
submenu: [
|
||
{
|
||
label: 'Increase Font Size',
|
||
accelerator: 'CmdOrCtrl+Shift+Plus',
|
||
click: () => mainWindow.webContents.send('adjust-font-size', 'increase')
|
||
},
|
||
{
|
||
label: 'Decrease Font Size',
|
||
accelerator: 'CmdOrCtrl+Shift+-',
|
||
click: () => mainWindow.webContents.send('adjust-font-size', 'decrease')
|
||
},
|
||
{
|
||
label: 'Reset Font Size',
|
||
accelerator: 'CmdOrCtrl+Shift+0',
|
||
click: () => mainWindow.webContents.send('adjust-font-size', 'reset')
|
||
}
|
||
]
|
||
},
|
||
{ type: 'separator' },
|
||
{
|
||
label: 'Spell Check',
|
||
type: 'checkbox',
|
||
checked: true,
|
||
click: (menuItem) => {
|
||
mainWindow.webContents.session.setSpellCheckerEnabled(menuItem.checked);
|
||
}
|
||
},
|
||
{ type: 'separator' },
|
||
{
|
||
label: 'Custom Preview CSS',
|
||
submenu: [
|
||
{
|
||
label: 'Load Custom Preview CSS...',
|
||
click: () => mainWindow.webContents.send('load-custom-css')
|
||
},
|
||
{
|
||
label: 'Clear Custom Preview CSS',
|
||
click: () => mainWindow.webContents.send('clear-custom-css')
|
||
}
|
||
]
|
||
},
|
||
{ type: 'separator' },
|
||
{ label: 'Reload', accelerator: 'CmdOrCtrl+R', role: 'reload' },
|
||
{ label: 'Toggle DevTools', accelerator: 'F12', role: 'toggleDevTools' },
|
||
{ type: 'separator' },
|
||
{ label: 'Zoom In', accelerator: 'CmdOrCtrl+Plus', role: 'zoomIn' },
|
||
{ label: 'Zoom Out', accelerator: 'CmdOrCtrl+-', role: 'zoomOut' },
|
||
{ label: 'Reset Zoom', accelerator: 'CmdOrCtrl+0', role: 'resetZoom' }
|
||
]
|
||
},
|
||
{
|
||
label: 'Batch',
|
||
submenu: [
|
||
{
|
||
label: 'Convert Markdown Folder...',
|
||
click: () => showBatchConversionDialog()
|
||
},
|
||
{ type: 'separator' },
|
||
{
|
||
label: 'Batch Image Conversion...',
|
||
click: () => mainWindow.webContents.send('show-batch-converter', 'image')
|
||
},
|
||
{
|
||
label: 'Batch Audio Conversion...',
|
||
click: () => mainWindow.webContents.send('show-batch-converter', 'audio')
|
||
},
|
||
{
|
||
label: 'Batch Video Conversion...',
|
||
click: () => mainWindow.webContents.send('show-batch-converter', 'video')
|
||
},
|
||
{
|
||
label: 'Batch PDF Conversion...',
|
||
click: () => mainWindow.webContents.send('show-batch-converter', 'pdf')
|
||
}
|
||
]
|
||
},
|
||
{
|
||
label: 'Convert',
|
||
submenu: [
|
||
{
|
||
label: 'Universal File Converter...',
|
||
accelerator: 'CmdOrCtrl+Shift+C',
|
||
click: () => showUniversalConverterDialog()
|
||
}
|
||
]
|
||
},
|
||
{
|
||
label: 'PDF Editor',
|
||
submenu: [
|
||
{
|
||
label: 'Open PDF File...',
|
||
accelerator: 'CmdOrCtrl+Shift+O',
|
||
click: () => openPDFFile()
|
||
},
|
||
{ type: 'separator' },
|
||
{
|
||
label: 'Merge PDFs...',
|
||
click: () => showPDFEditorDialog('merge')
|
||
},
|
||
{
|
||
label: 'Split PDF...',
|
||
click: () => showPDFEditorDialog('split')
|
||
},
|
||
{
|
||
label: 'Compress PDF...',
|
||
click: () => showPDFEditorDialog('compress')
|
||
},
|
||
{
|
||
type: 'separator'
|
||
},
|
||
{
|
||
label: 'Rotate Pages...',
|
||
click: () => showPDFEditorDialog('rotate')
|
||
},
|
||
{
|
||
label: 'Delete Pages...',
|
||
click: () => showPDFEditorDialog('delete')
|
||
},
|
||
{
|
||
label: 'Reorder Pages...',
|
||
click: () => showPDFEditorDialog('reorder')
|
||
},
|
||
{
|
||
type: 'separator'
|
||
},
|
||
{
|
||
label: 'Add Watermark...',
|
||
click: () => showPDFEditorDialog('watermark')
|
||
},
|
||
{
|
||
type: 'separator'
|
||
},
|
||
{
|
||
label: 'Security',
|
||
submenu: [
|
||
{
|
||
label: 'Add Password Protection...',
|
||
click: () => showPDFEditorDialog('encrypt')
|
||
},
|
||
{
|
||
label: 'Remove Password...',
|
||
click: () => showPDFEditorDialog('decrypt')
|
||
},
|
||
{
|
||
label: 'Set Permissions...',
|
||
click: () => showPDFEditorDialog('permissions')
|
||
}
|
||
]
|
||
},
|
||
{
|
||
type: 'separator'
|
||
},
|
||
{
|
||
label: 'About PDF Editor',
|
||
click: () => {
|
||
dialog.showMessageBox(mainWindow, {
|
||
type: 'info',
|
||
title: 'About PDF Editor',
|
||
message: 'PDF Editor',
|
||
detail: 'Comprehensive PDF editing capabilities powered by pdf-lib.\n\nFeatures:\n• Merge multiple PDF files\n• Split PDF into separate files\n• Compress PDF to reduce file size\n• Rotate pages (90°, 180°, 270°)\n• Delete unwanted pages\n• Reorder pages\n• Add text watermarks\n\nSecurity Features:\n• Password protection (encryption)\n• Remove passwords (decryption)\n• Set document permissions\n\n100% offline and open-source.',
|
||
buttons: ['OK']
|
||
});
|
||
}
|
||
}
|
||
]
|
||
},
|
||
{
|
||
label: 'Tools',
|
||
submenu: [
|
||
{
|
||
label: 'Table Generator',
|
||
accelerator: 'CmdOrCtrl+Shift+T',
|
||
click: () => openTableGenerator()
|
||
},
|
||
{
|
||
label: 'ASCII Art Generator',
|
||
accelerator: 'CmdOrCtrl+Shift+A',
|
||
click: () => openAsciiGenerator()
|
||
},
|
||
{ type: 'separator' },
|
||
{
|
||
label: 'Document Compare',
|
||
click: () => mainWindow.webContents.send('show-document-compare')
|
||
}
|
||
]
|
||
},
|
||
{
|
||
label: 'Help',
|
||
submenu: [
|
||
{
|
||
label: 'About MarkdownConverter',
|
||
click: () => showAboutDialog()
|
||
},
|
||
{ type: 'separator' },
|
||
{
|
||
label: 'Dependencies & Requirements',
|
||
click: () => showDependenciesDialog()
|
||
},
|
||
{ type: 'separator' },
|
||
{
|
||
label: 'Documentation',
|
||
click: () => shell.openExternal('https://github.com/amitwh/markdown-converter')
|
||
},
|
||
{
|
||
label: 'Report Issue',
|
||
click: () => shell.openExternal('https://github.com/amitwh/markdown-converter/issues')
|
||
},
|
||
{
|
||
label: 'Check for Updates',
|
||
click: () => shell.openExternal('https://github.com/amitwh/markdown-converter/releases')
|
||
}
|
||
]
|
||
}
|
||
];
|
||
|
||
const menu = Menu.buildFromTemplate(template);
|
||
Menu.setApplicationMenu(menu);
|
||
}
|
||
|
||
// Show About Dialog with logo
|
||
function showAboutDialog() {
|
||
const aboutWindow = new BrowserWindow({
|
||
width: 500,
|
||
height: 600,
|
||
parent: mainWindow,
|
||
modal: true,
|
||
resizable: false,
|
||
minimizable: false,
|
||
maximizable: false,
|
||
webPreferences: {
|
||
nodeIntegration: false,
|
||
contextIsolation: true
|
||
},
|
||
icon: path.join(__dirname, '../assets/icon.png')
|
||
});
|
||
|
||
// Convert images to base64 for data URL compatibility
|
||
let logoBase64 = '';
|
||
let iconBase64 = '';
|
||
try {
|
||
const logoPath = path.join(__dirname, '../assets/logo.png');
|
||
const iconPath = path.join(__dirname, '../assets/icon.png');
|
||
if (fs.existsSync(logoPath)) {
|
||
logoBase64 = 'data:image/png;base64,' + fs.readFileSync(logoPath).toString('base64');
|
||
}
|
||
if (fs.existsSync(iconPath)) {
|
||
iconBase64 = 'data:image/png;base64,' + fs.readFileSync(iconPath).toString('base64');
|
||
}
|
||
} catch (e) {
|
||
console.error('Error loading about dialog images:', e);
|
||
}
|
||
|
||
const aboutHTML = `
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>About MarkdownConverter</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body {
|
||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||
background: linear-gradient(135deg, #464646 0%, #0d0b09 100%);
|
||
color: #e3e3e3;
|
||
padding: 30px;
|
||
text-align: center;
|
||
}
|
||
.logo { width: 120px; margin-bottom: 20px; border-radius: 20px; }
|
||
h1 { font-size: 24px; margin-bottom: 5px; color: #fff; }
|
||
.version { color: #e5461f; font-size: 14px; margin-bottom: 20px; }
|
||
.company { display: flex; align-items: center; justify-content: center; gap: 10px; margin-bottom: 20px; }
|
||
.company-logo { height: 24px; }
|
||
.section { background: rgba(255,255,255,0.1); border-radius: 10px; padding: 15px; margin: 15px 0; text-align: left; }
|
||
.section h3 { color: #e5461f; font-size: 14px; margin-bottom: 10px; }
|
||
.section p, .section li { font-size: 12px; line-height: 1.6; }
|
||
ul { padding-left: 20px; }
|
||
.footer { margin-top: 20px; font-size: 11px; color: #9a9696; }
|
||
a { color: #e5461f; text-decoration: none; }
|
||
a:hover { text-decoration: underline; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<img src="${iconBase64}" class="logo" alt="MarkdownConverter">
|
||
<h1>MarkdownConverter</h1>
|
||
<div class="version">Version ${app.getVersion()}</div>
|
||
|
||
<div class="company">
|
||
<span>by</span>
|
||
<img src="${logoBase64}" class="company-logo" alt="ConcreteInfo">
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h3>Features</h3>
|
||
<ul>
|
||
<li>Professional Markdown editing with syntax highlighting</li>
|
||
<li>Universal file converter (Image, Audio, Video, PDF)</li>
|
||
<li>Batch conversion for all media types</li>
|
||
<li>Advanced PDF Editor (merge, split, watermark, encrypt)</li>
|
||
<li>Export to PDF, DOCX, HTML, LaTeX, EPUB & more</li>
|
||
<li>23+ beautiful themes including ConcreteInfo theme</li>
|
||
<li>ASCII Art & Table generators</li>
|
||
<li>Code file syntax highlighting</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h3>Contact</h3>
|
||
<p>Email: <a href="mailto:amit.wh@gmail.com">amit.wh@gmail.com</a></p>
|
||
<p>Website: <a href="https://github.com/amitwh/markdown-converter">GitHub Repository</a></p>
|
||
</div>
|
||
|
||
<div class="footer">
|
||
<p>License: MIT</p>
|
||
<p>© 2024-2026 ConcreteInfo. All rights reserved.</p>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
|
||
aboutWindow.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(aboutHTML));
|
||
aboutWindow.setMenuBarVisibility(false);
|
||
}
|
||
|
||
// Show Dependencies Dialog
|
||
function showDependenciesDialog() {
|
||
const depsWindow = new BrowserWindow({
|
||
width: 600,
|
||
height: 700,
|
||
parent: mainWindow,
|
||
modal: true,
|
||
resizable: true,
|
||
webPreferences: {
|
||
nodeIntegration: false,
|
||
contextIsolation: true
|
||
},
|
||
icon: path.join(__dirname, '../assets/icon.png')
|
||
});
|
||
|
||
const depsHTML = `
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>Dependencies & Requirements</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body {
|
||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||
background: #f5f5f5;
|
||
color: #464646;
|
||
padding: 30px;
|
||
}
|
||
h1 { color: #e5461f; font-size: 20px; margin-bottom: 20px; border-bottom: 2px solid #e5461f; padding-bottom: 10px; }
|
||
h2 { color: #464646; font-size: 16px; margin: 20px 0 10px; }
|
||
.dep-card {
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
padding: 15px;
|
||
margin: 10px 0;
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||
}
|
||
.dep-name { font-weight: bold; color: #0d0b09; }
|
||
.dep-desc { font-size: 13px; color: #666; margin: 5px 0; }
|
||
.dep-link { color: #e5461f; font-size: 12px; text-decoration: none; }
|
||
.dep-link:hover { text-decoration: underline; }
|
||
.required { background: #fff3cd; border-left: 4px solid #ffc107; }
|
||
.optional { background: #d4edda; border-left: 4px solid #28a745; }
|
||
.tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; margin-left: 10px; }
|
||
.tag-required { background: #ffc107; color: #000; }
|
||
.tag-optional { background: #28a745; color: #fff; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>Dependencies & Requirements</h1>
|
||
|
||
<h2>Required Dependencies</h2>
|
||
|
||
<div class="dep-card required">
|
||
<div class="dep-name">Pandoc <span class="tag tag-required">Required</span></div>
|
||
<div class="dep-desc">Universal document converter. Required for most export formats (PDF, DOCX, LaTeX, EPUB).</div>
|
||
<a class="dep-link" href="https://pandoc.org/installing.html" target="_blank">https://pandoc.org/installing.html</a>
|
||
</div>
|
||
|
||
<h2>Optional Dependencies (for extended features)</h2>
|
||
|
||
<div class="dep-card optional">
|
||
<div class="dep-name">FFmpeg <span class="tag tag-optional">Optional</span></div>
|
||
<div class="dep-desc">Required for audio/video conversion and processing.</div>
|
||
<a class="dep-link" href="https://ffmpeg.org/download.html" target="_blank">https://ffmpeg.org/download.html</a>
|
||
</div>
|
||
|
||
<div class="dep-card optional">
|
||
<div class="dep-name">ImageMagick <span class="tag tag-optional">Optional</span></div>
|
||
<div class="dep-desc">Required for image format conversion and processing.</div>
|
||
<a class="dep-link" href="https://imagemagick.org/script/download.php" target="_blank">https://imagemagick.org/script/download.php</a>
|
||
</div>
|
||
|
||
<div class="dep-card optional">
|
||
<div class="dep-name">LibreOffice <span class="tag tag-optional">Optional</span></div>
|
||
<div class="dep-desc">Required for enhanced document conversion (Office formats).</div>
|
||
<a class="dep-link" href="https://www.libreoffice.org/download/download/" target="_blank">https://www.libreoffice.org/download/download/</a>
|
||
</div>
|
||
|
||
<div class="dep-card optional">
|
||
<div class="dep-name">MiKTeX / TeX Live <span class="tag tag-optional">Optional</span></div>
|
||
<div class="dep-desc">Required for PDF export via LaTeX (higher quality PDFs).</div>
|
||
<a class="dep-link" href="https://miktex.org/download" target="_blank">https://miktex.org/download</a>
|
||
</div>
|
||
|
||
<h2>Bundled Libraries</h2>
|
||
|
||
<div class="dep-card">
|
||
<div class="dep-name">pdf-lib</div>
|
||
<div class="dep-desc">PDF manipulation library for merge, split, watermark, and encryption features.</div>
|
||
</div>
|
||
|
||
<div class="dep-card">
|
||
<div class="dep-name">marked</div>
|
||
<div class="dep-desc">Markdown parser for preview rendering.</div>
|
||
</div>
|
||
|
||
<div class="dep-card">
|
||
<div class="dep-name">highlight.js</div>
|
||
<div class="dep-desc">Syntax highlighting for code blocks.</div>
|
||
</div>
|
||
|
||
<div class="dep-card">
|
||
<div class="dep-name">DOMPurify</div>
|
||
<div class="dep-desc">HTML sanitization for security.</div>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
|
||
depsWindow.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(depsHTML));
|
||
depsWindow.setMenuBarVisibility(false);
|
||
}
|
||
|
||
// Open PDF File for viewing/editing
|
||
function openPDFFile() {
|
||
const files = dialog.showOpenDialogSync(mainWindow, {
|
||
properties: ['openFile'],
|
||
filters: [
|
||
{ name: 'PDF Files', extensions: ['pdf'] }
|
||
]
|
||
});
|
||
|
||
if (files && files[0]) {
|
||
const stats = fs.statSync(files[0]);
|
||
if (stats.size > MAX_FILE_SIZE) {
|
||
dialog.showErrorBox('File Too Large', `File exceeds the ${MAX_FILE_SIZE_MB}MB size limit.`);
|
||
return;
|
||
}
|
||
mainWindow.webContents.send('open-pdf-viewer', files[0]);
|
||
}
|
||
}
|
||
|
||
function openFile() {
|
||
const files = dialog.showOpenDialogSync(mainWindow, {
|
||
properties: ['openFile'],
|
||
filters: [
|
||
{ name: 'Markdown', extensions: ['md', 'markdown'] },
|
||
{ name: 'Developer Formats', extensions: ['json', 'yaml', 'yml', 'xml', 'toml'] },
|
||
{ name: 'All Files', extensions: ['*'] }
|
||
]
|
||
});
|
||
|
||
if (files && files[0]) {
|
||
const stats = fs.statSync(files[0]);
|
||
if (stats.size > MAX_FILE_SIZE) {
|
||
dialog.showErrorBox('File Too Large', `File exceeds the ${MAX_FILE_SIZE_MB}MB size limit.`);
|
||
return;
|
||
}
|
||
currentFile = files[0];
|
||
const ext = path.extname(currentFile).toLowerCase().slice(1);
|
||
let content = fs.readFileSync(currentFile, 'utf-8');
|
||
|
||
// Wrap developer format files in code blocks for markdown display
|
||
const devFormats = ['json', 'yaml', 'yml', 'xml', 'toml'];
|
||
if (devFormats.includes(ext)) {
|
||
content = convertDataToMarkdown(content, ext);
|
||
}
|
||
|
||
mainWindow.webContents.send('file-opened', { path: currentFile, content });
|
||
}
|
||
}
|
||
|
||
function openPdfFile() {
|
||
const files = dialog.showOpenDialogSync(mainWindow, {
|
||
properties: ['openFile'],
|
||
filters: [
|
||
{ name: 'PDF Files', extensions: ['pdf'] },
|
||
{ name: 'All Files', extensions: ['*'] }
|
||
]
|
||
});
|
||
|
||
if (files && files[0]) {
|
||
const stats = fs.statSync(files[0]);
|
||
if (stats.size > MAX_FILE_SIZE) {
|
||
dialog.showErrorBox('File Too Large', `File exceeds the ${MAX_FILE_SIZE_MB}MB size limit.`);
|
||
return;
|
||
}
|
||
mainWindow.webContents.send('open-pdf-file', files[0]);
|
||
}
|
||
}
|
||
|
||
function saveAsFile() {
|
||
const file = dialog.showSaveDialogSync(mainWindow, {
|
||
defaultExt: '.md',
|
||
filters: [
|
||
{ name: 'Markdown', extensions: ['md', 'markdown'] },
|
||
{ name: 'All Files', extensions: ['*'] }
|
||
]
|
||
});
|
||
|
||
if (file) {
|
||
currentFile = file;
|
||
mainWindow.webContents.send('get-content-for-save', file);
|
||
}
|
||
}
|
||
|
||
function exportFile(format) {
|
||
if (!currentFile) {
|
||
dialog.showErrorBox('Error', 'Please save the file first');
|
||
return;
|
||
}
|
||
|
||
// Show export options dialog
|
||
showExportOptionsDialog(format);
|
||
}
|
||
|
||
function showExportOptionsDialog(format) {
|
||
mainWindow.webContents.send('show-export-dialog', format);
|
||
}
|
||
|
||
function showBatchConversionDialog() {
|
||
mainWindow.webContents.send('show-batch-dialog');
|
||
}
|
||
|
||
// Select Word Template
|
||
async function selectWordTemplate() {
|
||
const result = await dialog.showOpenDialog(mainWindow, {
|
||
title: 'Select Word Template',
|
||
filters: [{ name: 'Word Document', extensions: ['docx'] }],
|
||
properties: ['openFile']
|
||
});
|
||
|
||
if (!result.canceled && result.filePaths.length > 0) {
|
||
wordTemplatePath = result.filePaths[0];
|
||
store.set('wordTemplatePath', wordTemplatePath);
|
||
|
||
dialog.showMessageBox(mainWindow, {
|
||
type: 'info',
|
||
title: 'Template Selected',
|
||
message: 'Word template has been updated',
|
||
detail: `Template: ${path.basename(wordTemplatePath)}`
|
||
});
|
||
}
|
||
}
|
||
|
||
// Template Settings Dialog
|
||
async function showTemplateSettings() {
|
||
const result = await dialog.showMessageBox(mainWindow, {
|
||
type: 'question',
|
||
title: 'Template Settings',
|
||
message: 'Configure Word Template Export',
|
||
detail: `Current template: ${wordTemplatePath ? path.basename(wordTemplatePath) : 'Default template'}\nContent starts from page: ${templateStartPage}\n\nWhich page should content start from?\n(Templates usually have cover pages, TOC, etc.)`,
|
||
buttons: ['Page 1', 'Page 2', 'Page 3', 'Page 4', 'Page 5', 'Custom...', 'Cancel'],
|
||
defaultId: templateStartPage - 1,
|
||
cancelId: 6
|
||
});
|
||
|
||
if (result.response === 6) return; // Cancel
|
||
|
||
let newStartPage;
|
||
if (result.response === 5) { // Custom
|
||
// Show input dialog for custom page number
|
||
mainWindow.webContents.send('show-custom-start-page-dialog', templateStartPage);
|
||
} else {
|
||
newStartPage = result.response + 1; // Convert button index to page number
|
||
templateStartPage = newStartPage;
|
||
store.set('templateStartPage', templateStartPage);
|
||
|
||
dialog.showMessageBox(mainWindow, {
|
||
type: 'info',
|
||
title: 'Settings Updated',
|
||
message: 'Template settings have been updated',
|
||
detail: `Content will now start from page ${templateStartPage}`
|
||
});
|
||
}
|
||
}
|
||
|
||
// Handle custom start page input from renderer
|
||
ipcMain.on('set-custom-start-page', (event, pageNumber) => {
|
||
const page = parseInt(pageNumber);
|
||
if (page >= 1 && page <= 100) {
|
||
templateStartPage = page;
|
||
store.set('templateStartPage', templateStartPage);
|
||
|
||
dialog.showMessageBox(mainWindow, {
|
||
type: 'info',
|
||
title: 'Settings Updated',
|
||
message: 'Template settings have been updated',
|
||
detail: `Content will now start from page ${templateStartPage}`
|
||
});
|
||
} else {
|
||
dialog.showErrorBox('Invalid Page Number', 'Please enter a page number between 1 and 100');
|
||
}
|
||
});
|
||
|
||
// Header & Footer Settings IPC Handlers
|
||
|
||
// Get current header/footer settings
|
||
ipcMain.on('get-header-footer-settings', (event) => {
|
||
event.reply('header-footer-settings-data', headerFooterSettings);
|
||
});
|
||
|
||
// Save header/footer settings
|
||
ipcMain.on('save-header-footer-settings', (event, settings) => {
|
||
headerFooterSettings = settings;
|
||
store.set('headerFooterSettings', headerFooterSettings);
|
||
|
||
dialog.showMessageBox(mainWindow, {
|
||
type: 'info',
|
||
title: 'Settings Saved',
|
||
message: 'Header and footer settings have been saved successfully!',
|
||
buttons: ['OK']
|
||
});
|
||
});
|
||
|
||
// Get current page settings
|
||
ipcMain.on('get-page-settings', (event) => {
|
||
event.reply('page-settings-data', pageSettings);
|
||
});
|
||
|
||
// Update page settings from export dialog
|
||
ipcMain.on('update-page-settings', (event, settings) => {
|
||
pageSettings = settings;
|
||
store.set('pageSettings', pageSettings);
|
||
console.log('Page settings updated:', pageSettings);
|
||
});
|
||
|
||
// Save header/footer logo image
|
||
// Browse for header/footer logo
|
||
ipcMain.on('browse-header-footer-logo', async (event, position) => {
|
||
try {
|
||
const result = await dialog.showOpenDialog(mainWindow, {
|
||
title: `Select ${position.charAt(0).toUpperCase() + position.slice(1)} Logo/Image`,
|
||
filters: [
|
||
{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp'] }
|
||
],
|
||
properties: ['openFile']
|
||
});
|
||
|
||
if (!result.canceled && result.filePaths.length > 0) {
|
||
const filePath = result.filePaths[0];
|
||
|
||
// Copy image to userData directory for persistent storage
|
||
const userDataPath = app.getPath('userData');
|
||
const logoDir = path.join(userDataPath, 'logos');
|
||
|
||
// Create logos directory if it doesn't exist
|
||
if (!fs.existsSync(logoDir)) {
|
||
fs.mkdirSync(logoDir, { recursive: true });
|
||
}
|
||
|
||
// Generate unique filename
|
||
const ext = path.extname(filePath);
|
||
const filename = `${position}_${Date.now()}${ext}`;
|
||
const destPath = path.join(logoDir, filename);
|
||
|
||
// Copy file
|
||
fs.copyFileSync(filePath, destPath);
|
||
|
||
// Update settings
|
||
if (position === 'header') {
|
||
headerFooterSettings.header.logo = destPath;
|
||
} else if (position === 'footer') {
|
||
headerFooterSettings.footer.logo = destPath;
|
||
}
|
||
|
||
event.reply('header-footer-logo-saved', { position, path: destPath });
|
||
}
|
||
} catch (error) {
|
||
console.error('Logo browse error:', error);
|
||
dialog.showErrorBox('Logo Error', sanitizeErrorMessage(`Failed to select logo: ${error.message}`));
|
||
}
|
||
});
|
||
|
||
ipcMain.on('save-header-footer-logo', async (event, { position, filePath }) => {
|
||
try {
|
||
if (!filePath) {
|
||
dialog.showErrorBox('Logo Error', 'Failed to save logo: The "path" argument must be of type string. Received undefined');
|
||
return;
|
||
}
|
||
|
||
// Copy image to userData directory for persistent storage
|
||
const userDataPath = app.getPath('userData');
|
||
const logoDir = path.join(userDataPath, 'logos');
|
||
|
||
// Create logos directory if it doesn't exist
|
||
if (!fs.existsSync(logoDir)) {
|
||
fs.mkdirSync(logoDir, { recursive: true });
|
||
}
|
||
|
||
// Verify source file exists
|
||
if (!fs.existsSync(filePath)) {
|
||
dialog.showErrorBox('Logo Error', sanitizeErrorMessage(`Source file not found: ${filePath}`));
|
||
return;
|
||
}
|
||
|
||
// Generate unique filename
|
||
const ext = path.extname(filePath);
|
||
const filename = `${position}_${Date.now()}${ext}`;
|
||
const destPath = path.join(logoDir, filename);
|
||
|
||
// Copy file
|
||
fs.copyFileSync(filePath, destPath);
|
||
|
||
// Update settings
|
||
if (position === 'header') {
|
||
headerFooterSettings.header.logo = destPath;
|
||
} else if (position === 'footer') {
|
||
headerFooterSettings.footer.logo = destPath;
|
||
}
|
||
|
||
event.reply('header-footer-logo-saved', { position, path: destPath });
|
||
} catch (error) {
|
||
console.error('Logo save error:', error);
|
||
dialog.showErrorBox('Logo Error', sanitizeErrorMessage(`Failed to save logo: ${error.message}`));
|
||
}
|
||
});
|
||
|
||
// Clear header/footer logo
|
||
ipcMain.on('clear-header-footer-logo', (event, position) => {
|
||
if (position === 'header') {
|
||
headerFooterSettings.header.logo = null;
|
||
} else if (position === 'footer') {
|
||
headerFooterSettings.footer.logo = null;
|
||
}
|
||
event.reply('header-footer-logo-cleared', position);
|
||
});
|
||
|
||
// Helper function to process dynamic fields in header/footer text
|
||
function processDynamicFields(text, metadata = {}) {
|
||
if (!text) return '';
|
||
|
||
const now = new Date();
|
||
const dateStr = now.toLocaleDateString();
|
||
const timeStr = now.toLocaleTimeString();
|
||
|
||
let result = text;
|
||
result = result.replace(/\$DATE\$/g, dateStr);
|
||
result = result.replace(/\$TIME\$/g, timeStr);
|
||
result = result.replace(/\$TITLE\$/g, metadata.title || 'Untitled');
|
||
result = result.replace(/\$AUTHOR\$/g, metadata.author || '');
|
||
result = result.replace(/\$FILENAME\$/g, metadata.filename || '');
|
||
|
||
// Note: $PAGE$ and $TOTAL$ are handled by Pandoc/export tools
|
||
|
||
return result;
|
||
}
|
||
|
||
// Add headers/footers to DOCX file using PizZip and docx4js
|
||
// Function to set page size in DOCX files
|
||
async function setDocxPageSize(docxPath) {
|
||
try {
|
||
const PizZip = require('pizzip');
|
||
|
||
// Read the DOCX file
|
||
const docxBuffer = fs.readFileSync(docxPath);
|
||
const zip = new PizZip(docxBuffer);
|
||
|
||
// Get document.xml
|
||
let documentXml = zip.file('word/document.xml').asText();
|
||
|
||
// Get page dimensions
|
||
let width, height;
|
||
const pageSize = PAGE_SIZES[pageSettings.size];
|
||
|
||
if (pageSize) {
|
||
width = pageSize.word.width;
|
||
height = pageSize.word.height;
|
||
} else if (pageSettings.customWidth && pageSettings.customHeight) {
|
||
// Parse custom dimensions (convert to twentieths of a point)
|
||
// Note: This is simplified - production code should handle various units
|
||
width = parseInt(pageSettings.customWidth) || 11906;
|
||
height = parseInt(pageSettings.customHeight) || 16838;
|
||
} else {
|
||
// Default to A4
|
||
width = 11906;
|
||
height = 16838;
|
||
}
|
||
|
||
// Swap dimensions for landscape
|
||
if (pageSettings.orientation === 'landscape') {
|
||
[width, height] = [height, width];
|
||
}
|
||
|
||
// Update all <w:pgSz> elements in section properties
|
||
const pgSzRegex = /<w:pgSz[^>]*\/>/g;
|
||
documentXml = documentXml.replace(pgSzRegex, () => {
|
||
return `<w:pgSz w:w="${width}" w:h="${height}" w:orient="${pageSettings.orientation}"/>`;
|
||
});
|
||
|
||
// If no pgSz found, add it to all sectPr elements
|
||
if (!pgSzRegex.test(documentXml)) {
|
||
const sectPrRegex = /<w:sectPr[^>]*>/g;
|
||
documentXml = documentXml.replace(sectPrRegex, (match) => {
|
||
return `${match}<w:pgSz w:w="${width}" w:h="${height}" w:orient="${pageSettings.orientation}"/>`;
|
||
});
|
||
}
|
||
|
||
// Save updated document.xml
|
||
zip.file('word/document.xml', documentXml);
|
||
|
||
// Write modified DOCX
|
||
const newDocxBuffer = zip.generate({ type: 'nodebuffer' });
|
||
fs.writeFileSync(docxPath, newDocxBuffer);
|
||
|
||
} catch (error) {
|
||
console.error('Failed to set DOCX page size:', error);
|
||
}
|
||
}
|
||
|
||
async function addHeaderFooterToDocx(docxPath, metadata = {}) {
|
||
if (!headerFooterSettings.enabled) return;
|
||
|
||
try {
|
||
const PizZip = require('pizzip');
|
||
|
||
// Read the DOCX file
|
||
const docxBuffer = fs.readFileSync(docxPath);
|
||
const zip = new PizZip(docxBuffer);
|
||
|
||
// Process dynamic fields
|
||
const headerLeft = processDynamicFields(headerFooterSettings.header.left, metadata);
|
||
const headerCenter = processDynamicFields(headerFooterSettings.header.center, metadata);
|
||
const headerRight = processDynamicFields(headerFooterSettings.header.right, metadata);
|
||
const footerLeft = processDynamicFields(headerFooterSettings.footer.left, metadata);
|
||
const footerCenter = processDynamicFields(headerFooterSettings.footer.center, metadata);
|
||
const footerRight = processDynamicFields(headerFooterSettings.footer.right, metadata);
|
||
|
||
// Create header XML
|
||
if (headerLeft || headerCenter || headerRight) {
|
||
const headerXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||
<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||
<w:p>
|
||
<w:pPr><w:jc w:val="left"/></w:pPr>
|
||
<w:r><w:t>${headerLeft || ''}</w:t></w:r>
|
||
</w:p>
|
||
<w:p>
|
||
<w:pPr><w:jc w:val="center"/></w:pPr>
|
||
<w:r><w:t>${headerCenter || ''}</w:t></w:r>
|
||
</w:p>
|
||
<w:p>
|
||
<w:pPr><w:jc w:val="right"/></w:pPr>
|
||
<w:r><w:t>${headerRight || ''}</w:t></w:r>
|
||
</w:p>
|
||
</w:hdr>`;
|
||
zip.file('word/header1.xml', headerXml);
|
||
}
|
||
|
||
// Create footer XML with page numbers
|
||
if (footerLeft || footerCenter || footerRight) {
|
||
let footerCenterXml = '';
|
||
if (footerCenter) {
|
||
// Handle $PAGE$ and $TOTAL$ in footer
|
||
if (footerCenter.includes('$PAGE$') || footerCenter.includes('$TOTAL$')) {
|
||
const parts = footerCenter.split(/(\$PAGE\$|\$TOTAL\$)/);
|
||
footerCenterXml = parts.map(part => {
|
||
if (part === '$PAGE$') {
|
||
return '<w:fldSimple w:instr="PAGE"/>';
|
||
} else if (part === '$TOTAL$') {
|
||
return '<w:fldSimple w:instr="NUMPAGES"/>';
|
||
} else {
|
||
return `<w:r><w:t>${part}</w:t></w:r>`;
|
||
}
|
||
}).join('');
|
||
} else {
|
||
footerCenterXml = `<w:r><w:t>${footerCenter}</w:t></w:r>`;
|
||
}
|
||
}
|
||
|
||
const footerXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||
<w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||
<w:p>
|
||
<w:pPr><w:jc w:val="left"/></w:pPr>
|
||
<w:r><w:t>${footerLeft || ''}</w:t></w:r>
|
||
</w:p>
|
||
<w:p>
|
||
<w:pPr><w:jc w:val="center"/></w:pPr>
|
||
${footerCenterXml}
|
||
</w:p>
|
||
<w:p>
|
||
<w:pPr><w:jc w:val="right"/></w:pPr>
|
||
<w:r><w:t>${footerRight || ''}</w:t></w:r>
|
||
</w:p>
|
||
</w:ftr>`;
|
||
zip.file('word/footer1.xml', footerXml);
|
||
}
|
||
|
||
// Update document.xml.rels to reference header/footer
|
||
let relsXml = zip.file('word/_rels/document.xml.rels').asText();
|
||
|
||
// Add header relationship if not exists
|
||
if ((headerLeft || headerCenter || headerRight) && !relsXml.includes('header1.xml')) {
|
||
const headerId = 'rId100';
|
||
const headerRel = `<Relationship Id="${headerId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/>`;
|
||
relsXml = relsXml.replace('</Relationships>', headerRel + '</Relationships>');
|
||
}
|
||
|
||
// Add footer relationship if not exists
|
||
if ((footerLeft || footerCenter || footerRight) && !relsXml.includes('footer1.xml')) {
|
||
const footerId = 'rId101';
|
||
const footerRel = `<Relationship Id="${footerId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/>`;
|
||
relsXml = relsXml.replace('</Relationships>', footerRel + '</Relationships>');
|
||
}
|
||
|
||
zip.file('word/_rels/document.xml.rels', relsXml);
|
||
|
||
// Update document.xml to use header/footer in sections
|
||
let documentXml = zip.file('word/document.xml').asText();
|
||
if ((headerLeft || headerCenter || headerRight || footerLeft || footerCenter || footerRight)) {
|
||
// Find all section properties and add header/footer references
|
||
const sectPrRegex = /<w:sectPr[^>]*>[\s\S]*?<\/w:sectPr>/g;
|
||
documentXml = documentXml.replace(sectPrRegex, (match) => {
|
||
let updated = match;
|
||
if ((headerLeft || headerCenter || headerRight) && !match.includes('headerReference')) {
|
||
updated = updated.replace('</w:sectPr>', '<w:headerReference w:type="default" r:id="rId100"/></w:sectPr>');
|
||
}
|
||
if ((footerLeft || footerCenter || footerRight) && !match.includes('footerReference')) {
|
||
updated = updated.replace('</w:sectPr>', '<w:footerReference w:type="default" r:id="rId101"/></w:sectPr>');
|
||
}
|
||
return updated;
|
||
});
|
||
}
|
||
|
||
zip.file('word/document.xml', documentXml);
|
||
|
||
// Write modified DOCX
|
||
const newDocxBuffer = zip.generate({ type: 'nodebuffer' });
|
||
fs.writeFileSync(docxPath, newDocxBuffer);
|
||
|
||
} catch (error) {
|
||
console.error('Failed to add headers/footers to DOCX:', error);
|
||
// Don't fail the export, just log the error
|
||
}
|
||
}
|
||
|
||
// Enhanced Word Export with Template Support
|
||
async function exportWordWithTemplate() {
|
||
if (!currentFile) {
|
||
dialog.showErrorBox('Error', 'Please save the file first');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Get markdown content
|
||
const content = fs.readFileSync(currentFile, 'utf-8');
|
||
|
||
// Show dialog for output file
|
||
const result = await dialog.showSaveDialog(mainWindow, {
|
||
title: 'Export to Word (Enhanced)',
|
||
defaultPath: currentFile.replace(/\.md$/, '.docx'),
|
||
filters: [{ name: 'Word Document', extensions: ['docx'] }]
|
||
});
|
||
|
||
if (result.canceled) return;
|
||
|
||
// Create exporter instance with selected template, start page, and page settings
|
||
const exporter = new WordTemplateExporter(wordTemplatePath, templateStartPage, pageSettings);
|
||
|
||
// Convert markdown to DOCX
|
||
await exporter.convert(content, result.filePath);
|
||
|
||
dialog.showMessageBox(mainWindow, {
|
||
type: 'info',
|
||
title: 'Export Successful',
|
||
message: 'Document exported successfully!',
|
||
detail: `Saved to: ${result.filePath}`
|
||
});
|
||
|
||
} catch (error) {
|
||
dialog.showErrorBox('Export Error', sanitizeErrorMessage(`Failed to export document: ${error.message}`));
|
||
}
|
||
}
|
||
|
||
// Enhanced PDF Export via Word Template
|
||
async function exportPDFViaWordTemplate() {
|
||
if (!currentFile) {
|
||
dialog.showErrorBox('Error', 'Please save the file first');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Get markdown content
|
||
const content = fs.readFileSync(currentFile, 'utf-8');
|
||
|
||
// Show dialog for output file
|
||
const result = await dialog.showSaveDialog(mainWindow, {
|
||
title: 'Export to PDF (Enhanced)',
|
||
defaultPath: currentFile.replace(/\.md$/, '.pdf'),
|
||
filters: [{ name: 'PDF Document', extensions: ['pdf'] }]
|
||
});
|
||
|
||
if (result.canceled) return;
|
||
|
||
// Step 1: Create temporary DOCX file using Word template
|
||
const tempDocxPath = result.filePath.replace(/\.pdf$/, '_temp.docx');
|
||
|
||
const exporter = new WordTemplateExporter(wordTemplatePath, templateStartPage);
|
||
await exporter.convert(content, tempDocxPath);
|
||
|
||
// Step 2: Convert DOCX to PDF using LibreOffice (using execFile for safety)
|
||
const soffice = process.platform === 'win32'
|
||
? 'C:\\Program Files\\LibreOffice\\program\\soffice.exe'
|
||
: 'soffice';
|
||
|
||
const outputDir = path.dirname(result.filePath);
|
||
const sofficeArgs = ['--headless', '--convert-to', 'pdf', '--outdir', outputDir, tempDocxPath];
|
||
|
||
execFile(soffice, sofficeArgs, (error, stdout, stderr) => {
|
||
// Clean up temporary DOCX file
|
||
try {
|
||
fs.unlinkSync(tempDocxPath);
|
||
} catch (e) {
|
||
console.error('Failed to delete temp file:', e);
|
||
}
|
||
|
||
if (error) {
|
||
dialog.showErrorBox('PDF Conversion Error',
|
||
sanitizeErrorMessage(`Failed to convert to PDF. Please ensure LibreOffice is installed.\n\nError: ${error.message}`));
|
||
return;
|
||
}
|
||
|
||
// LibreOffice creates file with same base name as input
|
||
const generatedPdfPath = tempDocxPath.replace(/\.docx$/, '.pdf');
|
||
|
||
// Rename if needed
|
||
if (generatedPdfPath !== result.filePath) {
|
||
try {
|
||
fs.renameSync(generatedPdfPath, result.filePath);
|
||
} catch (e) {
|
||
console.error('Failed to rename PDF:', e);
|
||
}
|
||
}
|
||
|
||
dialog.showMessageBox(mainWindow, {
|
||
type: 'info',
|
||
title: 'Export Successful',
|
||
message: 'PDF exported successfully using Word template!',
|
||
detail: `Saved to: ${result.filePath}`
|
||
});
|
||
});
|
||
|
||
} catch (error) {
|
||
dialog.showErrorBox('Export Error', sanitizeErrorMessage(`Failed to export PDF: ${error.message}`));
|
||
}
|
||
}
|
||
|
||
// Universal File Converter integration
|
||
function showUniversalConverterDialog() {
|
||
mainWindow.webContents.send('show-universal-converter-dialog');
|
||
}
|
||
|
||
// PDF Editor dialog
|
||
function showPDFEditorDialog(operation) {
|
||
mainWindow.webContents.send('show-pdf-editor-dialog', operation);
|
||
}
|
||
|
||
// Handle PDF editor from toolbar (with optional file path)
|
||
ipcMain.on('show-pdf-editor-from-toolbar', (event, { operation, filePath }) => {
|
||
mainWindow.webContents.send('show-pdf-editor-dialog', operation, filePath);
|
||
});
|
||
|
||
// Check if conversion tool is available (using execFile for safety)
|
||
function checkConverterAvailable(tool) {
|
||
return new Promise((resolve) => {
|
||
const isWin = process.platform === 'win32';
|
||
const locateCmd = isWin ? 'where' : 'which';
|
||
let toolName;
|
||
|
||
switch (tool) {
|
||
case 'libreoffice':
|
||
toolName = 'soffice';
|
||
break;
|
||
case 'imagemagick':
|
||
toolName = isWin ? 'magick' : 'convert';
|
||
break;
|
||
case 'ffmpeg': {
|
||
// ffmpeg-static is always bundled — check it directly
|
||
const ffmpegBin = getFFmpegPath();
|
||
resolve(fs.existsSync(ffmpegBin) || ffmpegBin === 'ffmpeg');
|
||
return;
|
||
}
|
||
default:
|
||
resolve(false);
|
||
return;
|
||
}
|
||
|
||
execFile(locateCmd, [toolName], (error) => {
|
||
resolve(!error);
|
||
});
|
||
});
|
||
}
|
||
|
||
// Handle universal file conversion
|
||
ipcMain.on('universal-convert', async (event, { tool, fromFormat, toFormat, filePath }) => {
|
||
if (!conversionLimiter()) {
|
||
mainWindow.webContents.send('conversion-status', 'Please wait before converting again...');
|
||
return;
|
||
}
|
||
try {
|
||
mainWindow.webContents.send('conversion-status', 'Checking converter availability...');
|
||
|
||
// Check if the required tool is available
|
||
const toolAvailable = await checkConverterAvailable(tool);
|
||
|
||
if (!toolAvailable) {
|
||
throw new Error(`${tool} is not installed or not found in PATH. Please install it first.`);
|
||
}
|
||
|
||
mainWindow.webContents.send('conversion-status', 'Converting file...');
|
||
|
||
const outputPath = filePath.replace(/\.[^/.]+$/, `.${toFormat}`);
|
||
let conversionInfo;
|
||
|
||
switch (tool) {
|
||
case 'libreoffice':
|
||
conversionInfo = convertWithLibreOffice(filePath, toFormat, outputPath);
|
||
break;
|
||
case 'imagemagick':
|
||
conversionInfo = convertWithImageMagick(filePath, outputPath);
|
||
break;
|
||
case 'ffmpeg':
|
||
conversionInfo = convertWithFFmpeg(filePath, outputPath);
|
||
break;
|
||
case 'pandoc':
|
||
conversionInfo = convertWithPandoc(filePath, outputPath);
|
||
break;
|
||
default:
|
||
throw new Error(`Unknown conversion tool: ${tool}`);
|
||
}
|
||
|
||
// Use execFile for safety (prevents command injection)
|
||
execFile(conversionInfo.command, conversionInfo.args, (error, stdout, stderr) => {
|
||
if (error) {
|
||
mainWindow.webContents.send('conversion-complete', {
|
||
success: false,
|
||
error: error.message
|
||
});
|
||
|
||
dialog.showMessageBox(mainWindow, {
|
||
type: 'error',
|
||
title: 'Conversion Failed',
|
||
message: `${tool} conversion failed`,
|
||
detail: stderr || error.message,
|
||
buttons: ['OK']
|
||
});
|
||
} else {
|
||
mainWindow.webContents.send('conversion-complete', {
|
||
success: true,
|
||
outputPath: outputPath
|
||
});
|
||
|
||
dialog.showMessageBox(mainWindow, {
|
||
type: 'info',
|
||
title: 'Conversion Complete',
|
||
message: 'File converted successfully!',
|
||
detail: `Saved to: ${outputPath}`,
|
||
buttons: ['OK']
|
||
});
|
||
}
|
||
});
|
||
} catch (error) {
|
||
mainWindow.webContents.send('conversion-complete', {
|
||
success: false,
|
||
error: error.message
|
||
});
|
||
|
||
dialog.showMessageBox(mainWindow, {
|
||
type: 'error',
|
||
title: 'Conversion Failed',
|
||
message: 'Universal conversion failed',
|
||
detail: error.message,
|
||
buttons: ['OK']
|
||
});
|
||
}
|
||
});
|
||
|
||
// Handle universal batch file conversion
|
||
ipcMain.on('universal-convert-batch', async (event, { tool, fromFormat, toFormat, inputFolder, outputFolder, includeSubfolders, advancedOptions }) => {
|
||
if (!conversionLimiter()) {
|
||
mainWindow.webContents.send('conversion-status', 'Please wait before converting again...');
|
||
return;
|
||
}
|
||
try {
|
||
const toolAvailable = await checkConverterAvailable(tool);
|
||
if (!toolAvailable) {
|
||
throw new Error(`${tool} is not installed or not found in PATH. Please install it first.`);
|
||
}
|
||
|
||
// Collect matching files
|
||
const files = [];
|
||
function collectFiles(dir) {
|
||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||
for (const entry of entries) {
|
||
const fullPath = path.join(dir, entry.name);
|
||
if (entry.isDirectory() && includeSubfolders) {
|
||
collectFiles(fullPath);
|
||
} else if (entry.isFile() && entry.name.toLowerCase().endsWith(`.${fromFormat}`)) {
|
||
files.push(fullPath);
|
||
}
|
||
}
|
||
}
|
||
collectFiles(inputFolder);
|
||
|
||
if (files.length === 0) {
|
||
mainWindow.webContents.send('conversion-complete', { success: false, error: `No .${fromFormat} files found in the selected folder.` });
|
||
return;
|
||
}
|
||
|
||
let completed = 0;
|
||
let failed = 0;
|
||
|
||
for (const filePath of files) {
|
||
const relativePath = path.relative(inputFolder, filePath);
|
||
const outputPath = path.join(outputFolder, relativePath.replace(/\.[^/.]+$/, `.${toFormat}`));
|
||
|
||
// Ensure output subdirectory exists
|
||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||
|
||
mainWindow.webContents.send('conversion-status', `Converting ${completed + 1}/${files.length}: ${path.basename(filePath)}`);
|
||
|
||
let conversionInfo;
|
||
switch (tool) {
|
||
case 'libreoffice': conversionInfo = convertWithLibreOffice(filePath, toFormat, outputPath); break;
|
||
case 'imagemagick': conversionInfo = convertWithImageMagick(filePath, outputPath); break;
|
||
case 'ffmpeg': conversionInfo = convertWithFFmpeg(filePath, outputPath); break;
|
||
case 'pandoc': conversionInfo = convertWithPandoc(filePath, outputPath); break;
|
||
default: throw new Error(`Unknown conversion tool: ${tool}`);
|
||
}
|
||
|
||
await new Promise((resolve) => {
|
||
execFile(conversionInfo.command, conversionInfo.args, (error) => {
|
||
if (error) { failed++; } else { completed++; }
|
||
resolve();
|
||
});
|
||
});
|
||
}
|
||
|
||
mainWindow.webContents.send('conversion-complete', {
|
||
success: true,
|
||
outputPath: outputFolder
|
||
});
|
||
|
||
dialog.showMessageBox(mainWindow, {
|
||
type: 'info',
|
||
title: 'Batch Conversion Complete',
|
||
message: `Batch conversion finished!`,
|
||
detail: `Converted: ${completed}/${files.length} files${failed > 0 ? ` (${failed} failed)` : ''}\nOutput: ${outputFolder}`,
|
||
buttons: ['OK']
|
||
});
|
||
} catch (error) {
|
||
mainWindow.webContents.send('conversion-complete', { success: false, error: error.message });
|
||
dialog.showMessageBox(mainWindow, {
|
||
type: 'error',
|
||
title: 'Batch Conversion Failed',
|
||
message: 'Batch conversion failed',
|
||
detail: error.message,
|
||
buttons: ['OK']
|
||
});
|
||
}
|
||
});
|
||
|
||
// LibreOffice conversion - returns {command, args} for execFile (safer than exec)
|
||
function convertWithLibreOffice(inputFile, outputFormat, outputPath) {
|
||
const outputDir = path.dirname(outputPath);
|
||
const soffice = process.platform === 'win32'
|
||
? 'C:\\Program Files\\LibreOffice\\program\\soffice.exe'
|
||
: 'soffice';
|
||
|
||
// LibreOffice conversion format mapping
|
||
const formatMap = {
|
||
'pdf': 'pdf',
|
||
'docx': 'docx',
|
||
'doc': 'doc',
|
||
'odt': 'odt',
|
||
'rtf': 'rtf',
|
||
'txt': 'txt',
|
||
'html': 'html',
|
||
'xlsx': 'xlsx',
|
||
'xls': 'xls',
|
||
'ods': 'ods',
|
||
'csv': 'csv',
|
||
'pptx': 'pptx',
|
||
'ppt': 'ppt',
|
||
'odp': 'odp'
|
||
};
|
||
|
||
const format = formatMap[outputFormat] || outputFormat;
|
||
|
||
// Return command and args for execFile
|
||
return {
|
||
command: soffice,
|
||
args: ['--headless', '--convert-to', format, '--outdir', outputDir, inputFile]
|
||
};
|
||
}
|
||
|
||
// ImageMagick conversion - returns {command, args} for execFile
|
||
function convertWithImageMagick(inputFile, outputPath) {
|
||
const magick = process.platform === 'win32' ? 'magick' : 'convert';
|
||
return {
|
||
command: magick,
|
||
args: [inputFile, outputPath]
|
||
};
|
||
}
|
||
|
||
// FFmpeg conversion - returns {command, args} for execFile
|
||
function convertWithFFmpeg(inputFile, outputPath) {
|
||
return {
|
||
command: getFFmpegPath(),
|
||
args: ['-i', inputFile, outputPath, '-y'],
|
||
};
|
||
}
|
||
|
||
// Pandoc conversion - returns {command, args} for execFile
|
||
function convertWithPandoc(inputFile, outputPath) {
|
||
return {
|
||
command: 'pandoc',
|
||
args: [inputFile, '-o', outputPath]
|
||
};
|
||
}
|
||
|
||
function performExportWithOptions(format, options) {
|
||
// Map format names to file extensions
|
||
const formatExtMap = {
|
||
revealjs: 'html',
|
||
beamer: 'pdf',
|
||
confluence: 'txt',
|
||
jira: 'txt',
|
||
};
|
||
const fileExt = formatExtMap[format] || format;
|
||
|
||
const outputFile = dialog.showSaveDialogSync(mainWindow, {
|
||
defaultPath: currentFile.replace(/\.[^/.]+$/, `.${fileExt}`),
|
||
filters: [
|
||
{ name: format.toUpperCase(), extensions: [fileExt] }
|
||
]
|
||
});
|
||
|
||
if (!outputFile) return; // User cancelled
|
||
|
||
console.log(`Attempting to export ${format} to:`, outputFile);
|
||
|
||
// Check pandoc availability first
|
||
checkPandocAvailability().then((hasPandoc) => {
|
||
console.log('Pandoc available:', hasPandoc);
|
||
|
||
if (!hasPandoc) {
|
||
// Handle formats that don't require pandoc
|
||
if (format === 'html') {
|
||
console.log('Using built-in HTML export');
|
||
exportToHTML(outputFile);
|
||
return;
|
||
} else if (format === 'pdf') {
|
||
console.log('Using built-in PDF export');
|
||
exportToPDFElectron(outputFile);
|
||
return;
|
||
} else {
|
||
dialog.showErrorBox('Export Error',
|
||
`Pandoc is required for ${format.toUpperCase()} export but is not installed or not found in PATH.\n\n` +
|
||
`Please install Pandoc from: https://pandoc.org/installing.html\n\n` +
|
||
`Alternatively, you can export to HTML or PDF using the built-in converters.`
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Use pandoc for export with advanced options
|
||
console.log('Using Pandoc for export');
|
||
let pandocCmd = `${getPandocPath()} "${currentFile}" -o "${outputFile}"`;
|
||
|
||
// Add template if specified
|
||
if (options.template && options.template !== 'default') {
|
||
pandocCmd += ` --template="${options.template}"`;
|
||
}
|
||
|
||
// Add metadata
|
||
if (options.metadata) {
|
||
for (const [key, value] of Object.entries(options.metadata)) {
|
||
if (value.trim()) {
|
||
pandocCmd += ` -M ${key}="${value.replace(/"/g, '\\"')}"`;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Add variables
|
||
if (options.variables) {
|
||
for (const [key, value] of Object.entries(options.variables)) {
|
||
if (value.trim()) {
|
||
pandocCmd += ` -V ${key}="${value.replace(/"/g, '\\"')}"`;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Add other options
|
||
if (options.toc) pandocCmd += ' --toc';
|
||
if (options.tocDepth) pandocCmd += ` --toc-depth=${options.tocDepth}`;
|
||
if (options.numberSections) pandocCmd += ' --number-sections';
|
||
if (options.citeproc) pandocCmd += ' --citeproc';
|
||
if (options.bibliography) pandocCmd += ` --bibliography="${options.bibliography}"`;
|
||
if (options.csl) pandocCmd += ` --csl="${options.csl}"`;
|
||
|
||
// Add specific options for PDF export to ensure proper generation
|
||
if (format === 'pdf') {
|
||
const pdfEngine = options.pdfEngine || 'xelatex'; // Default to xelatex
|
||
pandocCmd += ` --pdf-engine="${pdfEngine}"`;
|
||
if (options.geometry) pandocCmd += ` -V geometry:"${options.geometry}"`;
|
||
|
||
// Add monospace font settings for code blocks (ASCII art preservation)
|
||
pandocCmd += ' -V monofont="Consolas"';
|
||
pandocCmd += ' --highlight-style=tango';
|
||
|
||
// Add header/footer if enabled
|
||
if (headerFooterSettings.enabled) {
|
||
const filename = currentFile ? path.basename(currentFile, path.extname(currentFile)) : 'document';
|
||
const metadata = { filename, title: filename, author: '' };
|
||
|
||
const headerLeft = processDynamicFields(headerFooterSettings.header.left, metadata);
|
||
const headerCenter = processDynamicFields(headerFooterSettings.header.center, metadata);
|
||
const headerRight = processDynamicFields(headerFooterSettings.header.right, metadata);
|
||
const footerLeft = processDynamicFields(headerFooterSettings.footer.left, metadata);
|
||
const footerCenter = processDynamicFields(headerFooterSettings.footer.center, metadata);
|
||
const footerRight = processDynamicFields(headerFooterSettings.footer.right, metadata);
|
||
|
||
// Create LaTeX header
|
||
const latexHeader = `
|
||
\\usepackage{fancyhdr}
|
||
\\pagestyle{fancy}
|
||
\\fancyhf{}
|
||
\\lhead{${headerLeft.replace(/\\/g, '\\\\')}}
|
||
\\chead{${headerCenter.replace(/\\/g, '\\\\')}}
|
||
\\rhead{${headerRight.replace(/\\/g, '\\\\')}}
|
||
\\lfoot{${footerLeft.replace(/\\/g, '\\\\')}}
|
||
\\cfoot{${footerCenter.replace(/[$]PAGE[$]/g, '\\\\thepage').replace(/[$]TOTAL[$]/g, '\\\\pageref{LastPage}').replace(/\\/g, '\\\\')}}
|
||
\\rfoot{${footerRight.replace(/\\/g, '\\\\')}}
|
||
\\renewcommand{\\headrulewidth}{0.4pt}
|
||
\\renewcommand{\\footrulewidth}{0.4pt}
|
||
`;
|
||
const headerFile = path.join(require('os').tmpdir(), `header_export_${Date.now()}.tex`);
|
||
fs.writeFileSync(headerFile, latexHeader, 'utf-8');
|
||
pandocCmd += ` --include-in-header="${headerFile}"`;
|
||
pandocCmd += ' --variable header-includes="\\\\usepackage{lastpage}"';
|
||
}
|
||
|
||
// Try with specified PDF engine (using runPandocCmd for safety)
|
||
runPandocCmd(pandocCmd, (error) => {
|
||
if (error) {
|
||
// Try fallback engines if the specified one fails
|
||
const fallbackEngines = ['pdflatex', 'lualatex'];
|
||
tryPdfFallback(currentFile, outputFile, fallbackEngines, 0, options, error);
|
||
} else {
|
||
showExportSuccess(outputFile);
|
||
}
|
||
});
|
||
} else if (format === 'docx') {
|
||
pandocCmd += ' -t docx';
|
||
exportWithPandoc(pandocCmd, outputFile, format);
|
||
} else if (format === 'pptx') {
|
||
// Add PowerPoint footer if enabled
|
||
if (headerFooterSettings.enabled && headerFooterSettings.footer.center) {
|
||
const filename = currentFile ? path.basename(currentFile, path.extname(currentFile)) : 'document';
|
||
const metadata = { filename, title: filename, author: '' };
|
||
const footerText = processDynamicFields(headerFooterSettings.footer.center, metadata);
|
||
pandocCmd += ` --variable footer="${footerText}"`;
|
||
}
|
||
exportWithPandoc(pandocCmd, outputFile, format);
|
||
} else if (format === 'json') {
|
||
pandocCmd = `${getPandocPath()} "${currentFile}" -t json -o "${outputFile}"`;
|
||
exportWithPandoc(pandocCmd, outputFile, format);
|
||
} else if (format === 'yaml' || format === 'xml' || format === 'toml') {
|
||
// For YAML/XML/TOML, save the raw markdown content with the new extension
|
||
try {
|
||
const content = fs.readFileSync(currentFile, 'utf-8');
|
||
fs.writeFileSync(outputFile, content, 'utf-8');
|
||
showExportSuccess(outputFile);
|
||
} catch (err) {
|
||
dialog.showErrorBox('Export Error', sanitizeErrorMessage(`Failed to export: ${err.message}`));
|
||
}
|
||
} else if (format === 'revealjs') {
|
||
let revealCmd = `${getPandocPath()} "${currentFile}" -t revealjs -s -o "${outputFile}" --slide-level=2`;
|
||
if (options) {
|
||
if (options.revealTheme) revealCmd += ` -V theme="${options.revealTheme}"`;
|
||
if (options.revealTransition) revealCmd += ` -V transition="${options.revealTransition}"`;
|
||
if (options.revealTransitionSpeed) revealCmd += ` -V transitionSpeed="${options.revealTransitionSpeed}"`;
|
||
if (options.revealControls !== undefined) revealCmd += ` -V controls="${options.revealControls}"`;
|
||
if (options.revealSlideNumber !== undefined) revealCmd += ` -V slideNumber="${options.revealSlideNumber}"`;
|
||
if (options.revealProgress !== undefined) revealCmd += ` -V progress="${options.revealProgress}"`;
|
||
if (options.revealHistory !== undefined) revealCmd += ` -V history="${options.revealHistory}"`;
|
||
if (options.revealCenter !== undefined) revealCmd += ` -V center="${options.revealCenter}"`;
|
||
|
||
// Support for templates, metadata, bibliography
|
||
if (options.template && options.template !== 'default') {
|
||
revealCmd += ` --template="${options.template}"`;
|
||
}
|
||
if (options.metadata) {
|
||
for (const [key, value] of Object.entries(options.metadata)) {
|
||
if (value.trim()) {
|
||
revealCmd += ` -M ${key}="${value.replace(/"/g, '\\"')}"`;
|
||
}
|
||
}
|
||
}
|
||
if (options.bibliography) revealCmd += ` --bibliography="${options.bibliography}"`;
|
||
if (options.csl) revealCmd += ` --csl="${options.csl}"`;
|
||
}
|
||
exportWithPandoc(revealCmd, outputFile, format);
|
||
} else if (format === 'beamer') {
|
||
pandocCmd = `${getPandocPath()} "${currentFile}" -t beamer -o "${outputFile}"`;
|
||
exportWithPandoc(pandocCmd, outputFile, format);
|
||
} else if (format === 'confluence' || format === 'jira') {
|
||
pandocCmd = `${getPandocPath()} "${currentFile}" -t jira -o "${outputFile}"`;
|
||
exportWithPandoc(pandocCmd, outputFile, format);
|
||
} else if (format === 'mobi') {
|
||
// First export to EPUB, then try ebook-convert if available
|
||
const epubFile = outputFile.replace(/\.mobi$/i, '.epub');
|
||
pandocCmd = `${getPandocPath()} "${currentFile}" -o "${epubFile}"`;
|
||
runPandocCmd(pandocCmd, (error) => {
|
||
if (error) {
|
||
dialog.showErrorBox('Export Error', sanitizeErrorMessage(`Failed to export EPUB intermediate: ${error.message}`));
|
||
return;
|
||
}
|
||
// Try ebook-convert (Calibre) for MOBI
|
||
execFile('ebook-convert', [epubFile, outputFile], (ebookError) => {
|
||
if (ebookError) {
|
||
dialog.showMessageBox(mainWindow, {
|
||
type: 'warning',
|
||
title: 'MOBI Export - Partial',
|
||
message: `Calibre's ebook-convert was not found. The file has been exported as EPUB instead.\n\nEPUB saved to: ${epubFile}\n\nTo get MOBI output, install Calibre from: https://calibre-ebook.com/`,
|
||
buttons: ['OK']
|
||
});
|
||
} else {
|
||
// Clean up intermediate EPUB
|
||
try { fs.unlinkSync(epubFile); } catch (e) { /* ignore */ }
|
||
showExportSuccess(outputFile);
|
||
}
|
||
});
|
||
});
|
||
} else {
|
||
// Generic export for other formats
|
||
exportWithPandoc(pandocCmd, outputFile, format);
|
||
}
|
||
}).catch((error) => {
|
||
console.error('Error checking pandoc availability:', error);
|
||
dialog.showErrorBox('Export Error', sanitizeErrorMessage(`Error checking system requirements: ${error.message}`));
|
||
});
|
||
}
|
||
|
||
function tryPdfFallback(inputFile, outputFile, engines, index, options, lastError) {
|
||
if (index >= engines.length) {
|
||
// All Pandoc PDF engines failed, fallback to Electron's built-in PDF export
|
||
console.log('All Pandoc PDF engines failed, falling back to Electron PDF export');
|
||
exportToPDFElectron(outputFile);
|
||
return;
|
||
}
|
||
|
||
const engine = engines[index];
|
||
let pandocCmd = `${getPandocPath()} "${inputFile}" --pdf-engine=${engine} -o "${outputFile}"`;
|
||
|
||
// Add monospace font settings for code blocks (ASCII art preservation)
|
||
pandocCmd += ' -V monofont="Consolas"';
|
||
pandocCmd += ' --highlight-style=tango';
|
||
|
||
// Add geometry if specified
|
||
if (options.geometry) pandocCmd = pandocCmd.replace(` -o `, ` -V geometry:"${options.geometry}" -o `);
|
||
|
||
// Add header/footer if enabled
|
||
if (headerFooterSettings.enabled) {
|
||
const filename = path.basename(inputFile, path.extname(inputFile));
|
||
const metadata = { filename, title: filename, author: options.metadata?.author || '' };
|
||
|
||
const headerLeft = processDynamicFields(headerFooterSettings.header.left, metadata);
|
||
const headerCenter = processDynamicFields(headerFooterSettings.header.center, metadata);
|
||
const headerRight = processDynamicFields(headerFooterSettings.header.right, metadata);
|
||
const footerLeft = processDynamicFields(headerFooterSettings.footer.left, metadata);
|
||
const footerCenter = processDynamicFields(headerFooterSettings.footer.center, metadata);
|
||
const footerRight = processDynamicFields(headerFooterSettings.footer.right, metadata);
|
||
|
||
// Create LaTeX header for fancyhdr
|
||
const latexHeader = `
|
||
\\usepackage{fancyhdr}
|
||
\\usepackage{lastpage}
|
||
\\pagestyle{fancy}
|
||
\\fancyhf{}
|
||
\\lhead{${headerLeft.replace(/\\/g, '\\\\')}}
|
||
\\chead{${headerCenter.replace(/\\/g, '\\\\')}}
|
||
\\rhead{${headerRight.replace(/\\/g, '\\\\')}}
|
||
\\lfoot{${footerLeft.replace(/\\/g, '\\\\')}}
|
||
\\cfoot{${footerCenter.replace(/\$PAGE\$/g, '\\\\thepage').replace(/\$TOTAL\$/g, '\\\\pageref{LastPage}').replace(/\\/g, '\\\\')}}
|
||
\\rfoot{${footerRight.replace(/\\/g, '\\\\')}}
|
||
\\renewcommand{\\headrulewidth}{0.4pt}
|
||
\\renewcommand{\\footrulewidth}{0.4pt}
|
||
`;
|
||
const headerFile = path.join(require('os').tmpdir(), `header_fallback_${Date.now()}.tex`);
|
||
fs.writeFileSync(headerFile, latexHeader, 'utf-8');
|
||
pandocCmd += ` --include-in-header="${headerFile}"`;
|
||
}
|
||
|
||
// Add all other options
|
||
if (options.template && options.template !== 'default') {
|
||
pandocCmd += ` --template="${options.template}"`;
|
||
}
|
||
|
||
if (options.metadata) {
|
||
for (const [key, value] of Object.entries(options.metadata)) {
|
||
if (value.trim()) {
|
||
pandocCmd += ` -M ${key}="${value.replace(/"/g, '\\"')}"`;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Use runPandocCmd for safety (prevents command injection)
|
||
runPandocCmd(pandocCmd, (error) => {
|
||
if (error) {
|
||
tryPdfFallback(inputFile, outputFile, engines, index + 1, options, error);
|
||
} else {
|
||
showExportSuccess(outputFile);
|
||
}
|
||
});
|
||
}
|
||
|
||
function showExportSuccess(outputFile) {
|
||
dialog.showMessageBox(mainWindow, {
|
||
type: 'info',
|
||
title: 'Export Complete',
|
||
message: `File exported successfully to ${outputFile}`,
|
||
buttons: ['OK']
|
||
});
|
||
}
|
||
|
||
// Helper function to export with pandoc (general) - uses runPandocCmd for safety
|
||
function exportWithPandoc(pandocCmd, outputFile, format) {
|
||
console.log(`Executing Pandoc command: ${pandocCmd}`);
|
||
|
||
runPandocCmd(pandocCmd, async (error, stdout, stderr) => {
|
||
if (error) {
|
||
console.error(`Pandoc error for ${format}:`, error);
|
||
console.error(`Pandoc stderr:`, stderr);
|
||
console.error(`Pandoc stdout:`, stdout);
|
||
|
||
// Provide more specific error messages
|
||
let errorMessage = `Failed to export to ${format.toUpperCase()}`;
|
||
|
||
if (error.message.includes('not found') || error.message.includes('not recognized')) {
|
||
errorMessage += '\n\nPandoc is not installed or not found in PATH.';
|
||
errorMessage += '\nPlease install Pandoc from: https://pandoc.org/installing.html';
|
||
} else if (stderr) {
|
||
errorMessage += `\n\nError details: ${stderr}`;
|
||
} else {
|
||
errorMessage += `\n\nError details: ${error.message}`;
|
||
}
|
||
|
||
errorMessage += `\n\nCommand used: ${pandocCmd}`;
|
||
|
||
dialog.showErrorBox('Export Error', sanitizeErrorMessage(errorMessage));
|
||
} else {
|
||
console.log(`Successfully exported to ${format}:`, outputFile);
|
||
console.log(`Pandoc stdout:`, stdout);
|
||
if (stderr) {
|
||
console.warn(`Pandoc stderr (non-fatal):`, stderr);
|
||
}
|
||
|
||
// Set page size for DOCX
|
||
if (format === 'docx') {
|
||
try {
|
||
await setDocxPageSize(outputFile);
|
||
console.log('Page size set for DOCX');
|
||
} catch (pageSizeError) {
|
||
console.error('Error setting page size for DOCX:', pageSizeError);
|
||
}
|
||
}
|
||
|
||
// Add headers/footers to DOCX if enabled
|
||
if (format === 'docx' && headerFooterSettings.enabled) {
|
||
try {
|
||
const filename = currentFile ? path.basename(currentFile, path.extname(currentFile)) : 'document';
|
||
const metadata = {
|
||
filename: filename,
|
||
title: filename,
|
||
author: ''
|
||
};
|
||
await addHeaderFooterToDocx(outputFile, metadata);
|
||
console.log('Headers/footers added to DOCX');
|
||
} catch (hfError) {
|
||
console.error('Error adding headers/footers to DOCX:', hfError);
|
||
// Continue with success message even if header/footer fails
|
||
}
|
||
}
|
||
|
||
// Set page size for ODT
|
||
if (format === 'odt') {
|
||
try {
|
||
await setDocxPageSize(outputFile); // ODT has similar structure
|
||
console.log('Page size set for ODT');
|
||
} catch (pageSizeError) {
|
||
console.error('Error setting page size for ODT:', pageSizeError);
|
||
}
|
||
}
|
||
|
||
// Add headers/footers to ODT if enabled
|
||
if (format === 'odt' && headerFooterSettings.enabled) {
|
||
// ODT format is similar to DOCX in structure, we could implement this
|
||
console.log('ODT header/footer support not yet implemented');
|
||
}
|
||
|
||
showExportSuccess(outputFile);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Helper function to export PDF with pandoc (with fallbacks) - uses runPandocCmd for safety
|
||
function exportWithPandocPDF(pandocCmd, outputFile) {
|
||
runPandocCmd(pandocCmd, (error, stdout, stderr) => {
|
||
if (error) {
|
||
console.log('XeLaTeX failed, trying PDFLaTeX...');
|
||
// Fallback to pdflatex
|
||
const fallbackCmd = pandocCmd.replace('--pdf-engine=xelatex', '--pdf-engine=pdflatex');
|
||
runPandocCmd(fallbackCmd, (fallbackError, fallbackStdout, fallbackStderr) => {
|
||
if (fallbackError) {
|
||
console.log('PDFLaTeX failed, trying Electron PDF...');
|
||
// Final fallback to Electron PDF
|
||
exportToPDFElectron(outputFile);
|
||
} else {
|
||
console.log('Successfully exported PDF with PDFLaTeX');
|
||
showExportSuccess(outputFile);
|
||
}
|
||
});
|
||
} else {
|
||
console.log('Successfully exported PDF with XeLaTeX');
|
||
showExportSuccess(outputFile);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Export to HTML using marked (no pandoc required)
|
||
function exportToHTML(outputFile) {
|
||
try {
|
||
const marked = require('marked');
|
||
const markdownContent = fs.readFileSync(currentFile, 'utf8');
|
||
const htmlContent = marked.parse(markdownContent);
|
||
|
||
const fullHtml = `<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Exported Document</title>
|
||
<style>
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||
line-height: 1.6;
|
||
color: #333;
|
||
max-width: 800px;
|
||
margin: 0 auto;
|
||
padding: 40px;
|
||
}
|
||
h1, h2, h3, h4, h5, h6 {
|
||
margin-top: 1.5em;
|
||
margin-bottom: 0.5em;
|
||
}
|
||
/* Inline code */
|
||
code {
|
||
background: #f4f4f4;
|
||
padding: 2px 4px;
|
||
border-radius: 3px;
|
||
font-family: Consolas, Monaco, 'Courier New', monospace;
|
||
font-size: 0.9em;
|
||
}
|
||
/* Code blocks - critical for ASCII art preservation */
|
||
pre {
|
||
background: #f5f5f5;
|
||
padding: 1em;
|
||
border-radius: 5px;
|
||
overflow-x: auto;
|
||
white-space: pre;
|
||
word-wrap: normal;
|
||
font-family: Consolas, Monaco, 'Courier New', monospace;
|
||
font-size: 13px;
|
||
line-height: 1.4;
|
||
border: 1px solid #e0e0e0;
|
||
margin: 1em 0;
|
||
}
|
||
pre code {
|
||
background: transparent;
|
||
padding: 0;
|
||
font-family: inherit;
|
||
font-size: inherit;
|
||
line-height: inherit;
|
||
white-space: pre;
|
||
word-wrap: normal;
|
||
display: block;
|
||
}
|
||
blockquote {
|
||
border-left: 4px solid #ddd;
|
||
margin-left: 0;
|
||
padding-left: 1em;
|
||
color: #666;
|
||
}
|
||
table {
|
||
border-collapse: collapse;
|
||
width: 100%;
|
||
margin: 1em 0;
|
||
}
|
||
th, td {
|
||
border: 1px solid #ddd;
|
||
padding: 8px;
|
||
text-align: left;
|
||
}
|
||
th {
|
||
background-color: #f4f4f4;
|
||
}
|
||
a {
|
||
color: #0066cc;
|
||
text-decoration: none;
|
||
}
|
||
a:hover {
|
||
text-decoration: underline;
|
||
}
|
||
img {
|
||
max-width: 100%;
|
||
height: auto;
|
||
}
|
||
@media print {
|
||
pre {
|
||
white-space: pre;
|
||
word-wrap: normal;
|
||
overflow: visible;
|
||
page-break-inside: avoid;
|
||
}
|
||
pre code {
|
||
white-space: pre;
|
||
word-wrap: normal;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
${htmlContent}
|
||
</body>
|
||
</html>`;
|
||
|
||
fs.writeFileSync(outputFile, fullHtml, 'utf8');
|
||
console.log('Successfully exported HTML');
|
||
showExportSuccess(outputFile);
|
||
} catch (error) {
|
||
console.error('HTML export error:', error);
|
||
dialog.showErrorBox('HTML Export Error', sanitizeErrorMessage(`Failed to export HTML: ${error.message}`));
|
||
}
|
||
}
|
||
|
||
// Export to PDF using Electron (no pandoc required)
|
||
function exportToPDFElectron(outputFile) {
|
||
try {
|
||
const marked = require('marked');
|
||
const markdownContent = fs.readFileSync(currentFile, 'utf8');
|
||
const htmlContent = marked.parse(markdownContent);
|
||
|
||
const fullHtml = `<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>PDF Export</title>
|
||
<style>
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||
line-height: 1.6;
|
||
color: #333;
|
||
max-width: 800px;
|
||
margin: 0 auto;
|
||
padding: 40px;
|
||
}
|
||
h1, h2, h3, h4, h5, h6 {
|
||
margin-top: 1.5em;
|
||
margin-bottom: 0.5em;
|
||
}
|
||
/* Inline code */
|
||
code {
|
||
background: #f4f4f4;
|
||
padding: 2px 4px;
|
||
border-radius: 3px;
|
||
font-family: Consolas, Monaco, 'Courier New', monospace;
|
||
font-size: 0.9em;
|
||
}
|
||
/* Code blocks - critical for ASCII art preservation */
|
||
pre {
|
||
background: #f5f5f5;
|
||
padding: 1em;
|
||
border-radius: 5px;
|
||
overflow-x: visible;
|
||
overflow-y: visible;
|
||
white-space: pre;
|
||
word-wrap: normal;
|
||
font-family: Consolas, Monaco, 'Courier New', monospace;
|
||
font-size: 12px;
|
||
line-height: 1.4;
|
||
border: 1px solid #e0e0e0;
|
||
margin: 1em 0;
|
||
}
|
||
pre code {
|
||
background: transparent;
|
||
padding: 0;
|
||
font-family: inherit;
|
||
font-size: inherit;
|
||
line-height: inherit;
|
||
white-space: pre;
|
||
word-wrap: normal;
|
||
display: block;
|
||
}
|
||
blockquote {
|
||
border-left: 4px solid #ddd;
|
||
margin-left: 0;
|
||
padding-left: 1em;
|
||
color: #666;
|
||
}
|
||
table {
|
||
border-collapse: collapse;
|
||
width: 100%;
|
||
margin: 1em 0;
|
||
}
|
||
th, td {
|
||
border: 1px solid #ddd;
|
||
padding: 8px;
|
||
text-align: left;
|
||
}
|
||
th {
|
||
background-color: #f4f4f4;
|
||
}
|
||
img {
|
||
max-width: 100%;
|
||
height: auto;
|
||
}
|
||
@media print {
|
||
body { padding: 20px; }
|
||
pre {
|
||
white-space: pre;
|
||
word-wrap: normal;
|
||
overflow: visible;
|
||
page-break-inside: avoid;
|
||
}
|
||
pre code {
|
||
white-space: pre;
|
||
word-wrap: normal;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
${htmlContent}
|
||
</body>
|
||
</html>`;
|
||
|
||
// Create a hidden window to render and export PDF
|
||
const pdfWindow = new BrowserWindow({
|
||
show: false,
|
||
webPreferences: {
|
||
nodeIntegration: true,
|
||
contextIsolation: false
|
||
}
|
||
});
|
||
|
||
pdfWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(fullHtml)}`).then(() => {
|
||
return pdfWindow.webContents.printToPDF({
|
||
marginsType: 1, // Use default margins
|
||
pageSize: 'A4',
|
||
printBackground: true,
|
||
printSelectionOnly: false,
|
||
landscape: false
|
||
});
|
||
}).then((pdfData) => {
|
||
fs.writeFileSync(outputFile, pdfData);
|
||
pdfWindow.close();
|
||
console.log('Successfully exported PDF with Electron');
|
||
showExportSuccess(outputFile);
|
||
}).catch((error) => {
|
||
pdfWindow.close();
|
||
console.error('Electron PDF export error:', error);
|
||
dialog.showErrorBox('PDF Export Error',
|
||
sanitizeErrorMessage(`Failed to export PDF using built-in engine: ${error.message}\n\n` +
|
||
`For better PDF export, please install Pandoc with LaTeX support.`)
|
||
);
|
||
});
|
||
} catch (error) {
|
||
console.error('PDF export setup error:', error);
|
||
dialog.showErrorBox('PDF Export Error', sanitizeErrorMessage(`Failed to setup PDF export: ${error.message}`));
|
||
}
|
||
}
|
||
|
||
function exportSpreadsheet(format) {
|
||
if (!currentFile) {
|
||
dialog.showErrorBox('Error', 'Please save the file first');
|
||
return;
|
||
}
|
||
|
||
// Request content from renderer
|
||
mainWindow.webContents.send('get-content-for-spreadsheet', format);
|
||
}
|
||
|
||
function importDocument() {
|
||
const files = dialog.showOpenDialogSync(mainWindow, {
|
||
properties: ['openFile'],
|
||
filters: [
|
||
{ name: 'Documents', extensions: ['docx', 'odt', 'rtf', 'html', 'htm', 'tex', 'epub', 'pdf', 'txt'] },
|
||
{ name: 'Presentations', extensions: ['pptx', 'odp'] },
|
||
{ name: 'Markup Languages', extensions: ['rst', 'textile', 'mediawiki', 'org', 'asciidoc', 'twiki', 'opml'] },
|
||
{ name: 'E-book Formats', extensions: ['epub', 'fb2'] },
|
||
{ name: 'LaTeX Formats', extensions: ['tex', 'latex', 'ltx'] },
|
||
{ name: 'Web Formats', extensions: ['html', 'htm', 'xhtml'] },
|
||
{ name: 'Wiki Formats', extensions: ['mediawiki', 'dokuwiki', 'tikiwiki', 'twiki'] },
|
||
{ name: 'CSV/TSV', extensions: ['csv', 'tsv'] },
|
||
{ name: 'Developer Formats', extensions: ['json', 'yaml', 'yml', 'xml', 'toml'] },
|
||
{ name: 'All Files', extensions: ['*'] }
|
||
]
|
||
});
|
||
|
||
if (files && files[0]) {
|
||
const inputFile = files[0];
|
||
const stats = fs.statSync(inputFile);
|
||
if (stats.size > MAX_FILE_SIZE) {
|
||
dialog.showErrorBox('File Too Large', `File exceeds the ${MAX_FILE_SIZE_MB}MB size limit.`);
|
||
return;
|
||
}
|
||
const ext = path.extname(inputFile).toLowerCase().slice(1);
|
||
const outputFile = inputFile.replace(/\.[^/.]+$/, '.md');
|
||
|
||
// Determine format-specific conversion options
|
||
let additionalOptions = '';
|
||
|
||
// For PDFs, extract text properly
|
||
if (ext === 'pdf') {
|
||
additionalOptions = '--pdf-engine=xelatex';
|
||
}
|
||
|
||
// For CSV/TSV, convert as tables
|
||
if (ext === 'csv' || ext === 'tsv') {
|
||
additionalOptions = '--from=csv -t markdown';
|
||
}
|
||
|
||
// For JSON, handle structure
|
||
if (ext === 'json') {
|
||
additionalOptions = '--from=json -t markdown';
|
||
}
|
||
|
||
// For YAML, XML, TOML - wrap content in code blocks directly
|
||
if (['yaml', 'yml', 'xml', 'toml'].includes(ext)) {
|
||
const rawContent = fs.readFileSync(inputFile, 'utf-8');
|
||
const mdContent = convertDataToMarkdown(rawContent, ext);
|
||
const mdOutputFile = inputFile.replace(/\.[^/.]+$/, '.md');
|
||
fs.writeFileSync(mdOutputFile, mdContent, 'utf-8');
|
||
currentFile = mdOutputFile;
|
||
mainWindow.webContents.send('file-opened', { path: mdOutputFile, content: mdContent });
|
||
dialog.showMessageBox(mainWindow, {
|
||
type: 'info',
|
||
title: 'Import Complete',
|
||
message: `Document imported successfully as ${path.basename(mdOutputFile)}\n\nOriginal format: ${ext.toUpperCase()}\nConverted to: Markdown`,
|
||
buttons: ['OK']
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Convert to markdown using pandoc (using runPandocCmd for safety)
|
||
const pandocCmd = `${getPandocPath()} "${inputFile}" -t markdown ${additionalOptions} -o "${outputFile}"`;
|
||
|
||
runPandocCmd(pandocCmd, (error, stdout, stderr) => {
|
||
if (error) {
|
||
dialog.showErrorBox('Import Error', sanitizeErrorMessage(`Failed to import: ${error.message}\n\nMake sure Pandoc is installed.\n\nSupported formats: DOCX, ODT, RTF, HTML, LaTeX, EPUB, PDF, PPTX, ODP, RST, Textile, MediaWiki, Org-mode, AsciiDoc, CSV, and more.`));
|
||
} else {
|
||
// Open the converted markdown file
|
||
currentFile = outputFile;
|
||
const content = fs.readFileSync(outputFile, 'utf-8');
|
||
mainWindow.webContents.send('file-opened', { path: outputFile, content });
|
||
|
||
dialog.showMessageBox(mainWindow, {
|
||
type: 'info',
|
||
title: 'Import Complete',
|
||
message: `Document imported successfully as ${path.basename(outputFile)}\n\nOriginal format: ${ext.toUpperCase()}\nConverted to: Markdown`,
|
||
buttons: ['OK']
|
||
});
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
|
||
function setTheme(theme) {
|
||
store.set('theme', theme);
|
||
mainWindow.webContents.send('theme-changed', theme);
|
||
}
|
||
|
||
// IPC handlers
|
||
ipcMain.on('save-file', (event, { path, content }) => {
|
||
fs.writeFileSync(path, content, 'utf-8');
|
||
currentFile = path;
|
||
});
|
||
|
||
ipcMain.on('save-current-file', (event, payload) => {
|
||
const content = typeof payload === 'string' ? payload : payload?.content;
|
||
const targetFile = typeof payload === 'string' ? currentFile : payload?.filePath || currentFile;
|
||
|
||
if (targetFile) {
|
||
fs.writeFileSync(targetFile, content, 'utf-8');
|
||
currentFile = targetFile;
|
||
} else {
|
||
saveAsFile();
|
||
}
|
||
});
|
||
|
||
ipcMain.on('get-theme', (event) => {
|
||
const theme = store.get('theme', 'atomonelight');
|
||
event.reply('theme-changed', theme);
|
||
});
|
||
|
||
// Handle tab file tracking for exports
|
||
ipcMain.on('set-current-file', (event, filePath) => {
|
||
currentFile = filePath;
|
||
});
|
||
|
||
// Handle actual printing when renderer is ready
|
||
ipcMain.on('do-print', (event, { withStyles }) => {
|
||
if (mainWindow) {
|
||
// Renderer has already hidden UI, waited 300ms, and prepared the page
|
||
// Print immediately - DOM is fully rendered
|
||
mainWindow.webContents.print({
|
||
silent: false,
|
||
printBackground: withStyles,
|
||
color: true,
|
||
margin: { marginType: 'default' }
|
||
});
|
||
}
|
||
});
|
||
|
||
// Handle printing with custom options from print preview dialog
|
||
ipcMain.on('do-print-with-options', (event, options) => {
|
||
if (!mainWindow) return;
|
||
|
||
const marginsMap = {
|
||
'default': { marginType: 'default' },
|
||
'narrow': { top: 0.4, bottom: 0.4, left: 0.4, right: 0.4 },
|
||
'wide': { top: 1.0, bottom: 1.0, left: 1.0, right: 1.0 },
|
||
'none': { top: 0, bottom: 0, left: 0, right: 0 },
|
||
};
|
||
|
||
const printOptions = {
|
||
silent: false,
|
||
printBackground: options.background,
|
||
landscape: options.orientation === 'landscape',
|
||
scaleFactor: options.scale / 100,
|
||
pageSize: options.paperSize,
|
||
};
|
||
|
||
if (options.margins && options.margins !== 'default') {
|
||
printOptions.margins = marginsMap[options.margins];
|
||
}
|
||
|
||
mainWindow.webContents.print(printOptions);
|
||
});
|
||
|
||
// Handle renderer ready for file association
|
||
ipcMain.on('renderer-ready', (event) => {
|
||
console.log('[MAIN] renderer-ready received, rendererReady was:', rendererReady);
|
||
if (mainWindow) {
|
||
mainWindow.webContents.executeJavaScript(`console.log('[MAIN->RENDERER] renderer-ready received, rendererReady was: ${rendererReady}')`);
|
||
}
|
||
rendererReady = true;
|
||
console.log('[MAIN] app.pendingFile:', app.pendingFile);
|
||
if (mainWindow) {
|
||
mainWindow.webContents.executeJavaScript(`console.log('[MAIN->RENDERER] app.pendingFile: ${app.pendingFile}')`);
|
||
}
|
||
if (app.pendingFile) {
|
||
console.log('[MAIN] Opening pending file:', app.pendingFile);
|
||
if (mainWindow) {
|
||
mainWindow.webContents.executeJavaScript(`console.log('[MAIN->RENDERER] Opening pending file: ${app.pendingFile}')`);
|
||
}
|
||
openFileFromPath(app.pendingFile);
|
||
app.pendingFile = null;
|
||
}
|
||
});
|
||
|
||
// Handle export with options
|
||
ipcMain.on('export-with-options', (event, { format, options }) => {
|
||
if (!conversionLimiter()) {
|
||
mainWindow.webContents.send('conversion-status', 'Please wait before converting again...');
|
||
return;
|
||
}
|
||
performExportWithOptions(format, options);
|
||
});
|
||
|
||
// Handle batch conversion
|
||
ipcMain.on('batch-convert', (event, { inputFolder, outputFolder, format, options }) => {
|
||
if (!conversionLimiter()) {
|
||
mainWindow.webContents.send('conversion-status', 'Please wait before converting again...');
|
||
return;
|
||
}
|
||
performBatchConversion(inputFolder, outputFolder, format, options);
|
||
});
|
||
|
||
// Handle folder selection for batch conversion
|
||
ipcMain.on('select-folder', (event, type) => {
|
||
const folder = dialog.showOpenDialogSync(mainWindow, {
|
||
properties: ['openDirectory']
|
||
});
|
||
|
||
if (folder && folder[0]) {
|
||
event.reply('folder-selected', { type, path: folder[0] });
|
||
}
|
||
});
|
||
|
||
ipcMain.on('export-spreadsheet', (event, { content, format }) => {
|
||
const outputFile = dialog.showSaveDialogSync(mainWindow, {
|
||
defaultPath: currentFile.replace(/\.[^/.]+$/, `.${format}`),
|
||
filters: [
|
||
{ name: format.toUpperCase(), extensions: [format] }
|
||
]
|
||
});
|
||
|
||
if (outputFile) {
|
||
try {
|
||
// Parse markdown content to extract tables
|
||
const tables = extractTablesFromMarkdown(content);
|
||
|
||
if (tables.length === 0) {
|
||
dialog.showErrorBox('Export Error', 'No tables found in the markdown content');
|
||
return;
|
||
}
|
||
|
||
if (format === 'csv') {
|
||
// Convert tables to CSV format
|
||
let csvContent = '';
|
||
tables.forEach((table, index) => {
|
||
if (index > 0) csvContent += '\n\n'; // Separate multiple tables
|
||
if (tables.length > 1) csvContent += `"Table ${index + 1}"\n`;
|
||
|
||
table.forEach(row => {
|
||
const csvRow = row.map(cell => {
|
||
// Escape quotes and wrap in quotes if necessary
|
||
const cleanCell = cell.replace(/"/g, '""');
|
||
return cleanCell.includes(',') || cleanCell.includes('"') || cleanCell.includes('\n')
|
||
? `"${cleanCell}"` : cleanCell;
|
||
}).join(',');
|
||
csvContent += csvRow + '\n';
|
||
});
|
||
});
|
||
|
||
fs.writeFileSync(outputFile, csvContent, 'utf-8');
|
||
}
|
||
|
||
dialog.showMessageBox(mainWindow, {
|
||
type: 'info',
|
||
title: 'Export Complete',
|
||
message: `${format.toUpperCase()} exported successfully to ${outputFile}`,
|
||
buttons: ['OK']
|
||
});
|
||
} catch (error) {
|
||
dialog.showErrorBox('Export Error', sanitizeErrorMessage(`Failed to export: ${error.message}`));
|
||
}
|
||
}
|
||
});
|
||
|
||
// Helper function to extract tables from markdown
|
||
function extractTablesFromMarkdown(markdown) {
|
||
const tables = [];
|
||
const lines = markdown.split('\n');
|
||
let currentTable = [];
|
||
let inTable = false;
|
||
|
||
for (const line of lines) {
|
||
if (line.includes('|')) {
|
||
if (!inTable) {
|
||
inTable = true;
|
||
currentTable = [];
|
||
}
|
||
|
||
// Skip separator lines (|---|---|)
|
||
if (!line.match(/^\s*\|?\s*:?-+:?\s*\|/)) {
|
||
const cells = line.split('|')
|
||
.map(cell => cell.trim())
|
||
.filter(cell => cell !== '');
|
||
|
||
if (cells.length > 0) {
|
||
currentTable.push(cells);
|
||
}
|
||
}
|
||
} else if (inTable && line.trim() === '') {
|
||
// End of table
|
||
if (currentTable.length > 0) {
|
||
tables.push(currentTable);
|
||
}
|
||
currentTable = [];
|
||
inTable = false;
|
||
}
|
||
}
|
||
|
||
// Add last table if exists
|
||
if (currentTable.length > 0) {
|
||
tables.push(currentTable);
|
||
}
|
||
|
||
return tables;
|
||
}
|
||
|
||
function performBatchConversion(inputFolder, outputFolder, format, options) {
|
||
if (!fs.existsSync(inputFolder)) {
|
||
dialog.showErrorBox('Error', 'Input folder does not exist');
|
||
return;
|
||
}
|
||
|
||
// Create output folder if it doesn't exist
|
||
if (!fs.existsSync(outputFolder)) {
|
||
try {
|
||
fs.mkdirSync(outputFolder, { recursive: true });
|
||
} catch (error) {
|
||
dialog.showErrorBox('Error', sanitizeErrorMessage(`Failed to create output folder: ${error.message}`));
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Find all markdown files in input folder
|
||
const markdownFiles = [];
|
||
|
||
function findMarkdownFiles(dir) {
|
||
const files = fs.readdirSync(dir);
|
||
|
||
for (const file of files) {
|
||
const fullPath = path.join(dir, file);
|
||
const stat = fs.statSync(fullPath);
|
||
|
||
if (stat.isDirectory()) {
|
||
findMarkdownFiles(fullPath); // Recursive search
|
||
} else if (file.match(/\.(md|markdown)$/i)) {
|
||
markdownFiles.push(fullPath);
|
||
}
|
||
}
|
||
}
|
||
|
||
findMarkdownFiles(inputFolder);
|
||
|
||
if (markdownFiles.length === 0) {
|
||
dialog.showErrorBox('No Files Found', 'No markdown files found in the selected folder');
|
||
return;
|
||
}
|
||
|
||
// Show progress dialog
|
||
let completedCount = 0;
|
||
const totalCount = markdownFiles.length;
|
||
|
||
// Process each file
|
||
const processNextFile = async (index) => {
|
||
if (index >= markdownFiles.length) {
|
||
// All files processed
|
||
dialog.showMessageBox(mainWindow, {
|
||
type: 'info',
|
||
title: 'Batch Conversion Complete',
|
||
message: `Successfully converted ${completedCount} out of ${totalCount} files to ${format.toUpperCase()}.`,
|
||
buttons: ['OK']
|
||
});
|
||
return;
|
||
}
|
||
|
||
const inputFile = markdownFiles[index];
|
||
const relativePath = path.relative(inputFolder, inputFile);
|
||
const baseName = path.basename(relativePath, path.extname(relativePath));
|
||
let outputExtension = format;
|
||
if (format === 'docx-enhanced') outputExtension = 'docx';
|
||
if (format === 'pdf-enhanced') outputExtension = 'pdf';
|
||
const outputFile = path.join(outputFolder, relativePath.replace(/\.(md|markdown)$/i, `.${outputExtension}`));
|
||
|
||
// Create subdirectories in output folder if needed
|
||
const outputDir = path.dirname(outputFile);
|
||
if (!fs.existsSync(outputDir)) {
|
||
fs.mkdirSync(outputDir, { recursive: true });
|
||
}
|
||
|
||
// Handle DOCX Enhanced format with WordTemplateExporter
|
||
if (format === 'docx-enhanced') {
|
||
try {
|
||
const content = fs.readFileSync(inputFile, 'utf-8');
|
||
const exporter = new WordTemplateExporter(wordTemplatePath, templateStartPage);
|
||
await exporter.convert(content, outputFile);
|
||
|
||
completedCount++;
|
||
|
||
// Update progress
|
||
mainWindow.webContents.send('batch-progress', {
|
||
completed: index + 1,
|
||
total: totalCount,
|
||
currentFile: path.basename(inputFile),
|
||
success: true
|
||
});
|
||
|
||
// Process next file
|
||
processNextFile(index + 1);
|
||
} catch (error) {
|
||
// Update progress with error
|
||
mainWindow.webContents.send('batch-progress', {
|
||
completed: index + 1,
|
||
total: totalCount,
|
||
currentFile: path.basename(inputFile),
|
||
success: false
|
||
});
|
||
|
||
// Process next file even if this one failed
|
||
processNextFile(index + 1);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Handle PDF Enhanced format with Word Template → PDF conversion
|
||
if (format === 'pdf-enhanced') {
|
||
try {
|
||
const content = fs.readFileSync(inputFile, 'utf-8');
|
||
|
||
// Step 1: Create temporary DOCX file using Word template
|
||
const tempDocxPath = outputFile.replace(/\.pdf$/, '_temp.docx');
|
||
const exporter = new WordTemplateExporter(wordTemplatePath, templateStartPage);
|
||
await exporter.convert(content, tempDocxPath);
|
||
|
||
// Step 2: Convert DOCX to PDF using LibreOffice (using execFile for safety)
|
||
const soffice = process.platform === 'win32'
|
||
? 'C:\\Program Files\\LibreOffice\\program\\soffice.exe'
|
||
: 'soffice';
|
||
|
||
const outputDir = path.dirname(outputFile);
|
||
const sofficeArgs = ['--headless', '--convert-to', 'pdf', '--outdir', outputDir, tempDocxPath];
|
||
|
||
execFile(soffice, sofficeArgs, (error, stdout, stderr) => {
|
||
// Clean up temporary DOCX file
|
||
try {
|
||
fs.unlinkSync(tempDocxPath);
|
||
} catch (e) {
|
||
console.error('Failed to delete temp file:', e);
|
||
}
|
||
|
||
if (error) {
|
||
// Update progress with error
|
||
mainWindow.webContents.send('batch-progress', {
|
||
completed: index + 1,
|
||
total: totalCount,
|
||
currentFile: path.basename(inputFile),
|
||
success: false
|
||
});
|
||
processNextFile(index + 1);
|
||
return;
|
||
}
|
||
|
||
// LibreOffice creates file with same base name as input
|
||
const generatedPdfPath = tempDocxPath.replace(/\.docx$/, '.pdf');
|
||
|
||
// Rename if needed
|
||
if (generatedPdfPath !== outputFile) {
|
||
try {
|
||
fs.renameSync(generatedPdfPath, outputFile);
|
||
} catch (e) {
|
||
console.error('Failed to rename PDF:', e);
|
||
}
|
||
}
|
||
|
||
completedCount++;
|
||
|
||
// Update progress
|
||
mainWindow.webContents.send('batch-progress', {
|
||
completed: index + 1,
|
||
total: totalCount,
|
||
currentFile: path.basename(inputFile),
|
||
success: true
|
||
});
|
||
|
||
// Process next file
|
||
processNextFile(index + 1);
|
||
});
|
||
|
||
} catch (error) {
|
||
// Update progress with error
|
||
mainWindow.webContents.send('batch-progress', {
|
||
completed: index + 1,
|
||
total: totalCount,
|
||
currentFile: path.basename(inputFile),
|
||
success: false
|
||
});
|
||
|
||
// Process next file even if this one failed
|
||
processNextFile(index + 1);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Build pandoc command for other formats
|
||
let pandocCmd = `${getPandocPath()} "${inputFile}" -o "${outputFile}"`;
|
||
|
||
// Add template if specified
|
||
if (options.template && options.template !== 'default') {
|
||
pandocCmd += ` --template="${options.template}"`;
|
||
}
|
||
|
||
// Add metadata
|
||
if (options.metadata) {
|
||
for (const [key, value] of Object.entries(options.metadata)) {
|
||
if (value.trim()) {
|
||
pandocCmd += ` -M ${key}="${value.replace(/"/g, '\\"')}"`;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Add variables
|
||
if (options.variables) {
|
||
for (const [key, value] of Object.entries(options.variables)) {
|
||
if (value.trim()) {
|
||
pandocCmd += ` -V ${key}="${value.replace(/"/g, '\\"')}"`;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Add other options
|
||
if (options.toc) pandocCmd += ' --toc';
|
||
if (options.tocDepth) pandocCmd += ` --toc-depth=${options.tocDepth}`;
|
||
if (options.numberSections) pandocCmd += ' --number-sections';
|
||
if (options.citeproc) pandocCmd += ' --citeproc';
|
||
if (options.bibliography) pandocCmd += ` --bibliography="${options.bibliography}"`;
|
||
if (options.csl) pandocCmd += ` --csl="${options.csl}"`;
|
||
|
||
// Add PDF-specific options with header/footer support
|
||
if (format === 'pdf') {
|
||
const pdfEngine = options.pdfEngine || 'xelatex';
|
||
pandocCmd += ` --pdf-engine=${pdfEngine}`;
|
||
if (options.geometry) pandocCmd += ` -V geometry:"${options.geometry}"`;
|
||
|
||
// Add monospace font settings for code blocks (ASCII art preservation)
|
||
pandocCmd += ' -V monofont="Consolas"';
|
||
pandocCmd += ' --highlight-style=tango';
|
||
|
||
// Add header/footer if enabled
|
||
if (headerFooterSettings.enabled) {
|
||
const filename = path.basename(inputFile, path.extname(inputFile));
|
||
const metadata = { filename, title: filename, author: '' };
|
||
|
||
const headerLeft = processDynamicFields(headerFooterSettings.header.left, metadata);
|
||
const headerCenter = processDynamicFields(headerFooterSettings.header.center, metadata);
|
||
const headerRight = processDynamicFields(headerFooterSettings.header.right, metadata);
|
||
const footerLeft = processDynamicFields(headerFooterSettings.footer.left, metadata);
|
||
const footerCenter = processDynamicFields(headerFooterSettings.footer.center, metadata);
|
||
const footerRight = processDynamicFields(headerFooterSettings.footer.right, metadata);
|
||
|
||
// Create LaTeX header
|
||
const latexHeader = `
|
||
\\usepackage{fancyhdr}
|
||
\\pagestyle{fancy}
|
||
\\fancyhf{}
|
||
\\lhead{${headerLeft.replace(/\\/g, '\\\\')}}
|
||
\\chead{${headerCenter.replace(/\\/g, '\\\\')}}
|
||
\\rhead{${headerRight.replace(/\\/g, '\\\\')}}
|
||
\\lfoot{${footerLeft.replace(/\\/g, '\\\\')}}
|
||
\\cfoot{${footerCenter.replace(/[$]PAGE[$]/g, '\\\\thepage').replace(/[$]TOTAL[$]/g, '\\\\pageref{LastPage}').replace(/\\/g, '\\\\')}}
|
||
\\rfoot{${footerRight.replace(/\\/g, '\\\\')}}
|
||
\\renewcommand{\\headrulewidth}{0.4pt}
|
||
\\renewcommand{\\footrulewidth}{0.4pt}
|
||
`;
|
||
const headerFile = path.join(require('os').tmpdir(), `header_batch_${Date.now()}.tex`);
|
||
fs.writeFileSync(headerFile, latexHeader, 'utf-8');
|
||
pandocCmd += ` --include-in-header="${headerFile}"`;
|
||
pandocCmd += ' --variable header-includes="\\\\usepackage{lastpage}"';
|
||
}
|
||
}
|
||
|
||
// Add DOCX-specific handling
|
||
if (format === 'docx') {
|
||
pandocCmd += ' -t docx';
|
||
}
|
||
|
||
// Add PowerPoint footer if enabled
|
||
if (format === 'pptx' && headerFooterSettings.enabled && headerFooterSettings.footer.center) {
|
||
const filename = path.basename(inputFile, path.extname(inputFile));
|
||
const metadata = { filename, title: filename, author: '' };
|
||
const footerText = processDynamicFields(headerFooterSettings.footer.center, metadata);
|
||
pandocCmd += ` --variable footer="${footerText}"`;
|
||
}
|
||
|
||
// Execute conversion (using runPandocCmd for safety)
|
||
runPandocCmd(pandocCmd, async (error, stdout, stderr) => {
|
||
if (!error) {
|
||
// Add headers/footers to DOCX if enabled
|
||
if (format === 'docx' && headerFooterSettings.enabled) {
|
||
try {
|
||
const filename = path.basename(inputFile, path.extname(inputFile));
|
||
const metadata = { filename, title: filename, author: '' };
|
||
await addHeaderFooterToDocx(outputFile, metadata);
|
||
} catch (hfError) {
|
||
console.error('Batch: Error adding headers/footers to DOCX:', hfError);
|
||
}
|
||
}
|
||
completedCount++;
|
||
}
|
||
|
||
// Update progress (you could send this to renderer for a progress bar)
|
||
mainWindow.webContents.send('batch-progress', {
|
||
completed: index + 1,
|
||
total: totalCount,
|
||
currentFile: path.basename(inputFile),
|
||
success: !error
|
||
});
|
||
|
||
// Process next file
|
||
processNextFile(index + 1);
|
||
});
|
||
};
|
||
|
||
// Start processing
|
||
processNextFile(0);
|
||
}
|
||
|
||
// Handle command line interface for file conversion
|
||
function handleCLIConversion(args) {
|
||
const command = args[0];
|
||
const filePath = args[args.length - 1]; // File path is always last argument
|
||
|
||
if (!fs.existsSync(filePath)) {
|
||
console.error(`Error: File not found: ${filePath}`);
|
||
app.quit();
|
||
return;
|
||
}
|
||
|
||
// Show conversion dialog for --convert command
|
||
if (command === '--convert') {
|
||
showConversionDialog(filePath);
|
||
return;
|
||
}
|
||
|
||
// Direct conversion for --convert-to command
|
||
if (command === '--convert-to' && args.length >= 3) {
|
||
const format = args[1];
|
||
performCLIConversion(filePath, format);
|
||
return;
|
||
}
|
||
|
||
console.error('Usage: --convert <file> OR --convert-to <format> <file>');
|
||
app.quit();
|
||
}
|
||
|
||
// Show conversion dialog for CLI
|
||
function showConversionDialog(filePath) {
|
||
const { dialog } = require('electron');
|
||
|
||
// Create a hidden window for dialog operations
|
||
const hiddenWindow = new BrowserWindow({
|
||
show: false,
|
||
webPreferences: {
|
||
nodeIntegration: true,
|
||
contextIsolation: false
|
||
}
|
||
});
|
||
|
||
const formats = [
|
||
{ name: 'PDF', value: 'pdf' },
|
||
{ name: 'HTML', value: 'html' },
|
||
{ name: 'DOCX', value: 'docx' },
|
||
{ name: 'LaTeX', value: 'latex' },
|
||
{ name: 'RTF', value: 'rtf' },
|
||
{ name: 'ODT', value: 'odt' },
|
||
{ name: 'PowerPoint', value: 'pptx' }
|
||
];
|
||
|
||
// Create format selection dialog using message box
|
||
const formatButtons = formats.map(f => f.name);
|
||
formatButtons.push('Cancel');
|
||
|
||
dialog.showMessageBox(hiddenWindow, {
|
||
type: 'question',
|
||
title: 'PanConverter - Choose Format',
|
||
message: `Convert "${path.basename(filePath)}" to:`,
|
||
detail: 'Select the output format for conversion',
|
||
buttons: formatButtons,
|
||
defaultId: 0,
|
||
cancelId: formatButtons.length - 1
|
||
}).then(result => {
|
||
if (result.response < formats.length) {
|
||
const selectedFormat = formats[result.response].value;
|
||
performCLIConversion(filePath, selectedFormat);
|
||
} else {
|
||
console.log('Conversion cancelled');
|
||
app.quit();
|
||
}
|
||
hiddenWindow.destroy();
|
||
});
|
||
}
|
||
|
||
// Perform CLI conversion
|
||
function performCLIConversion(inputPath, format) {
|
||
try {
|
||
const content = fs.readFileSync(inputPath, 'utf-8');
|
||
const outputPath = inputPath.replace(/\.[^/.]+$/, `.${format}`);
|
||
|
||
console.log(`Converting "${path.basename(inputPath)}" to ${format.toUpperCase()}...`);
|
||
|
||
// Use existing export functions but with CLI output (using runPandocCmd for safety)
|
||
const pandocCommand = buildPandocCommand(content, format, outputPath);
|
||
|
||
runPandocCmd(pandocCommand, (error, stdout, stderr) => {
|
||
if (error) {
|
||
console.error(`Conversion failed: ${error.message}`);
|
||
if (stderr) console.error(`Details: ${stderr}`);
|
||
app.quit();
|
||
return;
|
||
}
|
||
|
||
console.log(`Successfully converted to: ${outputPath}`);
|
||
|
||
// Show Windows notification (using exec for PowerShell is acceptable here - hardcoded command)
|
||
if (process.platform === 'win32') {
|
||
const iconPath = path.join(__dirname, '../assets/icon.png');
|
||
execFile('powershell', ['-Command', `New-BurntToastNotification -Text 'PanConverter', 'File converted to ${format.toUpperCase()}' -AppLogo '${iconPath}'`], () => {});
|
||
}
|
||
|
||
app.quit();
|
||
});
|
||
} catch (error) {
|
||
console.error(`Error reading file: ${error.message}`);
|
||
app.quit();
|
||
}
|
||
}
|
||
|
||
// Build Pandoc command for CLI conversion
|
||
function buildPandocCommand(content, format, outputPath) {
|
||
const inputFile = path.join(require('os').tmpdir(), `panconverter_temp_${Date.now()}.md`);
|
||
fs.writeFileSync(inputFile, content, 'utf-8');
|
||
|
||
let command = `pandoc "${inputFile}" -o "${outputPath}"`;
|
||
|
||
// Get metadata for dynamic fields
|
||
const filename = currentFile ? path.basename(currentFile, path.extname(currentFile)) : 'document';
|
||
const metadata = {
|
||
filename: filename,
|
||
title: filename,
|
||
author: '',
|
||
};
|
||
|
||
switch (format) {
|
||
case 'pdf':
|
||
command += ' --pdf-engine=xelatex --variable geometry:margin=1in';
|
||
|
||
// Add page size and orientation
|
||
const pageSize = PAGE_SIZES[pageSettings.size];
|
||
if (pageSize) {
|
||
command += ` -V geometry:papersize=${pageSize.pandoc}`;
|
||
} else if (pageSettings.customWidth && pageSettings.customHeight) {
|
||
// Custom page size
|
||
command += ` -V geometry:paperwidth=${pageSettings.customWidth}`;
|
||
command += ` -V geometry:paperheight=${pageSettings.customHeight}`;
|
||
}
|
||
|
||
// Add orientation
|
||
if (pageSettings.orientation === 'landscape') {
|
||
command += ' -V geometry:landscape';
|
||
}
|
||
|
||
// Add monospace font settings for code blocks (ASCII art preservation)
|
||
command += ' -V monofont="Consolas"';
|
||
command += ' --highlight-style=tango';
|
||
|
||
// Add header/footer if enabled
|
||
if (headerFooterSettings.enabled) {
|
||
// Process dynamic fields
|
||
const headerLeft = processDynamicFields(headerFooterSettings.header.left, metadata);
|
||
const headerCenter = processDynamicFields(headerFooterSettings.header.center, metadata);
|
||
const headerRight = processDynamicFields(headerFooterSettings.header.right, metadata);
|
||
const footerLeft = processDynamicFields(headerFooterSettings.footer.left, metadata);
|
||
const footerCenter = processDynamicFields(headerFooterSettings.footer.center, metadata);
|
||
const footerRight = processDynamicFields(headerFooterSettings.footer.right, metadata);
|
||
|
||
// Add Pandoc variables for fancyhdr package
|
||
if (headerLeft) command += ` --variable header-left="${headerLeft}"`;
|
||
if (headerCenter) command += ` --variable header-center="${headerCenter}"`;
|
||
if (headerRight) command += ` --variable header-right="${headerRight}"`;
|
||
if (footerLeft) command += ` --variable footer-left="${footerLeft}"`;
|
||
if (footerCenter) command += ` --variable footer-center="${footerCenter}"`;
|
||
if (footerRight) command += ` --variable footer-right="${footerRight}"`;
|
||
|
||
// Create custom LaTeX header with fancyhdr
|
||
const latexHeader = `
|
||
\\usepackage{fancyhdr}
|
||
\\pagestyle{fancy}
|
||
\\fancyhf{}
|
||
\\lhead{${headerLeft.replace(/\\/g, '\\\\')}}
|
||
\\chead{${headerCenter.replace(/\\/g, '\\\\')}}
|
||
\\rhead{${headerRight.replace(/\\/g, '\\\\')}}
|
||
\\lfoot{${footerLeft.replace(/\\/g, '\\\\')}}
|
||
\\cfoot{${footerCenter.replace(/[$]PAGE[$]/g, '\\\\thepage').replace(/[$]TOTAL[$]/g, '\\\\pageref{LastPage}').replace(/\\/g, '\\\\')}}
|
||
\\rfoot{${footerRight.replace(/\\/g, '\\\\')}}
|
||
\\renewcommand{\\headrulewidth}{0.4pt}
|
||
\\renewcommand{\\footrulewidth}{0.4pt}
|
||
`;
|
||
const headerFile = path.join(require('os').tmpdir(), `header_${Date.now()}.tex`);
|
||
fs.writeFileSync(headerFile, latexHeader, 'utf-8');
|
||
command += ` --include-in-header="${headerFile}"`;
|
||
|
||
// Add lastpage package for $TOTAL$ support
|
||
command += ' --variable header-includes="\\\\usepackage{lastpage}"';
|
||
}
|
||
break;
|
||
|
||
case 'html':
|
||
command += ' --self-contained --css';
|
||
break;
|
||
|
||
case 'docx':
|
||
command += ' --reference-doc';
|
||
|
||
// For DOCX, header/footer are handled via reference document or separate processing
|
||
// We'll add a note that DOCX headers/footers require reference doc or post-processing
|
||
break;
|
||
|
||
case 'odt':
|
||
// ODT headers/footers are handled via reference document
|
||
break;
|
||
|
||
case 'latex':
|
||
command += ' --standalone';
|
||
break;
|
||
|
||
case 'pptx':
|
||
command += ' --slide-level=2';
|
||
// PowerPoint footer can be added with --variable
|
||
if (headerFooterSettings.enabled && headerFooterSettings.footer.center) {
|
||
const footerText = processDynamicFields(headerFooterSettings.footer.center, metadata);
|
||
command += ` --variable footer="${footerText}"`;
|
||
}
|
||
break;
|
||
|
||
case 'json':
|
||
command = `pandoc "${inputFile}" -t json -o "${outputPath}"`;
|
||
break;
|
||
|
||
case 'yaml':
|
||
command = `pandoc "${inputFile}" -t markdown -o "${outputPath}"`;
|
||
break;
|
||
|
||
case 'xml':
|
||
command = `pandoc "${inputFile}" -t jats -o "${outputPath}"`;
|
||
break;
|
||
|
||
case 'toml':
|
||
// TOML: save raw markdown content with .toml extension
|
||
command = `pandoc "${inputFile}" -t markdown -o "${outputPath}"`;
|
||
break;
|
||
|
||
case 'revealjs':
|
||
command = `pandoc "${inputFile}" -t revealjs -s -o "${outputPath}" --slide-level=2`;
|
||
break;
|
||
|
||
case 'beamer':
|
||
command = `pandoc "${inputFile}" -t beamer -o "${outputPath}"`;
|
||
break;
|
||
|
||
case 'confluence':
|
||
case 'jira':
|
||
command = `pandoc "${inputFile}" -t jira -o "${outputPath}"`;
|
||
break;
|
||
}
|
||
|
||
return command;
|
||
}
|
||
|
||
app.whenReady().then(() => {
|
||
// Load saved Word template path and settings
|
||
wordTemplatePath = store.get('wordTemplatePath', null);
|
||
templateStartPage = store.get('templateStartPage', 3);
|
||
|
||
// Load header/footer settings
|
||
const savedHFSettings = store.get('headerFooterSettings', null);
|
||
if (savedHFSettings) {
|
||
headerFooterSettings = savedHFSettings;
|
||
}
|
||
|
||
// Load page size settings
|
||
const savedPageSettings = store.get('pageSettings', null);
|
||
if (savedPageSettings) {
|
||
pageSettings = savedPageSettings;
|
||
}
|
||
|
||
// Check for command line conversion requests
|
||
const args = process.argv.slice(2);
|
||
if (args.length >= 2 && (args[0] === '--convert' || args[0] === '--convert-to')) {
|
||
handleCLIConversion(args);
|
||
return; // Don't create window for CLI operations
|
||
}
|
||
|
||
createWindow();
|
||
|
||
// Handle file association on app startup
|
||
// In packaged apps, process.argv structure is different:
|
||
// Development: ['electron', 'app.js', 'file.md'] - need slice(2)
|
||
// Packaged: ['PanConverter.exe', 'file.md'] - need slice(1)
|
||
// We'll check all arguments after the executable
|
||
console.log('[MAIN] Full command line args:', JSON.stringify(process.argv));
|
||
console.log('[MAIN] App is packaged:', app.isPackaged);
|
||
|
||
// Start from index 1 (skip executable) and check each argument
|
||
const startIndex = app.isPackaged ? 1 : 2;
|
||
const fileArgs = process.argv.slice(startIndex);
|
||
console.log('[MAIN] File args to process (starting from index', startIndex + '):', fileArgs);
|
||
|
||
for (const arg of fileArgs) {
|
||
console.log('[MAIN] Checking arg:', arg);
|
||
if ((arg.endsWith('.md') || arg.endsWith('.markdown'))) {
|
||
// Try to resolve the path (might be relative)
|
||
const resolvedPath = path.isAbsolute(arg) ? arg : path.resolve(process.cwd(), arg);
|
||
console.log('[MAIN] Resolved path:', resolvedPath);
|
||
if (fs.existsSync(resolvedPath)) {
|
||
// Store the file to open after window is ready
|
||
console.log('[MAIN] Setting pendingFile to:', resolvedPath);
|
||
app.pendingFile = resolvedPath;
|
||
break;
|
||
} else {
|
||
console.log('[MAIN] File does not exist:', resolvedPath);
|
||
}
|
||
}
|
||
}
|
||
console.log('[MAIN] Final app.pendingFile:', app.pendingFile);
|
||
});
|
||
|
||
app.on('window-all-closed', () => {
|
||
if (process.platform !== 'darwin') {
|
||
app.quit();
|
||
}
|
||
});
|
||
|
||
app.on('activate', () => {
|
||
if (BrowserWindow.getAllWindows().length === 0) {
|
||
createWindow();
|
||
}
|
||
});
|
||
|
||
// IPC handlers for recent files
|
||
ipcMain.on('save-recent-files', (event, recentFiles) => {
|
||
try {
|
||
const userDataPath = app.getPath('userData');
|
||
const recentFilesPath = path.join(userDataPath, 'recent-files.json');
|
||
fs.writeFileSync(recentFilesPath, JSON.stringify(recentFiles, null, 2));
|
||
} catch (error) {
|
||
console.error('Error saving recent files:', error);
|
||
}
|
||
});
|
||
|
||
ipcMain.on('clear-recent-files', (event) => {
|
||
try {
|
||
const userDataPath = app.getPath('userData');
|
||
const recentFilesPath = path.join(userDataPath, 'recent-files.json');
|
||
fs.writeFileSync(recentFilesPath, JSON.stringify([], null, 2));
|
||
// Rebuild menu to reflect changes
|
||
createMenu();
|
||
event.reply('recent-files-cleared');
|
||
} catch (error) {
|
||
console.error('Error clearing recent files:', error);
|
||
}
|
||
});
|
||
|
||
// Handle file opening on macOS
|
||
app.on('open-file', (event, filePath) => {
|
||
event.preventDefault();
|
||
if (mainWindow && rendererReady) {
|
||
openFileFromPath(filePath);
|
||
} else {
|
||
// Store the file path to open after window and renderer are ready
|
||
app.pendingFile = filePath;
|
||
}
|
||
});
|
||
|
||
// Handle file opening from command line or file association
|
||
function openFileFromPath(filePath) {
|
||
console.log('[MAIN] openFileFromPath called with:', filePath);
|
||
console.log('[MAIN] rendererReady:', rendererReady, 'mainWindow exists:', !!mainWindow, 'webContents exists:', !!(mainWindow?.webContents));
|
||
if (fs.existsSync(filePath)) {
|
||
const stats = fs.statSync(filePath);
|
||
if (stats.size > MAX_FILE_SIZE) {
|
||
dialog.showErrorBox('File Too Large', `File exceeds the ${MAX_FILE_SIZE_MB}MB size limit.`);
|
||
return;
|
||
}
|
||
currentFile = filePath;
|
||
const content = fs.readFileSync(filePath, 'utf-8');
|
||
console.log('[MAIN] File read successfully, content length:', content.length);
|
||
console.log('[MAIN] About to send file-opened. mainWindow:', !!mainWindow, 'webContents:', !!(mainWindow?.webContents), 'rendererReady:', rendererReady);
|
||
if (mainWindow && mainWindow.webContents && rendererReady) {
|
||
// Send file immediately - renderer-ready means UI is initialized
|
||
console.log('[MAIN] Sending file-opened to renderer');
|
||
mainWindow.webContents.send('file-opened', { path: filePath, content });
|
||
} else {
|
||
// Store file to open after renderer is ready
|
||
console.log('[MAIN] Storing as pending file');
|
||
app.pendingFile = filePath;
|
||
}
|
||
} else {
|
||
console.error('[MAIN] File does not exist:', filePath);
|
||
}
|
||
}
|
||
|
||
// ========================================
|
||
// PDF OPERATIONS — delegates to main/PDFOperations.js
|
||
// ========================================
|
||
|
||
ipcMain.on('process-pdf-operation', async (event, data) => {
|
||
try {
|
||
mainWindow.webContents.send('pdf-operation-progress', {
|
||
message: `Processing ${data.operation}...`,
|
||
progress: 10
|
||
});
|
||
|
||
const result = await PDFOperations.executeOperation(data.operation, data);
|
||
mainWindow.webContents.send('pdf-operation-complete', result);
|
||
} catch (error) {
|
||
mainWindow.webContents.send('pdf-operation-complete', {
|
||
success: false,
|
||
error: error.message
|
||
});
|
||
}
|
||
});
|
||
|
||
ipcMain.on('get-pdf-page-count', async (event, filePath) => {
|
||
try {
|
||
const count = await PDFOperations.getPageCount(filePath);
|
||
event.reply('pdf-page-count', { count });
|
||
} catch (error) {
|
||
event.reply('pdf-page-count', { error: error.message });
|
||
}
|
||
});
|
||
|
||
// IPC Handler for folder selection (for PDF operations)
|
||
ipcMain.on('select-pdf-folder', (event, inputId) => {
|
||
const folder = dialog.showOpenDialogSync(mainWindow, {
|
||
properties: ['openDirectory']
|
||
});
|
||
|
||
if (folder && folder[0]) {
|
||
event.reply('pdf-folder-selected', { inputId, path: folder[0] });
|
||
}
|
||
});
|
||
|
||
// ============================================
|
||
// ASCII Art Generator Window
|
||
// ============================================
|
||
let asciiGeneratorWindow = null;
|
||
|
||
function openAsciiGenerator() {
|
||
if (asciiGeneratorWindow) {
|
||
asciiGeneratorWindow.focus();
|
||
return;
|
||
}
|
||
|
||
asciiGeneratorWindow = new BrowserWindow({
|
||
width: 800,
|
||
height: 700,
|
||
parent: mainWindow,
|
||
modal: false,
|
||
title: 'ASCII Art Generator',
|
||
icon: path.join(__dirname, '../assets/icon.png'),
|
||
webPreferences: {
|
||
nodeIntegration: false,
|
||
contextIsolation: true,
|
||
preload: path.join(__dirname, 'preload.js')
|
||
}
|
||
});
|
||
|
||
asciiGeneratorWindow.loadFile(path.join(__dirname, 'ascii-generator.html'));
|
||
asciiGeneratorWindow.setMenuBarVisibility(false);
|
||
|
||
asciiGeneratorWindow.on('closed', () => {
|
||
asciiGeneratorWindow = null;
|
||
});
|
||
}
|
||
|
||
ipcMain.on('open-ascii-generator', () => {
|
||
openAsciiGenerator();
|
||
});
|
||
|
||
// ============================================
|
||
// Table Generator Window
|
||
// ============================================
|
||
let tableGeneratorWindow = null;
|
||
|
||
function openTableGenerator() {
|
||
if (tableGeneratorWindow) {
|
||
tableGeneratorWindow.focus();
|
||
return;
|
||
}
|
||
|
||
tableGeneratorWindow = new BrowserWindow({
|
||
width: 900,
|
||
height: 700,
|
||
parent: mainWindow,
|
||
modal: false,
|
||
title: 'Table Generator',
|
||
icon: path.join(__dirname, '../assets/icon.png'),
|
||
webPreferences: {
|
||
nodeIntegration: false,
|
||
contextIsolation: true,
|
||
preload: path.join(__dirname, 'preload.js')
|
||
}
|
||
});
|
||
|
||
tableGeneratorWindow.loadFile(path.join(__dirname, 'table-generator.html'));
|
||
tableGeneratorWindow.setMenuBarVisibility(false);
|
||
|
||
tableGeneratorWindow.on('closed', () => {
|
||
tableGeneratorWindow = null;
|
||
});
|
||
}
|
||
|
||
ipcMain.on('open-table-generator', () => {
|
||
openTableGenerator();
|
||
});
|
||
|
||
// IPC Handler for loading document templates
|
||
ipcMain.handle('load-template', async (event, filename) => {
|
||
try {
|
||
const templatePath = path.join(__dirname, 'templates', filename);
|
||
return fs.readFileSync(templatePath, 'utf-8');
|
||
} catch (err) {
|
||
console.error('Failed to load template:', err);
|
||
return null;
|
||
}
|
||
});
|
||
|
||
// IPC Handler for saving pasted/dropped images
|
||
ipcMain.handle('save-pasted-image', async (event, { base64, ext }) => {
|
||
try {
|
||
let saveDir;
|
||
|
||
if (currentFile) {
|
||
// Save relative to current file
|
||
saveDir = path.join(path.dirname(currentFile), 'assets');
|
||
} else {
|
||
// Use temp directory
|
||
saveDir = path.join(app.getPath('temp'), 'markdown-converter-images');
|
||
}
|
||
|
||
if (!fs.existsSync(saveDir)) {
|
||
fs.mkdirSync(saveDir, { recursive: true });
|
||
}
|
||
|
||
const filename = `image-${Date.now()}.${ext}`;
|
||
const filePath = path.join(saveDir, filename);
|
||
const buffer = Buffer.from(base64, 'base64');
|
||
fs.writeFileSync(filePath, buffer);
|
||
|
||
return { relativePath: `assets/${filename}`, absolutePath: filePath };
|
||
} catch (error) {
|
||
console.error('Failed to save pasted image:', error);
|
||
return null;
|
||
}
|
||
});
|
||
|
||
// IPC Handler to receive generated content from generator windows
|
||
ipcMain.on('insert-generated-content', (event, content) => {
|
||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||
mainWindow.webContents.send('insert-content', content);
|
||
}
|
||
});
|
||
|
||
// ============================================
|
||
// File Explorer IPC Handlers
|
||
// ============================================
|
||
ipcMain.handle('list-directory', async (event, dirPath) => {
|
||
try {
|
||
if (!dirPath) {
|
||
const result = await dialog.showOpenDialog(mainWindow, {
|
||
properties: ['openDirectory']
|
||
});
|
||
if (result.canceled || !result.filePaths[0]) return null;
|
||
dirPath = result.filePaths[0];
|
||
}
|
||
|
||
// Validate path to prevent traversal attacks
|
||
const validation = validatePath(dirPath);
|
||
if (!validation.valid) {
|
||
console.error('[SECURITY] Invalid directory path:', validation.error);
|
||
return null;
|
||
}
|
||
|
||
if (!isPathAccessible(validation.resolved)) {
|
||
return null;
|
||
}
|
||
|
||
const entries = fs.readdirSync(validation.resolved, { withFileTypes: true })
|
||
.filter(e => !e.name.startsWith('.'))
|
||
.sort((a, b) => {
|
||
if (a.isDirectory() && !b.isDirectory()) return -1;
|
||
if (!a.isDirectory() && b.isDirectory()) return 1;
|
||
return a.name.localeCompare(b.name);
|
||
})
|
||
.map(e => ({
|
||
name: e.name,
|
||
isDirectory: e.isDirectory(),
|
||
size: e.isDirectory() ? 0 : fs.statSync(path.join(validation.resolved, e.name)).size,
|
||
modified: fs.statSync(path.join(validation.resolved, e.name)).mtimeMs,
|
||
path: path.join(validation.resolved, e.name)
|
||
}));
|
||
return { path: validation.resolved, entries };
|
||
} catch (err) {
|
||
console.error('list-directory error:', err);
|
||
return null;
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('select-custom-css', async (event) => {
|
||
const result = dialog.showOpenDialogSync(mainWindow, {
|
||
title: 'Select Custom Preview CSS',
|
||
properties: ['openFile'],
|
||
filters: [
|
||
{ name: 'CSS Stylesheets', extensions: ['css'] }
|
||
]
|
||
});
|
||
|
||
if (result && result[0]) {
|
||
const filePath = result[0];
|
||
const content = fs.readFileSync(filePath, 'utf-8');
|
||
return { path: filePath, content };
|
||
}
|
||
return null;
|
||
});
|
||
|
||
|
||
|
||
ipcMain.handle('read-file', async (event, filePath) => {
|
||
const validation = validatePath(filePath);
|
||
if (!validation.valid || !isPathAccessible(validation.resolved)) {
|
||
throw new Error(validation.error || 'Invalid file path');
|
||
}
|
||
|
||
return fs.readFileSync(validation.resolved, 'utf-8');
|
||
});
|
||
|
||
ipcMain.handle('write-file', async (event, payload) => {
|
||
const validation = resolveWritablePath(payload?.path);
|
||
if (!validation.valid) {
|
||
throw new Error(validation.error || 'Invalid file path');
|
||
}
|
||
|
||
fs.mkdirSync(path.dirname(validation.resolved), { recursive: true });
|
||
fs.writeFileSync(validation.resolved, payload?.content ?? '', 'utf-8');
|
||
return { path: validation.resolved };
|
||
});
|
||
|
||
ipcMain.handle('delete-file', async (event, filePath) => {
|
||
const validation = validatePath(filePath);
|
||
if (!validation.valid || !isPathAccessible(validation.resolved)) {
|
||
throw new Error(validation.error || 'Invalid file path');
|
||
}
|
||
|
||
fs.rmSync(validation.resolved, { recursive: true, force: false });
|
||
return true;
|
||
});
|
||
|
||
ipcMain.handle('ensure-directory', async (event, dirPath) => {
|
||
const validation = resolveWritablePath(dirPath);
|
||
if (!validation.valid) {
|
||
throw new Error(validation.error || 'Invalid directory path');
|
||
}
|
||
|
||
fs.mkdirSync(validation.resolved, { recursive: true });
|
||
return validation.resolved;
|
||
});
|
||
|
||
ipcMain.handle('path-exists', async (event, filePath) => {
|
||
const validation = resolveWritablePath(filePath);
|
||
return validation.valid ? fs.existsSync(validation.resolved) : false;
|
||
});
|
||
|
||
ipcMain.handle('is-directory', async (event, filePath) => {
|
||
const validation = validatePath(filePath);
|
||
if (!validation.valid || !isPathAccessible(validation.resolved)) {
|
||
return false;
|
||
}
|
||
|
||
return fs.statSync(validation.resolved).isDirectory();
|
||
});
|
||
|
||
ipcMain.handle('copy-path', async (event, payload) => {
|
||
const sourceValidation = validatePath(payload?.source);
|
||
const destinationValidation = resolveWritablePath(payload?.destination);
|
||
|
||
if (!sourceValidation.valid || !isPathAccessible(sourceValidation.resolved)) {
|
||
throw new Error(sourceValidation.error || 'Invalid source path');
|
||
}
|
||
if (!destinationValidation.valid) {
|
||
throw new Error(destinationValidation.error || 'Invalid destination path');
|
||
}
|
||
|
||
fs.mkdirSync(path.dirname(destinationValidation.resolved), { recursive: true });
|
||
fs.cpSync(sourceValidation.resolved, destinationValidation.resolved, { recursive: true });
|
||
return { source: sourceValidation.resolved, destination: destinationValidation.resolved };
|
||
});
|
||
|
||
ipcMain.handle('move-path', async (event, payload) => {
|
||
const sourceValidation = validatePath(payload?.source);
|
||
const destinationValidation = resolveWritablePath(payload?.destination);
|
||
|
||
if (!sourceValidation.valid || !isPathAccessible(sourceValidation.resolved)) {
|
||
throw new Error(sourceValidation.error || 'Invalid source path');
|
||
}
|
||
if (!destinationValidation.valid) {
|
||
throw new Error(destinationValidation.error || 'Invalid destination path');
|
||
}
|
||
|
||
fs.mkdirSync(path.dirname(destinationValidation.resolved), { recursive: true });
|
||
|
||
try {
|
||
fs.renameSync(sourceValidation.resolved, destinationValidation.resolved);
|
||
} catch (error) {
|
||
if (error.code !== 'EXDEV') {
|
||
throw error;
|
||
}
|
||
|
||
fs.cpSync(sourceValidation.resolved, destinationValidation.resolved, { recursive: true });
|
||
fs.rmSync(sourceValidation.resolved, { recursive: true, force: false });
|
||
}
|
||
|
||
return { source: sourceValidation.resolved, destination: destinationValidation.resolved };
|
||
});
|
||
|
||
// Open a file by path (from explorer panel)
|
||
ipcMain.on('open-file-path', (event, filePath) => {
|
||
try {
|
||
// Validate path to prevent traversal attacks
|
||
const validation = validatePath(filePath);
|
||
if (!validation.valid) {
|
||
console.error('[SECURITY] Invalid file path:', validation.error);
|
||
return;
|
||
}
|
||
|
||
if (!isPathAccessible(validation.resolved)) {
|
||
return;
|
||
}
|
||
|
||
const stat = fs.statSync(validation.resolved);
|
||
if (stat.size > MAX_FILE_SIZE) return;
|
||
currentFile = validation.resolved;
|
||
const content = fs.readFileSync(validation.resolved, 'utf-8');
|
||
mainWindow.webContents.send('file-opened', { path: validation.resolved, content });
|
||
} catch (err) {
|
||
console.error('open-file-path error:', err);
|
||
}
|
||
});
|
||
|
||
// ============================================
|
||
// Git IPC Handlers
|
||
// ============================================
|
||
ipcMain.handle('git-status', async () => {
|
||
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
|
||
return GitOperations.getStatus(dir);
|
||
});
|
||
|
||
ipcMain.handle('git-stage', async (event, { files }) => {
|
||
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
|
||
return GitOperations.stage(dir, files);
|
||
});
|
||
|
||
ipcMain.handle('git-commit', async (event, { message }) => {
|
||
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
|
||
return GitOperations.commit(dir, message);
|
||
});
|
||
|
||
ipcMain.handle('git-log', async () => {
|
||
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
|
||
return GitOperations.log(dir);
|
||
});
|
||
|
||
// ============================================
|
||
// Snippets IPC Handlers
|
||
// ============================================
|
||
const snippetsPath = path.join(app.getPath('userData'), 'snippets.json');
|
||
|
||
function loadSnippets() {
|
||
try {
|
||
if (fs.existsSync(snippetsPath)) {
|
||
return JSON.parse(fs.readFileSync(snippetsPath, 'utf-8'));
|
||
}
|
||
} catch (err) { console.error('Failed to load snippets:', err); }
|
||
return [];
|
||
}
|
||
|
||
function saveSnippetsFile(snippets) {
|
||
fs.writeFileSync(snippetsPath, JSON.stringify(snippets, null, 2));
|
||
}
|
||
|
||
ipcMain.handle('get-snippets', async () => loadSnippets());
|
||
|
||
ipcMain.handle('save-snippet', async (event, snippet) => {
|
||
const snippets = loadSnippets();
|
||
const existing = snippets.findIndex(s => s.id === snippet.id);
|
||
if (existing >= 0) snippets[existing] = snippet;
|
||
else snippets.push(snippet);
|
||
saveSnippetsFile(snippets);
|
||
return snippets;
|
||
});
|
||
|
||
ipcMain.handle('delete-snippet', async (event, id) => {
|
||
const snippets = loadSnippets().filter(s => s.id !== id);
|
||
saveSnippetsFile(snippets);
|
||
return snippets;
|
||
});
|
||
|
||
// ============================================
|
||
// Code Execution (REPL) IPC Handler
|
||
// ============================================
|
||
ipcMain.handle('execute-code', async (event, { code, language }) => {
|
||
const timeout = 10000;
|
||
|
||
return new Promise((resolve) => {
|
||
let cmd, args;
|
||
if (language === 'javascript' || language === 'js') {
|
||
cmd = 'node';
|
||
args = ['-e', code];
|
||
} else if (language === 'python' || language === 'py') {
|
||
cmd = process.platform === 'win32' ? 'python' : 'python3';
|
||
args = ['-c', code];
|
||
} else if (language === 'bash' || language === 'sh') {
|
||
cmd = process.platform === 'win32' ? 'cmd' : 'bash';
|
||
args = process.platform === 'win32' ? ['/c', code] : ['-c', code];
|
||
} else {
|
||
resolve({ error: `Unsupported language: ${language}` });
|
||
return;
|
||
}
|
||
|
||
execFile(cmd, args, { timeout, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
|
||
resolve({
|
||
stdout: stdout || '',
|
||
stderr: stderr || '',
|
||
error: err?.killed ? 'Execution timed out (10s limit)' : (err?.message || null)
|
||
});
|
||
});
|
||
});
|
||
});
|