Author SHA1 Message Date
pj 04a0a49660 changes 2026-06-22 13:44:00 -04:00
pj 457209f934 fix(import): isolate bad chapters and bound nesting depth
A deeply nested document could overflow the stack and abort the entire
import. Bound block recursion depth and wrap each chapter build in a
try/catch so one unreadable chapter is skipped instead of failing the
whole import.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:40:24 -04:00
pj b94b51140a fix(import): validate ISBN with checksum
The importer accepted any 9-13 digit run as the ISBN, so a UUID or
arbitrary number became a bogus ISBN. Prefer scheme-tagged identifiers
and accept only values that pass the ISBN-10/13 length and checksum
test, leaving it empty otherwise.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:39:25 -04:00
pj 5caf66a6fc fix(import): import the cover image
The EPUB cover was discarded and replaced with the default generated
cover. Locate the cover via the manifest cover-image property or the
meta name="cover" reference, resolve it to a data URI, and import it as
an image cover.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:38:35 -04:00
pj 983d8fd2c0 fix(import): keep table rows together
Tables were flattened to one paragraph per cell, losing row grouping.
Join each row's cells into a single paragraph so the row structure
survives (the model has no table node).

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:37:31 -04:00
pj f4079a6f54 fix(import): keep inline svg and unresolved image alt text
Inline svg elements were dropped entirely and an img whose source could
not be resolved was discarded. Serialize a standalone svg to a
data:image/svg+xml figure, and fall back to an unresolved image's alt
text as a paragraph instead of losing it.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:36:50 -04:00
pj a54e7a42ef fix(import): preserve link, underline, strike and code marks
EPUB import only mapped bold and italic; a, u, s/del/strike and code
elements lost their formatting (kept as plain text). Map them to the
matching marks, consistent with what the exporters now render.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:36:07 -04:00
pj 0485177282 chore(security): restrictive content security policy
security.csp was null (no CSP). Add a restrictive policy as
defense-in-depth: self-only by default, data:/blob: images for cover
and figure URLs, inline styles for React/inline style attributes, and
blob: workers for pdf.js. Needs verification against a production build
(covers, preview, export, IPC).

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:35:09 -04:00
pj 3572c7a6b9 fix(security): validate book id paths
load/save/delete_book built paths from the raw id, and ids round-trip
from file JSON, so a crafted id could escape the library directory.
Route all three through a book_path helper that rejects anything but
[A-Za-z0-9_-].

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:34:51 -04:00
pj 2f98f5ec1c fix(editor): prune chapter positions on delete and guard quota
Saved cursor/scroll and active-chapter entries accumulated per book
forever and were never removed when a book was deleted. Add
clearPositions and call it from the delete flow, and guard localStorage
writes against quota or unavailable storage.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:33:23 -04:00
pj 608ba2f21b fix(persist): schema-version guard and editor content check
A book file's declared schema was never read, so a file from a newer
app version would be opened, normalized and autosaved over in the old
format. Refuse to open books whose schema is newer than supported.
Enable the editor content check so chapters with unreadable content
warn the user instead of silently loading stripped content that the
next edit would persist.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:32:33 -04:00
pj b63951f814 fix(library): surface unreadable books
list_books silently skipped any .margin file it could not read, parse,
or that lacked an id, so a truncated manuscript just vanished. Emit a
corrupt placeholder entry instead and show it in the library as a
non-openable card with a recovery hint pointing at the .bak backup.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:31:04 -04:00
pj aa75b3ebda fix(preview): destroy pdf.js document on recompile
The preview created a new PDF document on every recompile but never
destroyed the old one, leaking the document and its worker transport.
Keep the loading task and destroy it on effect cleanup.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:29:30 -04:00
pj 4e5ea6d855 fix(ui): handle library-open errors
Opening a book card swallowed failures: a corrupt or unreadable file
failed silently and normalizeBook could throw on a missing chapters
array. Surface load errors as a toast, throw a clear message on
unparseable JSON, and make normalizeBook tolerant of missing chapters,
metadata and settings.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:29:29 -04:00
pj 9eabef9041 feat(export): surface typst warnings and glyph-coverage notice
Typst compile warnings were discarded. Capture them via the Warned
result and emit a pdf-warnings event (only from the export path, not
the live preview) that App shows as a non-fatal toast; the export still
succeeds. Also scan book text before reporting success and warn when it
contains scripts the embedded Latin fonts cannot render (CJK, Arabic,
Hebrew, Devanagari, Thai, emoji).

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:28:16 -04:00
pj 5c0040955e fix(export): correct list nesting, marks, line-start escaping, svg type
Four export-correctness fixes intermingled across the Typst and EPUB
renderers:

- H7: neutralize newlines in escaped text and apply leading-marker
  escaping (= - + / and N.) to the first text node of every block
  (paragraph, heading, list item, blockquote) and figure captions, even
  when that run carries a mark, so author text can never inject Typst
  markup or phantom headings/TOC entries.
- H9: render lists recursively so nested lists survive and multi-
  paragraph list items keep their paragraph breaks (Typst functional
  list()/enum(); EPUB nested <ul>/<ol> and multiple <p> in <li>).
