diff --git a/package.json b/package.json index ec91ff3..8e2655f 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,11 @@ "dist": "electron-builder --publish=never", "dist:all": "electron-builder -mwl", "download-tools": "node scripts/download-tools.js", - "generate-icons": "node scripts/generate-icons.js" + "generate-icons": "node scripts/generate-icons.js", + "tauri": "tauri", + "tauri:dev": "tauri dev", + "tauri:build": "tauri build", + "tauri:debug": "tauri build --debug" }, "keywords": [ "markdown", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..f5837e7 --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "markdown-converter" +version = "4.3.0" +description = "Professional Markdown editor and universal file converter" +authors = ["ConcreteInfo "] +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 diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs new file mode 100644 index 0000000..f117d84 --- /dev/null +++ b/src-tauri/src/commands/app.rs @@ -0,0 +1,34 @@ +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, 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 = serde_json::from_str(&content).unwrap_or_default(); + Ok(files) + } else { + Ok(Vec::new()) + } +} + +#[tauri::command] +pub fn save_recent_files(files: Vec) -> 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(()) +} diff --git a/src-tauri/src/commands/dialog.rs b/src-tauri/src/commands/dialog.rs new file mode 100644 index 0000000..64fbe16 --- /dev/null +++ b/src-tauri/src/commands/dialog.rs @@ -0,0 +1,28 @@ +use tauri_plugin_dialog::DialogExt; +use tauri::CommandResult; +use crate::error::AppError; + +#[tauri::command] +pub async fn open_file_dialog(app: tauri::AppHandle) -> CommandResult, 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) -> CommandResult, 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, AppError> { + let folder = app.dialog().file().blocking_pick_folder(); + Ok(folder.map(|p| p.to_string())) +} diff --git a/src-tauri/src/commands/export.rs b/src-tauri/src/commands/export.rs new file mode 100644 index 0000000..3625c12 --- /dev/null +++ b/src-tauri/src/commands/export.rs @@ -0,0 +1,63 @@ +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 { + 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, + pub template: Option, +} + +#[tauri::command] +pub async fn check_pandoc_available() -> CommandResult { + let output = Command::new("pandoc") + .arg("--version") + .output(); + Ok(output.is_ok()) +} diff --git a/src-tauri/src/commands/file.rs b/src-tauri/src/commands/file.rs new file mode 100644 index 0000000..0cfaf47 --- /dev/null +++ b/src-tauri/src/commands/file.rs @@ -0,0 +1,72 @@ +use std::fs; +use std::path::Path; +use tauri::CommandResult; +use crate::error::AppError; + +#[tauri::command] +pub async fn read_file(path: String) -> CommandResult { + 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, 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, +} diff --git a/src-tauri/src/commands/git.rs b/src-tauri/src/commands/git.rs new file mode 100644 index 0000000..4a53b1d --- /dev/null +++ b/src-tauri/src/commands/git.rs @@ -0,0 +1,113 @@ +use git2::{Repository, Status, DiffOptions}; +use tauri::CommandResult; +use crate::error::AppError; + +#[tauri::command] +pub async fn git_status(repo_path: String) -> CommandResult, AppError> { + let repo = Repository::open(&repo_path).map_err(|e| AppError::Git(e.to_string()))?; + let mut statuses = Vec::new(); + + repo.statuses(None).map_err(|e| AppError::Git(e.to_string()))?.foreach(|entry| { + if let Some(status) = entry.status() { + let path = entry.path().unwrap_or("").to_string(); + statuses.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), + }); + } + true + }).map_err(|e| AppError::Git(e.to_string()))?; + + Ok(statuses) +} + +#[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 { + 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) -> CommandResult, 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) -> CommandResult { + 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, _origin, 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("")) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs new file mode 100644 index 0000000..a266ef6 --- /dev/null +++ b/src-tauri/src/commands/mod.rs @@ -0,0 +1,6 @@ +pub mod file; +pub mod export; +pub mod git; +pub mod app; +pub mod pdf; +pub mod dialog; diff --git a/src-tauri/src/commands/pdf.rs b/src-tauri/src/commands/pdf.rs new file mode 100644 index 0000000..31a3556 --- /dev/null +++ b/src-tauri/src/commands/pdf.rs @@ -0,0 +1,28 @@ +use tauri::CommandResult; +use crate::error::AppError; + +#[tauri::command] +pub async fn get_pdf_page_count(path: String) -> CommandResult { + // Placeholder — actual implementation in pdf_ops.rs + Ok(0) +} + +#[tauri::command] +pub async fn merge_pdfs(paths: Vec, output: String) -> CommandResult { + Ok(output) +} + +#[tauri::command] +pub async fn split_pdf(input: String, output_dir: String, pages_per_split: usize) -> CommandResult, AppError> { + Ok(Vec::new()) +} + +#[tauri::command] +pub async fn rotate_pdf(input: String, output: String, degrees: i32) -> CommandResult<(), AppError> { + Ok(()) +} + +#[tauri::command] +pub async fn delete_pdf_pages(input: String, output: String, pages: Vec) -> CommandResult<(), AppError> { + Ok(()) +} diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs new file mode 100644 index 0000000..52d6703 --- /dev/null +++ b/src-tauri/src/error.rs @@ -0,0 +1,32 @@ +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(&self, serializer: S) -> Result + where + S: serde::ser::Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000..e8ae4bc --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,53 @@ +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, + ]) + .setup(|app| { + log::info!("MarkdownConverter starting up"); + 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"); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..bfb1bf4 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,5 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + markdown_converter_lib::run() +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..692804b --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,56 @@ +{ + "$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" + ], + "resources": {}, + "windows": { + "certificateThumbprint": null, + "digestAlgorithm": "sha256", + "timestampUrl": "" + } + }, + "plugins": { + "shell": { + "open": true, + "scope": [ + { "name": "pandoc", "cmd": "pandoc", "args": true }, + { "name": "ffmpeg", "cmd": "ffmpeg", "args": true } + ] + } + } +}