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:
landon
2026-07-25 15:17:42 -05:00
commit f473b63ee3
11 changed files with 6201 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
Generated
+4306
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "md-manuscript"
version = "0.1.0"
edition = "2021"
description = "Draggable markdown manuscript editor with git sync and ODT export"
[dependencies]
eframe = { version = "0.29", default-features = true }
egui = "0.29"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
pulldown-cmark = { version = "0.12", default-features = false }
zip = { version = "2", default-features = false, features = ["deflate"] }
dirs = "5"
[profile.release]
opt-level = 2
+194
View File
@@ -0,0 +1,194 @@
# Installing md-manuscript on a Surface tablet (Debian 12 "Bookworm")
These steps build the app from source and set it up for touch use on a Microsoft
Surface running Debian 12 with the GNOME desktop. You do **not** need the
`linux-surface` custom kernel for this app — any working Debian 12 desktop is
fine.
Everything is done in a terminal. If your Surface is detached from its keyboard,
turn on the on-screen keyboard first (see [Surface tips](#surface-specific-tips)).
---
## 1. One-time system setup
### a. Build tools
Debian 12 ships Rust 1.63 in `apt`, which is **too old** for this project.
Install the Rust toolchain with `rustup` instead, plus a C linker.
```sh
sudo apt update
sudo apt install -y build-essential curl git
# Install rustup (choose option 1, the default, when prompted)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Load cargo into the current shell (or just open a new terminal)
. "$HOME/.cargo/env"
rustc --version # should print 1.8x or newer
```
### b. Runtime GUI libraries
The compiled binary loads its graphics/windowing libraries at runtime (it links
against only libc/libgcc). Most are already present under GNOME, but install the
full set so it runs on both Wayland and X11:
```sh
sudo apt install -y \
libgl1 libegl1 libgl1-mesa-dri \
libwayland-client0 libwayland-egl1 libwayland-cursor0 \
libxkbcommon0 libxkbcommon-x11-0 \
libx11-6 libx11-xcb1 libxcursor1 libxi6 libxrandr2 libxinerama1 libxrender1
```
---
## 2. Get the source onto the tablet
Copy the `md-manuscript` project directory to the tablet (via USB, `scp`, or a
sync tool), **or** clone it if you've pushed it to a git remote:
```sh
# Example if you pushed it somewhere:
git clone <your-repo-url> ~/md-manuscript
```
The rest of these steps assume the source is at `~/md-manuscript`.
---
## 3. Build
```sh
cd ~/md-manuscript
cargo build --release
```
The first build downloads dependencies and takes a few minutes on Surface
hardware. The result is `~/md-manuscript/target/release/md-manuscript`.
> If the build stops with *"rustc 1.87.0 is not supported by … image@0.25.x"*,
> your Rust is older than expected — run `rustup update` and rebuild.
---
## 4. Install the binary and a launcher
Put the binary on your `PATH` and add a GNOME app entry so it appears in the
Activities/app grid and can be pinned to the dash (handy for touch):
```sh
# 4a. Binary
install -Dm755 target/release/md-manuscript ~/.local/bin/md-manuscript
# 4b. Desktop launcher
mkdir -p ~/.local/share/applications
cat > ~/.local/share/applications/md-manuscript.desktop <<'EOF'
[Desktop Entry]
Type=Application
Name=md-manuscript
Comment=Draggable markdown manuscript editor with git sync and ODT export
Exec=md-manuscript
Icon=text-editor
Categories=Office;TextEditor;
Terminal=false
EOF
# 4c. Refresh the app list
update-desktop-database ~/.local/share/applications 2>/dev/null || true
```
Make sure `~/.local/bin` is on your `PATH` (it is by default on Debian 12 if the
directory exists at login — otherwise add this to `~/.bashrc` and re-login):
```sh
export PATH="$HOME/.local/bin:$PATH"
```
You can now launch **md-manuscript** by tapping its icon, or run
`md-manuscript` from a terminal.
---
## 5. First run
1. In the top bar, set **Workspace** (default `~/Manuscript`) and tap **Open**
the folder is created if missing.
2. Tap **Init git** once to version-control the workspace.
3. To sync across devices, add a remote yourself, then use the **⟳ Sync** button:
```sh
cd ~/Manuscript
git remote add origin <url>
git push -u origin main
```
You'll also want to set your git identity once on the tablet:
```sh
git config --global user.name "Your Name"
git config --global user.email "you@example.com"
```
---
## Surface-specific tips
**On-screen keyboard (detached from Type Cover).**
GNOME Settings → *Accessibility* → *Typing* → turn on **Screen Keyboard**. It
pops up automatically when you tap a text field. For terminal-heavy setup you may
find it easier to attach the keyboard for the one-time install.
**HiDPI / tiny or huge UI.**
Surface screens are very high resolution. The app follows the desktop's scale
factor, so set it once in GNOME Settings → *Displays* → **Scale** (enable
*Fractional Scaling* if you want values like 150%/175%). If you run under X11 and
the size is still off, launch with an explicit factor:
```sh
WINIT_X11_SCALE_FACTOR=1.75 md-manuscript
```
**Touch gestures.**
- **Tap** a file name to open it.
- **Reorder** by pressing the `` handle and dragging up/down with your finger.
- **Scroll** the file list or editor with a one-finger drag.
**Wayland vs X11.**
GNOME on Debian 12 defaults to Wayland; the app supports it natively. If you hit
a rendering glitch, log out and pick *"GNOME on Xorg"* from the gear menu on the
login screen, or force X11 for one launch:
```sh
WINIT_UNIX_BACKEND=x11 md-manuscript
```
---
## Updating
```sh
cd ~/md-manuscript
git pull # if you cloned it; otherwise re-copy the source
cargo build --release
install -Dm755 target/release/md-manuscript ~/.local/bin/md-manuscript
```
## Uninstall
```sh
rm -f ~/.local/bin/md-manuscript
rm -f ~/.local/share/applications/md-manuscript.desktop
# Your manuscripts in ~/Manuscript and config in ~/.config/md-manuscript are left intact.
```
---
## Troubleshooting
| Symptom | Fix |
|---|---|
| `error while loading shared libraries: libGL.so.1` (or similar) | Install the runtime libraries in step 1b. |
| Window fails with *"neither WAYLAND_DISPLAY nor DISPLAY is set"* | You're in a text-only session/SSH; run it from the tablet's graphical desktop. |
| Build error mentioning `rustc … is not supported` | `rustup update`, then `cargo build --release` again. |
| `cargo: command not found` | Run `. "$HOME/.cargo/env"` or open a new terminal after installing rustup. |
| UI text far too small/large | Adjust GNOME *Displays → Scale*, or set `WINIT_X11_SCALE_FACTOR` (X11). |
| **⟳ Sync** does nothing / errors in the Log panel | Set `git config --global user.name/user.email`, and ensure the `origin` remote and your SSH/HTTPS credentials are configured. |
+89
View File
@@ -0,0 +1,89 @@
# md-manuscript
A small desktop application for Debian that treats a directory of markdown files
as an ordered manuscript.
* **Left pane** — every `*.md` file in the workspace directory, in a manual order
you set by **dragging the `⠿` handle** up and down.
* **Right pane** — a markdown text editor for the selected file (optional live
preview via the *Preview* checkbox).
* **Order + contents are saved and version-controlled with git.** The order lives
in `order.json` inside the workspace, so it syncs along with the files.
* **Export** the whole manuscript as a single concatenated `.odt` file, with a
chapter heading before each file's content.
Built in Rust with [`egui`](https://github.com/emilk/egui)/`eframe`. The ODT
writer is native (no `pandoc`/LibreOffice needed at runtime).
## Build
```sh
cargo build --release
# binary: target/release/md-manuscript
```
Requires a Rust toolchain. The GUI needs a normal desktop session (X11 or
Wayland) with OpenGL — the usual runtime libraries on Debian:
```sh
sudo apt install libgl1 libxkbcommon0 libwayland-client0 libx11-6
```
(Only needed to *run* the GUI; building does not require them.)
## Install
```sh
install -Dm755 target/release/md-manuscript ~/.local/bin/md-manuscript
```
Optionally add a desktop launcher at
`~/.local/share/applications/md-manuscript.desktop`:
```ini
[Desktop Entry]
Type=Application
Name=md-manuscript
Exec=md-manuscript
Categories=Office;TextEditor;
Terminal=false
```
## Use
1. Set the **Workspace** path in the top bar (default: `~/Manuscript`) and press
**Open**. The directory is created if it does not exist.
2. Press **Init git** once to make the workspace a git repository. To sync with
another machine, add a remote yourself:
```sh
cd ~/Manuscript
git remote add origin <url>
git push -u origin main
```
After that, the **⟳ Sync (git)** button commits all changes, `pull --rebase`s,
and pushes.
3. Create files with ** New**, edit on the right, `Ctrl+S` (or the Save button)
to write to disk. Drag the `` handles to reorder.
4. Set the **Chapter title** for the open file if you want to override the
auto-derived one (the field shows the automatic title as a hint). Leaving it
blank falls back to the file's first level-1 heading (`# ...`), or the file
name. Titles are saved to `titles.json` and sync with git.
5. Set the **Export** path and press **Export ODT** to produce the concatenated
document. Each file becomes a chapter headed by its (overridden or automatic)
title; the source's leading `# heading` is consumed as that title slot.
## Files the app writes
| Location | Purpose |
|---|---|
| `<workspace>/*.md` | your manuscript files |
| `<workspace>/order.json` | the manual ordering (committed to git) |
| `<workspace>/titles.json` | chapter-title overrides (committed to git) |
| `~/.config/md-manuscript/config.json` | last workspace, export path, prefs |
## Markdown supported in ODT export
Headings, paragraphs, **bold**, *italic*, ***both***, `inline code`, fenced/indented
code blocks (monospace, whitespace preserved), ~~strikethrough~~, ordered and
unordered (nested) lists, block quotes, horizontal rules, and links. Images are
rendered as a text placeholder (not embedded).
+736
View File
@@ -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}")
}
+61
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
_ => 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('"', "&quot;");
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("&amp;"),
'<' => self.out.push_str("&lt;"),
'>' => self.out.push_str("&gt;"),
_ => 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="&#8226;"><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="&#9702;"><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="&#9642;"><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>"));
}
}
+84
View File
@@ -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
}