mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 10:00:17 +05:30
- Make the HTML preprocessor code-block and inline-code aware so code examples containing <style> / <div> / comments are preserved. - Strip all raw <div> tags (not just alignment attributes) to avoid malformed output from unmatched closing tags. - Handle uppercase tags and single/unquoted attributes. - Create temporary DOCX input files inside private mkdtemp directories instead of predictable names in the shared temp directory. - Wrap batch DOCX preprocessing in try/catch so one unreadable file does not abort the entire batch. - Add regression tests for the edge cases above.
4791 lines
147 KiB
JavaScript
4791 lines
147 KiB
JavaScript
const { app, BrowserWindow, Menu, dialog, ipcMain, shell } = require('electron');
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
const { execFile } = 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}`;
|
||
}
|
||
}
|
||
|
||
// 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';
|
||
}
|
||
|
||
// 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
|
||
// ============================================
|
||
/**
|
||
* 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 {
|
||
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 {
|
||
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 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 the command element when it is pandoc (bare or a full path to the binary)
|
||
const commandName = path.basename(parsed.command).replace(/\.exe$/i, '');
|
||
const args = commandName === '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']
|
||
|
||
const startIndex = app.isPackaged ? 1 : 2;
|
||
const fileArgs = commandLine.slice(startIndex);
|
||
for (const arg of fileArgs) {
|
||
if (arg.endsWith('.md') || arg.endsWith('.markdown')) {
|
||
const resolvedPath = path.isAbsolute(arg) ? arg : path.resolve(workingDirectory, arg);
|
||
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', () => {});
|
||
}
|
||
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 {
|
||
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);
|
||
});
|
||
|
||
// 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: getPandocPath(),
|
||
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
|
||
|
||
// Check pandoc availability first
|
||
checkPandocAvailability()
|
||
.then((hasPandoc) => {
|
||
if (!hasPandoc) {
|
||
// Handle formats that don't require pandoc
|
||
if (format === 'html') {
|
||
exportToHTML(outputFile);
|
||
return;
|
||
} else if (format === 'pdf') {
|
||
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
|
||
|
||
let inputFile = currentFile;
|
||
let tempInputDir = null;
|
||
let tempInputFile = null;
|
||
|
||
// Pre-process markdown for Word output so that <style> blocks, HTML
|
||
// comments, and <div> tags do not appear as visible text.
|
||
if (format === 'docx') {
|
||
const content = fs.readFileSync(currentFile, 'utf-8');
|
||
const cleanedContent = WordTemplateExporter.preprocessMarkdownForWordExport(content);
|
||
tempInputDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'mc_export_'));
|
||
tempInputFile = path.join(tempInputDir, path.basename(currentFile));
|
||
fs.writeFileSync(tempInputFile, cleanedContent, 'utf-8');
|
||
inputFile = tempInputFile;
|
||
}
|
||
|
||
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 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, () => {
|
||
if (tempInputFile) {
|
||
try {
|
||
fs.unlinkSync(tempInputFile);
|
||
} catch {
|
||
// Ignore cleanup errors
|
||
}
|
||
}
|
||
if (tempInputDir) {
|
||
try {
|
||
fs.rmdirSync(tempInputDir);
|
||
} catch {
|
||
// Ignore cleanup errors
|
||
}
|
||
}
|
||
});
|
||
} 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 {
|
||
/* 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
|
||
|
||
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, onComplete) {
|
||
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 {
|
||
if (stderr) {
|
||
console.warn(`Pandoc stderr (non-fatal):`, stderr);
|
||
}
|
||
|
||
// Set page size for DOCX
|
||
if (format === 'docx') {
|
||
try {
|
||
await setDocxPageSize(outputFile);
|
||
} 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);
|
||
} 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
|
||
} catch (pageSizeError) {
|
||
console.error('Error setting page size for ODT:', pageSizeError);
|
||
}
|
||
}
|
||
|
||
// Add headers/footers to ODT if enabled
|
||
if (format === 'odt' && headerFooterSettings.enabled) {
|
||
}
|
||
showExportSuccess(outputFile);
|
||
}
|
||
if (typeof onComplete === 'function') {
|
||
onComplete(error);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 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');
|
||
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();
|
||
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) => {
|
||
rendererReady = true;
|
||
if (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, includeSubfolders }) => {
|
||
if (!conversionLimiter()) {
|
||
mainWindow.webContents.send('conversion-status', 'Please wait before converting again...');
|
||
return;
|
||
}
|
||
performBatchConversion(inputFolder, outputFolder, format, options, includeSubfolders);
|
||
});
|
||
|
||
// 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;
|
||
}
|
||
async function performBatchConversion(inputFolder, outputFolder, format, options, includeSubfolders = true) {
|
||
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, recurse = true) {
|
||
const files = fs.readdirSync(dir);
|
||
for (const file of files) {
|
||
const fullPath = path.join(dir, file);
|
||
const stat = fs.statSync(fullPath);
|
||
if (stat.isDirectory()) {
|
||
if (recurse) {
|
||
findMarkdownFiles(fullPath, recurse);
|
||
}
|
||
} else if (file.match(/\.(md|markdown)$/i)) {
|
||
markdownFiles.push(fullPath);
|
||
}
|
||
}
|
||
}
|
||
findMarkdownFiles(inputFolder, includeSubfolders);
|
||
if (markdownFiles.length === 0) {
|
||
dialog.showErrorBox('No Files Found', 'No markdown files found in the selected folder');
|
||
return;
|
||
}
|
||
|
||
// Verify Pandoc is available for formats that require it
|
||
const needsPandoc = !['docx-enhanced', 'pdf-enhanced'].includes(format);
|
||
if (needsPandoc) {
|
||
const hasPandoc = await checkPandocAvailability();
|
||
if (!hasPandoc) {
|
||
dialog.showErrorBox(
|
||
'Pandoc Not Found',
|
||
'Pandoc is required for batch conversion but was not found.\n\n' +
|
||
'Please install Pandoc from https://pandoc.org/installing.html or run: npm run download-tools'
|
||
);
|
||
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
|
||
const allSucceeded = completedCount === totalCount;
|
||
const allFailed = completedCount === 0;
|
||
dialog.showMessageBox(mainWindow, {
|
||
type: allFailed ? 'error' : 'info',
|
||
title: allSucceeded ? 'Batch Conversion Complete' : 'Batch Conversion Finished',
|
||
message: allFailed
|
||
? `No files were converted to ${format.toUpperCase()}.`
|
||
: `Converted ${completedCount} out of ${totalCount} files to ${format.toUpperCase()}.`,
|
||
buttons: ['OK'],
|
||
});
|
||
return;
|
||
}
|
||
const inputFile = markdownFiles[index];
|
||
const relativePath = path.relative(inputFolder, inputFile);
|
||
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 {
|
||
// 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 {
|
||
// 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 pandocInputFile = inputFile;
|
||
let batchTempInputDir = null;
|
||
let batchTempInputFile = null;
|
||
|
||
// Pre-process markdown for Word output so that <style> blocks, HTML
|
||
// comments, and <div> tags do not appear as visible text.
|
||
if (format === 'docx') {
|
||
try {
|
||
const content = fs.readFileSync(inputFile, 'utf-8');
|
||
const cleanedContent = WordTemplateExporter.preprocessMarkdownForWordExport(content);
|
||
batchTempInputDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'mc_batch_export_'));
|
||
batchTempInputFile = path.join(batchTempInputDir, path.basename(inputFile));
|
||
fs.writeFileSync(batchTempInputFile, cleanedContent, 'utf-8');
|
||
pandocInputFile = batchTempInputFile;
|
||
} catch (preprocessError) {
|
||
console.error(
|
||
`Batch: Failed to pre-process ${path.basename(inputFile)}:`,
|
||
preprocessError.message
|
||
);
|
||
mainWindow.webContents.send('batch-progress', {
|
||
completed: index + 1,
|
||
total: totalCount,
|
||
currentFile: path.basename(inputFile),
|
||
success: false,
|
||
});
|
||
processNextFile(index + 1);
|
||
return;
|
||
}
|
||
}
|
||
|
||
let pandocCmd = `${getPandocPath()} "${pandocInputFile}" -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) => {
|
||
// Clean up temporary pre-processed input file and directory
|
||
if (batchTempInputFile) {
|
||
try {
|
||
fs.unlinkSync(batchTempInputFile);
|
||
} catch {
|
||
// Ignore cleanup errors
|
||
}
|
||
}
|
||
if (batchTempInputDir) {
|
||
try {
|
||
fs.rmdirSync(batchTempInputDir);
|
||
} catch {
|
||
// Ignore cleanup errors
|
||
}
|
||
}
|
||
|
||
if (error) {
|
||
console.error(`Batch: Failed to convert ${path.basename(inputFile)}:`, error.message, stderr);
|
||
} else {
|
||
// 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 {
|
||
app.quit();
|
||
}
|
||
hiddenWindow.destroy();
|
||
});
|
||
}
|
||
|
||
// Perform CLI conversion
|
||
function performCLIConversion(inputPath, format) {
|
||
try {
|
||
const content = fs.readFileSync(inputPath, 'utf-8');
|
||
const outputPath = inputPath.replace(/\.[^/.]+$/, `.${format}`);
|
||
// 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;
|
||
}
|
||
// 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
|
||
|
||
// Start from index 1 (skip executable) and check each argument
|
||
const startIndex = app.isPackaged ? 1 : 2;
|
||
const fileArgs = process.argv.slice(startIndex);
|
||
for (const arg of fileArgs) {
|
||
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);
|
||
if (fs.existsSync(resolvedPath)) {
|
||
// Store the file to open after window is ready
|
||
|
||
app.pendingFile = resolvedPath;
|
||
break;
|
||
} else {
|
||
}
|
||
}
|
||
}
|
||
});
|
||
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) {
|
||
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');
|
||
if (mainWindow && mainWindow.webContents && rendererReady) {
|
||
// Send file immediately - renderer-ready means UI is initialized
|
||
|
||
mainWindow.webContents.send('file-opened', {
|
||
path: filePath,
|
||
content,
|
||
});
|
||
} else {
|
||
// Store file to open after renderer is ready
|
||
|
||
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,
|
||
});
|
||
}
|
||
);
|
||
});
|
||
});
|