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:
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+4306
File diff suppressed because it is too large
Load Diff
+17
@@ -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
|
||||
@@ -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. |
|
||||
@@ -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
@@ -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