Initial commit: md-manuscript editor
Rust/egui desktop app: draggable markdown file list, editor, git sync, per-file chapter titles, and native ODT export. Includes README and Debian 12 (Surface) install guide. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+736
@@ -0,0 +1,736 @@
|
||||
use crate::config::Config;
|
||||
use crate::gitsync;
|
||||
use crate::odt::{self, Chapter};
|
||||
use crate::order;
|
||||
use eframe::egui;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub struct App {
|
||||
config: Config,
|
||||
/// Ordered markdown file names (relative to the workspace).
|
||||
files: Vec<String>,
|
||||
/// Per-file chapter title overrides (file name -> title). Missing/empty means
|
||||
/// the title is derived automatically at export time.
|
||||
titles: HashMap<String, String>,
|
||||
/// Editing buffer for the selected file's chapter title override.
|
||||
title_input: String,
|
||||
selected: Option<usize>,
|
||||
/// Editor contents for the selected file.
|
||||
buffer: String,
|
||||
dirty: bool,
|
||||
/// Editable copy of the workspace path shown in the top bar.
|
||||
workspace_input: String,
|
||||
/// Editable copy of the export path.
|
||||
export_input: String,
|
||||
new_name: String,
|
||||
rename_input: String,
|
||||
pending_delete: bool,
|
||||
status: String,
|
||||
show_log: bool,
|
||||
git_log: String,
|
||||
is_repo: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
|
||||
let config = Config::load();
|
||||
let mut app = App {
|
||||
workspace_input: config.workspace.display().to_string(),
|
||||
export_input: config.export_path.display().to_string(),
|
||||
config,
|
||||
files: Vec::new(),
|
||||
titles: HashMap::new(),
|
||||
title_input: String::new(),
|
||||
selected: None,
|
||||
buffer: String::new(),
|
||||
dirty: false,
|
||||
new_name: String::new(),
|
||||
rename_input: String::new(),
|
||||
pending_delete: false,
|
||||
status: String::new(),
|
||||
show_log: false,
|
||||
git_log: String::new(),
|
||||
is_repo: false,
|
||||
};
|
||||
app.open_workspace();
|
||||
app
|
||||
}
|
||||
|
||||
fn workspace(&self) -> &Path {
|
||||
&self.config.workspace
|
||||
}
|
||||
|
||||
/// (Re)load the file list for the current workspace, creating the directory
|
||||
/// if needed, and refresh git status.
|
||||
fn open_workspace(&mut self) {
|
||||
let ws = self.config.workspace.clone();
|
||||
if let Err(e) = std::fs::create_dir_all(&ws) {
|
||||
self.status = format!("Cannot create workspace: {e}");
|
||||
return;
|
||||
}
|
||||
self.files = order::resolve_order(&ws);
|
||||
self.titles = order::read_titles(&ws);
|
||||
// Drop overrides for files that no longer exist.
|
||||
self.titles.retain(|name, _| self.files.contains(name));
|
||||
self.is_repo = gitsync::is_repo(&ws);
|
||||
self.selected = None;
|
||||
self.buffer.clear();
|
||||
self.dirty = false;
|
||||
self.pending_delete = false;
|
||||
if !self.files.is_empty() {
|
||||
self.select(0);
|
||||
}
|
||||
self.persist_order();
|
||||
self.status = format!("{} file(s) in {}", self.files.len(), ws.display());
|
||||
}
|
||||
|
||||
fn persist_order(&self) {
|
||||
let _ = order::write_order(self.workspace(), &self.files);
|
||||
}
|
||||
|
||||
fn persist_titles(&self) {
|
||||
let _ = order::write_titles(self.workspace(), &self.titles);
|
||||
}
|
||||
|
||||
/// Store the title override for the selected file from `title_input`.
|
||||
/// An empty value removes the override (falls back to the auto title).
|
||||
fn set_title_for_current(&mut self) {
|
||||
if let Some(idx) = self.selected {
|
||||
if let Some(name) = self.files.get(idx).cloned() {
|
||||
let trimmed = self.title_input.trim();
|
||||
if trimmed.is_empty() {
|
||||
self.titles.remove(&name);
|
||||
} else {
|
||||
self.titles.insert(name, trimmed.to_string());
|
||||
}
|
||||
self.persist_titles();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn path_for(&self, name: &str) -> PathBuf {
|
||||
self.workspace().join(name)
|
||||
}
|
||||
|
||||
/// Save the in-memory buffer to disk if it has unsaved changes.
|
||||
fn save_current(&mut self) {
|
||||
if let Some(idx) = self.selected {
|
||||
if self.dirty {
|
||||
if let Some(name) = self.files.get(idx) {
|
||||
let path = self.path_for(name);
|
||||
match std::fs::write(&path, &self.buffer) {
|
||||
Ok(_) => {
|
||||
self.dirty = false;
|
||||
self.status = format!("Saved {name}");
|
||||
}
|
||||
Err(e) => self.status = format!("Save failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn select(&mut self, idx: usize) {
|
||||
if self.selected == Some(idx) {
|
||||
return;
|
||||
}
|
||||
self.save_current();
|
||||
if let Some(name) = self.files.get(idx) {
|
||||
let path = self.path_for(name);
|
||||
self.buffer = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
self.selected = Some(idx);
|
||||
self.dirty = false;
|
||||
self.pending_delete = false;
|
||||
self.rename_input = Path::new(name)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
self.title_input = self.titles.get(name).cloned().unwrap_or_default();
|
||||
}
|
||||
}
|
||||
|
||||
fn create_file(&mut self) {
|
||||
let mut stem = self.new_name.trim().to_string();
|
||||
if stem.is_empty() {
|
||||
self.status = "Enter a name for the new file".to_string();
|
||||
return;
|
||||
}
|
||||
if stem.to_lowercase().ends_with(".md") {
|
||||
stem.truncate(stem.len() - 3);
|
||||
}
|
||||
let name = format!("{stem}.md");
|
||||
let path = self.path_for(&name);
|
||||
if path.exists() {
|
||||
self.status = format!("{name} already exists");
|
||||
return;
|
||||
}
|
||||
let seed = format!("# {stem}\n\n");
|
||||
match std::fs::write(&path, seed) {
|
||||
Ok(_) => {
|
||||
self.files.push(name.clone());
|
||||
self.persist_order();
|
||||
let idx = self.files.len() - 1;
|
||||
self.selected = None; // force reload of buffer
|
||||
self.select(idx);
|
||||
self.new_name.clear();
|
||||
self.status = format!("Created {name}");
|
||||
}
|
||||
Err(e) => self.status = format!("Create failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_selected(&mut self) {
|
||||
if let Some(idx) = self.selected {
|
||||
if let Some(name) = self.files.get(idx).cloned() {
|
||||
let path = self.path_for(&name);
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(_) => {
|
||||
self.files.remove(idx);
|
||||
self.titles.remove(&name);
|
||||
self.persist_titles();
|
||||
self.persist_order();
|
||||
self.selected = None;
|
||||
self.buffer.clear();
|
||||
self.dirty = false;
|
||||
if !self.files.is_empty() {
|
||||
self.select(idx.min(self.files.len() - 1));
|
||||
}
|
||||
self.status = format!("Deleted {name}");
|
||||
}
|
||||
Err(e) => self.status = format!("Delete failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
self.pending_delete = false;
|
||||
}
|
||||
|
||||
fn rename_selected(&mut self) {
|
||||
let Some(idx) = self.selected else { return };
|
||||
let mut stem = self.rename_input.trim().to_string();
|
||||
if stem.to_lowercase().ends_with(".md") {
|
||||
stem.truncate(stem.len() - 3);
|
||||
}
|
||||
if stem.is_empty() {
|
||||
self.status = "Enter a new name".to_string();
|
||||
return;
|
||||
}
|
||||
let new_name = format!("{stem}.md");
|
||||
let Some(old_name) = self.files.get(idx).cloned() else {
|
||||
return;
|
||||
};
|
||||
if new_name == old_name {
|
||||
return;
|
||||
}
|
||||
let new_path = self.path_for(&new_name);
|
||||
if new_path.exists() {
|
||||
self.status = format!("{new_name} already exists");
|
||||
return;
|
||||
}
|
||||
// Persist any pending edits under the old name first.
|
||||
self.save_current();
|
||||
match std::fs::rename(self.path_for(&old_name), &new_path) {
|
||||
Ok(_) => {
|
||||
self.files[idx] = new_name.clone();
|
||||
if let Some(title) = self.titles.remove(&old_name) {
|
||||
self.titles.insert(new_name.clone(), title);
|
||||
self.persist_titles();
|
||||
}
|
||||
self.persist_order();
|
||||
self.status = format!("Renamed to {new_name}");
|
||||
}
|
||||
Err(e) => self.status = format!("Rename failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn git_init(&mut self) {
|
||||
let outcome = gitsync::init(self.workspace());
|
||||
self.git_log = outcome.log;
|
||||
self.show_log = true;
|
||||
self.is_repo = gitsync::is_repo(self.workspace());
|
||||
self.status = if self.is_repo {
|
||||
"Initialised git repository".to_string()
|
||||
} else {
|
||||
"git init failed (see log)".to_string()
|
||||
};
|
||||
}
|
||||
|
||||
fn git_sync(&mut self) {
|
||||
self.save_current();
|
||||
self.persist_order();
|
||||
self.persist_titles();
|
||||
let msg = format!(
|
||||
"Sync manuscript {}",
|
||||
chrono_like_timestamp()
|
||||
);
|
||||
let outcome = gitsync::sync(self.workspace(), &msg);
|
||||
self.git_log = outcome.log;
|
||||
self.show_log = true;
|
||||
self.status = if outcome.ok {
|
||||
"Sync complete".to_string()
|
||||
} else {
|
||||
"Sync finished with errors (see log)".to_string()
|
||||
};
|
||||
}
|
||||
|
||||
fn export_odt(&mut self) {
|
||||
self.save_current();
|
||||
let mut chapters = Vec::new();
|
||||
for name in &self.files {
|
||||
let markdown = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
|
||||
// The leading `# heading` is always consumed as the chapter-title
|
||||
// slot; a manual override, if set, replaces the derived title.
|
||||
let (auto_title, body) = split_title(&markdown, name);
|
||||
let title = self
|
||||
.titles
|
||||
.get(name)
|
||||
.map(|t| t.trim())
|
||||
.filter(|t| !t.is_empty())
|
||||
.map(|t| t.to_string())
|
||||
.unwrap_or(auto_title);
|
||||
chapters.push(Chapter {
|
||||
title,
|
||||
markdown: body,
|
||||
});
|
||||
}
|
||||
let out = PathBuf::from(self.export_input.trim());
|
||||
if let Some(parent) = out.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
match odt::export(&chapters, &out) {
|
||||
Ok(_) => {
|
||||
self.config.export_path = out.clone();
|
||||
self.config.save();
|
||||
self.status = format!("Exported {} chapter(s) to {}", chapters.len(), out.display());
|
||||
}
|
||||
Err(e) => self.status = format!("Export failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn reorder(&mut self, from: usize, mut to: usize) {
|
||||
if from >= self.files.len() || from == to {
|
||||
return;
|
||||
}
|
||||
// Remember the selected file by name so selection follows the move.
|
||||
let selected_name = self.selected.and_then(|i| self.files.get(i)).cloned();
|
||||
|
||||
let item = self.files.remove(from);
|
||||
if from < to {
|
||||
to -= 1;
|
||||
}
|
||||
to = to.min(self.files.len());
|
||||
self.files.insert(to, item);
|
||||
self.persist_order();
|
||||
|
||||
if let Some(name) = selected_name {
|
||||
self.selected = self.files.iter().position(|n| *n == name);
|
||||
}
|
||||
self.status = "Reordered".to_string();
|
||||
}
|
||||
|
||||
// ---- UI ----------------------------------------------------------------
|
||||
|
||||
fn top_bar(&mut self, ctx: &egui::Context) {
|
||||
egui::TopBottomPanel::top("top").show(ctx, |ui| {
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Workspace:");
|
||||
let resp = ui.add(
|
||||
egui::TextEdit::singleline(&mut self.workspace_input)
|
||||
.desired_width(360.0),
|
||||
);
|
||||
if ui.button("Open").clicked()
|
||||
|| (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
|
||||
{
|
||||
self.save_current();
|
||||
self.config.workspace = PathBuf::from(self.workspace_input.trim());
|
||||
self.config.save();
|
||||
self.open_workspace();
|
||||
}
|
||||
ui.separator();
|
||||
if self.is_repo {
|
||||
if ui.button("⟳ Sync (git)").clicked() {
|
||||
self.git_sync();
|
||||
}
|
||||
} else if ui.button("Init git").clicked() {
|
||||
self.git_init();
|
||||
}
|
||||
if ui.button("Log").clicked() {
|
||||
self.show_log = !self.show_log;
|
||||
}
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Export:");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.export_input).desired_width(360.0),
|
||||
);
|
||||
if ui.button("Export ODT").clicked() {
|
||||
self.export_odt();
|
||||
}
|
||||
ui.separator();
|
||||
if ui
|
||||
.checkbox(&mut self.config.show_preview, "Preview")
|
||||
.changed()
|
||||
{
|
||||
self.config.save();
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
});
|
||||
|
||||
egui::TopBottomPanel::bottom("status").show(ctx, |ui| {
|
||||
ui.add_space(2.0);
|
||||
ui.horizontal(|ui| {
|
||||
let dirty = if self.dirty { " • unsaved" } else { "" };
|
||||
ui.label(format!("{}{dirty}", self.status));
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
});
|
||||
}
|
||||
|
||||
fn left_pane(&mut self, ctx: &egui::Context) {
|
||||
egui::SidePanel::left("files")
|
||||
.resizable(true)
|
||||
.default_width(260.0)
|
||||
.show(ctx, |ui| {
|
||||
ui.add_space(4.0);
|
||||
ui.heading("Files");
|
||||
ui.label(
|
||||
egui::RichText::new("drag ⠿ to reorder")
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
ui.separator();
|
||||
|
||||
let mut clicked: Option<usize> = None;
|
||||
let mut from_to: Option<(usize, usize)> = None;
|
||||
let pointer = ui.input(|i| i.pointer.interact_pos());
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.max_height(ui.available_height() - 120.0)
|
||||
.show(ui, |ui| {
|
||||
for idx in 0..self.files.len() {
|
||||
let name = self.files[idx].clone();
|
||||
let selected = self.selected == Some(idx);
|
||||
let row = ui
|
||||
.horizontal(|ui| {
|
||||
ui.dnd_drag_source(
|
||||
egui::Id::new(("dnd", &name)),
|
||||
idx,
|
||||
|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new("⠿").monospace().weak(),
|
||||
);
|
||||
},
|
||||
);
|
||||
if ui
|
||||
.add_sized(
|
||||
[ui.available_width(), 20.0],
|
||||
egui::SelectableLabel::new(selected, &name),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
clicked = Some(idx);
|
||||
}
|
||||
})
|
||||
.response;
|
||||
|
||||
// Drop handling: is a dragged item hovering this row?
|
||||
if let Some(_payload) = row.dnd_hover_payload::<usize>() {
|
||||
let rect = row.rect;
|
||||
let before = pointer
|
||||
.map(|p| p.y < rect.center().y)
|
||||
.unwrap_or(true);
|
||||
let y = if before { rect.top() } else { rect.bottom() };
|
||||
ui.painter().hline(
|
||||
rect.x_range(),
|
||||
y,
|
||||
egui::Stroke::new(
|
||||
2.0,
|
||||
ui.visuals().selection.stroke.color,
|
||||
),
|
||||
);
|
||||
if let Some(payload) = row.dnd_release_payload::<usize>() {
|
||||
let target = if before { idx } else { idx + 1 };
|
||||
from_to = Some((*payload, target));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(idx) = clicked {
|
||||
self.select(idx);
|
||||
}
|
||||
if let Some((from, to)) = from_to {
|
||||
self.reorder(from, to);
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.new_name)
|
||||
.hint_text("new file name")
|
||||
.desired_width(150.0),
|
||||
);
|
||||
if ui.button("+ New").clicked() {
|
||||
self.create_file();
|
||||
}
|
||||
});
|
||||
|
||||
if self.selected.is_some() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.rename_input)
|
||||
.hint_text("rename")
|
||||
.desired_width(150.0),
|
||||
);
|
||||
if ui.button("Rename").clicked() {
|
||||
self.rename_selected();
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if !self.pending_delete {
|
||||
if ui.button("🗑 Delete").clicked() {
|
||||
self.pending_delete = true;
|
||||
}
|
||||
} else {
|
||||
ui.label("Delete file?");
|
||||
if ui
|
||||
.button(egui::RichText::new("Yes").color(egui::Color32::RED))
|
||||
.clicked()
|
||||
{
|
||||
self.delete_selected();
|
||||
}
|
||||
if ui.button("No").clicked() {
|
||||
self.pending_delete = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn central(&mut self, ctx: &egui::Context) {
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
match self.selected {
|
||||
Some(idx) => {
|
||||
let name = self.files[idx].clone();
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading(&name);
|
||||
if ui.button("💾 Save").clicked() {
|
||||
self.dirty = true; // ensure save runs
|
||||
self.save_current();
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Chapter title:");
|
||||
let auto = split_title(&self.buffer, &name).0;
|
||||
let resp = ui.add(
|
||||
egui::TextEdit::singleline(&mut self.title_input)
|
||||
.hint_text(format!("auto: {auto}"))
|
||||
.desired_width(320.0),
|
||||
);
|
||||
if resp.changed() {
|
||||
self.set_title_for_current();
|
||||
}
|
||||
ui.label(
|
||||
egui::RichText::new("used as the ODT chapter heading")
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
if self.config.show_preview {
|
||||
// Editor + read-only source-ish preview side by side.
|
||||
let full = ui.available_size();
|
||||
ui.horizontal_top(|ui| {
|
||||
let col_w = full.x / 2.0 - 6.0;
|
||||
ui.allocate_ui(egui::vec2(col_w, full.y), |ui| {
|
||||
self.editor(ui);
|
||||
});
|
||||
ui.separator();
|
||||
ui.allocate_ui(egui::vec2(col_w, full.y), |ui| {
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("preview")
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
render_preview(ui, &self.buffer);
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
self.editor(ui);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ui.centered_and_justified(|ui| {
|
||||
ui.label("Select a file on the left, or create a new one.");
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn editor(&mut self, ui: &mut egui::Ui) {
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("editor")
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
let resp = ui.add(
|
||||
egui::TextEdit::multiline(&mut self.buffer)
|
||||
.code_editor()
|
||||
.desired_width(f32::INFINITY)
|
||||
.desired_rows(30),
|
||||
);
|
||||
if resp.changed() {
|
||||
self.dirty = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// Ctrl+S saves.
|
||||
if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::S)) {
|
||||
self.dirty = true;
|
||||
self.save_current();
|
||||
}
|
||||
|
||||
self.top_bar(ctx);
|
||||
self.left_pane(ctx);
|
||||
|
||||
if self.show_log {
|
||||
egui::TopBottomPanel::bottom("gitlog")
|
||||
.resizable(true)
|
||||
.default_height(160.0)
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("git output").strong());
|
||||
if ui.button("Hide").clicked() {
|
||||
self.show_log = false;
|
||||
}
|
||||
});
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut self.git_log.as_str())
|
||||
.code_editor()
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
self.central(ctx);
|
||||
}
|
||||
|
||||
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
||||
self.save_current();
|
||||
self.persist_order();
|
||||
self.persist_titles();
|
||||
self.config.save();
|
||||
}
|
||||
}
|
||||
|
||||
/// Very small markdown-ish preview (headings emphasised, everything else plain).
|
||||
fn render_preview(ui: &mut egui::Ui, markdown: &str) {
|
||||
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
||||
let parser = Parser::new_ext(markdown, Options::ENABLE_STRIKETHROUGH);
|
||||
let mut heading: Option<HeadingLevel> = None;
|
||||
let mut line = String::new();
|
||||
let mut bold = false;
|
||||
let mut italic = false;
|
||||
|
||||
let flush = |ui: &mut egui::Ui, line: &mut String, heading: &mut Option<HeadingLevel>| {
|
||||
if line.trim().is_empty() {
|
||||
line.clear();
|
||||
*heading = None;
|
||||
return;
|
||||
}
|
||||
let text = line.clone();
|
||||
match heading {
|
||||
Some(HeadingLevel::H1) => {
|
||||
ui.label(egui::RichText::new(text).size(22.0).strong());
|
||||
}
|
||||
Some(HeadingLevel::H2) => {
|
||||
ui.label(egui::RichText::new(text).size(18.0).strong());
|
||||
}
|
||||
Some(_) => {
|
||||
ui.label(egui::RichText::new(text).size(15.0).strong());
|
||||
}
|
||||
None => {
|
||||
ui.label(text);
|
||||
}
|
||||
}
|
||||
line.clear();
|
||||
*heading = None;
|
||||
};
|
||||
|
||||
for event in parser {
|
||||
match event {
|
||||
Event::Start(Tag::Heading { level, .. }) => heading = Some(level),
|
||||
Event::End(TagEnd::Heading(_)) => flush(ui, &mut line, &mut heading),
|
||||
Event::End(TagEnd::Paragraph) => flush(ui, &mut line, &mut heading),
|
||||
Event::Start(Tag::Item) => line.push_str("• "),
|
||||
Event::End(TagEnd::Item) => flush(ui, &mut line, &mut heading),
|
||||
Event::Start(Tag::Strong) => bold = true,
|
||||
Event::End(TagEnd::Strong) => bold = false,
|
||||
Event::Start(Tag::Emphasis) => italic = true,
|
||||
Event::End(TagEnd::Emphasis) => italic = false,
|
||||
Event::Text(t) | Event::Code(t) => {
|
||||
let _ = (bold, italic);
|
||||
line.push_str(&t);
|
||||
}
|
||||
Event::SoftBreak | Event::HardBreak => line.push(' '),
|
||||
Event::Rule => {
|
||||
ui.separator();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
flush(ui, &mut line, &mut heading);
|
||||
}
|
||||
|
||||
/// Extract the chapter title from a markdown document: use the first level-1
|
||||
/// heading if the document starts with one (and drop it from the body so it is
|
||||
/// not duplicated), otherwise fall back to the file name stem.
|
||||
fn split_title(markdown: &str, filename: &str) -> (String, String) {
|
||||
let stem = Path::new(filename)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(filename)
|
||||
.to_string();
|
||||
|
||||
let lines: Vec<&str> = markdown.lines().collect();
|
||||
let mut idx = 0;
|
||||
while idx < lines.len() && lines[idx].trim().is_empty() {
|
||||
idx += 1;
|
||||
}
|
||||
if idx < lines.len() {
|
||||
let l = lines[idx].trim_start();
|
||||
if let Some(rest) = l.strip_prefix("# ") {
|
||||
let title = rest.trim().to_string();
|
||||
let mut body_lines: Vec<&str> = Vec::new();
|
||||
body_lines.extend_from_slice(&lines[..idx]);
|
||||
body_lines.extend_from_slice(&lines[idx + 1..]);
|
||||
return (title, body_lines.join("\n"));
|
||||
}
|
||||
}
|
||||
(stem, markdown.to_string())
|
||||
}
|
||||
|
||||
/// A dependency-free timestamp for commit messages (UTC seconds since epoch).
|
||||
fn chrono_like_timestamp() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
format!("@{secs}")
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Persistent application configuration (which workspace to open, plus UI prefs).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
/// Directory that holds the markdown files and the git repository.
|
||||
pub workspace: PathBuf,
|
||||
/// Last used ODT export path.
|
||||
pub export_path: PathBuf,
|
||||
/// Whether to show the live preview pane.
|
||||
#[serde(default)]
|
||||
pub show_preview: bool,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
let workspace = default_workspace();
|
||||
let export_path = workspace.join("manuscript.odt");
|
||||
Config {
|
||||
workspace,
|
||||
export_path,
|
||||
show_preview: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The dedicated directory where manuscripts live by default: ~/Manuscript.
|
||||
pub fn default_workspace() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("Manuscript")
|
||||
}
|
||||
|
||||
/// Location of the config file: ~/.config/md-manuscript/config.json
|
||||
fn config_path() -> PathBuf {
|
||||
dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("md-manuscript")
|
||||
.join("config.json")
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Config {
|
||||
let path = config_path();
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
|
||||
Err(_) => Config::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self) {
|
||||
let path = config_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
if let Ok(text) = serde_json::to_string_pretty(self) {
|
||||
let _ = std::fs::write(&path, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Result of a git operation: combined human-readable log for display.
|
||||
pub struct GitOutcome {
|
||||
pub ok: bool,
|
||||
pub log: String,
|
||||
}
|
||||
|
||||
fn run_git(workspace: &Path, args: &[&str]) -> (bool, String) {
|
||||
let output = Command::new("git")
|
||||
.current_dir(workspace)
|
||||
.args(args)
|
||||
.output();
|
||||
match output {
|
||||
Ok(out) => {
|
||||
let mut text = String::new();
|
||||
text.push_str(&format!("$ git {}\n", args.join(" ")));
|
||||
text.push_str(&String::from_utf8_lossy(&out.stdout));
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
if !err.trim().is_empty() {
|
||||
text.push_str(&err);
|
||||
}
|
||||
(out.status.success(), text)
|
||||
}
|
||||
Err(e) => (false, format!("failed to launch git: {e}\n")),
|
||||
}
|
||||
}
|
||||
|
||||
/// True if the workspace directory is inside a git working tree.
|
||||
pub fn is_repo(workspace: &Path) -> bool {
|
||||
let (ok, out) = run_git(workspace, &["rev-parse", "--is-inside-work-tree"]);
|
||||
ok && out.contains("true")
|
||||
}
|
||||
|
||||
/// True if a remote named `origin` is configured.
|
||||
fn has_origin(workspace: &Path) -> bool {
|
||||
let (ok, out) = run_git(workspace, &["remote"]);
|
||||
ok && out.lines().any(|l| l.trim() == "origin")
|
||||
}
|
||||
|
||||
/// Initialise a new git repository in the workspace with a starter .gitignore.
|
||||
pub fn init(workspace: &Path) -> GitOutcome {
|
||||
let mut log = String::new();
|
||||
let (ok1, o1) = run_git(workspace, &["init"]);
|
||||
log.push_str(&o1);
|
||||
// Make sure we are on a predictable default branch name.
|
||||
let (_, o2) = run_git(workspace, &["symbolic-ref", "HEAD", "refs/heads/main"]);
|
||||
log.push_str(&o2);
|
||||
GitOutcome { ok: ok1, log }
|
||||
}
|
||||
|
||||
/// Commit all local changes and, if `origin` exists, pull (rebase) then push.
|
||||
/// This is the one-button "sync" operation.
|
||||
pub fn sync(workspace: &Path, message: &str) -> GitOutcome {
|
||||
let mut log = String::new();
|
||||
let mut ok = true;
|
||||
|
||||
let (_, o) = run_git(workspace, &["add", "-A"]);
|
||||
log.push_str(&o);
|
||||
|
||||
// Commit only if there is something staged; otherwise git returns non-zero,
|
||||
// which is fine and should not abort the sync.
|
||||
let (has_changes, status) = run_git(workspace, &["status", "--porcelain"]);
|
||||
let dirty = has_changes && !status.lines().skip(1).all(|l| l.trim().is_empty());
|
||||
if dirty {
|
||||
let (c_ok, c_out) = run_git(workspace, &["commit", "-m", message]);
|
||||
log.push_str(&c_out);
|
||||
ok &= c_ok;
|
||||
} else {
|
||||
log.push_str("(nothing to commit)\n");
|
||||
}
|
||||
|
||||
if has_origin(workspace) {
|
||||
let (p_ok, p_out) = run_git(workspace, &["pull", "--rebase", "--autostash"]);
|
||||
log.push_str(&p_out);
|
||||
ok &= p_ok;
|
||||
|
||||
let (push_ok, push_out) = run_git(workspace, &["push"]);
|
||||
log.push_str(&push_out);
|
||||
// A failed push often just means no upstream is set yet; surface it but
|
||||
// do not treat a missing upstream as a hard failure of the whole sync.
|
||||
if !push_ok && push_out.contains("no upstream") {
|
||||
let branch = current_branch(workspace);
|
||||
let (u_ok, u_out) =
|
||||
run_git(workspace, &["push", "--set-upstream", "origin", &branch]);
|
||||
log.push_str(&u_out);
|
||||
ok &= u_ok;
|
||||
} else {
|
||||
ok &= push_ok;
|
||||
}
|
||||
} else {
|
||||
log.push_str("(no 'origin' remote configured — committed locally only)\n");
|
||||
}
|
||||
|
||||
GitOutcome { ok, log }
|
||||
}
|
||||
|
||||
fn current_branch(workspace: &Path) -> String {
|
||||
let (_, out) = run_git(workspace, &["rev-parse", "--abbrev-ref", "HEAD"]);
|
||||
out.lines()
|
||||
.last()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "main".to_string())
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
// md-manuscript: a draggable markdown manuscript editor with git sync and
|
||||
// native ODT export. GUI built with egui/eframe.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod app;
|
||||
mod config;
|
||||
mod gitsync;
|
||||
mod odt;
|
||||
mod order;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_inner_size([1100.0, 720.0])
|
||||
.with_min_inner_size([700.0, 400.0])
|
||||
.with_title("md-manuscript"),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native(
|
||||
"md-manuscript",
|
||||
options,
|
||||
Box::new(|cc| Ok(Box::new(app::App::new(cc)))),
|
||||
)
|
||||
}
|
||||
+580
@@ -0,0 +1,580 @@
|
||||
//! Native OpenDocument Text (.odt) generation from markdown.
|
||||
//!
|
||||
//! An .odt file is a ZIP archive containing (at minimum):
|
||||
//! * `mimetype` - the media type, stored uncompressed and first
|
||||
//! * `META-INF/manifest.xml` - lists the archive members
|
||||
//! * `styles.xml` - named paragraph / text / list styles
|
||||
//! * `content.xml` - the document body
|
||||
//!
|
||||
//! We parse each markdown document with pulldown-cmark and emit ODF flow
|
||||
//! elements. Each source file becomes a chapter: a "Heading 1" with the chapter
|
||||
//! title, followed by the converted body (whose own headings are shifted down a
|
||||
//! level so they nest under the chapter heading).
|
||||
|
||||
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
/// One chapter to export.
|
||||
pub struct Chapter {
|
||||
pub title: String,
|
||||
pub markdown: String,
|
||||
}
|
||||
|
||||
/// Escape text for use inside XML character data.
|
||||
fn esc(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Escape text for use inside an XML attribute value.
|
||||
fn esc_attr(s: &str) -> String {
|
||||
let mut out = esc(s);
|
||||
out = out.replace('"', """);
|
||||
out
|
||||
}
|
||||
|
||||
/// Which block-level element is currently open in the output stream.
|
||||
#[derive(PartialEq, Clone, Copy)]
|
||||
enum Block {
|
||||
None,
|
||||
Para,
|
||||
Heading(u8),
|
||||
}
|
||||
|
||||
struct Converter {
|
||||
out: String,
|
||||
block: Block,
|
||||
bold: bool,
|
||||
italic: bool,
|
||||
strike: bool,
|
||||
code: bool,
|
||||
link: Option<String>,
|
||||
/// Stack of list markers: true = ordered, false = unordered.
|
||||
list_stack: Vec<bool>,
|
||||
/// True while inside a blockquote (affects paragraph style).
|
||||
quote_depth: u32,
|
||||
/// Buffer collecting the text of the current code block.
|
||||
code_block: Option<String>,
|
||||
/// Heading level offset so document headings nest under the chapter title.
|
||||
heading_offset: u8,
|
||||
}
|
||||
|
||||
impl Converter {
|
||||
fn new(heading_offset: u8) -> Self {
|
||||
Converter {
|
||||
out: String::new(),
|
||||
block: Block::None,
|
||||
bold: false,
|
||||
italic: false,
|
||||
strike: false,
|
||||
code: false,
|
||||
link: None,
|
||||
list_stack: Vec::new(),
|
||||
quote_depth: 0,
|
||||
code_block: None,
|
||||
heading_offset,
|
||||
}
|
||||
}
|
||||
|
||||
fn para_style(&self) -> &'static str {
|
||||
if self.quote_depth > 0 {
|
||||
"Quotations"
|
||||
} else {
|
||||
"Text_20_body"
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure a block-level element is open to receive inline content.
|
||||
fn ensure_inline_target(&mut self) {
|
||||
if self.block == Block::None {
|
||||
let style = self.para_style();
|
||||
self.out
|
||||
.push_str(&format!("<text:p text:style-name=\"{style}\">"));
|
||||
self.block = Block::Para;
|
||||
}
|
||||
}
|
||||
|
||||
fn close_block(&mut self) {
|
||||
match self.block {
|
||||
Block::Para => self.out.push_str("</text:p>"),
|
||||
Block::Heading(_) => self.out.push_str("</text:h>"),
|
||||
Block::None => {}
|
||||
}
|
||||
self.block = Block::None;
|
||||
}
|
||||
|
||||
/// Emit an inline text run with the currently active character formatting.
|
||||
fn write_text(&mut self, text: &str) {
|
||||
self.ensure_inline_target();
|
||||
let escaped = esc(text);
|
||||
|
||||
// Choose a text (character) style for the active emphasis flags.
|
||||
let span_style = if self.code {
|
||||
Some("Source_20_Text")
|
||||
} else {
|
||||
match (self.bold, self.italic, self.strike) {
|
||||
(true, true, _) => Some("T_bolditalic"),
|
||||
(true, false, false) => Some("T_bold"),
|
||||
(false, true, false) => Some("T_italic"),
|
||||
(_, _, true) => Some("T_strike"),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
let mut run = String::new();
|
||||
if let Some(style) = span_style {
|
||||
run.push_str(&format!("<text:span text:style-name=\"{style}\">"));
|
||||
run.push_str(&escaped);
|
||||
run.push_str("</text:span>");
|
||||
} else {
|
||||
run.push_str(&escaped);
|
||||
}
|
||||
|
||||
if let Some(href) = &self.link {
|
||||
self.out.push_str(&format!(
|
||||
"<text:a xlink:type=\"simple\" xlink:href=\"{}\">{run}</text:a>",
|
||||
esc_attr(href)
|
||||
));
|
||||
} else {
|
||||
self.out.push_str(&run);
|
||||
}
|
||||
}
|
||||
|
||||
fn start_list(&mut self, ordered: bool) {
|
||||
self.close_block();
|
||||
let style = if ordered { "L_number" } else { "L_bullet" };
|
||||
self.out
|
||||
.push_str(&format!("<text:list text:style-name=\"{style}\">"));
|
||||
self.list_stack.push(ordered);
|
||||
}
|
||||
|
||||
fn end_list(&mut self) {
|
||||
self.close_block();
|
||||
self.out.push_str("</text:list>");
|
||||
self.list_stack.pop();
|
||||
}
|
||||
|
||||
fn flush_code_block(&mut self) {
|
||||
if let Some(buf) = self.code_block.take() {
|
||||
// Trailing newline from the fenced block is not a real line.
|
||||
let content = buf.strip_suffix('\n').unwrap_or(&buf);
|
||||
for line in content.split('\n') {
|
||||
self.out
|
||||
.push_str("<text:p text:style-name=\"Preformatted_Text\">");
|
||||
self.write_preformatted_line(line);
|
||||
self.out.push_str("</text:p>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a single code line, preserving runs of spaces and tabs.
|
||||
fn write_preformatted_line(&mut self, line: &str) {
|
||||
let mut chars = line.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
' ' => {
|
||||
let mut count = 1;
|
||||
while chars.peek() == Some(&' ') {
|
||||
chars.next();
|
||||
count += 1;
|
||||
}
|
||||
if count == 1 {
|
||||
self.out.push(' ');
|
||||
} else {
|
||||
self.out
|
||||
.push_str(&format!("<text:s text:c=\"{count}\"/>"));
|
||||
}
|
||||
}
|
||||
'\t' => self.out.push_str("<text:tab/>"),
|
||||
'&' => self.out.push_str("&"),
|
||||
'<' => self.out.push_str("<"),
|
||||
'>' => self.out.push_str(">"),
|
||||
_ => self.out.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn heading_number(&self, level: HeadingLevel) -> u8 {
|
||||
let base = match level {
|
||||
HeadingLevel::H1 => 1,
|
||||
HeadingLevel::H2 => 2,
|
||||
HeadingLevel::H3 => 3,
|
||||
HeadingLevel::H4 => 4,
|
||||
HeadingLevel::H5 => 5,
|
||||
HeadingLevel::H6 => 6,
|
||||
};
|
||||
(base + self.heading_offset).min(10)
|
||||
}
|
||||
|
||||
fn run(mut self, markdown: &str) -> String {
|
||||
let mut opts = Options::empty();
|
||||
opts.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
let parser = Parser::new_ext(markdown, opts);
|
||||
|
||||
for event in parser {
|
||||
match event {
|
||||
Event::Start(tag) => self.start_tag(tag),
|
||||
Event::End(tag) => self.end_tag(tag),
|
||||
Event::Text(t) => {
|
||||
if let Some(buf) = self.code_block.as_mut() {
|
||||
buf.push_str(&t);
|
||||
} else {
|
||||
self.write_text(&t);
|
||||
}
|
||||
}
|
||||
Event::Code(t) => {
|
||||
self.code = true;
|
||||
self.write_text(&t);
|
||||
self.code = false;
|
||||
}
|
||||
Event::SoftBreak => {
|
||||
if self.code_block.is_some() {
|
||||
// handled via Text
|
||||
} else {
|
||||
self.write_text(" ");
|
||||
}
|
||||
}
|
||||
Event::HardBreak => {
|
||||
self.ensure_inline_target();
|
||||
self.out.push_str("<text:line-break/>");
|
||||
}
|
||||
Event::Rule => {
|
||||
self.close_block();
|
||||
self.out
|
||||
.push_str("<text:p text:style-name=\"Horizontal_Line\"></text:p>");
|
||||
}
|
||||
Event::Html(_) | Event::InlineHtml(_) => { /* skip raw HTML */ }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.close_block();
|
||||
self.out
|
||||
}
|
||||
|
||||
fn start_tag(&mut self, tag: Tag) {
|
||||
match tag {
|
||||
Tag::Paragraph => {
|
||||
// Lazily opened by the first inline content.
|
||||
self.close_block();
|
||||
}
|
||||
Tag::Heading { level, .. } => {
|
||||
self.close_block();
|
||||
let n = self.heading_number(level);
|
||||
self.out.push_str(&format!(
|
||||
"<text:h text:style-name=\"Heading_20_{n}\" text:outline-level=\"{n}\">"
|
||||
));
|
||||
self.block = Block::Heading(n);
|
||||
}
|
||||
Tag::BlockQuote(_) => {
|
||||
self.close_block();
|
||||
self.quote_depth += 1;
|
||||
}
|
||||
Tag::CodeBlock(kind) => {
|
||||
self.close_block();
|
||||
let _ = match kind {
|
||||
CodeBlockKind::Fenced(_) => (),
|
||||
CodeBlockKind::Indented => (),
|
||||
};
|
||||
self.code_block = Some(String::new());
|
||||
}
|
||||
Tag::List(first) => {
|
||||
self.start_list(first.is_some());
|
||||
}
|
||||
Tag::Item => {
|
||||
self.out.push_str("<text:list-item>");
|
||||
}
|
||||
Tag::Emphasis => self.italic = true,
|
||||
Tag::Strong => self.bold = true,
|
||||
Tag::Strikethrough => self.strike = true,
|
||||
Tag::Link { dest_url, .. } => {
|
||||
self.link = Some(dest_url.to_string());
|
||||
}
|
||||
Tag::Image { title, dest_url, .. } => {
|
||||
// We do not embed images; surface a readable placeholder.
|
||||
let label = if !title.is_empty() {
|
||||
title.to_string()
|
||||
} else {
|
||||
dest_url.to_string()
|
||||
};
|
||||
self.write_text(&format!("[image: {label}]"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn end_tag(&mut self, tag: TagEnd) {
|
||||
match tag {
|
||||
TagEnd::Paragraph => self.close_block(),
|
||||
TagEnd::Heading(_) => self.close_block(),
|
||||
TagEnd::BlockQuote(_) => {
|
||||
self.close_block();
|
||||
if self.quote_depth > 0 {
|
||||
self.quote_depth -= 1;
|
||||
}
|
||||
}
|
||||
TagEnd::CodeBlock => self.flush_code_block(),
|
||||
TagEnd::List(_) => self.end_list(),
|
||||
TagEnd::Item => {
|
||||
self.close_block();
|
||||
self.out.push_str("</text:list-item>");
|
||||
}
|
||||
TagEnd::Emphasis => self.italic = false,
|
||||
TagEnd::Strong => self.bold = false,
|
||||
TagEnd::Strikethrough => self.strike = false,
|
||||
TagEnd::Link => self.link = None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert one markdown document into an ODF body fragment, with headings shifted
|
||||
/// down by `heading_offset` levels.
|
||||
fn markdown_to_body(markdown: &str, heading_offset: u8) -> String {
|
||||
Converter::new(heading_offset).run(markdown)
|
||||
}
|
||||
|
||||
const MIMETYPE: &str = "application/vnd.oasis.opendocument.text";
|
||||
|
||||
fn manifest_xml() -> String {
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">
|
||||
<manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.text"/>
|
||||
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
|
||||
<manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>
|
||||
</manifest:manifest>"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn styles_xml() -> String {
|
||||
// Heading sizes for the seven levels we may emit (chapter = 1).
|
||||
let sizes = [18, 16, 14, 13, 12, 11, 11, 11, 11, 11];
|
||||
let mut headings = String::new();
|
||||
for (i, size) in sizes.iter().enumerate() {
|
||||
let n = i + 1;
|
||||
headings.push_str(&format!(
|
||||
"<style:style style:name=\"Heading_20_{n}\" style:display-name=\"Heading {n}\" \
|
||||
style:family=\"paragraph\" style:parent-style-name=\"Heading\" \
|
||||
style:next-style-name=\"Text_20_body\" style:default-outline-level=\"{n}\">\
|
||||
<style:text-properties fo:font-size=\"{size}pt\" fo:font-weight=\"bold\"/>\
|
||||
</style:style>"
|
||||
));
|
||||
}
|
||||
|
||||
format!(
|
||||
r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" office:version="1.2">
|
||||
<office:styles>
|
||||
<style:default-style style:family="paragraph">
|
||||
<style:paragraph-properties fo:hyphenation-ladder-count="no-limit"/>
|
||||
<style:text-properties fo:font-size="11pt" fo:language="en" fo:country="US"/>
|
||||
</style:default-style>
|
||||
<style:style style:name="Standard" style:family="paragraph" style:class="text"/>
|
||||
<style:style style:name="Text_20_body" style:display-name="Text body" style:family="paragraph" style:parent-style-name="Standard" style:class="text">
|
||||
<style:paragraph-properties fo:margin-top="0in" fo:margin-bottom="0.1in"/>
|
||||
</style:style>
|
||||
<style:style style:name="Heading" style:family="paragraph" style:parent-style-name="Standard" style:next-style-name="Text_20_body" style:class="text">
|
||||
<style:paragraph-properties fo:margin-top="0.2in" fo:margin-bottom="0.08in" fo:keep-with-next="always"/>
|
||||
<style:text-properties fo:font-size="14pt" fo:font-weight="bold"/>
|
||||
</style:style>
|
||||
{headings}
|
||||
<style:style style:name="Quotations" style:family="paragraph" style:parent-style-name="Standard" style:class="html">
|
||||
<style:paragraph-properties fo:margin-left="0.4in" fo:margin-right="0.4in" fo:margin-top="0.05in" fo:margin-bottom="0.05in"/>
|
||||
<style:text-properties fo:font-style="italic"/>
|
||||
</style:style>
|
||||
<style:style style:name="Preformatted_Text" style:display-name="Preformatted Text" style:family="paragraph" style:parent-style-name="Standard" style:class="html">
|
||||
<style:paragraph-properties fo:margin-top="0in" fo:margin-bottom="0in"/>
|
||||
<style:text-properties style:font-name="Liberation Mono" fo:font-family="'Liberation Mono'" style:font-family-generic="modit" fo:font-size="10pt"/>
|
||||
</style:style>
|
||||
<style:style style:name="Horizontal_Line" style:display-name="Horizontal Line" style:family="paragraph" style:parent-style-name="Standard" style:class="html">
|
||||
<style:paragraph-properties fo:margin-top="0.05in" fo:margin-bottom="0.1in" fo:border-bottom="0.5pt solid #808080" fo:padding="0in" style:join-border="false"/>
|
||||
</style:style>
|
||||
<style:style style:name="T_bold" style:family="text">
|
||||
<style:text-properties fo:font-weight="bold"/>
|
||||
</style:style>
|
||||
<style:style style:name="T_italic" style:family="text">
|
||||
<style:text-properties fo:font-style="italic"/>
|
||||
</style:style>
|
||||
<style:style style:name="T_bolditalic" style:family="text">
|
||||
<style:text-properties fo:font-weight="bold" fo:font-style="italic"/>
|
||||
</style:style>
|
||||
<style:style style:name="T_strike" style:family="text">
|
||||
<style:text-properties style:text-line-through-style="solid" style:text-line-through-type="single"/>
|
||||
</style:style>
|
||||
<style:style style:name="Source_20_Text" style:display-name="Source Text" style:family="text">
|
||||
<style:text-properties style:font-name="Liberation Mono" fo:font-family="'Liberation Mono'" style:font-family-generic="modit" fo:font-size="10pt"/>
|
||||
</style:style>
|
||||
<style:style style:name="Internet_20_Link" style:display-name="Internet Link" style:family="text">
|
||||
<style:text-properties fo:color="#0563c1" style:text-underline-style="solid" style:text-underline-width="auto" style:text-underline-color="font-color"/>
|
||||
</style:style>
|
||||
<text:list-style style:name="L_bullet">
|
||||
<text:list-level-style-bullet text:level="1" text:bullet-char="•"><style:list-level-properties text:space-before="0.25in" text:min-label-width="0.25in"/></text:list-level-style-bullet>
|
||||
<text:list-level-style-bullet text:level="2" text:bullet-char="◦"><style:list-level-properties text:space-before="0.5in" text:min-label-width="0.25in"/></text:list-level-style-bullet>
|
||||
<text:list-level-style-bullet text:level="3" text:bullet-char="▪"><style:list-level-properties text:space-before="0.75in" text:min-label-width="0.25in"/></text:list-level-style-bullet>
|
||||
</text:list-style>
|
||||
<text:list-style style:name="L_number">
|
||||
<text:list-level-style-number text:level="1" style:num-format="1" text:num-suffix="."><style:list-level-properties text:space-before="0.25in" text:min-label-width="0.25in"/></text:list-level-style-number>
|
||||
<text:list-level-style-number text:level="2" style:num-format="a" text:num-suffix="."><style:list-level-properties text:space-before="0.5in" text:min-label-width="0.25in"/></text:list-level-style-number>
|
||||
<text:list-level-style-number text:level="3" style:num-format="i" text:num-suffix="."><style:list-level-properties text:space-before="0.75in" text:min-label-width="0.25in"/></text:list-level-style-number>
|
||||
</text:list-style>
|
||||
</office:styles>
|
||||
<office:automatic-styles>
|
||||
<style:page-layout style:name="pm1">
|
||||
<style:page-layout-properties fo:page-width="8.5in" fo:page-height="11in" fo:margin-top="1in" fo:margin-bottom="1in" fo:margin-left="1in" fo:margin-right="1in"/>
|
||||
</style:page-layout>
|
||||
</office:automatic-styles>
|
||||
<office:master-styles>
|
||||
<style:master-page style:name="Standard" style:page-layout-name="pm1"/>
|
||||
</office:master-styles>
|
||||
</office:document-styles>"##
|
||||
)
|
||||
}
|
||||
|
||||
fn content_xml(chapters: &[Chapter]) -> String {
|
||||
let mut body = String::new();
|
||||
for chapter in chapters {
|
||||
// Chapter heading (always level 1); the document's own headings are
|
||||
// shifted down by one so they nest beneath it.
|
||||
body.push_str(&format!(
|
||||
"<text:h text:style-name=\"Heading_20_1\" text:outline-level=\"1\">{}</text:h>",
|
||||
esc(&chapter.title)
|
||||
));
|
||||
body.push_str(&markdown_to_body(&chapter.markdown, 1));
|
||||
}
|
||||
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" office:version="1.2">
|
||||
<office:body>
|
||||
<office:text>
|
||||
{body}
|
||||
</office:text>
|
||||
</office:body>
|
||||
</office:document-content>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Write the chapters to an .odt file at `out_path`.
|
||||
pub fn export(chapters: &[Chapter], out_path: &Path) -> std::io::Result<()> {
|
||||
let file = std::fs::File::create(out_path)?;
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
|
||||
// mimetype MUST be the first entry and stored without compression.
|
||||
let stored = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
zip.start_file("mimetype", stored)
|
||||
.map_err(to_io)?;
|
||||
zip.write_all(MIMETYPE.as_bytes())?;
|
||||
|
||||
let deflated =
|
||||
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
|
||||
zip.add_directory("META-INF/", deflated).map_err(to_io)?;
|
||||
zip.start_file("META-INF/manifest.xml", deflated)
|
||||
.map_err(to_io)?;
|
||||
zip.write_all(manifest_xml().as_bytes())?;
|
||||
|
||||
zip.start_file("styles.xml", deflated).map_err(to_io)?;
|
||||
zip.write_all(styles_xml().as_bytes())?;
|
||||
|
||||
zip.start_file("content.xml", deflated).map_err(to_io)?;
|
||||
zip.write_all(content_xml(chapters).as_bytes())?;
|
||||
|
||||
zip.finish().map_err(to_io)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn to_io(e: zip::result::ZipError) -> std::io::Error {
|
||||
std::io::Error::new(std::io::ErrorKind::Other, e)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn body_covers_common_markdown() {
|
||||
let md = "\
|
||||
Some **bold** and *italic* and ***both*** and `code` and ~~gone~~.
|
||||
|
||||
## A subheading
|
||||
|
||||
- one
|
||||
- two
|
||||
- nested
|
||||
|
||||
1. first
|
||||
2. second
|
||||
|
||||
> a quote
|
||||
|
||||
```
|
||||
let x = 1;
|
||||
indented();
|
||||
```
|
||||
|
||||
A [link](https://example.com) and a rule:
|
||||
|
||||
---
|
||||
|
||||
Done.";
|
||||
let body = markdown_to_body(md, 1);
|
||||
assert!(body.contains("<text:span text:style-name=\"T_bold\">bold</text:span>"));
|
||||
assert!(body.contains("<text:span text:style-name=\"T_italic\">italic</text:span>"));
|
||||
assert!(body.contains("T_bolditalic"));
|
||||
assert!(body.contains("Source_20_Text"));
|
||||
assert!(body.contains("T_strike"));
|
||||
// Heading level 2 in source shifts to Heading_20_3 (offset 1).
|
||||
assert!(body.contains("text:style-name=\"Heading_20_3\""));
|
||||
assert!(body.contains("<text:list text:style-name=\"L_bullet\">"));
|
||||
assert!(body.contains("<text:list text:style-name=\"L_number\">"));
|
||||
assert!(body.contains("Quotations"));
|
||||
assert!(body.contains("Preformatted_Text"));
|
||||
assert!(body.contains("xlink:href=\"https://example.com\""));
|
||||
assert!(body.contains("Horizontal_Line"));
|
||||
// spaces in indented code preserved
|
||||
assert!(body.contains("<text:s text:c=\"4\"/>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_writes_valid_archive() {
|
||||
let chapters = vec![
|
||||
Chapter {
|
||||
title: "Chapter One".to_string(),
|
||||
markdown: "Hello **world**.\n\n## Scene\n\nText.".to_string(),
|
||||
},
|
||||
Chapter {
|
||||
title: "Chapter Two".to_string(),
|
||||
markdown: "- a\n- b\n".to_string(),
|
||||
},
|
||||
];
|
||||
let dir = std::env::temp_dir();
|
||||
let out = dir.join("md_manuscript_test.odt");
|
||||
export(&chapters, &out).expect("export ok");
|
||||
|
||||
// Re-open the archive and check required members exist with mimetype first.
|
||||
let file = std::fs::File::open(&out).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(file).unwrap();
|
||||
assert_eq!(zip.file_names().next().unwrap(), "mimetype");
|
||||
let names: Vec<String> = zip.file_names().map(|s| s.to_string()).collect();
|
||||
for required in ["mimetype", "META-INF/manifest.xml", "styles.xml", "content.xml"] {
|
||||
assert!(names.iter().any(|n| n == required), "missing {required}");
|
||||
}
|
||||
use std::io::Read;
|
||||
let mut content = String::new();
|
||||
zip.by_name("content.xml")
|
||||
.unwrap()
|
||||
.read_to_string(&mut content)
|
||||
.unwrap();
|
||||
assert!(content.contains(">Chapter One</text:h>"));
|
||||
assert!(content.contains(">Chapter Two</text:h>"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// Name of the file (inside the workspace) that stores the manual ordering.
|
||||
/// It is committed to git so the order syncs across machines.
|
||||
pub const ORDER_FILE: &str = "order.json";
|
||||
|
||||
/// Name of the file that stores per-file chapter title overrides
|
||||
/// (a JSON object mapping `file name` -> `chapter title`). Also committed to git.
|
||||
pub const TITLES_FILE: &str = "titles.json";
|
||||
|
||||
/// Read the chapter-title overrides. Missing/invalid -> empty map.
|
||||
pub fn read_titles(workspace: &Path) -> HashMap<String, String> {
|
||||
let path = workspace.join(TITLES_FILE);
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
|
||||
Err(_) => HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the chapter-title overrides as JSON into the workspace.
|
||||
pub fn write_titles(workspace: &Path, titles: &HashMap<String, String>) -> std::io::Result<()> {
|
||||
let path = workspace.join(TITLES_FILE);
|
||||
let text = serde_json::to_string_pretty(titles).unwrap_or_else(|_| "{}".to_string());
|
||||
std::fs::write(path, text)
|
||||
}
|
||||
|
||||
/// Read the saved order (a JSON array of file names). Missing/invalid -> empty.
|
||||
fn read_saved_order(workspace: &Path) -> Vec<String> {
|
||||
let path = workspace.join(ORDER_FILE);
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the given order as JSON into the workspace.
|
||||
pub fn write_order(workspace: &Path, order: &[String]) -> std::io::Result<()> {
|
||||
let path = workspace.join(ORDER_FILE);
|
||||
let text = serde_json::to_string_pretty(order).unwrap_or_else(|_| "[]".to_string());
|
||||
std::fs::write(path, text)
|
||||
}
|
||||
|
||||
/// List all `*.md` files currently present in the workspace (file names only).
|
||||
fn list_markdown_files(workspace: &Path) -> Vec<String> {
|
||||
let mut files = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(workspace) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
if let Some(ext) = path.extension() {
|
||||
if ext.eq_ignore_ascii_case("md") {
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
files.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
/// Produce the effective ordered list of markdown files:
|
||||
/// 1. saved-order entries that still exist on disk, in saved order
|
||||
/// 2. any new `.md` files not yet in the saved order, appended alphabetically
|
||||
pub fn resolve_order(workspace: &Path) -> Vec<String> {
|
||||
let saved = read_saved_order(workspace);
|
||||
let mut present = list_markdown_files(workspace);
|
||||
present.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
|
||||
|
||||
let mut result: Vec<String> = Vec::new();
|
||||
for name in &saved {
|
||||
if present.contains(name) && !result.contains(name) {
|
||||
result.push(name.clone());
|
||||
}
|
||||
}
|
||||
for name in present {
|
||||
if !result.contains(&name) {
|
||||
result.push(name);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
Reference in New Issue
Block a user