- H10: render strike, underline and inline code marks in both exporters
  (Typst #strike/#underline/#raw; EPUB <s>/<u>/<code> with css).
- H8b: normalize +-bearing image subtypes so an imported svg+xml cover
  becomes cover.svg with the correct media type.

Validated the generated Typst constructs compile with typst 0.15.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:24:19 -04:00
pj 6e2ca9c4bc fix(persist): persist new books on create
A freshly created book lived only in memory until the first edit, so
quitting beforehand lost it and it never appeared in the library. Save
it to disk on creation via a shared createAndOpenBook used by both the
Library card and the New Book menu action.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:17:50 -04:00
pj 258d5e5209 fix(persist): guard markSaved against in-flight edits
markSaved cleared dirty unconditionally, so an edit made while a save
was in flight had its dirty flag wiped and its follow-up save dropped.
Only clear dirty when the book reference is unchanged since the save
started (immutable updates give a fresh reference per edit).

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:17:50 -04:00
pj 14eb3c4427 fix(persist): flush pending save on navigate-away and quit
Edits are saved on an 800ms debounce, so leaving a book within that
window lost the latest changes. Flush a dirty book before openBook /
closeBook swap state (covers All books, open-another, Cmd+N), and add a
window close-request handler that saves before destroying on quit.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:16:33 -04:00
pj 0bc3e36ee6 feat(editor): persist active chapter per book
Remember which chapter was open per book and restore it on reopen,
falling back to the first chapter when the saved id is no longer valid.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:15:01 -04:00
pj c0da4c53db fix(editor): isolate undo history per chapter
Key the editor by chapter id so each chapter gets a fresh TipTap
instance with its own history. Previously a single instance was reused
across chapters, so the content swap entered the undo stack and Ctrl+Z
after switching restored the prior chapter's doc into the current one,
which autosave then persisted.

With remount, save the cursor/scroll position on unmount (from a ref,
not the editor, which is destroyed by then) and load it on mount,
replacing the in-place chapter-switch bookkeeping.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:13:12 -04:00
pj 92941a2758 fix(persist): atomic+durable .margin writes
Route save_book, write_file, write_bytes through a shared atomic_write
helper: write to a temp file, fsync, then rename into place so a crash
mid-write can never leave a truncated or empty manuscript. Rotate the
prior copy to .bak for manuscripts (not exports) so one good copy
always survives.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
2026-06-22 13:12:39 -04:00
pj 61bd7cf6e5 changes 2026-06-22 13:03:11 -04:00
28 changed files with 569 additions and 219 deletions

No files matched your search

+1 -1
View File
@@ -4704,7 +4704,7 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "margin-app"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"base64 0.22.1",
"harper-core",
+1
View File
@@ -5,6 +5,7 @@
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-destroy",
"opener:default",
"dialog:default",
"updater:default",
+32 -6
View File
@@ -7,6 +7,16 @@ pub struct BookSummary {
id: String,
title: String,
author: String,
corrupt: bool,
}
fn corrupt_summary(stem: &str) -> BookSummary {
BookSummary {
id: stem.to_string(),
title: "Unreadable book".to_string(),
author: String::new(),
corrupt: true,
}
}
fn library_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
@@ -19,6 +29,13 @@ fn library_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(dir)
}
fn book_path(app: &tauri::AppHandle, id: &str) -> Result<PathBuf, String> {
if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return Err("invalid book id".to_string());
}
Ok(library_dir(app)?.join(format!("{id}.margin")))
}
#[tauri::command]
pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
let dir = library_dir(&app)?;
@@ -31,16 +48,24 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
if path.extension().and_then(|e| e.to_str()) != Some("margin") {
continue;
}
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let contents = match fs::read_to_string(&path) {
Ok(contents) => contents,
Err(_) => continue,
Err(_) => {
books.push(corrupt_summary(stem));
continue;
}
};
let value: serde_json::Value = match serde_json::from_str(&contents) {
Ok(value) => value,
Err(_) => continue,
Err(_) => {
books.push(corrupt_summary(stem));
continue;
}
};
let id = value.get("id").and_then(|v| v.as_str()).unwrap_or("");
if id.is_empty() {
books.push(corrupt_summary(stem));
continue;
}
let metadata = value.get("metadata");
@@ -58,6 +83,7 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
id: id.to_string(),
title,
author,
corrupt: false,
});
}
Ok(books)
@@ -65,19 +91,19 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
#[tauri::command]
pub fn load_book(app: tauri::AppHandle, id: String) -> Result<String, String> {
let path = library_dir(&app)?.join(format!("{id}.margin"));
let path = book_path(&app, &id)?;
fs::read_to_string(&path).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn save_book(app: tauri::AppHandle, id: String, contents: String) -> Result<(), String> {
let path = library_dir(&app)?.join(format!("{id}.margin"));
fs::write(&path, contents).map_err(|e| e.to_string())
let path = book_path(&app, &id)?;
crate::project::atomic_write(&path, contents.as_bytes(), true)
}
#[tauri::command]
pub fn delete_book(app: tauri::AppHandle, id: String) -> Result<(), String> {
let path = library_dir(&app)?.join(format!("{id}.margin"));
let path = book_path(&app, &id)?;
if path.exists() {
fs::remove_file(&path).map_err(|e| e.to_string())?;
}
+24 -7
View File
@@ -1,7 +1,8 @@
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use serde::Deserialize;
use typst::diag::{Severity, SourceDiagnostic};
use tauri::Emitter;
use typst::diag::{Severity, SourceDiagnostic, Warned};
use typst::layout::PagedDocument;
use typst_as_lib::TypstEngine;
@@ -16,7 +17,7 @@ pub struct ImageInput {
data: String,
}
fn compile(source: String, images: &[ImageInput]) -> Result<Vec<u8>, String> {
fn compile(source: String, images: &[ImageInput]) -> Result<(Vec<u8>, String), String> {
let mut binaries: Vec<(&str, Vec<u8>)> = Vec::with_capacity(images.len());
for image in images {
let bytes = STANDARD
@@ -31,14 +32,30 @@ fn compile(source: String, images: &[ImageInput]) -> Result<Vec<u8>, String> {
.with_static_file_resolver(binaries)
.build();
let document: PagedDocument = engine.compile().output.map_err(|e| format_diagnostics(&e))?;
typst_pdf::pdf(&document, &Default::default()).map_err(|d| format_source_diagnostics(&d))
let Warned { output, warnings } = engine.compile();
let document: PagedDocument = output.map_err(|e| format_diagnostics(&e))?;
let bytes =
typst_pdf::pdf(&document, &Default::default()).map_err(|d| format_source_diagnostics(&d))?;
let warning_text = if warnings.is_empty() {
String::new()
} else {
format_source_diagnostics(&warnings)
};
Ok((bytes, warning_text))
}
#[tauri::command(async)]
pub fn compile_pdf(source: String, images: Vec<ImageInput>) -> Result<tauri::ipc::Response, String> {
compile(source, &images).map(tauri::ipc::Response::new)
pub fn compile_pdf(
app: tauri::AppHandle,
source: String,
images: Vec<ImageInput>,
emit_warnings: bool,
) -> Result<tauri::ipc::Response, String> {
let (bytes, warnings) = compile(source, &images)?;
if emit_warnings && !warnings.is_empty() {
app.emit("pdf-warnings", warnings).ok();
}
Ok(tauri::ipc::Response::new(bytes))
}
fn format_diagnostics(error: &typst_as_lib::TypstAsLibError) -> String {
+30 -3
View File
@@ -1,6 +1,33 @@
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use std::fs;
use std::fs::{self, File};
use std::io::Write;
use std::path::{Path, PathBuf};
fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
let mut name = path.as_os_str().to_owned();
name.push(suffix);
PathBuf::from(name)
}
pub fn atomic_write(path: &Path, bytes: &[u8], backup: bool) -> Result<(), String> {
let tmp = with_suffix(path, ".tmp");
{
let mut f = File::create(&tmp).map_err(|e| e.to_string())?;
f.write_all(bytes).map_err(|e| e.to_string())?;
f.sync_all().map_err(|e| e.to_string())?;
}
if path.exists() {
if backup {
let bak = with_suffix(path, ".bak");
let _ = fs::remove_file(&bak);
fs::rename(path, &bak).map_err(|e| e.to_string())?;
} else {
fs::remove_file(path).map_err(|e| e.to_string())?;
}
}
fs::rename(&tmp, path).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn read_file(path: String) -> Result<String, String> {
@@ -9,11 +36,11 @@ pub fn read_file(path: String) -> Result<String, String> {
#[tauri::command]
pub fn write_file(path: String, contents: String) -> Result<(), String> {
fs::write(&path, contents).map_err(|e| e.to_string())
atomic_write(Path::new(&path), contents.as_bytes(), true)
}
#[tauri::command]
pub fn write_bytes(path: String, data: String) -> Result<(), String> {
let bytes = STANDARD.decode(data.as_bytes()).map_err(|e| e.to_string())?;
fs::write(&path, bytes).map_err(|e| e.to_string())
atomic_write(Path::new(&path), &bytes, false)
}
+1 -1
View File
@@ -21,7 +21,7 @@
}
],
"security": {
"csp": null
"csp": "default-src 'self'; img-src 'self' data: blob:; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self' blob:; connect-src 'self' ipc: http://ipc.localhost"
}
},
"bundle": {
+22 -2
View File
@@ -1,10 +1,11 @@
import { useEffect } from "react";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Library } from "./components/Library";
import { EditorView } from "./components/EditorView";
import { useBook } from "./store/useBook";
import { isDesktop } from "./ipc";
import { newBook } from "./library";
import { createAndOpenBook, saveBook } from "./library";
import { runExport } from "./export/run";
import { checkForUpdates } from "./updater";
@@ -17,11 +18,30 @@ function App() {
checkForUpdates(true);
const unlisten = listen<string>("menu-action", (event) => {
const state = useBook.getState();
if (event.payload === "new-book") state.openBook(newBook());
if (event.payload === "new-book") createAndOpenBook(state.openBook, state.setNotice);
else if (event.payload === "export-pdf") runExport("pdf");
else if (event.payload === "export-epub") runExport("epub");
else if (event.payload === "check-updates") checkForUpdates(false);
});
const unlistenWarn = listen<string>("pdf-warnings", (event) => {
useBook.getState().setNotice(`PDF exported with warnings:\n${event.payload}`);
});
return () => {
unlisten.then((stop) => stop());
unlistenWarn.then((stop) => stop());
};
}, []);
useEffect(() => {
if (!isDesktop) return;
const win = getCurrentWindow();
const unlisten = win.onCloseRequested(async (event) => {
const { book, dirty } = useBook.getState();
if (!book || !dirty) return;
event.preventDefault();
await saveBook(book).catch(() => {});
win.destroy();
});
return () => {
unlisten.then((stop) => stop());
};
+11 -2
View File
@@ -75,8 +75,13 @@ export function EditorView() {
const saveNow = useCallback(() => {
const current = useBook.getState().book;
if (current) saveBook(current).then(markSaved).catch((e) => setNotice(`Save failed: ${e}`));
}, [markSaved]);
if (!current) return;
saveBook(current)
.then(() => {
if (useBook.getState().book === current) markSaved();
})
.catch((e) => setNotice(`Save failed: ${e}`));
}, [markSaved, setNotice]);
useEffect(() => {
if (!book || !dirty || !isDesktop) return;
@@ -263,11 +268,15 @@ export function EditorView() {
)}
</header>
<Editor
key={chapter.id}
bookId={book.id}
chapterId={chapter.id}
content={chapter.content}
onChange={(content) => setChapterContent(chapter.id, content)}
onReady={setEditor}
onContentError={() =>
setNotice("This chapter contains content Margin couldn't fully read; editing may drop the unrecognized parts.")
}
/>
</article>
<FloatingToolbar editor={editor} />
+19 -5
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
import { type BookSummary, deleteBook, exampleBook, listBooks, loadBook, newBook, saveBook } from "../library";
import { type BookSummary, createAndOpenBook, deleteBook, exampleBook, listBooks, loadBook, saveBook } from "../library";
import type { Book } from "../model/book";
import { importEpub } from "../import/epub";
import { clearPositions } from "../editor/positions";
import { isDesktop } from "../ipc";
import { ConfirmDialog } from "./ConfirmDialog";
import { Icon } from "./Icon";
@@ -38,6 +39,7 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
const removeBook = async () => {
if (!pendingDelete) return;
await deleteBook(pendingDelete.id);
clearPositions(pendingDelete.id);
setPendingDelete(null);
refresh();
};
@@ -64,7 +66,7 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
<div className="library">
<header className="library-head" data-tauri-drag-region />
<div className="shelf">
<button className="card card-action" onClick={() => onOpen(newBook())}>
<button className="card card-action" onClick={() => createAndOpenBook(onOpen, setNotice)}>
<Icon d="M12 5v14M5 12h14" size={20} />
<span>New book</span>
</button>
@@ -78,13 +80,25 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
<span className="card-badge">Example</span>
</button>
)}
{books.map((b) => (
<div key={b.id} className="card card-book" onClick={() => loadBook(b.id).then(onOpen)}>
{books.map((b) =>
b.corrupt ? (
<div key={b.id} className="card card-book card-corrupt">
<span className="card-title">{b.title}</span>
<span className="card-author">Couldn't be read; a .bak backup may sit beside it.</span>
<RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
</div>
) : (
<div
key={b.id}
className="card card-book"
onClick={() => loadBook(b.id).then(onOpen).catch((e) => setNotice(`Could not open book: ${e}`))}
>
<span className="card-title">{b.title || "Untitled"}</span>
{b.author && <span className="card-author">{b.author}</span>}
<RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
</div>
))}
),
)}
</div>
{notice && (
<div className="toast" onClick={() => setNotice(null)}>
+3 -1
View File
@@ -12,10 +12,11 @@ export function PdfPreview({ data }: { data: Uint8Array }) {
const container = ref.current;
if (!container) return;
let cancelled = false;
const loadingTask = pdfjs.getDocument({ data: data.slice() });
(async () => {
try {
const doc = await pdfjs.getDocument({ data: data.slice() }).promise;
const doc = await loadingTask.promise;
if (cancelled) return;
const width = container.clientWidth;
const dpr = window.devicePixelRatio || 1;
@@ -47,6 +48,7 @@ export function PdfPreview({ data }: { data: Uint8Array }) {
return () => {
cancelled = true;
loadingTask.destroy();
};
}, [data]);
+19 -19
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react";
import type { JSONContent } from "@tiptap/core";
import { editorExtensions } from "./extensions";
import { loadPosition, savePosition } from "./positions";
import { loadPosition, savePosition, type ChapterPosition } from "./positions";
interface EditorProps {
bookId: string;
@@ -10,24 +10,30 @@ interface EditorProps {
content: JSONContent;
onChange: (content: JSONContent) => void;
onReady: (editor: TiptapEditor | null) => void;
onContentError: (error: Error) => void;
}
export function Editor({ bookId, chapterId, content, onChange, onReady }: EditorProps) {
export function Editor({ bookId, chapterId, content, onChange, onReady, onContentError }: EditorProps) {
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const onContentErrorRef = useRef(onContentError);
onContentErrorRef.current = onContentError;
const openChapter = useRef(chapterId);
const latest = useRef<ChapterPosition | null>(null);
const editor = useEditor({
extensions: editorExtensions,
content,
immediatelyRender: false,
enableContentCheck: true,
editorProps: { attributes: { class: "prose" } },
onContentError: ({ error }) => onContentErrorRef.current(error),
onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()),
});
useEffect(() => {
onReady(editor);
return () => onReady(null);
}, [editor, onReady]);
useEffect(() => {
@@ -35,15 +41,6 @@ export function Editor({ bookId, chapterId, content, onChange, onReady }: Editor
let active = true;
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
const previous = openChapter.current;
if (previous !== chapterId) {
const { from, to } = editor.state.selection;
savePosition(bookId, previous, { from, to, scroll: scroller?.scrollTop ?? 0 });
}
openChapter.current = chapterId;
editor.commands.setContent(content, { emitUpdate: false });
const saved = loadPosition(bookId, chapterId);
const size = editor.state.doc.content.size;
const selection = saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0;
@@ -63,18 +60,20 @@ export function Editor({ bookId, chapterId, content, onChange, onReady }: Editor
active = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chapterId, editor, bookId]);
}, [editor]);
useEffect(() => {
if (!editor) return;
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
let timer: ReturnType<typeof setTimeout>;
const persist = () => {
clearTimeout(timer);
timer = setTimeout(() => {
const capture = () => {
const { from, to } = editor.state.selection;
savePosition(bookId, openChapter.current, { from, to, scroll: scroller?.scrollTop ?? 0 });
}, 400);
latest.current = { from, to, scroll: scroller?.scrollTop ?? 0 };
};
const persist = () => {
capture();
clearTimeout(timer);
timer = setTimeout(() => savePosition(bookId, chapterId, latest.current!), 400);
};
editor.on("selectionUpdate", persist);
scroller?.addEventListener("scroll", persist, { passive: true });
@@ -82,8 +81,9 @@ export function Editor({ bookId, chapterId, content, onChange, onReady }: Editor
clearTimeout(timer);
editor.off("selectionUpdate", persist);
scroller?.removeEventListener("scroll", persist);
if (latest.current) savePosition(bookId, chapterId, latest.current);
};
}, [editor, bookId]);
}, [editor, bookId, chapterId]);
return <EditorContent editor={editor} className="editor-host" />;
}
+38 -1
View File
@@ -16,6 +16,14 @@ function readAll(): Store {
}
}
function write(key: string, value: unknown): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
return;
}
}
export function loadPosition(bookId: string, chapterId: string): ChapterPosition | null {
return readAll()[bookId]?.[chapterId] ?? null;
}
@@ -23,5 +31,34 @@ export function loadPosition(bookId: string, chapterId: string): ChapterPosition
export function savePosition(bookId: string, chapterId: string, position: ChapterPosition): void {
const all = readAll();
(all[bookId] ||= {})[chapterId] = position;
localStorage.setItem(KEY, JSON.stringify(all));
write(KEY, all);
}
const ACTIVE_KEY = "margin-active-chapter";
function readActive(): Record<string, string> {
try {
return JSON.parse(localStorage.getItem(ACTIVE_KEY) || "{}") as Record<string, string>;
} catch {
return {};
}
}
export function loadActiveChapter(bookId: string): string | null {
return readActive()[bookId] ?? null;
}
export function saveActiveChapter(bookId: string, chapterId: string): void {
const all = readActive();
all[bookId] = chapterId;
write(ACTIVE_KEY, all);
}
export function clearPositions(bookId: string): void {
const positions = readAll();
const active = readActive();
delete positions[bookId];
delete active[bookId];
write(KEY, positions);
write(ACTIVE_KEY, active);
}
+14 -6
View File
@@ -25,7 +25,7 @@ function slug(text: string): string {
function imageExtension(dataUrl: string): string {
const match = /^data:image\/([a-z0-9.+-]+)/i.exec(dataUrl);
const kind = (match?.[1] ?? "png").toLowerCase();
const kind = (match?.[1] ?? "png").toLowerCase().split("+")[0];
return kind === "jpeg" ? "jpg" : kind;
}
@@ -42,8 +42,11 @@ function inline(node: JSONContent): string {
if (node.type === "text") {
let text = esc(node.text ?? "");
const marks = node.marks ?? [];
if (marks.some((m) => m.type === "code")) text = `<code>${text}</code>`;
if (marks.some((m) => m.type === "bold")) text = `<strong>${text}</strong>`;
if (marks.some((m) => m.type === "italic")) text = `<em>${text}</em>`;
if (marks.some((m) => m.type === "strike")) text = `<s>${text}</s>`;
if (marks.some((m) => m.type === "underline")) text = `<u>${text}</u>`;
const href = marks.find((m) => m.type === "link")?.attrs?.href;
if (href) text = `<a href="${attr(href)}">${text}</a>`;
return text;
@@ -56,10 +59,10 @@ function inlines(content: JSONContent[] = []): string {
return content.map(inline).join("");
}
function listItem(item: JSONContent): string {
function listItem(item: JSONContent, paths: Map<string, string>): string {
const inner = (item.content ?? [])
.filter((c) => c.type === "paragraph")
.map((p) => inlines(p.content))
.map((child) => block(child, paths))
.filter((s) => s.length > 0)
.join("");
return `<li>${inner}</li>`;
}
@@ -92,9 +95,9 @@ function block(node: JSONContent, paths: Map<string, string>): string {
case "blockquote":
return `<blockquote>${(node.content ?? []).map((n) => block(n, paths)).join("")}</blockquote>`;
case "bulletList":
return `<ul>${(node.content ?? []).map(listItem).join("")}</ul>`;
return `<ul>${(node.content ?? []).map((li) => listItem(li, paths)).join("")}</ul>`;
case "orderedList":
return `<ol>${(node.content ?? []).map(listItem).join("")}</ol>`;
return `<ol>${(node.content ?? []).map((li) => listItem(li, paths)).join("")}</ol>`;
case "horizontalRule":
return `<hr class="scene-break"/>`;
case "figure":
@@ -406,6 +409,11 @@ h2, h3 {
margin: 0;
}
code {
font-family: "SF Mono", Menlo, Consolas, monospace;
font-size: 0.9em;
}
blockquote {
margin: 1.2em 1.6em;
font-style: italic;
+1 -1
View File
@@ -9,7 +9,7 @@ const EPUB = [{ name: "EPUB", extensions: ["epub"] }];
export async function exportPdf(book: Book): Promise<void> {
const { source, images } = bookToPdfInputs(book);
const bytes = await compilePdf(source, images);
const bytes = await compilePdf(source, images, true);
await saveBytes(bytes, `${book.metadata.title || "Untitled"}.pdf`, PDF);
}
+6
View File
@@ -1,5 +1,6 @@
import type { Book } from "../model/book";
import { exportEpub, exportPdf } from "./exporters";
import { unsupportedScripts } from "./typst";
import { useBook } from "../store/useBook";
import { isDesktop } from "../ipc";
@@ -29,6 +30,11 @@ export async function runExport(format: "pdf" | "epub"): Promise<void> {
await nextPaint();
try {
await run(book);
if (format === "pdf") {
const scripts = unsupportedScripts(book);
if (scripts.length)
setNotice(`Some ${scripts.join(", ")} characters may not render — the PDF fonts cover Latin scripts only.`);
}
} catch (e) {
setNotice(`${label} export failed: ${e}`);
} finally {
+53 -20
View File
@@ -10,27 +10,32 @@ const TRIM: Record<TrimSize, { w: string; h: string }> = {
};
const INLINE_SPECIAL = /[\\#$*_`<>@~[\]]/g;
const LINE_SEPARATORS = /[\n\r\v\f\u2028\u2029]+/g;
function esc(text: string): string {
return text.replace(INLINE_SPECIAL, (m) => "\\" + m);
return text.replace(LINE_SEPARATORS, " ").replace(INLINE_SPECIAL, (m) => "\\" + m);
}
function guardLineStart(line: string): string {
if (/^[=\-+/]/.test(line)) return "\\" + line;
if (/^\d+\./.test(line)) return line.replace(".", "\\.");
return line;
function guardLineStart(text: string): string {
if (/^[=\-+/]/.test(text)) return "\\" + text;
if (/^\d+\./.test(text)) return text.replace(".", "\\.");
return text;
}
function str(value: string): string {
return JSON.stringify(value);
}
function inline(node: JSONContent): string {
function inline(node: JSONContent, atLineStart: boolean): string {
if (node.type === "text") {
let text = esc(node.text ?? "");
const marks = node.marks ?? [];
if (marks.some((m) => m.type === "code")) return `#raw(${str(node.text ?? "")})`;
let text = esc(node.text ?? "");
if (atLineStart) text = guardLineStart(text);
if (marks.some((m) => m.type === "italic")) text = `#emph[${text}]`;
if (marks.some((m) => m.type === "bold")) text = `#strong[${text}]`;
if (marks.some((m) => m.type === "strike")) text = `#strike[${text}]`;
if (marks.some((m) => m.type === "underline")) text = `#underline[${text}]`;
const href = marks.find((m) => m.type === "link")?.attrs?.href;
if (href) text = `#link(${str(href)})[${text}]`;
return text;
@@ -39,15 +44,15 @@ function inline(node: JSONContent): string {
return "";
}
function inlines(content: JSONContent[] = []): string {
return content.map(inline).join("");
function inlines(content: JSONContent[] = [], atLineStart = false): string {
return content.map((node, i) => inline(node, atLineStart && i === 0)).join("");
}
function listItem(item: JSONContent): string {
return (item.content ?? [])
.filter((c) => c.type === "paragraph")
.map((p) => inlines(p.content))
.join(" ");
function listItem(item: JSONContent, paths: Map<string, string>): string {
const parts = (item.content ?? [])
.map((child) => block(child, paths))
.filter((s) => s.length > 0);
return `[${parts.join("\n\n")}]`;
}
function figure(node: JSONContent, paths: Map<string, string>): string {
@@ -59,7 +64,7 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
}
const placed = placement as FigurePlacement;
const width = (isResizablePlacement(placed) ? node.attrs?.width : null) ?? FIGURE_WIDTH[placed] ?? 100;
const caption = node.attrs?.caption ? `, caption: [${esc(node.attrs.caption)}]` : "";
const caption = node.attrs?.caption ? `, caption: [${guardLineStart(esc(node.attrs.caption))}]` : "";
const float = placement === "float-top" ? ", placement: top" : "";
return `#figure(image(${str(path)}, width: ${width}%)${caption}${float})`;
}
@@ -67,18 +72,18 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
function block(node: JSONContent, paths: Map<string, string>): string {
switch (node.type) {
case "paragraph": {
const body = guardLineStart(inlines(node.content));
const body = inlines(node.content, true);
if (!body.trim()) return "~";
return node.attrs?.indent ? `#h(1.3em)${body}` : body;
}
case "heading":
return `#heading(level: ${node.attrs?.level ?? 2})[${inlines(node.content)}]`;
return `#heading(level: ${node.attrs?.level ?? 2})[${inlines(node.content, true)}]`;
case "blockquote":
return `#blockquote[${(node.content ?? []).map((n) => block(n, paths)).join("\n\n")}]`;
case "bulletList":
return (node.content ?? []).map((li) => `- ${listItem(li)}`).join("\n");
return `#list(${(node.content ?? []).map((li) => listItem(li, paths)).join(", ")})`;
case "orderedList":
return (node.content ?? []).map((li) => `+ ${listItem(li)}`).join("\n");
return `#enum(${(node.content ?? []).map((li) => listItem(li, paths)).join(", ")})`;
case "horizontalRule":
return "#scenebreak";
case "figure":
@@ -188,7 +193,7 @@ function preamble(book: Book): string {
function imageExtension(dataUrl: string): string {
const match = /^data:image\/([a-z0-9.+-]+)/i.exec(dataUrl);
const kind = (match?.[1] ?? "png").toLowerCase();
const kind = (match?.[1] ?? "png").toLowerCase().split("+")[0];
return kind === "jpeg" ? "jpg" : kind;
}
@@ -251,6 +256,34 @@ export function extractImages(book: Book): { images: ImageInput[]; paths: Map<st
return collectImages(book.chapters.map((chapter) => chapter.content));
}
const SCRIPT_RANGES: { label: string; test: RegExp }[] = [
{ label: "CJK", test: /[぀-鿿가-힯豈-﫿]/u },
{ label: "Arabic", test: /[؀-ۿݐ-ݿ]/u },
{ label: "Hebrew", test: /[֐-׿]/u },
{ label: "Devanagari", test: /[ऀ-ॿ]/u },
{ label: "Thai", test: /[฀-๿]/u },
{ label: "emoji", test: /[☀-➿]|[\u{1f000}-\u{1faff}]/u },
];
function collectText(book: Book): string {
const parts = [book.metadata.title, book.metadata.subtitle, book.metadata.author];
const visit = (node: JSONContent) => {
if (node.type === "text" && node.text) parts.push(node.text);
if (node.attrs?.caption) parts.push(String(node.attrs.caption));
(node.content ?? []).forEach(visit);
};
book.chapters.forEach((chapter) => {
parts.push(chapter.title);
visit(chapter.content);
});
return parts.join("\n");
}
export function unsupportedScripts(book: Book): string[] {
const text = collectText(book);
return SCRIPT_RANGES.filter((s) => s.test.test(text)).map((s) => s.label);
}
function cleanTitle(title: string): string {
return (title || "")
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ")
+121 -20
View File
@@ -1,7 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import type { JSONContent } from "@tiptap/core";
import { type Book, type BookMetadata, type Chapter, createCover } from "../model/book";
import { type Book, type BookMetadata, type Chapter, type Cover, createCover } from "../model/book";
export interface RawFile {
path: string;
@@ -9,7 +9,7 @@ export interface RawFile {
encoding: "utf8" | "base64";
}
type Mark = { type: string };
type Mark = { type: string; attrs?: Record<string, unknown> };
const BLOCK_TAGS = new Set([
"p", "div", "section", "article", "main", "header", "footer", "aside",
@@ -81,8 +81,8 @@ function hasMark(marks: Mark[], type: string): boolean {
return marks.some((m) => m.type === type);
}
function addMark(marks: Mark[], type: string): Mark[] {
return hasMark(marks, type) ? marks : [...marks, { type }];
function addMark(marks: Mark[], type: string, attrs?: Record<string, unknown>): Mark[] {
return hasMark(marks, type) ? marks : [...marks, attrs ? { type, attrs } : { type }];
}
function collectInline(node: Node, marks: Mark[], out: JSONContent[]): void {
@@ -102,6 +102,15 @@ function collectInline(node: Node, marks: Mark[], out: JSONContent[]): void {
collectInline(el, addMark(marks, "bold"), out);
} else if (tag === "em" || tag === "i") {
collectInline(el, addMark(marks, "italic"), out);
} else if (tag === "u") {
collectInline(el, addMark(marks, "underline"), out);
} else if (tag === "s" || tag === "strike" || tag === "del") {
collectInline(el, addMark(marks, "strike"), out);
} else if (tag === "code") {
collectInline(el, addMark(marks, "code"), out);
} else if (tag === "a") {
const href = el.getAttribute("href");
collectInline(el, href ? addMark(marks, "link", { href }) : marks, out);
} else if (!BLOCK_TAGS.has(tag)) {
collectInline(el, marks, out);
}
@@ -137,7 +146,7 @@ function meaningful(inline: JSONContent[]): boolean {
function imgFigure(img: Element, caption = ""): JSONContent[] {
const src = img.getAttribute("src");
if (!src || !src.startsWith("data:")) return [];
if (src && src.startsWith("data:")) {
return [
{
type: "figure",
@@ -145,6 +154,16 @@ function imgFigure(img: Element, caption = ""): JSONContent[] {
},
];
}
const alt = (img.getAttribute("alt") ?? "").trim();
return alt ? [{ type: "paragraph", content: [{ type: "text", text: alt }] }] : [];
}
function svgFigure(el: Element): JSONContent[] {
const markup = el.outerHTML;
if (!markup) return [];
const src = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(markup)))}`;
return [{ type: "figure", attrs: { src, alt: "", caption: "", placement: "full-width" } }];
}
function figureFrom(el: Element): JSONContent[] {
const img = el.querySelector("img");
@@ -165,18 +184,35 @@ function hasBlockChild(el: Element): boolean {
return Array.from(el.children).some((c) => BLOCK_TAGS.has(c.localName.toLowerCase()));
}
function listItems(el: Element): JSONContent[] {
function listItems(el: Element, depth: number): JSONContent[] {
const items: JSONContent[] = [];
Array.from(el.children)
.filter((c) => c.localName.toLowerCase() === "li")
.forEach((li) => {
const content = blocksFrom(li);
const content = blocksFrom(li, depth + 1);
items.push({ type: "listItem", content: content.length ? content : [{ type: "paragraph" }] });
});
return items;
}
function blockFromElement(el: Element): JSONContent[] {
function tableBlocks(el: Element): JSONContent[] {
const out: JSONContent[] = [];
Array.from(el.querySelectorAll("tr")).forEach((tr) => {
const content: JSONContent[] = [];
Array.from(tr.children)
.filter((c) => c.localName.toLowerCase() === "td" || c.localName.toLowerCase() === "th")
.forEach((cell) => {
const inline = inlineOf(cell);
if (!meaningful(inline)) return;
if (content.length) content.push({ type: "text", text: " · " });
content.push(...inline);
});
if (meaningful(content)) out.push({ type: "paragraph", content });
});
return out;
}
function blockFromElement(el: Element, depth: number): JSONContent[] {
const tag = el.localName.toLowerCase();
switch (tag) {
case "p":
@@ -192,15 +228,15 @@ function blockFromElement(el: Element): JSONContent[] {
return meaningful(content) ? [{ type: "heading", attrs: { level }, content }] : [];
}
case "blockquote": {
const inner = blocksFrom(el);
const inner = blocksFrom(el, depth + 1);
return [{ type: "blockquote", content: inner.length ? inner : [{ type: "paragraph" }] }];
}
case "ul": {
const items = listItems(el);
const items = listItems(el, depth);
return items.length ? [{ type: "bulletList", content: items }] : [];
}
case "ol": {
const items = listItems(el);
const items = listItems(el, depth);
return items.length ? [{ type: "orderedList", content: items }] : [];
}
case "hr":
@@ -209,22 +245,27 @@ function blockFromElement(el: Element): JSONContent[] {
return figureFrom(el);
case "img":
return imgFigure(el);
case "svg":
return svgFigure(el);
case "table":
return tableBlocks(el);
case "script":
case "style":
return [];
default:
return hasBlockChild(el) ? blocksFrom(el) : paragraphAndFigures(el);
return hasBlockChild(el) ? blocksFrom(el, depth + 1) : paragraphAndFigures(el);
}
}
function blocksFrom(parent: Node): JSONContent[] {
function blocksFrom(parent: Node, depth = 0): JSONContent[] {
if (depth > 64) return [];
const out: JSONContent[] = [];
parent.childNodes.forEach((child) => {
if (child.nodeType === 3) {
const text = (child.nodeValue ?? "").replace(/\s+/g, " ").trim();
if (text !== "") out.push({ type: "paragraph", content: [{ type: "text", text }] });
} else if (child.nodeType === 1) {
out.push(...blockFromElement(child as Element));
out.push(...blockFromElement(child as Element, depth));
}
});
return out;
@@ -234,6 +275,41 @@ function headingText(node: JSONContent): string {
return (node.content ?? []).map((n) => n.text ?? "").join("").trim();
}
function isbnChecksum(s: string): boolean {
if (s.length === 10) {
let sum = 0;
for (let i = 0; i < 10; i++) {
const v = s[i] === "X" ? 10 : Number(s[i]);
if (Number.isNaN(v) || (s[i] === "X" && i !== 9)) return false;
sum += v * (10 - i);
}
return sum % 11 === 0;
}
if (s.length === 13 && /^\d{13}$/.test(s)) {
let sum = 0;
for (let i = 0; i < 13; i++) sum += Number(s[i]) * (i % 2 === 0 ? 1 : 3);
return sum % 10 === 0;
}
return false;
}
function findIsbn(opf: Document): string {
const ids = Array.from(opf.getElementsByTagName("*")).filter((el) => el.localName === "identifier");
const ranked = ids
.map((el) => ({
text: el.textContent ?? "",
tagged:
/isbn/i.test(el.getAttribute("opf:scheme") || el.getAttribute("scheme") || "") ||
/urn:isbn:/i.test(el.textContent ?? ""),
}))
.sort((a, b) => Number(b.tagged) - Number(a.tagged));
for (const { text } of ranked) {
const cleaned = text.replace(/[^0-9Xx]/g, "").toUpperCase();
if (isbnChecksum(cleaned)) return cleaned;
}
return "";
}
interface ManifestItem {
href: string;
mediaType: string;
@@ -317,6 +393,11 @@ function readNavTitles(
return titles;
}
function fileToDataUri(file: RawFile, mime: string): string {
const data = file.encoding === "base64" ? file.data : btoa(unescape(encodeURIComponent(file.data)));
return `data:${mime};base64,${data}`;
}
function resolveImages(
doc: Document,
baseDir: string,
@@ -335,12 +416,29 @@ function resolveImages(
img.removeAttribute("src");
return;
}
const mime = mimeByPath.get(resolved) || mimeFromExt(resolved);
const data = file.encoding === "base64" ? file.data : btoa(unescape(encodeURIComponent(file.data)));
img.setAttribute("src", `data:${mime};base64,${data}`);
img.setAttribute("src", fileToDataUri(file, mimeByPath.get(resolved) || mimeFromExt(resolved)));
});
}
function findCover(
opf: Document,
manifest: Map<string, ManifestItem>,
byPath: Map<string, RawFile>,
): Cover {
let item = Array.from(manifest.values()).find((m) => m.properties.split(/\s+/).includes("cover-image"));
if (!item) {
const metaCover = Array.from(opf.getElementsByTagName("*")).find(
(el) => el.localName === "meta" && el.getAttribute("name") === "cover",
);
const id = metaCover?.getAttribute("content");
if (id) item = manifest.get(id);
}
if (!item) return createCover();
const file = byPath.get(normalize(item.href));
if (!file) return createCover();
return { ...createCover(), kind: "image", image: fileToDataUri(file, item.mediaType || mimeFromExt(item.href)) };
}
function buildChapter(
file: RawFile,
baseDir: string,
@@ -394,18 +492,21 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B
spine.forEach((item, i) => {
const file = byPath.get(normalize(item.href));
if (!file) return;
try {
chapters.push(
buildChapter(file, dirOf(item.href), navTitles.get(item.href), i, byPath, manifest),
);
} catch {
return;
}
});
if (!chapters.length) throw new Error("Not a valid EPUB: no readable chapters found.");
const identifier = localText(opf, "identifier");
const metadata: BookMetadata = {
title: localText(opf, "title") || fallbackName,
subtitle: "",
author: localText(opf, "creator"),
isbn: /\d{9,13}/.test(identifier) ? identifier.replace(/[^0-9Xx]/g, "") : "",
isbn: findIsbn(opf),
language: localText(opf, "language") || "en",
};
@@ -415,7 +516,7 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B
metadata,
theme: "quiet-press",
settings: { trim: "6x9", bleed: true },
cover: createCover(),
cover: findCover(opf, manifest, byPath),
chapters,
};
}
+6 -2
View File
@@ -7,7 +7,11 @@ export interface ImageInput {
data: string;
}
export async function compilePdf(source: string, images: ImageInput[] = []): Promise<Uint8Array> {
const buffer = await invoke<ArrayBuffer>("compile_pdf", { source, images });
export async function compilePdf(
source: string,
images: ImageInput[] = [],
emitWarnings = false,
): Promise<Uint8Array> {
const buffer = await invoke<ArrayBuffer>("compile_pdf", { source, images, emitWarnings });
return new Uint8Array(buffer);
}
+27 -2
View File
@@ -1,11 +1,12 @@
import { invoke } from "@tauri-apps/api/core";
import { isDesktop } from "./ipc";
import { type Book, createBook, starterBook } from "./model/book";
import { type Book, SCHEMA_VERSION, createBook, schemaVersion, starterBook } from "./model/book";
export interface BookSummary {
id: string;
title: string;
author: string;
corrupt?: boolean;
}
export async function listBooks(): Promise<BookSummary[]> {
@@ -15,7 +16,16 @@ export async function listBooks(): Promise<BookSummary[]> {
export async function loadBook(id: string): Promise<Book> {
const contents = await invoke<string>("load_book", { id });
return JSON.parse(contents) as Book;
let book: Book;
try {
book = JSON.parse(contents) as Book;
} catch {
throw new Error("the book file is corrupt or unreadable");
}
if (schemaVersion(book.schema) > SCHEMA_VERSION) {
throw new Error("this book was made with a newer version of Margin; update the app to open it");
}
return book;
}
export async function saveBook(book: Book): Promise<void> {
@@ -35,3 +45,18 @@ export function newBook(): Book {
export function exampleBook(): Book {
return starterBook();
}
export async function createAndOpenBook(
open: (book: Book) => void,
onError?: (message: string) => void,
): Promise<void> {
const book = newBook();
if (isDesktop) {
try {
await saveBook(book);
} catch (e) {
onError?.(`Could not create book: ${e}`);
}
}
open(book);
}
+19 -3
View File
@@ -115,6 +115,14 @@ export interface Book {
chapters: Chapter[];
}
export const SCHEMA_VERSION = 1;
export function schemaVersion(schema: unknown): number {
if (typeof schema !== "string") return 0;
const match = /^margin\/(\d+)$/.exec(schema);
return match ? Number(match[1]) : 0;
}
function paragraph(text: string): JSONContent {
return { type: "paragraph", content: [{ type: "text", text }] };
}
@@ -140,11 +148,19 @@ export function createCover(): Cover {
return { kind: "default", image: "", bg: COVER_PALETTES[0].bg, ink: COVER_PALETTES[0].ink };
}
const DEFAULT_METADATA: BookMetadata = { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" };
const DEFAULT_SETTINGS: BookSettings = { trim: "6x9", bleed: true };
export function normalizeBook(book: Book): Book {
const chapters = (Array.isArray(book.chapters) ? book.chapters : []).filter(Boolean);
return {
...book,
metadata: { ...DEFAULT_METADATA, ...book.metadata },
settings: { ...DEFAULT_SETTINGS, ...book.settings },
cover: book.cover ? { ...createCover(), ...book.cover } : createCover(),
chapters: book.chapters.map((c) => (c.updatedAt ? c : { ...c, updatedAt: Date.now() })),
chapters: (chapters.length ? chapters : [createChapter()]).map((c) =>
c.updatedAt ? c : { ...c, updatedAt: Date.now() },
),
};
}
@@ -152,9 +168,9 @@ export function createBook(): Book {
return {
schema: "margin/1",
id: crypto.randomUUID(),
metadata: { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" },
metadata: { ...DEFAULT_METADATA },
theme: "quiet-press",
settings: { trim: "6x9", bleed: true },
settings: { ...DEFAULT_SETTINGS },
cover: createCover(),
chapters: [createChapter("Chapter One")],
};
+24 -3
View File
@@ -12,6 +12,8 @@ import {
createPage,
normalizeBook,
} from "../model/book";
import { loadActiveChapter, saveActiveChapter } from "../editor/positions";
import { saveBook } from "../library";
export const COVER_ID = "__cover__";
@@ -40,7 +42,11 @@ interface BookState {
markSaved: () => void;
}
export const useBook = create<BookState>((set) => ({
function flushOutgoing(book: Book | null, dirty: boolean): void {
if (book && dirty) saveBook(book).catch(() => {});
}
export const useBook = create<BookState>((set, get) => ({
book: null,
activeChapterId: "",
dirty: false,
@@ -49,10 +55,17 @@ export const useBook = create<BookState>((set) => ({
setExporting: (label) => set({ exporting: label }),
setNotice: (message) => set({ notice: message }),
openBook: (book) => {
flushOutgoing(get().book, get().dirty);
const normalized = normalizeBook(book);
set({ book: normalized, activeChapterId: normalized.chapters[0]?.id ?? "", dirty: false });
const saved = loadActiveChapter(normalized.id);
const valid = saved === COVER_ID || normalized.chapters.some((c) => c.id === saved);
const activeChapterId = valid ? (saved as string) : normalized.chapters[0]?.id ?? "";
set({ book: normalized, activeChapterId, dirty: false });
},
closeBook: () => {
flushOutgoing(get().book, get().dirty);
set({ book: null, activeChapterId: "", dirty: false });
},
closeBook: () => set({ book: null, activeChapterId: "", dirty: false }),
setActiveChapter: (id) => set({ activeChapterId: id }),
setChapterContent: (id, content) =>
set((s) =>
@@ -132,3 +145,11 @@ export const useBook = create<BookState>((set) => ({
set((s) => (s.book ? { dirty: true, book: { ...s.book, cover: { ...s.book.cover, ...patch } } } : {})),
markSaved: () => set({ dirty: false }),
}));
let lastActive = "";
useBook.subscribe((s) => {
if (s.book && s.activeChapterId && s.activeChapterId !== lastActive) {
lastActive = s.activeChapterId;
saveActiveChapter(s.book.id, s.activeChapterId);
}
});
+10
View File
@@ -1209,6 +1209,16 @@ body.resizing {
box-shadow: 0 4px 10px rgba(35, 32, 27, 0.1), 0 20px 38px rgba(35, 32, 27, 0.13);
}
.card-corrupt {
cursor: default;
opacity: 0.65;
border-style: dashed;
}
.card-corrupt:hover {
transform: none;
}
.card-title {
font-family: var(--font-book);
font-size: 20px;
+4 -1
View File
@@ -1,10 +1,12 @@
---
import { site, platforms } from "../data/site";
import { getRelease } from "../data/release";
interface Props {
large?: boolean;
}
const { large = false } = Astro.props;
const release = await getRelease();
---
<div class="dl">
@@ -26,7 +28,7 @@ const { large = false } = Astro.props;
platforms.map((p) => (
<a
class="dl__chip"
href={site.releasesUrl}
href={release.urls[p.id]}
data-download-os={p.id}
rel="noopener"
download
@@ -57,6 +59,7 @@ const { large = false } = Astro.props;
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 44px;
height: 44px;
background: #ffffff;
+21 -35
View File
@@ -1,14 +1,13 @@
---
import { site } from "../data/site";
import { site, platforms } from "../data/site";
import { getRelease } from "../data/release";
const { repo, releasesUrl } = site;
const { urls, version } = await getRelease();
const matches = Object.fromEntries(platforms.map((p) => [p.id, p.match]));
---
<script define:vars={{ repo, releasesUrl }}>
const SUFFIXES = {
mac: [".dmg"],
windows: [".msi", ".exe"],
linux: [".AppImage", ".deb", ".rpm"],
};
<script define:vars={{ repo, releasesUrl, urls, version, matches }}>
const LABELS = { mac: "macOS", windows: "Windows", linux: "Linux" };
function detectOS() {
@@ -21,32 +20,31 @@ const { repo, releasesUrl } = site;
}
function pickAsset(assets, os) {
for (const sfx of SUFFIXES[os] || []) {
for (const sfx of matches[os] || []) {
const hit = assets.find((a) => a.name.toLowerCase().endsWith(sfx.toLowerCase()));
if (hit) return hit.browser_download_url;
}
return null;
}
function apply(map, ver) {
const os = detectOS();
document.querySelectorAll("[data-download]").forEach((el) => {
el.dataset.detectedOs = os;
const label = el.querySelector("[data-dl-label]");
if (label) label.textContent = "Download for " + LABELS[os];
if (map[os]) el.href = map[os];
});
document.querySelectorAll(".dl__alt [data-download-os]").forEach((el) => {
document.querySelectorAll("[data-download-os]").forEach((el) => {
const url = map[el.dataset.downloadOs];
if (url) el.href = url;
});
document.querySelectorAll(".dl__os [data-download-os]").forEach((el) => {
if (el.dataset.downloadOs === os) el.remove();
});
document.querySelectorAll("[data-osrow]").forEach((row) => {
const primary = row.querySelector(`[data-download-os="${os}"]`);
if (primary) {
primary.classList.add("is-detected");
row.prepend(primary);
if (ver) document.querySelectorAll("[data-version]").forEach((el) => (el.textContent = ver));
}
});
apply(urls, version);
(async () => {
try {
@@ -56,23 +54,11 @@ const { repo, releasesUrl } = site;
if (!res.ok) return;
const data = await res.json();
const assets = data.assets || [];
const version = data.tag_name || "";
document.querySelectorAll("[data-download]").forEach((el) => {
const url = pickAsset(assets, el.dataset.detectedOs || os);
if (url) el.href = url;
});
document.querySelectorAll("[data-download-os]").forEach((el) => {
const url = pickAsset(assets, el.dataset.downloadOs);
if (url) el.href = url;
});
if (version) {
document.querySelectorAll("[data-version]").forEach((el) => {
el.textContent = version;
});
}
const fresh = {};
for (const os of Object.keys(matches)) fresh[os] = pickAsset(assets, os) || urls[os] || releasesUrl;
apply(fresh, data.tag_name || version);
} catch (e) {
/* keep the releases-page fallback links */
/* keep the build-time links */
}
})();
</script>
+6 -3
View File
@@ -1,14 +1,17 @@
---
import { site } from "../data/site";
import { getRelease } from "../data/release";
import Logo from "./Logo.astro";
const release = await getRelease();
const cols = [
{
title: "Get Margin",
links: [
{ label: "Download for macOS", href: site.releasesUrl, os: "mac" },
{ label: "Download for Windows", href: site.releasesUrl, os: "windows" },
{ label: "Download for Linux", href: site.releasesUrl, os: "linux" },
{ label: "Download for macOS", href: release.urls.mac, os: "mac" },
{ label: "Download for Windows", href: release.urls.windows, os: "windows" },
{ label: "Download for Linux", href: release.urls.linux, os: "linux" },
],
},
{
+2 -73
View File
@@ -1,7 +1,7 @@
---
import AppWindow from "./AppWindow.astro";
import DownloadButtons from "./DownloadButtons.astro";
import OfferBadge from "./OfferBadge.astro";
import { site, platforms } from "../data/site";
---
<section class="hero">
@@ -21,25 +21,7 @@ import { site, platforms } from "../data/site";
</div>
<div class="container hero__get" id="download">
<div class="hero__buttons" data-osrow>
{
platforms.map((p) => (
<a
class="osbtn"
href={site.releasesUrl}
data-download-os={p.id}
rel="noopener"
download
>
<img class="osbtn__icon" src={`/logos/${p.logo}`} alt="" />
<span>Download for {p.label}</span>
</a>
))
}
</div>
<p class="hero__meta">
<span data-version>v0.1.2</span> · Works offline · No account
</p>
<DownloadButtons large />
<div class="hero__offer">
<OfferBadge />
</div>
@@ -75,62 +57,9 @@ import { site, platforms } from "../data/site";
margin-top: clamp(24px, 3vw, 32px);
scroll-margin-top: 96px;
}
.hero__buttons {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
}
.osbtn {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 0.82em 1.35em;
background: #ffffff;
border: 1px solid var(--line-strong);
border-radius: 999px;
color: #23201b;
font-family: var(--font-ui);
font-size: 1rem;
font-weight: 600;
box-shadow: var(--shadow-page);
transition:
transform 0.18s var(--ease),
box-shadow 0.18s var(--ease),
border-color 0.18s var(--ease);
}
.osbtn:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-pop);
}
.osbtn.is-detected {
border-color: var(--ink);
}
.osbtn__icon {
width: 18px;
height: 18px;
flex: none;
object-fit: contain;
}
.hero__meta {
margin: 16px 0 0;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.8rem;
color: var(--ink-faint);
text-align: center;
}
.hero__offer {
display: flex;
justify-content: center;
margin-top: 22px;
}
@media (max-width: 480px) {
.hero__buttons {
flex-direction: column;
align-items: stretch;
}
.osbtn {
justify-content: center;
}
}
</style>
+52
View File
@@ -0,0 +1,52 @@
// Resolves the latest GitHub release to a direct download URL per OS, at build
// time. The picked file follows each platform's `match` order in site.ts.
// Falls back to the releases page if the release can't be read.
import { site, platforms } from "./site";
type Asset = { name: string; browser_download_url: string };
export type Release = {
version: string;
urls: Record<string, string>;
};
function pickAsset(assets: Asset[], match: string[]): string | null {
for (const sfx of match) {
const hit = assets.find((a) => a.name.toLowerCase().endsWith(sfx.toLowerCase()));
if (hit) return hit.browser_download_url;
}
return null;
}
function fallback(): Release {
return {
version: "",
urls: Object.fromEntries(platforms.map((p) => [p.id, site.releasesUrl])),
};
}
async function fetchRelease(): Promise<Release> {
try {
const res = await fetch(`https://api.github.com/repos/${site.repo}/releases/latest`, {
headers: { Accept: "application/vnd.github+json" },
signal: AbortSignal.timeout(8000),
});
if (!res.ok) return fallback();
const data = await res.json();
const assets: Asset[] = data.assets || [];
const urls = Object.fromEntries(
platforms.map((p) => [p.id, pickAsset(assets, p.match) ?? site.releasesUrl]),
);
return { version: data.tag_name ?? "", urls };
} catch {
return fallback();
}
}
let cached: Promise<Release> | null = null;
export function getRelease(): Promise<Release> {
cached ??= fetchRelease();
return cached;
}
+2 -2
View File
@@ -39,8 +39,8 @@ export type Platform = {
export const platforms: Platform[] = [
{ id: "mac", label: "macOS", logo: "macos.svg", match: [".dmg"] },
{ id: "windows", label: "Windows", logo: "windows.svg", match: [".msi", ".exe"] },
{ id: "linux", label: "Linux", logo: "linux.svg", match: [".AppImage", ".deb", ".rpm"] },
{ id: "windows", label: "Windows", logo: "windows.svg", match: [".exe", ".msi"] },
{ id: "linux", label: "Linux", logo: "linux.svg", match: [".deb", ".AppImage", ".rpm"] },
];
// Full-color store logos, served from /public/logos. Shown on white tiles so