Compare commits

..
3 Commits
Author SHA1 Message Date
amitwh baf644d62b fix(modal): prevent duplicate ModalManager declaration
window.ModalManager was set unconditionally, causing "Identifier
'ModalManager' has already been declared" when script tag in HTML
also loaded ModalManager before renderer.js required it.

Now checks !window.ModalManager before setting.

Amit Haridas
2026-05-22 22:08:06 +05:30
amitwh f9a5420ad2 4.4.1: update version everywhere, fix DOMPurify initialization
- Bump version to 4.4.1
- DOMPurify now initialized with window context (fixes markdown rendering)
- Add 'it' to eslint globals

Amit Haridas
2026-05-22 21:54:05 +05:30
amitwh f8361174f2 chore: add it to eslint globals, add debug logging to createEditor
Amit Haridas
2026-05-22 21:42:34 +05:30
36 changed files with 371 additions and 1333 deletions
-96
View File
@@ -1,96 +0,0 @@
# Building MarkdownConverter with Tauri 2.x
## Prerequisites
1. **Node.js 18+** and npm
2. **Rust 1.75+** — install via [rustup](https://rustup.rs/)
```bash
# Install Rust
winget install Rust.Rustup # Windows
# OR
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh # macOS/Linux
# Set stable as default
rustup default stable
# Install Tauri CLI (alternative to npm package)
cargo install tauri-cli --locked
```
## Development
```bash
# Install dependencies
npm install
# Run frontend dev server
npm run dev
# In another terminal, run Tauri dev
npm run tauri:dev
# Or use Tauri CLI directly (if installed globally via cargo)
cargo tauri dev
```
## Production Build
```bash
# Build the frontend first
npm run build:frontend
# Then build Tauri app
npm run tauri:build
# Output:
# Windows: src-tauri/target/release/markdown-converter.exe
# Installer: dist/MarkdownConverter-Setup-4.3.0.exe
```
## Troubleshooting
### Rust not found
Ensure Rust is in PATH: `rustc --version`
### WebView2 not installed (Windows)
Download from https://developer.microsoft.com/en-us/microsoft-edge/webview2/
### pandoc not found
Pandoc must be installed and in PATH. Download from https://pandoc.org/installing.html
## File Structure
```
src-tauri/
src/
main.rs # Binary entry point
lib.rs # Tauri app setup + command registration
commands/ # Rust commands (file, git, pdf, export, etc.)
menu.rs # Native menu
tray.rs # System tray
pdf_ops.rs # PDF processing
plugin_manager.rs
error.rs
Cargo.toml
tauri.conf.json
capabilities/default.json
icons/
src/
renderer.js # Main renderer (updated for Tauri invoke/listen)
tauri-commands.js # Tauri command bridge
index.html # Main HTML (copied to dist/index.html)
dist/
index.html # Built frontend output
```
## Key Differences from Electron
| Aspect | Electron | Tauri |
|--------|----------|-------|
| IPC | ipcRenderer.invoke() | invoke() from @tauri-apps/api |
| Events | ipcRenderer.on() | listen() from @tauri-apps/api |
| File access | Node.js fs module | Rust std::fs or tauri-plugin-fs |
| Native menus | Electron Menu API | tauri menu API |
| Settings | electron-store | tauri-plugin-store |
| Binary size | ~150MB | ~10MB |
+1
View File
@@ -75,6 +75,7 @@ module.exports = [
jest: 'readonly',
describe: 'readonly',
test: 'readonly',
it: 'readonly',
expect: 'readonly',
beforeEach: 'readonly',
afterEach: 'readonly',
+2 -9
View File
@@ -1,13 +1,10 @@
{
"name": "markdown-converter",
"version": "4.3.0",
"version": "4.4.1",
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
"main": "src/main.js",
"scripts": {
"start": "electron .",
"dev": "vite",
"build:frontend": "vite build",
"preview": "vite preview",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
@@ -27,11 +24,7 @@
"dist": "electron-builder --publish=never",
"dist:all": "electron-builder -mwl",
"download-tools": "node scripts/download-tools.js",
"generate-icons": "node scripts/generate-icons.js",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"tauri:debug": "tauri build --debug"
"generate-icons": "node scripts/generate-icons.js"
},
"keywords": [
"markdown",
-5
View File
@@ -1,5 +0,0 @@
[build]
rustflags = ["-C", "target-cpu=native"]
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
-45
View File
@@ -1,45 +0,0 @@
[package]
name = "markdown-converter"
version = "4.3.0"
description = "Professional Markdown editor and universal file converter"
authors = ["ConcreteInfo <amit.wh@gmail.com>"]
edition = "2021"
[lib]
name = "markdown_converter_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["devtools", "protocol-asset"] }
tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-process = "2"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-notification = "2"
tauri-plugin-log = { version = "2", features = ["colored"] }
tauri-plugin-store = "2"
tauri-plugin-os = "2"
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
git2 = "0.19"
log = "0.4"
walkdir = "2"
dirs = "5"
chrono = "0.4"
thiserror = "1"
[target.'cfg(windows)'.dependencies]
winreg = "0.52"
[profile.release]
panic = "abort"
codegen-units = 1
lto = true
opt-level = "s"
strip = true
-3
View File
@@ -1,3 +0,0 @@
fn main() {
tauri_build::build()
}
-49
View File
@@ -1,49 +0,0 @@
{
"$schema": "https://schema.tauri.app/config/2/capabilities",
"identifier": "default",
"description": "MarkdownConverter capabilities",
"windows": ["main"],
"permissions": [
"core:default",
"core:event:default",
"core:window:default",
"core:window:allow-create",
"core:window:allow-close",
"core:window:allow-set-focus",
"core:window:allow-show",
"core:window:allow-hide",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-unmaximize",
"core:window:allow-set-title",
"core:webview:default",
"core:webview:allow-create-webview-window",
"shell:allow-open",
"shell:allow-execute",
"dialog:allow-open",
"dialog:allow-save",
"dialog:allow-message",
"dialog:allow-ask",
"dialog:allow-confirm",
"fs:allow-read",
"fs:allow-write",
"fs:allow-exists",
"fs:allow-mkdir",
"fs:allow-remove",
"fs:allow-rename",
"fs:allow-copy-file",
"fs:default",
"fs:allow-read-dir",
"fs:allow-read-file",
"fs:allow-write-file",
"process:allow-exit",
"process:allow-restart",
"clipboard-manager:allow-read",
"clipboard-manager:allow-write",
"notification:default",
"os:default",
"opener:default",
"store:default",
"log:default"
]
}
@@ -1,7 +0,0 @@
{
"name": "writing-studio",
"version": "1.0.0",
"description": "Goal tracking, manuscript panels, snapshots, and sprint engine for writers",
"author": "ConcreteInfo",
"builtin": true
}
-34
View File
@@ -1,34 +0,0 @@
use std::fs;
use tauri::CommandResult;
use crate::error::AppError;
#[tauri::command]
pub fn get_app_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
#[tauri::command]
pub fn get_recent_files() -> CommandResult<Vec<String>, AppError> {
let config_dir = dirs::config_dir()
.ok_or_else(|| AppError::Config("Cannot find config directory".to_string()))?;
let recent_path = config_dir.join("markdown-converter").join("recent-files.json");
if recent_path.exists() {
let content = fs::read_to_string(&recent_path).map_err(|e| AppError::FileRead(e.to_string()))?;
let files: Vec<String> = serde_json::from_str(&content).unwrap_or_default();
Ok(files)
} else {
Ok(Vec::new())
}
}
#[tauri::command]
pub fn save_recent_files(files: Vec<String>) -> CommandResult<(), AppError> {
let config_dir = dirs::config_dir()
.ok_or_else(|| AppError::Config("Cannot find config directory".to_string()))?;
let app_dir = config_dir.join("markdown-converter");
fs::create_dir_all(&app_dir).map_err(|e| AppError::FileWrite(e.to_string()))?;
let recent_path = app_dir.join("recent-files.json");
let content = serde_json::to_string_pretty(&files).map_err(|e| AppError::Config(e.to_string()))?;
fs::write(&recent_path, content).map_err(|e| AppError::FileWrite(e.to_string()))?;
Ok(())
}
-28
View File
@@ -1,28 +0,0 @@
use tauri_plugin_dialog::DialogExt;
use tauri::CommandResult;
use crate::error::AppError;
#[tauri::command]
pub async fn open_file_dialog(app: tauri::AppHandle) -> CommandResult<Option<String>, AppError> {
let file = app.dialog()
.file()
.add_filter("Markdown", &["md", "markdown"])
.add_filter("All Files", &["*"]);
let result = file.blocking_pick_file();
Ok(result.map(|p| p.to_string()))
}
#[tauri::command]
pub async fn save_file_dialog(app: tauri::AppHandle, default_name: Option<String>) -> CommandResult<Option<String>, AppError> {
let file = app.dialog()
.file()
.set_file_name(default_name.unwrap_or_else(|| "untitled.md".to_string()));
let result = file.blocking_save_file();
Ok(result.map(|p| p.to_string()))
}
#[tauri::command]
pub async fn select_folder_dialog(app: tauri::AppHandle) -> CommandResult<Option<String>, AppError> {
let folder = app.dialog().file().blocking_pick_folder();
Ok(folder.map(|p| p.to_string()))
}
-63
View File
@@ -1,63 +0,0 @@
use std::process::Command;
use tauri::CommandResult;
use crate::error::AppError;
#[tauri::command]
pub async fn export_markdown(
input_path: String,
output_path: String,
format: String,
options: ExportOptions,
) -> CommandResult<String, AppError> {
let mut args = vec![input_path.clone(), "-o".to_string(), output_path.clone()];
match format.as_str() {
"pdf" => {
args.push("--pdf-engine=xelatex".to_string());
if options.standalone.unwrap_or(false) {
args.push("-s".to_string());
}
},
"docx" => {
args.push("--to=docx".to_string());
if options.standalone.unwrap_or(false) {
args.push("-s".to_string());
}
},
"html" => {
args.push("--to=html".to_string());
if options.standalone.unwrap_or(false) {
args.push("-s".to_string());
}
},
_ => {
args.push(format!("--to={}", format));
}
}
let output = Command::new("pandoc")
.args(&args)
.output()
.map_err(|e| AppError::Export(e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(AppError::Export(stderr.to_string()));
}
Ok(output_path)
}
#[derive(serde::Deserialize)]
pub struct ExportOptions {
pub standalone: Option<bool>,
pub template: Option<String>,
}
#[tauri::command]
pub async fn check_pandoc_available() -> CommandResult<bool, AppError> {
let output = Command::new("pandoc")
.arg("--version")
.output();
Ok(output.is_ok())
}
-72
View File
@@ -1,72 +0,0 @@
use std::fs;
use std::path::Path;
use tauri::CommandResult;
use crate::error::AppError;
#[tauri::command]
pub async fn read_file(path: String) -> CommandResult<String, AppError> {
fs::read_to_string(&path).map_err(|e| AppError::FileRead(e.to_string()))
}
#[tauri::command]
pub async fn write_file(path: String, content: String) -> CommandResult<(), AppError> {
if let Some(parent) = Path::new(&path).parent() {
fs::create_dir_all(parent).map_err(|e| AppError::FileWrite(e.to_string()))?;
}
fs::write(&path, content).map_err(|e| AppError::FileWrite(e.to_string()))
}
#[tauri::command]
pub async fn delete_file(path: String) -> CommandResult<(), AppError> {
fs::remove_file(&path).map_err(|e| AppError::FileDelete(e.to_string()))
}
#[tauri::command]
pub async fn path_exists(path: String) -> bool {
Path::new(&path).exists()
}
#[tauri::command]
pub async fn is_directory(path: String) -> bool {
Path::new(&path).is_dir()
}
#[tauri::command]
pub async fn list_directory(path: String) -> CommandResult<Vec<FileEntry>, AppError> {
let mut entries = Vec::new();
for entry in walkdir::WalkDir::new(&path).max_depth(1).into_iter().filter_map(|e| e.ok()) {
let p = entry.path();
if p == Path::new(&path) { continue; }
entries.push(FileEntry {
name: p.file_name().unwrap_or_default().to_string_lossy().to_string(),
path: p.to_string_lossy().to_string(),
is_dir: p.is_dir(),
is_file: p.is_file(),
});
}
Ok(entries)
}
#[tauri::command]
pub async fn ensure_directory(path: String) -> CommandResult<(), AppError> {
fs::create_dir_all(&path).map_err(|e| AppError::FileWrite(e.to_string()))
}
#[tauri::command]
pub async fn copy_path(source: String, destination: String) -> CommandResult<(), AppError> {
fs::copy(&source, &destination).map_err(|e| AppError::FileCopy(e.to_string()))?;
Ok(())
}
#[tauri::command]
pub async fn move_path(source: String, destination: String) -> CommandResult<(), AppError> {
fs::rename(&source, &destination).map_err(|e| AppError::FileMove(e.to_string()))
}
#[derive(serde::Serialize)]
pub struct FileEntry {
pub name: String,
pub path: String,
pub is_dir: bool,
pub is_file: bool,
}
-113
View File
@@ -1,113 +0,0 @@
use git2::{Repository, Status, DiffOptions};
use tauri::CommandResult;
use crate::error::AppError;
#[tauri::command]
pub async fn git_status(repo_path: String) -> CommandResult<Vec<GitStatusEntry>, AppError> {
let repo = Repository::open(&repo_path).map_err(|e| AppError::Git(e.to_string()))?;
let mut entries = Vec::new();
let statuses = repo.statuses(None).map_err(|e| AppError::Git(e.to_string()))?;
for entry in statuses.iter() {
if let Some(status) = entry.status() {
let path = entry.path().unwrap_or("").to_string();
entries.push(GitStatusEntry {
path,
is_staged: status.intersects(Status::INDEX_NEW | Status::INDEX_MODIFIED | Status::INDEX_DELETED),
is_modified: status.intersects(Status::WT_MODIFIED),
is_new: status.intersects(Status::WT_NEW),
is_deleted: status.intersects(Status::WT_DELETED),
});
}
}
Ok(entries)
}
#[derive(serde::Serialize)]
pub struct GitStatusEntry {
pub path: String,
pub is_staged: bool,
pub is_modified: bool,
pub is_new: bool,
pub is_deleted: bool,
}
#[tauri::command]
pub async fn git_stage(repo_path: String, path: String) -> CommandResult<(), AppError> {
let repo = Repository::open(&repo_path).map_err(|e| AppError::Git(e.to_string()))?;
let mut index = repo.index().map_err(|e| AppError::Git(e.to_string()))?;
index.add_path(std::path::Path::new(&path)).map_err(|e| AppError::Git(e.to_string()))?;
index.write().map_err(|e| AppError::Git(e.to_string()))?;
Ok(())
}
#[tauri::command]
pub async fn git_commit(repo_path: String, message: String) -> CommandResult<String, AppError> {
let repo = Repository::open(&repo_path).map_err(|e| AppError::Git(e.to_string()))?;
let mut index = repo.index().map_err(|e| AppError::Git(e.to_string()))?;
let oid = index.write_tree().map_err(|e| AppError::Git(e.to_string()))?;
let tree = repo.find_tree(oid).map_err(|e| AppError::Git(e.to_string()))?;
let signature = repo.signature().map_err(|e| AppError::Git(e.to_string()))?;
let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
let commit = repo.commit(
Some("HEAD"), &signature, &signature, &message, &tree,
parent.as_ref(),
).map_err(|e| AppError::Git(e.to_string()))?;
Ok(commit.to_string())
}
#[tauri::command]
pub async fn git_log(repo_path: String, limit: Option<usize>) -> CommandResult<Vec<GitCommitEntry>, AppError> {
let repo = Repository::open(&repo_path).map_err(|e| AppError::Git(e.to_string()))?;
let mut revwalk = repo.revwalk().map_err(|e| AppError::Git(e.to_string()))?;
revwalk.push_head().map_err(|e| AppError::Git(e.to_string()))?;
let max = limit.unwrap_or(50);
let mut commits = Vec::new();
for (i, oid) in revwalk.enumerate() {
if i >= max { break; }
let commit = repo.find_commit(oid).map_err(|e| AppError::Git(e.to_string()))?;
commits.push(GitCommitEntry {
hash: commit.id().to_string(),
message: commit.message().unwrap_or("").to_string(),
author: commit.author().name().unwrap_or("").to_string(),
date: chrono::DateTime::from_timestamp(commit.time().seconds(), 0)
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_default(),
});
}
Ok(commits)
}
#[derive(serde::Serialize)]
pub struct GitCommitEntry {
pub hash: String,
pub message: String,
pub author: String,
pub date: String,
}
#[tauri::command]
pub async fn git_diff(repo_path: String, path: Option<String>) -> CommandResult<String, AppError> {
let repo = Repository::open(&repo_path).map_err(|e| AppError::Git(e.to_string()))?;
let mut opts = DiffOptions::new();
if let Some(p) = path {
opts.path_filter(&p);
}
let diff = repo.diff_index_to_workdir(None, Some(&mut opts))
.map_err(|e| AppError::Git(e.to_string()))?;
let mut out = Vec::new();
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
let prefix = match line.origin() {
'+' => " +",
'-' => " -",
' ' => " ",
_ => "",
};
if let Ok(content) = std::str::from_utf8(line.content()) {
out.push(format!("{}{}", prefix, content));
}
true
}).map_err(|e| AppError::Git(e.to_string()))?;
Ok(out.join(""))
}
-7
View File
@@ -1,7 +0,0 @@
pub mod file;
pub mod export;
pub mod git;
pub mod app;
pub mod pdf;
pub mod dialog;
pub mod plugins;
-29
View File
@@ -1,29 +0,0 @@
use tauri::CommandResult;
use crate::error::AppError;
use crate::pdf_ops::PdfProcessor;
#[tauri::command]
pub async fn get_pdf_page_count(path: String) -> CommandResult<usize, AppError> {
PdfProcessor::get_page_count(&path)
}
#[tauri::command]
pub async fn merge_pdfs(paths: Vec<String>, output: String) -> CommandResult<String, AppError> {
PdfProcessor::merge_pdfs(&paths, &output)?;
Ok(output)
}
#[tauri::command]
pub async fn split_pdf(input: String, output_dir: String, pages_per_split: usize) -> CommandResult<Vec<String>, AppError> {
PdfProcessor::split_pdf(&input, &output_dir, pages_per_split)
}
#[tauri::command]
pub async fn rotate_pdf(input: String, output: String, degrees: i32) -> CommandResult<(), AppError> {
PdfProcessor::rotate_pages(&input, &output, degrees)
}
#[tauri::command]
pub async fn delete_pdf_pages(input: String, output: String, pages: Vec<usize>) -> CommandResult<(), AppError> {
PdfProcessor::delete_pages(&input, &output, &pages)
}
-31
View File
@@ -1,31 +0,0 @@
use crate::error::AppError;
use crate::plugin_manager::PluginMetadata;
#[tauri::command]
pub fn list_plugins() -> Vec<PluginMetadata> {
// This would be connected to the app state in a full implementation
// For now, return the built-in plugins
vec![
PluginMetadata {
name: "writing-studio".to_string(),
version: "1.0.0".to_string(),
description: "Goal tracking, manuscript panels, snapshots, and sprint engine for writers".to_string(),
author: "ConcreteInfo".to_string(),
builtin: true,
}
]
}
#[tauri::command]
pub fn get_plugin(name: String) -> Option<PluginMetadata> {
match name.as_str() {
"writing-studio" => Some(PluginMetadata {
name: "writing-studio".to_string(),
version: "1.0.0".to_string(),
description: "Goal tracking, manuscript panels, snapshots, and sprint engine for writers".to_string(),
author: "ConcreteInfo".to_string(),
builtin: true,
}),
_ => None,
}
}
-32
View File
@@ -1,32 +0,0 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("File read error: {0}")]
FileRead(String),
#[error("File write error: {0}")]
FileWrite(String),
#[error("File delete error: {0}")]
FileDelete(String),
#[error("File copy error: {0}")]
FileCopy(String),
#[error("File move error: {0}")]
FileMove(String),
#[error("Export error: {0}")]
Export(String),
#[error("Git error: {0}")]
Git(String),
#[error("PDF error: {0}")]
Pdf(String),
#[error("Config error: {0}")]
Config(String),
}
impl serde::Serialize for AppError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
-69
View File
@@ -1,69 +0,0 @@
pub mod menu;
pub mod tray;
pub mod pdf_ops;
pub mod plugin_manager;
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_log::Builder::new().build())
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![
crate::commands::file::read_file,
crate::commands::file::write_file,
crate::commands::file::delete_file,
crate::commands::file::path_exists,
crate::commands::file::is_directory,
crate::commands::file::list_directory,
crate::commands::file::ensure_directory,
crate::commands::file::copy_path,
crate::commands::file::move_path,
crate::commands::export::export_markdown,
crate::commands::export::check_pandoc_available,
crate::commands::git::git_status,
crate::commands::git::git_stage,
crate::commands::git::git_commit,
crate::commands::git::git_log,
crate::commands::git::git_diff,
crate::commands::app::get_app_version,
crate::commands::app::get_recent_files,
crate::commands::app::save_recent_files,
crate::commands::pdf::get_pdf_page_count,
crate::commands::pdf::merge_pdfs,
crate::commands::pdf::split_pdf,
crate::commands::pdf::rotate_pdf,
crate::commands::pdf::delete_pdf_pages,
crate::commands::dialog::open_file_dialog,
crate::commands::dialog::save_file_dialog,
crate::commands::dialog::select_folder_dialog,
crate::commands::plugins::list_plugins,
crate::commands::plugins::get_plugin,
])
.setup(|app| {
log::info!("MarkdownConverter starting up");
// Set up native menu
let menu = crate::menu::create_menu(app.handle())?;
app.set_menu(menu)?;
app.on_menu_event(|app, event| crate::menu::handle_menu_event(app, event));
// Set up system tray
let _ = crate::tray::create_tray(app.handle());
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
let _ = window.emit("file-save", ());
}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
-5
View File
@@ -1,5 +0,0 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
markdown_converter_lib::run()
}
-168
View File
@@ -1,168 +0,0 @@
use tauri::{AppHandle, Emitter, menu::{Menu, MenuItem, Submenu, PredefinedMenuItem}};
pub fn create_menu(app: &AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
let file_menu = Submenu::with_items(
app,
"File",
true,
&[
&MenuItem::with_id(app, "new", "New", true, Some("CmdOrCtrl+N"))?,
&MenuItem::with_id(app, "open", "Open...", true, Some("CmdOrCtrl+O"))?,
&MenuItem::with_id(app, "open_pdf", "Open PDF...", true, Some("CmdOrCtrl+Shift+O"))?,
&MenuItem::with_id(app, "save", "Save", true, Some("CmdOrCtrl+S"))?,
&MenuItem::with_id(app, "save_as", "Save As...", true, Some("CmdOrCtrl+Shift+S"))?,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "print", "Print Preview", true, Some("CmdOrCtrl+P"))?,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "import", "Import Document...", true, Some("CmdOrCtrl+I"))?,
&Submenu::with_items(
app,
"Export",
true,
&[
&MenuItem::with_id(app, "export_html", "HTML", true, None)?,
&MenuItem::with_id(app, "export_pdf", "PDF", true, None)?,
&MenuItem::with_id(app, "export_docx", "DOCX", true, None)?,
&MenuItem::with_id(app, "export_latex", "LaTeX", true, None)?,
&MenuItem::with_id(app, "export_rtf", "RTF", true, None)?,
&MenuItem::with_id(app, "export_odt", "ODT", true, None)?,
&MenuItem::with_id(app, "export_epub", "EPUB", true, None)?,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "export_pptx", "PowerPoint (PPTX)", true, None)?,
&MenuItem::with_id(app, "export_csv", "CSV (Tables)", true, None)?,
],
)?,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "quit", "Quit", true, Some("CmdOrCtrl+Q"))?,
],
)?;
let edit_menu = Submenu::with_items(
app,
"Edit",
true,
&[
&MenuItem::with_id(app, "undo", "Undo", true, Some("CmdOrCtrl+Z"))?,
&MenuItem::with_id(app, "redo", "Redo", true, Some("CmdOrCtrl+Shift+Z"))?,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "cut", "Cut", true, Some("CmdOrCtrl+X"))?,
&MenuItem::with_id(app, "copy", "Copy", true, Some("CmdOrCtrl+C"))?,
&MenuItem::with_id(app, "paste", "Paste", true, Some("CmdOrCtrl+V"))?,
&MenuItem::with_id(app, "select_all", "Select All", true, Some("CmdOrCtrl+A"))?,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "find", "Find & Replace", true, Some("CmdOrCtrl+F"))?,
],
)?;
let view_menu = Submenu::with_items(
app,
"View",
true,
&[
&MenuItem::with_id(app, "toggle_preview", "Toggle Preview", true, Some("CmdOrCtrl+Shift+V"))?,
&MenuItem::with_id(app, "command_palette", "Command Palette", true, Some("CmdOrCtrl+Shift+P"))?,
&PredefinedMenuItem::separator(app)?,
&Submenu::with_items(
app,
"Sidebar",
true,
&[
&MenuItem::with_id(app, "sidebar_explorer", "File Explorer", true, None)?,
&MenuItem::with_id(app, "sidebar_git", "Git", true, None)?,
&MenuItem::with_id(app, "sidebar_snippets", "Snippets", true, None)?,
&MenuItem::with_id(app, "sidebar_templates", "Templates", true, None)?,
],
)?,
&MenuItem::with_id(app, "toggle_bottom_panel", "Bottom Panel (REPL)", true, None)?,
&PredefinedMenuItem::separator(app)?,
&Submenu::with_items(
app,
"Theme",
true,
&[
&MenuItem::with_id(app, "theme_light", "Atom One Light", true, None)?,
&MenuItem::with_id(app, "theme_dark", "Dark", true, None)?,
&MenuItem::with_id(app, "theme_solarized", "Solarized Light", true, None)?,
&MenuItem::with_id(app, "theme_monokai", "Monokai", true, None)?,
&MenuItem::with_id(app, "theme_github", "GitHub Light", true, None)?,
],
)?,
],
)?;
let batch_menu = Submenu::with_items(
app,
"Batch",
true,
&[
&MenuItem::with_id(app, "batch_convert_md", "Convert Markdown Folder...", true, None)?,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "batch_image", "Batch Image Conversion...", true, None)?,
&MenuItem::with_id(app, "batch_audio", "Batch Audio Conversion...", true, None)?,
&MenuItem::with_id(app, "batch_video", "Batch Video Conversion...", true, None)?,
],
)?;
let convert_menu = Submenu::with_items(
app,
"Convert",
true,
&[
&MenuItem::with_id(app, "universal_converter", "Universal File Converter...", true, Some("CmdOrCtrl+Shift+C"))?,
],
)?;
let tools_menu = Submenu::with_items(
app,
"Tools",
true,
&[
&MenuItem::with_id(app, "table_generator", "Table Generator", true, Some("CmdOrCtrl+Shift+T"))?,
&MenuItem::with_id(app, "ascii_generator", "ASCII Art Generator", true, Some("CmdOrCtrl+Shift+A"))?,
],
)?;
let menu = Menu::with_items(
app,
&[
&file_menu,
&edit_menu,
&view_menu,
&batch_menu,
&convert_menu,
&tools_menu,
],
)?;
Ok(menu)
}
pub fn handle_menu_event(app: &AppHandle, event: menu::MenuEvent) {
let id = event.id().as_ref();
match id {
"new" => { let _ = app.emit("file-new", ()); }
"open" => { let _ = app.emit("menu-open", "open"); }
"save" => { let _ = app.emit("file-save", ()); }
"toggle_preview" => { let _ = app.emit("toggle-preview", ()); }
"command_palette" => { let _ = app.emit("toggle-command-palette", ()); }
"toggle_bottom_panel" => { let _ = app.emit("toggle-bottom-panel", ()); }
"sidebar_explorer" => { let _ = app.emit("toggle-sidebar-panel", "explorer"); }
"sidebar_git" => { let _ = app.emit("toggle-sidebar-panel", "git"); }
"sidebar_snippets" => { let _ = app.emit("toggle-sidebar-panel", "snippets"); }
"sidebar_templates" => { let _ = app.emit("toggle-sidebar-panel", "templates"); }
"theme_light" => { let _ = app.emit("theme-changed", "atomonelight"); }
"theme_dark" => { let _ = app.emit("theme-changed", "dark"); }
"theme_solarized" => { let _ = app.emit("theme-changed", "solarized"); }
"theme_monokai" => { let _ = app.emit("theme-changed", "monokai"); }
"theme_github" => { let _ = app.emit("theme-changed", "github"); }
"undo" => { let _ = app.emit("undo", ()); }
"redo" => { let _ = app.emit("redo", ()); }
"find" => { let _ = app.emit("toggle-find", ()); }
"quit" => { app.exit(0); }
"cut" => { /* native cut handled by OS */ }
"copy" => { /* native copy handled by OS */ }
"paste" => { /* native paste handled by OS */ }
"select_all" => { /* native select all handled by OS */ }
_ => {}
}
}
-106
View File
@@ -1,106 +0,0 @@
use std::fs::File;
use std::io::Write;
use printpdf::*;
use crate::error::AppError;
pub struct PdfProcessor;
impl PdfProcessor {
pub fn get_page_count(path: &str) -> Result<usize, AppError> {
let file = File::open(path).map_err(|e| AppError::Pdf(format!("Failed to open {}: {}", path, e)))?;
let reader = PdfReader::new(file).map_err(|e| AppError::Pdf(format!("Failed to read PDF: {}", e)))?;
Ok(reader.get_pages().len())
}
pub fn merge_pdfs(paths: &[String], output: &str) -> Result<(), AppError> {
let mut output_doc = PdfDocument::new("Merged PDF", Mm(210.0), Mm(297.0), None);
for path in paths {
let file = File::open(path).map_err(|e| AppError::Pdf(format!("Failed to open {}: {}", path, e)))?;
let reader = PdfReader::new(file).map_err(|e| AppError::Pdf(format!("Failed to read {}: {}", path, e)))?;
for (page_idx, (_, page)) in reader.get_pages().iter().enumerate() {
if let Ok((doc_idx, page_obj)) = reader.get_page(page_idx) {
let _ = output_doc.add_page(doc_idx as usize, page_obj, None);
}
}
}
let output_file = File::create(output).map_err(|e| AppError::Pdf(format!("Failed to create output: {}", e)))?;
output_doc.save(&output_file).map_err(|e| AppError::Pdf(format!("Failed to save PDF: {}", e)))?;
Ok(())
}
pub fn split_pdf(input: &str, output_dir: &str, pages_per_split: usize) -> Result<Vec<String>, AppError> {
let file = File::open(input).map_err(|e| AppError::Pdf(format!("Failed to open input: {}", e)))?;
let reader = PdfReader::new(file).map_err(|e| AppError::Pdf(format!("Failed to read PDF: {}", e)))?;
let total_pages = reader.get_pages().len();
let mut output_files = Vec::new();
for start in (0..total_pages).step_by(pages_per_split) {
let end = (start + pages_per_split).min(total_pages);
let output_path = format!("{}/split_{}_{}.pdf", output_dir, start, end);
let mut new_doc = PdfDocument::new(
&format!("Pages {} - {}", start + 1, end),
Mm(210.0),
Mm(297.0),
None,
);
for page_idx in start..end {
if let Ok((doc_idx, page_obj)) = reader.get_page(page_idx) {
let _ = new_doc.add_page(doc_idx as usize, page_obj, None);
}
}
let out_file = File::create(&output_path).map_err(|e| AppError::Pdf(format!("Failed to create {}: {}", output_path, e)))?;
new_doc.save(&out_file).map_err(|e| AppError::Pdf(format!("Failed to save split PDF: {}", e)))?;
output_files.push(output_path);
}
Ok(output_files)
}
pub fn rotate_pages(input: &str, output: &str, degrees: i32) -> Result<(), AppError> {
let file = File::open(input).map_err(|e| AppError::Pdf(format!("Failed to open input: {}", e)))?;
let mut reader = PdfReader::new(file).map_err(|e| AppError::Pdf(format!("Failed to read PDF: {}", e)))?;
// For rotation, we'll recreate the PDF with rotated pages
let mut output_doc = PdfDocument::new("Rotated PDF", Mm(210.0), Mm(297.0), None);
for (page_idx, (_, _)) in reader.get_pages().iter().enumerate() {
if let Ok((doc_idx, page_obj)) = reader.get_page(page_idx) {
let _ = output_doc.add_page(doc_idx as usize, page_obj, None);
}
}
let output_file = File::create(output).map_err(|e| AppError::Pdf(format!("Failed to create output: {}", e)))?;
output_doc.save(&output_file).map_err(|e| AppError::Pdf(format!("Failed to save rotated PDF: {}", e)))?;
Ok(())
}
pub fn delete_pages(input: &str, output: &str, pages_to_delete: &[usize]) -> Result<(), AppError> {
let file = File::open(input).map_err(|e| AppError::Pdf(format!("Failed to open input: {}", e)))?;
let reader = PdfReader::new(file).map_err(|e| AppError::Pdf(format!("Failed to read PDF: {}", e)))?;
let total_pages = reader.get_pages().len();
let delete_set: std::collections::HashSet<usize> = pages_to_delete.iter().cloned().collect();
let mut output_doc = PdfDocument::new("Modified PDF", Mm(210.0), Mm(297.0), None);
for page_idx in 0..total_pages {
if delete_set.contains(&page_idx) {
continue;
}
if let Ok((doc_idx, page_obj)) = reader.get_page(page_idx) {
let _ = output_doc.add_page(doc_idx as usize, page_obj, None);
}
}
let output_file = File::create(output).map_err(|e| AppError::Pdf(format!("Failed to create output: {}", e)))?;
output_doc.save(&output_file).map_err(|e| AppError::Pdf(format!("Failed to save modified PDF: {}", e)))?;
Ok(())
}
}
-101
View File
@@ -1,101 +0,0 @@
use std::collections::HashMap;
use std::path::PathBuf;
use crate::error::AppError;
#[derive(Debug, Clone)]
pub struct PluginMetadata {
pub name: String,
pub version: String,
pub description: String,
pub author: String,
pub builtin: bool,
}
pub struct PluginManager {
plugins: HashMap<String, PluginMetadata>,
enabled: Vec<String>,
}
impl PluginManager {
pub fn new() -> Self {
PluginManager {
plugins: HashMap::new(),
enabled: Vec::new(),
}
}
pub fn load_plugins(&mut self, plugin_dir: PathBuf) -> Result<(), AppError> {
if !plugin_dir.exists() {
return Ok(());
}
for entry in walkdir::WalkDir::new(&plugin_dir)
.max_depth(1)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_dir() {
continue;
}
let manifest_path = path.join("manifest.json");
if manifest_path.exists() {
let content = std::fs::read_to_string(&manifest_path)
.map_err(|e| AppError::Config(format!("Failed to read manifest: {}", e)))?;
#[derive(serde::Deserialize)]
struct RawPluginMetadata {
name: String,
version: String,
description: String,
author: String,
builtin: Option<bool>,
}
let raw: RawPluginMetadata = serde_json::from_str(&content)
.map_err(|e| AppError::Config(format!("Failed to parse manifest: {}", e)))?;
let metadata = PluginMetadata {
name: raw.name,
version: raw.version,
description: raw.description,
author: raw.author,
builtin: raw.builtin.unwrap_or(false),
};
self.plugins.insert(metadata.name.clone(), metadata);
}
}
Ok(())
}
pub fn enable_plugin(&mut self, name: &str) {
if !self.enabled.contains(&name.to_string()) {
self.enabled.push(name.to_string());
}
}
pub fn disable_plugin(&mut self, name: &str) {
self.enabled.retain(|n| n != name);
}
pub fn get_enabled(&self) -> Vec<String> {
self.enabled.clone()
}
pub fn list_plugins(&self) -> Vec<PluginMetadata> {
self.plugins.values().cloned().collect()
}
pub fn get_plugin(&self, name: &str) -> Option<PluginMetadata> {
self.plugins.get(name).cloned()
}
}
impl Default for PluginManager {
fn default() -> Self {
Self::new()
}
}
-44
View File
@@ -1,44 +0,0 @@
use tauri::{AppHandle, Emitter, tray::{TrayIconBuilder, MouseButton, MouseButtonState, TrayIconEvent}, menu::{Menu, MenuItem}};
pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
let tray_menu = Menu::with_items(
app,
&[
&MenuItem::with_id(app, "show", "Show MarkdownConverter", true, None)?,
&MenuItem::with_id(app, "new_file", "New File", true, None)?,
&MenuItem::with_id(app, "open_file", "Open File...", true, None)?,
&MenuItem::with_id(app, "quit", "Quit", true, None)?,
],
)?;
let _tray = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&tray_menu)
.menu_on_left_click(false)
.on_menu_event(|app, event| {
match event.id().as_ref() {
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
"new_file" => { let _ = app.emit("file-new", ()); }
"open_file" => { let _ = app.emit("menu-open", "open"); }
"quit" => { app.exit(0); }
_ => {}
}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event {
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
})
.build(app)?;
Ok(())
}
-77
View File
@@ -1,77 +0,0 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "MarkdownConverter",
"version": "4.3.0",
"identifier": "com.concreteinfo.markdownconverter",
"build": {
"devUrl": "http://localhost:1420",
"frontendDist": "../dist",
"devtools": true
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "MarkdownConverter",
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"center": true,
"fileDropEnabled": true
}
],
"security": {
"csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://kroki.io https://plantuml.com"
}
},
"bundle": {
"active": true,
"targets": ["nsis", "msi"],
"icon": [
"../assets/icons/32x32.png",
"../assets/icons/128x128.png",
"../assets/icons/128x128@2x.png",
"../assets/icons/icon.icns",
"../assets/icons/icon.ico"
],
"fileAssociations": [
{
"ext": ["md", "markdown"],
"name": "Markdown Document",
"description": "Markdown Document",
"role": "Editor",
"mimeType": "text/markdown"
},
{
"ext": ["pdf"],
"name": "PDF Document",
"description": "PDF Document",
"role": "Viewer",
"mimeType": "application/pdf"
}
],
"resources": {},
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "",
"nsis": {
"languages": ["English", "German", "French", "Spanish", "Portuguese", "Japanese", "Korean", "SimpChinese", "TradChinese"],
"displayLanguageSelector": true,
"installMode": "currentUser"
}
}
},
"plugins": {
"shell": {
"open": true,
"scope": [
{ "name": "pandoc", "cmd": "pandoc", "args": true },
{ "name": "ffmpeg", "cmd": "ffmpeg", "args": true }
]
}
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
* Implements file system operations for Electron using IPC.
* This abstracts file operations to enable easier testing and migration.
*
* @version 4.3.0
* @version 4.4.1
*/
/**
+1 -1
View File
@@ -5,7 +5,7 @@
* Adapters abstract file system, conversion, and system operations
* to enable easier testing and future platform migration.
*
* @version 4.3.0
* @version 4.4.1
*/
/**
+5
View File
@@ -59,6 +59,11 @@ const jetBrainsMonoTheme = EditorView.theme({
* @returns {EditorView} the created editor view
*/
function createEditor(parentElement, options = {}) {
console.log('[createEditor] Called with parentElement:', parentElement?.id, 'dimensions:', parentElement?.clientWidth, 'x', parentElement?.clientHeight);
if (!parentElement) {
console.error('[createEditor] ERROR: parentElement is null or undefined!');
return null;
}
const {
content = '',
onChange = () => {},
+4 -1
View File
@@ -2,6 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<!-- CSP: unsafe-inline/unsafe-eval required for marked.js extensions and Mermaid -->
<!-- TODO: Migrate to nonce-based CSP for better security -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://www.plantuml.com;">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MarkdownConverter</title>
<!-- Design tokens - loaded first for CSS variable availability -->
@@ -1602,6 +1605,6 @@
</div>
<script src="utils/ModalManager.js"></script>
<script type="module" src="./renderer.js"></script>
<script src="renderer.js"></script>
</body>
</html>
+1 -1
View File
@@ -9,7 +9,7 @@
* - All IPC channels are explicitly whitelisted
* - Prevents XSS from escalating to full system access
*
* @version 4.3.0
* @version 4.4.1
*/
const { contextBridge, ipcRenderer } = require('electron');
+21 -22
View File
@@ -1,10 +1,9 @@
/**
* MarkdownConverter Renderer Process
* @version 4.3.0
* @version 4.4.1
*/
// Electron removed - using tauri-commands.js
import { file, events, app, exportDoc, git, pdf, dialog } from './tauri-commands.js';
const { ipcRenderer } = require('electron');
const marked = require('marked');
const { markedHighlight } = require('marked-highlight');
const createDOMPurify = require('dompurify');
@@ -501,7 +500,7 @@ class TabManager {
// Notify main process about current file for exports
const tab = this.tabs.get(tabId);
const result = await file.write(path, content);
ipcRenderer.send('set-current-file', tab?.filePath || null);
// Refresh outline panel for new tab content
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
@@ -924,7 +923,7 @@ class TabManager {
// Only auto-save if content has changed since last save
if (tab.lastSavedContent !== tab.content) {
await file.write(tab.filePath, tab.content);
ipcRenderer.send('save-file', { path: tab.filePath, content: tab.content });
tab.lastSavedContent = tab.content;
// Show brief auto-save indicator
@@ -963,7 +962,7 @@ class TabManager {
// Save to localStorage and sync with main process
localStorage.setItem('recentFiles', JSON.stringify(this.recentFiles));
await app.saveRecentFiles(this.recentFiles);
ipcRenderer.send('save-recent-files', this.recentFiles);
}
getRecentFiles() {
@@ -1372,7 +1371,7 @@ class TabManager {
this.updateBreadcrumb();
// Notify main process about current file for exports
// handled via event
ipcRenderer.send('set-current-file', filePath);
console.log('File opened successfully');
}
@@ -1489,7 +1488,7 @@ document.addEventListener('DOMContentLoaded', async () => {
sidebarManager.registerPanel('explorer', {
title: 'Explorer',
render: (container) => getRenderExplorerPanel()(container, {
listDirectory: (dir) => file.listDirectory(dir),
listDirectory: (dir) => ipcRenderer.invoke('list-directory', dir),
onFileOpen: (filePath) => ipcRenderer.send('open-file-path', filePath),
currentDir: explorerCurrentDir,
})
@@ -1497,11 +1496,11 @@ document.addEventListener('DOMContentLoaded', async () => {
sidebarManager.registerPanel('git', {
title: 'Git',
render: (container) => getRenderGitPanel()(container, {
gitStatus: () => git.status(tab?.filePath ? path.dirname(tab.filePath) : null),
gitDiff: (file) => git.diff(path.dirname(file) || null, file),
gitStage: (files) => git.stage(path.dirname(files[0]) || null, files),
gitCommit: (message) => git.commit(getGitRepoPath(), message),
gitLog: () => git.log(getGitRepoPath()),
gitStatus: () => ipcRenderer.invoke('git-status'),
gitDiff: (file) => ipcRenderer.invoke('git-diff', { file }),
gitStage: (files) => ipcRenderer.invoke('git-stage', { files }),
gitCommit: (message) => ipcRenderer.invoke('git-commit', { message }),
gitLog: () => ipcRenderer.invoke('git-log'),
})
});
sidebarManager.registerPanel('snippets', {
@@ -1516,7 +1515,7 @@ document.addEventListener('DOMContentLoaded', async () => {
sidebarManager.registerPanel('templates', {
title: 'Templates',
render: (container) => getRenderTemplatesPanel()(container, async (file) => {
const templateContent = await ipcRenderer.invoke('load-template', file),
const templateContent = await ipcRenderer.invoke('load-template', file);
if (templateContent) {
const content = templateContent.replace(/\{\{DATE\}\}/g, new Date().toISOString().split('T')[0]);
tabManager.createNewTab();
@@ -1837,11 +1836,11 @@ document.addEventListener('DOMContentLoaded', async () => {
});
// IPC event listeners
events.onFileNew(() => {
ipcRenderer.on('file-new', () => {
tabManager.createNewTab();
});
events.onFileOpened((event, data) => {
ipcRenderer.on('file-opened', (event, data) => {
console.log('[RENDERER] file-opened received:', data.path, 'content length:', data.content.length);
if (tabManager) {
tabManager.openFile(data.path, data.content);
@@ -1850,7 +1849,7 @@ events.onFileOpened((event, data) => {
}
});
events.onFileSave(() => {
ipcRenderer.on('file-save', () => {
const currentContent = tabManager.getCurrentContent();
const currentFilePath = tabManager.getCurrentFilePath();
// send to main process which will save or trigger save-as dialog
@@ -1878,12 +1877,12 @@ ipcRenderer.on('get-content-for-spreadsheet', (event, format) => {
ipcRenderer.send('export-spreadsheet', { content: currentContent, format });
});
events.onTogglePreview(() => {
ipcRenderer.on('toggle-preview', () => {
tabManager.isPreviewVisible = !tabManager.isPreviewVisible;
tabManager.updatePreviewVisibility();
});
events.onToggleFind(() => {
ipcRenderer.on('toggle-find', () => {
if (window.modals.findModal.isOpen()) {
window.modals.findModal.close();
} else {
@@ -1892,7 +1891,7 @@ events.onToggleFind(() => {
}
});
events.onThemeChanged((event, theme) => {
ipcRenderer.on('theme-changed', (event, theme) => {
console.log('[RENDERER] Theme changed to:', theme);
document.body.className = `theme-${theme}`;
@@ -1905,7 +1904,7 @@ events.onThemeChanged((event, theme) => {
});
// Undo/Redo handlers — delegate to CodeMirror's built-in history
events.onUndo(() => {
ipcRenderer.on('undo', () => {
if (tabManager) {
const tab = tabManager.tabs.get(tabManager.activeTabId);
if (tab?.editorView) {
@@ -1914,7 +1913,7 @@ events.onUndo(() => {
}
});
events.onRedo(() => {
ipcRenderer.on('redo', () => {
if (tabManager) {
const tab = tabManager.tabs.get(tabManager.activeTabId);
if (tab?.editorView) {
-96
View File
@@ -1,96 +0,0 @@
// Tauri Commands Bridge for MarkdownConverter
// Replaces preload.js — uses Tauri's invoke() instead of ipcRenderer
const { invoke } = window.__TAURI__.core;
const { listen } = window.__TAURI__.event;
// ============================================
// FILE OPERATIONS
// ============================================
export const file = {
read: (path) => invoke('read_file', { path }),
write: (path, content) => invoke('write_file', { path, content }),
delete: (path) => invoke('delete_file', { path }),
exists: (path) => invoke('path_exists', { path }),
isDirectory: (path) => invoke('is_directory', { path }),
listDirectory: (path) => invoke('list_directory', { path }),
ensureDir: (path) => invoke('ensure_directory', { path }),
copy: (source, destination) => invoke('copy_path', { source, destination }),
move: (source, destination) => invoke('move_path', { source, destination }),
};
// ============================================
// EXPORT OPERATIONS
// ============================================
export const exportDoc = {
markdown: (inputPath, outputPath, format, options) =>
invoke('export_markdown', { inputPath, outputPath, format, options }),
checkPandoc: () => invoke('check_pandoc_available', {}),
};
// ============================================
// GIT OPERATIONS
// ============================================
export const git = {
status: (repoPath) => invoke('git_status', { repoPath }),
stage: (repoPath, path) => invoke('git_stage', { repoPath, path }),
commit: (repoPath, message) => invoke('git_commit', { repoPath, message }),
log: (repoPath, limit) => invoke('git_log', { repoPath, limit }),
diff: (repoPath, path) => invoke('git_diff', { repoPath, path }),
};
// ============================================
// APP OPERATIONS
// ============================================
export const app = {
getVersion: () => invoke('get_app_version'),
getRecentFiles: () => invoke('get_recent_files'),
saveRecentFiles: (files) => invoke('save_recent_files', { files }),
};
// ============================================
// PDF OPERATIONS
// ============================================
export const pdf = {
getPageCount: (path) => invoke('get_pdf_page_count', { path }),
merge: (paths, output) => invoke('merge_pdfs', { paths, output }),
split: (input, outputDir, pagesPerSplit) => invoke('split_pdf', { input, outputDir, pagesPerSplit }),
rotate: (input, output, degrees) => invoke('rotate_pdf', { input, output, degrees }),
deletePages: (input, output, pages) => invoke('delete_pdf_pages', { input, output, pages }),
};
// ============================================
// DIALOG OPERATIONS
// ============================================
export const dialog = {
openFile: () => invoke('open_file_dialog', {}),
saveFile: (defaultName) => invoke('save_file_dialog', { defaultName }),
selectFolder: () => invoke('select_folder_dialog', {}),
};
// ============================================
// EVENT LISTENERS
// ============================================
export const events = {
onFileNew: (callback) => listen('file-new', callback),
onFileOpened: (callback) => listen('file-opened', callback),
onFileSave: (callback) => listen('file-save', callback),
onTogglePreview: (callback) => listen('toggle-preview', callback),
onToggleFind: (callback) => listen('toggle-find', callback),
onUndo: (callback) => listen('undo', callback),
onRedo: (callback) => listen('redo', callback),
onThemeChanged: (callback) => listen('theme-changed', callback),
onToggleCommandPalette: (callback) => listen('toggle-command-palette', callback),
onToggleSidebarPanel: (callback) => listen('toggle-sidebar-panel', callback),
onToggleBottomPanel: (callback) => listen('toggle-bottom-panel', callback),
onConversionComplete: (callback) => listen('conversion-complete', callback),
onConversionStatus: (callback) => listen('conversion-status', callback),
};
// ============================================
// PLUGIN OPERATIONS
// ============================================
export const plugins = {
list: () => invoke('list_plugins', {}),
get: (name) => invoke('get_plugin', { name }),
};
+3 -3
View File
@@ -1,6 +1,6 @@
/**
* ModalManager - Unified modal system with accessibility support
* @version 4.3.0
* @version 4.4.1
*/
class ModalManager {
#modal;
@@ -237,8 +237,8 @@ class ModalManager {
}
}
// Export for use in renderer
if (typeof window !== 'undefined') {
// Export for use in renderer - avoid duplicate declaration
if (typeof window !== 'undefined' && !window.ModalManager) {
window.ModalManager = ModalManager;
}
-15
View File
@@ -1,15 +0,0 @@
import { defineConfig } from 'vite';
export default defineConfig({
root: '.',
base: './',
build: {
outDir: '../dist',
emptyOutDir: true,
},
server: {
port: 1420,
strictPort: true,
},
clearScreen: false,
});
+123
View File
@@ -0,0 +1,123 @@
/**
* Git Operations Utilities Test Suite
* Tests for async patterns and error handling
* @version 4.3.0
*/
describe('GitOperations Utilities', () => {
describe('error handling patterns', () => {
it('should handle git errors gracefully', () => {
const errorResponse = { error: 'Not a git repository' };
expect(errorResponse).toHaveProperty('error');
expect(errorResponse.error).toContain('repository');
});
it('should return error object on failure', () => {
const failureResult = { error: 'Failed to commit' };
expect(failureResult).toBeDefined();
expect(failureResult.error).toBeTruthy();
});
});
describe('async patterns', () => {
it('should handle async operations', async () => {
const asyncFn = async () => {
return { status: 'success' };
};
const result = await asyncFn();
expect(result).toHaveProperty('status');
});
it('should handle async errors', async () => {
const asyncFnWithError = async () => {
throw new Error('Git operation failed');
};
try {
await asyncFnWithError();
expect(true).toBe(false); // Should not reach here
} catch (err) {
expect(err.message).toContain('Git');
}
});
});
describe('git operations', () => {
it('should document expected operations', () => {
const gitOps = [
'getStatus',
'stage',
'commit',
'log',
'diff',
'branch',
'checkout',
'push',
'pull'
];
expect(gitOps.length).toBeGreaterThan(0);
gitOps.forEach(op => {
expect(typeof op).toBe('string');
expect(op.length).toBeGreaterThan(0);
});
});
it('should handle directory paths', () => {
const paths = [
'/home/user/project',
'./current/dir',
'../parent/dir'
];
paths.forEach(pathStr => {
expect(typeof pathStr).toBe('string');
expect(pathStr.length).toBeGreaterThan(0);
});
});
it('should handle commit messages', () => {
const messages = [
'fix: bug in git panel',
'feat: add new feature',
'refactor: clean up code'
];
messages.forEach(msg => {
expect(typeof msg).toBe('string');
expect(msg.length).toBeGreaterThan(0);
});
});
});
describe('response structures', () => {
it('should return status info', () => {
const statusResponse = {
conflicted: [],
created: [],
deleted: [],
modified: [],
renamed: [],
staged: ['file.md']
};
expect(statusResponse).toHaveProperty('staged');
expect(Array.isArray(statusResponse.staged)).toBe(true);
});
it('should return log entries', () => {
const logResponse = {
all: [
{ hash: 'abc123', message: 'fix: something' }
],
latest: { hash: 'abc123', message: 'fix: something' }
};
expect(logResponse).toHaveProperty('all');
expect(Array.isArray(logResponse.all)).toBe(true);
});
});
});
+95
View File
@@ -0,0 +1,95 @@
/**
* PDF Operations Utilities Test Suite
* Tests for helper functions and logic patterns
* @version 4.3.0
*/
describe('PDFOperations Utilities', () => {
describe('page range parsing', () => {
it('should parse single page numbers', () => {
// Test logic: parsing "1" should extract page index 0
const input = '1';
const pages = [0]; // Parsed result
expect(pages.length).toBe(1);
expect(pages[0]).toBe(0);
});
it('should parse page ranges', () => {
// Test logic: parsing "1-3" should extract pages 0, 1, 2
const input = '1-3';
const pages = [0, 1, 2]; // Expected result
expect(pages.length).toBe(3);
expect(pages).toEqual([0, 1, 2]);
});
it('should handle multiple ranges', () => {
// Test logic: parsing "1-2,4-5" should extract pages 0,1,3,4
const input = '1-2,4-5';
const pages = [0, 1, 3, 4]; // Expected result
expect(pages.length).toBe(4);
expect(pages).toEqual([0, 1, 3, 4]);
});
it('should sort pages in ascending order', () => {
const unsorted = [2, 0, 3, 1];
const sorted = unsorted.sort((a, b) => a - b);
expect(sorted).toEqual([0, 1, 2, 3]);
});
});
describe('hex color conversion', () => {
it('should validate hex color format', () => {
const validHex = '#FF5733';
const isValid = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.test(validHex);
expect(isValid).toBe(true);
});
it('should detect invalid hex colors', () => {
const invalidColors = ['#GG5733', '#12345', 'notahex'];
invalidColors.forEach(color => {
const isValid = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.test(color);
expect(isValid).toBe(false);
});
});
});
describe('error handling', () => {
it('should handle empty input', () => {
const input = '';
expect(input.length).toBe(0);
// Should not process empty strings
expect(input === '').toBe(true);
});
it('should handle invalid page numbers', () => {
const testPages = [
{ input: '0', isValid: false },
{ input: '-1', isValid: false },
{ input: 'abc', isValid: false },
{ input: '1', isValid: true },
{ input: '5', isValid: true }
];
testPages.forEach(({ input, isValid }) => {
const num = parseInt(input);
if (isNaN(num)) {
// Non-numeric string
expect(isValid).toBe(false);
} else if (num < 1) {
// Zero or negative
expect(isValid).toBe(false);
} else {
// Positive number
expect(isValid).toBe(true);
}
});
});
});
});
+114
View File
@@ -0,0 +1,114 @@
/**
* Security: Path Traversal Prevention Test Suite
* @version 4.3.0
*/
const path = require('path');
describe('Security: Path Handling', () => {
describe('path traversal prevention', () => {
it('should detect path traversal patterns', () => {
const maliciousPaths = [
'../etc/passwd',
'../../sensitive',
'./../outside'
];
maliciousPaths.forEach(pathStr => {
// Path traversal attempts contain .. patterns
expect(pathStr).toMatch(/\.\./);
});
});
it('should normalize relative paths safely', () => {
const safePaths = [
'./documents/file.md',
'relative/path/file.txt'
];
safePaths.forEach(pathStr => {
const normalized = path.normalize(pathStr);
// Safe relative paths should normalize cleanly
expect(normalized).toBeDefined();
expect(typeof normalized).toBe('string');
});
});
it('should detect absolute paths', () => {
const absolutePath = '/etc/passwd';
const isAbsolute = path.isAbsolute(absolutePath);
// Linux/Mac: /path is absolute
if (process.platform !== 'win32') {
expect(isAbsolute).toBe(true);
}
});
it('should safely join paths with base directory', () => {
const baseDir = '/safe/base/directory';
const userInput = 'documents/file.md';
const joined = path.join(baseDir, userInput);
// Result should contain the safe base
expect(joined).toContain('base');
expect(joined).toContain('documents');
});
});
describe('filename safety', () => {
it('should identify safe filenames', () => {
const safeNames = [
'document.md',
'my-file.txt',
'file_name.pdf',
'report_2026_04_24.xlsx'
];
safeNames.forEach(name => {
// Safe names should not contain path separators or null bytes
const isSafe = !/[\\/\0]/.test(name) && name.length > 0;
expect(isSafe).toBe(true);
});
});
it('should flag filenames with path separators', () => {
const problematicNames = [
'file/with/slashes.txt',
'file\\with\\backslashes.txt'
];
problematicNames.forEach(name => {
// These contain path separators and should be flagged
const hasPathSeparators = /[\\/]/.test(name);
expect(hasPathSeparators).toBe(true);
});
});
it('should enforce minimum filename length', () => {
const emptyName = '';
expect(emptyName.length).toBe(0);
expect(emptyName.length > 0).toBe(false);
});
});
describe('validation patterns', () => {
it('should validate path existence check pattern', () => {
const validationPattern = /^[a-zA-Z0-9._\-/]+$/;
const validPaths = ['documents/file.md', 'folder_2026/data.csv'];
validPaths.forEach(pathStr => {
// These should match a reasonable filename pattern
expect(typeof pathStr).toBe('string');
});
});
it('should prevent null byte injection', () => {
const pathWithNullByte = 'file.txt\0.exe';
const isSafe = !pathWithNullByte.includes('\0');
expect(isSafe).toBe(false); // Has null byte, not safe
});
});
});