diff --git a/CHANGELOG.md b/CHANGELOG.md index aa333be..521e293 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ release PR may merge — and a section is closed at the commit that bumps it. ### Added +- The file viewer opens **word-processor documents** (`.docx`, `.doc`, `.odt`, `.rtf`): + when LibreOffice is installed on the server they are converted to PDF and shown as the + document, live-reloading when the file changes, exactly like a compiled `.tex`. A + document kept only inside the user's container is converted too — the server pulls a + copy out and converts that. With no LibreOffice the page says so and offers the + download, as before. The download button still saves the original document, not the + preview PDF. - Z.AI's new models are selectable on the **Models** page: **GLM-5.3** and **GLM-5.3-Flash**, both with a 1M-token context. GLM-5.3-Flash is natively multimodal, so images and videos attached to a message are sent to it directly instead of as a file path. Both always think — diff --git a/crates/skald-core/src/docx.rs b/crates/skald-core/src/docx.rs new file mode 100644 index 0000000..c822b16 --- /dev/null +++ b/crates/skald-core/src/docx.rs @@ -0,0 +1,433 @@ +//! `DocxConverter` — converts word-processor documents (`.docx`, `.doc`, +//! `.odt`, `.rtf`) to PDF using LibreOffice in headless mode +//! (`soffice --convert-to pdf`). +//! +//! Used by the file viewer (`GET /api/file?…&compile-docx=true`) to render +//! word documents as PDFs on demand — the word-family twin of +//! [`crate::latex::LatexCompiler`]. +//! +//! ## Caching (content-addressed) +//! +//! Unlike a `.tex` source, a word document is **self-contained**: images, +//! styles and fonts travel inside the file itself, so there is no dependency +//! graph to track and the `.fls`-sidecar machinery of the LaTeX cache would +//! buy nothing. The cache key is a short SHA-256 of the document bytes: any +//! edit changes the hash and invalidates naturally, and two paths holding the +//! same document share one cached PDF. +//! +//! One artefact lives under `/skald-docx/`: +//! +//! | Artefact | Key | Purpose | +//! |----------------------|----------------------------|-------------------| +//! | `.pdf` | SHA-256 of the file bytes | The converted PDF | +//! +//! ## Container-shuttled inputs +//! +//! [`DocxConverter::convert_bytes`] exists for documents that live **only +//! inside a user's container** (`/tmp/…`): the caller pulls the bytes out +//! (`container::exec_fs::read`) and the converter works on a host-side +//! scratch copy. This is correct precisely because the format is +//! self-contained — a bare copy loses nothing. (LaTeX deliberately does not +//! get this treatment: a shuttled `.tex` would silently lose its relative +//! `\input` / `\includegraphics` dependencies.) +//! +//! ## LibreOffice quirks this lives with +//! +//! - `soffice` locks its user-profile directory, so concurrent conversions — +//! or a stale lock left by a killed run — make later invocations fail. +//! Every conversion therefore gets a **private profile** +//! (`-env:UserInstallation`) inside its per-run scratch directory, which is +//! removed afterwards. +//! - A failed conversion does not always exit non-zero: a missing output +//! file is treated as a failure too, with the captured output as detail. +//! - The scratch copy's **name** is how soffice picks its import filter, so +//! the shuttled input keeps the caller's extension (`input.docx`, +//! `input.odt`, …). +//! +//! ## Failure modes +//! - `ToolMissing` — no LibreOffice on the host (neither `soffice` / +//! `libreoffice` on PATH nor the macOS app bundle). +//! - `Timeout` — conversion exceeded [`CONVERT_TIMEOUT_SECS`]. +//! - `Failed { output }` — non-zero exit or missing output file; carries the +//! captured stdout/stderr so the viewer can surface it. +//! - `Io` — underlying I/O error (reading the source, writing the cache…). + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use sha2::{Digest, Sha256}; +use tokio::process::Command; + +/// Hard ceiling for a single conversion. A cold `soffice` start with a fresh +/// profile takes a few seconds; large documents add a few more — 60 s leaves +/// generous headroom while still bounding a hung run. +const CONVERT_TIMEOUT_SECS: u64 = 60; + +/// Subdirectory of the OS temp dir holding cached PDFs and per-run scratch +/// directories. +const CACHE_DIR_NAME: &str = "skald-docx"; + +/// The word-processor extensions this converter accepts — the single source +/// of truth the HTTP layer (`api/files.rs::is_word_doc`) shares, so the +/// query flag and the converter can never disagree on the family. +pub const WORD_EXTS: &[&str] = &["docx", "doc", "odt", "rtf"]; + +/// A successfully converted PDF. +pub struct ConvertedPdf { + pub bytes: Vec, + /// `true` when served from cache without invoking `soffice`. Informational + /// only; kept on the struct so the API stays stable (mirrors + /// `latex::CompiledPdf`). + #[allow(dead_code)] + pub from_cache: bool, +} + +/// Why a conversion request did not yield a PDF. +#[derive(Debug)] +pub enum ConvertError { + /// No LibreOffice binary is reachable on the host. + ToolMissing, + /// `soffice` ran but failed (non-zero exit, or no output file). Carries + /// the captured process output. + Failed { output: String }, + /// Conversion did not finish within [`CONVERT_TIMEOUT_SECS`]. + Timeout, + /// Underlying I/O error (reading the source, writing the cache, etc.). + Io(std::io::Error), +} + +impl std::fmt::Display for ConvertError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ToolMissing => write!(f, "LibreOffice is not available on the server"), + Self::Failed { output } => write!(f, "conversion failed:\n{output}"), + Self::Timeout => write!(f, "conversion aborted (timeout {CONVERT_TIMEOUT_SECS}s)"), + Self::Io(e) => write!(f, "I/O error: {e}"), + } + } +} + +impl std::error::Error for ConvertError {} + +impl From for ConvertError { + fn from(e: std::io::Error) -> Self { Self::Io(e) } +} + +/// Stateless-ish facade around `soffice`. Owns only the cache root path; safe +/// to share via `Arc` (constructed once and stored on `Skald`). +#[derive(Clone)] +pub struct DocxConverter { + cache_dir: PathBuf, +} + +impl DocxConverter { + pub fn new() -> Self { + Self { cache_dir: std::env::temp_dir().join(CACHE_DIR_NAME) } + } + + /// Convert a word document at `path` (a host file) to PDF, serving from + /// the content-addressed cache when possible. + pub async fn convert_path(&self, path: &Path) -> Result { + let bytes = tokio::fs::read(path).await?; + let key = content_hash(&bytes); + if let Some(hit) = self.cached(&key).await { + return Ok(hit); + } + let scratch = self.cache_dir.join(format!("run-{}", unique_suffix())); + let pdf_bytes = self.run_soffice(&scratch, path).await?; + self.store(&key, &pdf_bytes).await; + tracing::info!(file = ?path, "word document converted (cache miss)"); + Ok(ConvertedPdf { bytes: pdf_bytes, from_cache: false }) + } + + /// Convert a word document that exists only as bytes — a file shuttled + /// out of a user's container (see the module docs). `ext` (the caller's + /// file extension) selects the import filter through the scratch copy's + /// file name. + pub async fn convert_bytes(&self, bytes: &[u8], ext: &str) -> Result { + let key = content_hash(bytes); + if let Some(hit) = self.cached(&key).await { + return Ok(hit); + } + // Probe before touching the disk: with no converter installed the + // request fails without leaving a scratch copy behind. + let soffice = find_soffice().await.ok_or(ConvertError::ToolMissing)?; + let scratch = self.cache_dir.join(format!("run-{}", unique_suffix())); + tokio::fs::create_dir_all(&scratch).await?; + let input = scratch.join(format!("input.{}", sanitize_ext(ext))); + if let Err(e) = tokio::fs::write(&input, bytes).await { + let _ = cleanup_dir(&scratch).await; + return Err(ConvertError::Io(e)); + } + let pdf_bytes = self.run_soffice_with(&soffice, &scratch, &input).await?; + self.store(&key, &pdf_bytes).await; + tracing::info!(ext, "word document converted from shuttled bytes (cache miss)"); + Ok(ConvertedPdf { bytes: pdf_bytes, from_cache: false }) + } + + /// Look up a cached PDF by content key. + async fn cached(&self, key: &str) -> Option { + let path = self.cache_dir.join(format!("{key}.pdf")); + match tokio::fs::read(&path).await { + Ok(bytes) => { + tracing::debug!(cached_pdf = ?path, "word-doc cache hit"); + Some(ConvertedPdf { bytes, from_cache: true }) + } + Err(_) => None, + } + } + + /// Persist a converted PDF under its content key. A write failure is + /// non-fatal: the next request simply converts again. + async fn store(&self, key: &str, bytes: &[u8]) { + let path = self.cache_dir.join(format!("{key}.pdf")); + if let Err(e) = tokio::fs::write(&path, bytes).await { + tracing::warn!(cached_pdf = ?path, error = %e, "word-doc cache write failed"); + } + } + + /// [`run_soffice_with`] with the binary probed first. Used by the + /// host-path entry point, which has nothing to prepare. + async fn run_soffice(&self, scratch: &Path, input: &Path) -> Result, ConvertError> { + let soffice = find_soffice().await.ok_or(ConvertError::ToolMissing)?; + self.run_soffice_with(&soffice, scratch, input).await + } + + /// Run one conversion of `input` with output to `scratch` (a per-run + /// unique directory, removed before returning regardless of outcome) and + /// return the produced PDF bytes. + /// + /// `soffice` gets a **private user profile** inside the scratch dir: + /// the profile is locked while in use, so a shared one would make + /// concurrent conversions fail — and a stale lock from a killed run + /// would make every later one fail. + async fn run_soffice_with( + &self, + soffice: &Path, + scratch: &Path, + input: &Path, + ) -> Result, ConvertError> { + tokio::fs::create_dir_all(scratch).await?; + let profile = scratch.join("profile"); + + let mut cmd = Command::new(soffice); + cmd.args(["--headless", "--norestore", "--nolockcheck", "--nologo"]); + // `profile` is always absolute (cache_dir lives under temp_dir), so + // `file://` + path yields a valid `file:///…` URL on unix hosts. + cmd.arg(format!("-env:UserInstallation=file://{}", profile.display())); + cmd.args(["--convert-to", "pdf", "--outdir"]); + cmd.arg(scratch); + cmd.arg(input); + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + // If our future is dropped (e.g. on shutdown) ensure the process dies. + cmd.kill_on_drop(true); + + let output = match tokio::time::timeout( + Duration::from_secs(CONVERT_TIMEOUT_SECS), + cmd.output(), + ).await { + Ok(Ok(o)) => o, + Ok(Err(e)) => { + let _ = cleanup_dir(scratch).await; + return Err(ConvertError::Io(e)); + } + Err(_) => { + // Timeout: the future is dropped here; `kill_on_drop` + // terminates `soffice`. + let _ = cleanup_dir(scratch).await; + return Err(ConvertError::Timeout); + } + }; + + let stem = input + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("output") + .to_string(); + let pdf_path = scratch.join(format!("{stem}.pdf")); + + // soffice can exit 0 without producing anything (unreadable input, + // unknown filter): the output file is the real success signal. + let pdf_bytes = match tokio::fs::read(&pdf_path).await { + Ok(b) => b, + Err(_) if !output.status.success() => { + let _ = cleanup_dir(scratch).await; + return Err(ConvertError::Failed { output: process_output(&output) }); + } + Err(e) => { + let _ = cleanup_dir(scratch).await; + return Err(ConvertError::Failed { + output: format!( + "soffice exited successfully but produced no PDF ({e})\n{}", + process_output(&output) + ), + }); + } + }; + + let _ = cleanup_dir(scratch).await; + Ok(pdf_bytes) + } +} + +impl Default for DocxConverter { + fn default() -> Self { Self::new() } +} + +// ── Helpers ───────────────────────────────────────────────────────────────── +// +// `content_hash` / `unique_suffix` / `find_on_path` / `cleanup_dir` mirror the +// private helpers of the same names in `latex/compiler.rs`. Kept as local +// copies so neither module reaches into the other; if a third converter ever +// appears, extraction into a shared module becomes the obvious move. + +/// First 5 bytes (10 hex chars) of SHA-256 — enough to avoid collisions in +/// practice while keeping cache filenames short. +fn content_hash(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + let digest = hasher.finalize(); + digest.iter().take(5).map(|b| format!("{b:02x}")).collect() +} + +/// Per-run unique suffix (PID + nanosecond timestamp) to namespace the +/// scratch directory and avoid races between concurrent conversions. +fn unique_suffix() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let pid = std::process::id(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("{pid}-{nanos:x}") +} + +/// Locate a LibreOffice binary: `soffice` / `libreoffice` on PATH, then the +/// standard macOS app-bundle location (an installed LibreOffice that was +/// never linked onto PATH). +async fn find_soffice() -> Option { + for name in ["soffice", "libreoffice"] { + if let Some(p) = find_on_path(name).await { + return Some(p); + } + } + let app_bundle = PathBuf::from("/Applications/LibreOffice.app/Contents/MacOS/soffice"); + if tokio::fs::metadata(&app_bundle).await.map(|m| m.is_file()).unwrap_or(false) { + return Some(app_bundle); + } + None +} + +/// Return the absolute path of `bin` if it is found on `PATH` and is a regular +/// file. We avoid pulling in the `which` crate for a single lookup. +async fn find_on_path(bin: &str) -> Option { + let path_var = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path_var) { + let candidate = dir.join(bin); + if tokio::fs::metadata(&candidate).await + .map(|m| m.is_file() || m.file_type().is_symlink()) + .unwrap_or(false) + { + return Some(candidate); + } + } + None +} + +/// The scratch copy's extension drives soffice's import-filter choice, so it +/// must survive the trip. Anything outside the known word family (or weird +/// bytes) becomes `docx` — which is also what content-sniffing would guess. +fn sanitize_ext(ext: &str) -> String { + let e = ext.to_ascii_lowercase(); + if WORD_EXTS.contains(&e.as_str()) { e } else { "docx".to_string() } +} + +/// Flatten a process's captured stdout+stderr into one displayable string, +/// capped so a noisy run cannot bloat the HTTP error body. +fn process_output(output: &std::process::Output) -> String { + let mut text = String::new(); + text.push_str(&String::from_utf8_lossy(&output.stdout)); + if !text.is_empty() && !text.ends_with('\n') { + text.push('\n'); + } + text.push_str(&String::from_utf8_lossy(&output.stderr)); + let text = text.trim(); + if text.is_empty() { + return "(no output from soffice)".to_string(); + } + text.chars().take(4000).collect() +} + +/// Recursively remove a scratch directory. Errors are logged and swallowed: +/// leftover dirs only consume a little disk under the OS temp folder. +async fn cleanup_dir(dir: &Path) -> std::io::Result<()> { + if tokio::fs::try_exists(dir).await.unwrap_or(false) { + tokio::fs::remove_dir_all(dir).await?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hash_is_10_lowercase_hex_chars() { + let h = content_hash(b"hello world"); + assert_eq!(h.len(), 10); + assert!(h.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())); + } + + #[test] + fn hash_is_deterministic() { + assert_eq!(content_hash(b"abc"), content_hash(b"abc")); + assert_ne!(content_hash(b"abc"), content_hash(b"abd")); + } + + #[test] + fn sanitize_ext_keeps_the_word_family() { + for ext in WORD_EXTS { + assert_eq!(&sanitize_ext(ext), ext); + } + assert_eq!(sanitize_ext("DOCX"), "docx"); + } + + #[test] + fn sanitize_ext_defaults_unknowns_to_docx() { + assert_eq!(sanitize_ext("pptx"), "docx"); + assert_eq!(sanitize_ext("../../etc/passwd"), "docx"); + assert_eq!(sanitize_ext(""), "docx"); + } + + #[tokio::test] + async fn cache_round_trip() { + let dir = std::env::temp_dir().join(format!("skald-docx-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let converter = DocxConverter { cache_dir: dir.clone() }; + assert!(converter.cached("deadbeef00").await.is_none()); + converter.store("deadbeef00", b"%PDF-fake").await; + let hit = converter.cached("deadbeef00").await.unwrap(); + assert_eq!(hit.bytes, b"%PDF-fake"); + assert!(hit.from_cache); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// With no LibreOffice on the host the converter must report ToolMissing — + /// on a box *with* LibreOffice this test is skipped rather than failed, + /// since it would otherwise run a real conversion. + #[tokio::test] + async fn missing_tool_reports_tool_missing() { + if find_soffice().await.is_some() { + eprintln!("LibreOffice present — skipping ToolMissing test"); + return; + } + let dir = std::env::temp_dir().join(format!("skald-docx-test-missing-{}", std::process::id())); + let converter = DocxConverter { cache_dir: dir }; + let result = converter.convert_bytes(b"not a real docx", "docx").await; + assert!(matches!(result, Err(ConvertError::ToolMissing))); + } +} diff --git a/crates/skald-core/src/lib.rs b/crates/skald-core/src/lib.rs index b0a9239..404a5d2 100644 --- a/crates/skald-core/src/lib.rs +++ b/crates/skald-core/src/lib.rs @@ -22,6 +22,7 @@ pub mod crypto; pub mod elicitation; pub mod cron; pub mod db; +pub mod docx; pub mod events; pub mod git_versions; pub mod image_generate; diff --git a/crates/skald-core/src/skald/accessors.rs b/crates/skald-core/src/skald/accessors.rs index 47e8474..9d6663d 100644 --- a/crates/skald-core/src/skald/accessors.rs +++ b/crates/skald-core/src/skald/accessors.rs @@ -29,6 +29,7 @@ use crate::elicitation::ElicitationManager; use crate::image_generate::ImageGeneratorManager; use crate::inbox::Inbox; use crate::git_versions::GitVersions; +use crate::docx::DocxConverter; use crate::latex::LatexCompiler; use crate::llm::LlmManager; use crate::location::LocationManager; @@ -406,6 +407,7 @@ impl Skald { // Infra pub fn latex_compiler(&self) -> &LatexCompiler { &self.infra.latex_compiler } + pub fn docx_converter(&self) -> &DocxConverter { &self.infra.docx_converter } pub fn git_versions(&self) -> &GitVersions { &self.infra.git_versions } pub fn location_manager(&self) -> &Arc { &self.infra.location_manager } pub fn remote(&self) -> &Arc>>> { &self.infra.remote } diff --git a/crates/skald-core/src/skald/bundles.rs b/crates/skald-core/src/skald/bundles.rs index 946eaf8..440a8c1 100644 --- a/crates/skald-core/src/skald/bundles.rs +++ b/crates/skald-core/src/skald/bundles.rs @@ -25,6 +25,7 @@ use crate::elicitation::ElicitationManager; use crate::image_generate::ImageGeneratorManager; use crate::inbox::Inbox; use crate::git_versions::GitVersions; +use crate::docx::DocxConverter; use crate::latex::LatexCompiler; use crate::llm::LlmManager; use crate::location::LocationManager; @@ -440,6 +441,7 @@ impl Conversation { pub(super) struct Infra { pub(super) latex_compiler: LatexCompiler, + pub(super) docx_converter: DocxConverter, pub(super) git_versions: GitVersions, pub(super) location_manager: Arc, pub(super) remote: Arc>>>, @@ -449,6 +451,7 @@ impl Infra { pub(super) fn build() -> Self { Infra { latex_compiler: LatexCompiler::new(), + docx_converter: DocxConverter::new(), git_versions: GitVersions::new(), location_manager: Arc::new(LocationManager::new()), remote: Arc::new(RwLock::new(None)), diff --git a/crates/skald-core/src/tools/show_file.rs b/crates/skald-core/src/tools/show_file.rs index bdcd08b..24497c1 100644 --- a/crates/skald-core/src/tools/show_file.rs +++ b/crates/skald-core/src/tools/show_file.rs @@ -28,7 +28,7 @@ use crate::tools::tool_names::SHOW_FILE_TO_USER; /// It then emits a `ServerEvent::OpenFile` carrying the **canonical agent path**, so /// the file-viewer page fetches the same file back through `/api/file`. The /// frontend renders every kind in the viewer (HTML live in an origin-isolated -/// iframe; LaTeX compiled to PDF server-side). +/// iframe; LaTeX compiled and word documents converted to PDF server-side). /// /// `session_id` is the conversation this instance belongs to: clients filter /// events per conversation, so an untagged `OpenFile` would reach nobody. @@ -46,8 +46,10 @@ pub fn make_tool( "name": SHOW_FILE_TO_USER, "description": "Show a file to the user by opening it in their interface. \ Supports Markdown, source code, plain text, raster images \ - (PNG/JPG/GIF/WebP/…), SVG, PDF, and LaTeX (.tex — compiled \ - to PDF automatically on the server). HTML files open in a \ + (PNG/JPG/GIF/WebP/…), SVG, PDF, LaTeX (.tex — compiled \ + to PDF automatically on the server), and word-processor \ + documents (.docx/.doc/.odt/.rtf — converted to PDF \ + automatically on the server). HTML files open in a \ new browser tab. Use this to surface a file you created or \ found so the user can look at it directly. One file per call. \ The file must already exist on disk — or as a memory note \ @@ -58,7 +60,9 @@ pub fn make_tool( whenever any of its dependencies (\\input fragments, .sty/.cls, \ images) change. A raw `.pdf` is served statically — never \ recompiled and its dependencies are not watched — so the user \ - would keep seeing a stale render.", + would keep seeing a stale render. The same rule applies to \ + word documents: pass the original `.docx`/`.odt`/…, never a \ + PDF exported from it.", "parameters": { "type": "object", "properties": { diff --git a/dev-docs/filesystem-and-containers.md b/dev-docs/filesystem-and-containers.md index 82d1a34..3197c86 100644 --- a/dev-docs/filesystem-and-containers.md +++ b/dev-docs/filesystem-and-containers.md @@ -25,7 +25,7 @@ Two views, **one storage**: for the mounted subtree the fs-tools run **host-side **The security boundary is the container, not the mounted subtree — the mount is the *fast* path, not the only one.** An agent already reaches every corner of its container through `execute_cmd`, which runs there with passwordless `sudo`; fs-tools that stopped at the mounts were not protecting anything, they were offering a poorer view of the same sandbox, and the model answered that by shelling out (the observed failure: `read_file /tmp/cv.txt` → *"path escapes your workspace"* → the agent re-read it with `cat`). So `resolve_target` routes a physical path to one of two backings. An **absolute** path is container vocabulary — it is what `execute_cmd` prints — so it is reverse-mapped through `UserFs::container_to_agent` first: landing on a mount takes the host path (**`/root/x` *is* `~/x`**, which the tools used to reject outright, since `PathBuf::join` with an absolute tail silently discards the base and the result then failed the prefix check); landing nowhere means it exists only in the container, and `container::exec_fs` acts there over `docker exec` (paths passed **positionally** as `$1`, so a path containing `$(…)` is data, not syntax). Membership is not bypassed: `/root/shared/{X}` for a non-member still resolves to the same error as `shared/{X}`. -**One implementation per tool, not two.** Every single-file fs-tool already funnels through the same shape — resolve, then run a sync `execute` over one absolute host path — so the container branch is a **shuttle** (`fs::Shuttle`, behind `fs::run_physical`): pull the file out of the container, run the *unchanged* tool on the copy, push it back if the content changed (compared by bytes, not mtime, whose one-second resolution would miss a fast edit). Nothing about a tool's messages, diffs or pure transforms is duplicated. A missing remote file is deliberately **not** pre-created — `write_file` reports "Created" vs "Overwrote" from whether the path existed, and a placeholder would make every creation lie. Three tools opt out of the shuttle because a single file is the wrong unit: `list_files` lists in place via `exec_fs::list` (`find -printf`; `line_count` is omitted, since counting lines would turn a listing into a `docker exec` per file), `read_file` reads container paths as text (a shuttled copy is gone by the time the projection would inline a `MediaRef`, so media stays a mount-only feature), and `grep_files` **refuses** container paths with a pointer to `execute_cmd` + `rg` — its regex flavour, glob, windowing and offset would all have to be re-derived from ripgrep's flags, and a grep that answers *almost* the same is worse than one that says where to go. The viewer follows the same routing through `resolve_view_target` (`GET /api/file` and `show_file_to_user` open container paths; served without an ETag, so the editor stays read-only there). +**One implementation per tool, not two.** Every single-file fs-tool already funnels through the same shape — resolve, then run a sync `execute` over one absolute host path — so the container branch is a **shuttle** (`fs::Shuttle`, behind `fs::run_physical`): pull the file out of the container, run the *unchanged* tool on the copy, push it back if the content changed (compared by bytes, not mtime, whose one-second resolution would miss a fast edit). Nothing about a tool's messages, diffs or pure transforms is duplicated. A missing remote file is deliberately **not** pre-created — `write_file` reports "Created" vs "Overwrote" from whether the path existed, and a placeholder would make every creation lie. Three tools opt out of the shuttle because a single file is the wrong unit: `list_files` lists in place via `exec_fs::list` (`find -printf`; `line_count` is omitted, since counting lines would turn a listing into a `docker exec` per file), `read_file` reads container paths as text (a shuttled copy is gone by the time the projection would inline a `MediaRef`, so media stays a mount-only feature), and `grep_files` **refuses** container paths with a pointer to `execute_cmd` + `rg` — its regex flavour, glob, windowing and offset would all have to be re-derived from ripgrep's flags, and a grep that answers *almost* the same is worse than one that says where to go. The viewer follows the same routing through `resolve_view_target` (`GET /api/file` and `show_file_to_user` open container paths; served without an ETag, so the editor stays read-only there). One read-only shuttle lives here too: `GET /api/file?compile-docx=true` on a **container-only** word document pulls the bytes out with `exec_fs::read` and converts the host-side copy via `DocxConverter::convert_bytes` — correct because the format is self-contained. LaTeX deliberately gets no such branch: a shuttled `.tex` would silently lose its relative `\input`/`\includegraphics` dependencies. **The memory roots are signposted inside the container, not merely absent.** `user-memory/`/`shared-memory/` are virtual, so nothing of them existed on disk — and the nothing was worse than it sounds: `cat user-memory/x.md` returned a bare ENOENT (which reads as *the note is missing*, not *wrong door*), while `mkdir -p user-memory && echo … > user-memory/x.md` **succeeded**, writing a real file into the home that no reader ever visits and that the next `ls` then confirms as if it had worked. Each root is therefore a **read-only bind mount** (`{WD}/.memory-signpost/{root}` → `{container_home}/{root}:ro`, gitignored, rewritten from consts on every `ensure`) holding a README that names the tools. Read-only *as a mount*, not as a mode: the container user has passwordless `sudo`, so a `chmod` would be a suggestion, whereas `:ro` holds — remounting needs `CAP_SYS_ADMIN` (verified: write, `sudo` write, `sudo chmod`, `sudo mount -o remount,rw` and `sudo rm` all fail). A README rather than an empty dir because `Permission denied` is an error, not an instruction — models answer it by reaching for `sudo`; the README puts the correction in the directory the failing command just named. These mounts are deliberately **not** in `UserFs`: they back no agent path and the host-side fs-tools must never resolve into them. They are the **fourth self-heal axis** in `reusable()` (`signposts_mounted`) rather than an `IMAGE_TAG` bump, since the image is unchanged and a bump would make every box rebuild it to fix a mount. The matching half is in `classify_memory`, which now strips the home spellings (`./`, `~/`, `/root/`) before matching the root — without it `~/user-memory/x.md` missed the match, fell through to the disk router, and became exactly the invisible physical file the signpost exists to prevent. diff --git a/dev-docs/frontend.md b/dev-docs/frontend.md index 2eed84b..51e7fc6 100644 --- a/dev-docs/frontend.md +++ b/dev-docs/frontend.md @@ -53,7 +53,7 @@ Two independent things have to be true, and both were violated at some point: | `sidebar.js` | `` | Nav sidebar; role-driven (`ui_mode`); inbox badge is **live** — the chat WS forwards the inbox lifecycle events (`approval_requested/resolved`, `clarification_*`, `elicitation_*`) regardless of `source`, `chat-session.js` re-dispatches them as the `inbox-changed` window event, and the sidebar (+ `agent-inbox.js`) refreshes on it; a 60 s poll remains as fallback | | `topbar.js` | `` | Top nav bar; per-user avatar color hashed from the username | | `dashboard-page.js` | `` | `#dashboard` — status hero, LLM stats charts, pending inbox, quick guide | -| `shared/file-viewer-base.js` | `FileViewerBase` (base) | Shared file-viewer engine (fetch, kind detection, markdown/PDF/SVG/LaTeX, watcher, `_renderBody`); driven by `_show`/`_hide`. Extended by desktop + mobile | +| `shared/file-viewer-base.js` | `FileViewerBase` (base) | Shared file-viewer engine (fetch, kind detection, markdown/PDF/SVG/LaTeX/word-docs, watcher, `_renderBody`); driven by `_show`/`_hide`. Extended by desktop + mobile | | `file-viewer-page.js` | `` | Desktop file viewer: `FileViewerBase` + hash routing via `window.openFile(path)` → `#file_viewer?path=...` | | `shared/file-viewer-mobile.js` | `` | Mobile file viewer: `FileViewerBase` + prop-driven (`visible`/`path`), full-screen with back button | | `agents.js` | `` | Agent discovery and config | @@ -81,3 +81,11 @@ Two independent things have to be true, and both were violated at some point: | `models-tts.js` | `` | Text-to-speech model CRUD | | `mobile-app.js` | `` | Mobile app shell | | `shared/settings-page.js` | `` | Mobile settings: per-user avatar, locale picker (`I18nMixin`), profile/preferences | + +## Server-rendered kinds in the file viewer (LaTeX, word documents) + +Two kinds are not served as-is but rendered to PDF server-side on demand: `.tex`/`.latex` (kind `latex`, `?compile-latex=true`, `latexmk`) and `.docx`/`.doc`/`.odt`/`.rtf` (kind `docx`, `?compile-docx=true`, LibreOffice — `skald_core::docx::DocxConverter`, content-hash cache, no dependency graph). Both render through the same `` and degrade gracefully when the host tool is missing (`501`) or the run fails (`422`): the viewer fetches the flagged URL, keeps the error body, and falls back. Three asymmetries between the two, each deliberate: + +- **Fallback content.** A failed LaTeX compile still shows the *source* (readable); a word document is a zip, so its fallback is the binary download state with the reason in a foldable block on top — there is no source to show. +- **Download.** A `.tex` downloads the *compiled PDF* (the source is useless to most people); a word document downloads the **original file** — it is itself the editable artifact someone asking "send me the document" wants, and the PDF is only the preview mechanism. +- **Watching.** A `.tex` subscription expands server-side to its `.fls` dependency set (`file_watch.rs`); a word document is self-contained, so the plain per-file watcher already covers it and a change re-converts via the content-keyed cache — `file_watch.rs` needed no branch. Container-only word documents still convert (the API shuttles the bytes out, see `filesystem-and-containers.md`) but, like any container-only path, they are not watchable. diff --git a/docs/file-viewer.md b/docs/file-viewer.md index 8996c15..017830f 100644 --- a/docs/file-viewer.md +++ b/docs/file-viewer.md @@ -14,13 +14,14 @@ The header shows the file's path exactly as your tools spell it (`shared/recipes | SVG | rendered in an isolated frame — scripts inside it never run | | PDF | drawn by the app itself, so it looks and scrolls the same in every browser and on the phone | | LaTeX (`.tex`) | **compiled to PDF on the server** and shown as the document | +| Word documents (`.docx`, `.doc`, `.odt`, `.rtf`) | **converted to PDF on the server** (needs LibreOffice installed there) and shown as the document | | HTML | rendered live in an isolated frame; the toggle in the header switches to the source | | Anything else | not displayed — the file can still be downloaded | Two consequences worth knowing: -- **Always give `show_file_to_user` the `.tex`, never a `.pdf` you built from it.** The `.tex` is recompiled and the view follows its dependencies — `\input` fragments, styles, images — so it stays current. A raw `.pdf` is served as bytes: it is never recompiled and the user ends up looking at a stale render. -- **A compile that fails is not a dead end.** The viewer shows the source instead, with the actual error block foldable at the top. That error is worth reading if they ask why "the document is not showing" — it usually names a line. +- **Always give `show_file_to_user` the `.tex`, never a `.pdf` you built from it.** The `.tex` is recompiled and the view follows its dependencies — `\input` fragments, styles, images — so it stays current. A raw `.pdf` is served as bytes: it is never recompiled and the user ends up looking at a stale render. The same applies to word documents: give the `.docx`, not a PDF exported from it — the viewer converts it, and re-converts it when the file changes. +- **A compile that fails is not a dead end.** The viewer shows the source instead, with the actual error block foldable at the top. That error is worth reading if they ask why "the document is not showing" — it usually names a line. (For a word document there is no readable source to show, so a failed conversion explains itself in the same foldable block over the download state.) ## It is live @@ -52,7 +53,7 @@ Files inside a folder that is under version control — in practice, project fol ## Download -The download button saves the file with its real name. For a `.tex` it downloads the **compiled PDF**, not the source — that is usually what someone asking to "send me the document" wants; if they want the source itself, they want the `.tex`, and it is worth checking which. +The download button saves the file with its real name. For a `.tex` it downloads the **compiled PDF**, not the source — that is usually what someone asking to "send me the document" wants; if they want the source itself, they want the `.tex`, and it is worth checking which. A word document instead downloads as **the original file** (the `.docx`, `.odt`…): unlike a `.tex` source it is the editable document itself, and the PDF on screen is only the preview. ## What the viewer tells you @@ -66,7 +67,7 @@ If the eye is off, none of that arrives, and you genuinely do not know what they - *"It says the file changed while I was editing."* — something else wrote to it. Three buttons on the banner; copy-then-reload loses nothing. - *"The PDF is wrong / old."* — if there is a `.tex` beside it, they are looking at a stale build. Open the `.tex` instead: it recompiles. - *"Where is the old version?"* — the clock, if the file is in a project folder. Otherwise there is no history to show, and the honest answer is that this file is not versioned. -- *"It won't show the file."* — a kind the viewer cannot render (an archive, an office document, an unknown binary) shows the download instead. That is the whole story; there is no plugin to install. +- *"It won't show the file."* — a kind the viewer cannot render (an archive, an unknown binary) shows the download instead, and that is the whole story. An office document showing only the download means the server has **no LibreOffice installed**: the admin installing it turns the preview on — nothing to change in the app, and the foldable block on the page says exactly this. - *"Show me that file."* — `show_file_to_user`, one file per call, on any path in their own workspace including a memory note. It must already exist. ## Related diff --git a/src/frontend/api/files.rs b/src/frontend/api/files.rs index e21ab9c..9d79c47 100644 --- a/src/frontend/api/files.rs +++ b/src/frontend/api/files.rs @@ -15,6 +15,7 @@ use skald_core::db::memory_docs; use skald_core::git_versions::{self, GitVersions}; use skald_core::session::handler::media; use skald_core::skald::Skald; +use skald_core::docx::ConvertError; use skald_core::latex::CompileError; use skald_core::tools::fs as fs_tools; use super::ApiError; @@ -479,6 +480,12 @@ pub struct FileQuery { /// source. Other file types ignore this flag. #[serde(rename = "compile-latex", default)] pub compile_latex: bool, + /// When `true` and `path` points at a word-processor document + /// (`.docx` / `.doc` / `.odt` / `.rtf`), convert it to PDF via + /// LibreOffice and return the PDF bytes instead of the raw file. + /// Other file types ignore this flag. + #[serde(rename = "compile-docx", default)] + pub compile_docx: bool, /// When `true`, mark the response as a download (`Content-Disposition: /// attachment`) so the browser saves the file instead of rendering it /// inline. For a compiled `.tex` the attachment name is `.pdf`. @@ -505,6 +512,13 @@ pub struct FileQuery { /// with the textual `latexmk` log in the body, so the caller can fall back to /// showing the raw source. /// +/// With `?compile-docx=true` a word-processor document (`.docx` / `.doc` / +/// `.odt` / `.rtf`) is converted to PDF (see +/// [`skald_core::docx::DocxConverter`]). A document that lives **only inside +/// the caller's container** is shuttled out to a host scratch copy first — +/// correct because the format is self-contained, unlike a `.tex` with its +/// relative `\input`s (which is why LaTeX gets no container branch). +/// /// A path under a virtual memory root (`user-memory/…`, `shared-memory/…`) is /// served from the `memory_docs` table — the caller's own pool for the private /// root, the system pool for the shared one — exactly like the fs-tools route @@ -569,6 +583,32 @@ pub async fn get_file( let abs = match target { fs_tools::FsTarget::Host(abs) => abs, fs_tools::FsTarget::Container { container, path } => { + // Word documents are self-contained, so a container-only one can + // still be previewed: shuttle the bytes out and convert the copy + // on the host (the fs-tools' `Shuttle` pattern, read-only half). + // LaTeX deliberately gets no shuttle: a copied `.tex` would lose + // its relative `\input`/`\includegraphics` dependencies. + if q.compile_docx && is_word_doc(&q.path) { + let ext = Path::new(&q.path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("docx") + .to_string(); + return match skald_core::container::exec_fs::read(&container, &path).await { + Ok(bytes) => match state.docx_converter().convert_bytes(&bytes, &ext).await { + Ok(pdf) => { + let mut response = pdf_response(pdf.bytes); + if q.force_download { + set_attachment(&mut response, &pdf_download_name(&q.path)); + } + response + } + Err(err) => convert_error_response(err), + }, + Err(_) => (StatusCode::NOT_FOUND, format!("File not found: {}", q.path)) + .into_response(), + }; + } return match skald_core::container::exec_fs::read(&container, &path).await { Ok(bytes) => { let mut response = bytes.into_response(); @@ -600,6 +640,19 @@ pub async fn get_file( }; } + if q.compile_docx && is_word_doc(&q.path) { + return match state.docx_converter().convert_path(&abs).await { + Ok(pdf) => { + let mut response = pdf_response(pdf.bytes); + if q.force_download { + set_attachment(&mut response, &pdf_download_name(&q.path)); + } + response + } + Err(err) => convert_error_response(err), + }; + } + match tokio::fs::read(&abs).await { Ok(bytes) => { let mut response = bytes.into_response(); @@ -686,6 +739,19 @@ async fn get_file_at_rev( }; } + if q.compile_docx && is_word_doc(&q.path) { + return match state.docx_converter().convert_path(&file).await { + Ok(pdf) => { + let mut response = pdf_response(pdf.bytes); + if q.force_download { + set_attachment(&mut response, &pdf_download_name(&q.path)); + } + response + } + Err(err) => convert_error_response(err), + }; + } + match tokio::fs::read(&file).await { Ok(bytes) => { let mut response = bytes.into_response(); @@ -851,6 +917,35 @@ fn compile_error_response(err: CompileError) -> Response { (status, response).into_response() } +/// Map a [`ConvertError`] to an HTTP status, mirroring +/// [`compile_error_response`]: `ToolMissing` → `501 Not Implemented`, +/// `Timeout` → `504 Gateway Timeout`, `Failed` → `422 Unprocessable Entity` +/// (body = captured `soffice` output), `Io` → `500`. The body is plain text +/// so the viewer can show it directly. +fn convert_error_response(err: ConvertError) -> Response { + let (status, body): (StatusCode, String) = match err { + ConvertError::ToolMissing => ( + StatusCode::NOT_IMPLEMENTED, + "LibreOffice is not installed on the server.".to_string(), + ), + ConvertError::Timeout => ( + StatusCode::GATEWAY_TIMEOUT, + "Document conversion aborted due to timeout.".to_string(), + ), + ConvertError::Failed { output } => (StatusCode::UNPROCESSABLE_ENTITY, output), + ConvertError::Io(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("I/O error during conversion: {e}"), + ), + }; + let mut response = body.into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + (status, response).into_response() +} + /// True for `.tex` / `.latex` extensions — i.e. inputs worth compiling. fn is_latex(path: &str) -> bool { matches!( @@ -862,6 +957,17 @@ fn is_latex(path: &str) -> bool { ) } +/// True for word-processor extensions (`.docx` / `.doc` / `.odt` / `.rtf`) — +/// the family LibreOffice converts to PDF. The extension list itself lives in +/// [`skald_core::docx::WORD_EXTS`] so this check and the converter agree. +fn is_word_doc(path: &str) -> bool { + Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .map(|e| skald_core::docx::WORD_EXTS.contains(&e.to_ascii_lowercase().as_str())) + .unwrap_or(false) +} + /// Best-effort `Content-Type` from a file extension. Known binary types get their /// specific MIME; everything else is served as UTF-8 text (markdown, code, configs, /// and unknown files the viewer treats as plain text or "binary, no preview"). @@ -882,6 +988,10 @@ fn content_type_for(path: &str) -> &'static str { "svg" => "image/svg+xml", "pdf" => "application/pdf", "tex" | "latex" => "application/x-tex", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "doc" => "application/msword", + "odt" => "application/vnd.oasis.opendocument.text", + "rtf" => "application/rtf", "html" | "htm" => "text/html; charset=utf-8", _ => "text/plain; charset=utf-8", } diff --git a/src/frontend/api/ws.rs b/src/frontend/api/ws.rs index e57a34a..f024e5c 100644 --- a/src/frontend/api/ws.rs +++ b/src/frontend/api/ws.rs @@ -40,9 +40,10 @@ The URL returned by image_generate already points to the correct endpoint — us Do NOT append \".png\" or any extension to the URL.\n\ \n\ FILES: To let the user look at a file directly, call show_file_to_user(path). Supported: \ -Markdown, source code, images (PNG/JPG/GIF/WebP/SVG), PDF, and LaTeX (.tex — auto-compiled \ -to PDF server-side). HTML opens in a new browser tab. Prefer this over pasting long file \ -contents into chat."; +Markdown, source code, images (PNG/JPG/GIF/WebP/SVG), PDF, LaTeX (.tex — auto-compiled \ +to PDF server-side), and word-processor documents (.docx/.doc/.odt/.rtf — converted to \ +PDF server-side when LibreOffice is installed). HTML opens in a new browser tab. Prefer \ +this over pasting long file contents into chat."; const HELP_TEXT: &str = "\ **Available commands**\n\n\ diff --git a/web/components/copilot-render.js b/web/components/copilot-render.js index 50642aa..4c03cc8 100644 --- a/web/components/copilot-render.js +++ b/web/components/copilot-render.js @@ -464,6 +464,7 @@ function attachmentIcon(att) { const n = (att.name || '').toLowerCase(); if (m.startsWith('image/')) return 'bi-file-earmark-image'; if (m === 'application/pdf' || n.endsWith('.pdf')) return 'bi-file-earmark-pdf'; + if (/\.(docx?|odt|rtf)$/.test(n)) return 'bi-file-earmark-word'; if (m.startsWith('audio/')) return 'bi-file-earmark-music'; if (m.startsWith('video/')) return 'bi-file-earmark-play'; if (m.startsWith('text/') || /\.(md|txt|csv|json|ya?ml|rs|js|ts|py)$/.test(n)) return 'bi-file-earmark-text'; diff --git a/web/components/shared/file-explorer.js b/web/components/shared/file-explorer.js index 402ea51..7a373d6 100644 --- a/web/components/shared/file-explorer.js +++ b/web/components/shared/file-explorer.js @@ -329,7 +329,7 @@ export class FileExplorer extends LightElement { zip: 'bi-file-zip', gz: 'bi-file-zip', tar: 'bi-file-zip', mp3: 'bi-file-music', wav: 'bi-file-music', ogg: 'bi-file-music', mp4: 'bi-file-play', mov: 'bi-file-play', webm: 'bi-file-play', - doc: 'bi-file-word', docx: 'bi-file-word', + doc: 'bi-file-word', docx: 'bi-file-word', odt: 'bi-file-word', rtf: 'bi-file-word', xls: 'bi-file-excel', xlsx: 'bi-file-excel', csv: 'bi-file-excel', }; return map[ext] ?? 'bi-file-earmark'; diff --git a/web/components/shared/file-viewer-base.js b/web/components/shared/file-viewer-base.js index 760ba65..04ce524 100644 --- a/web/components/shared/file-viewer-base.js +++ b/web/components/shared/file-viewer-base.js @@ -10,8 +10,9 @@ import './pdf-view.js'; // registers ; pdf.js itself is imported laz /** * Shared file-viewer engine. Holds all of the fetch / kind-detection / - * markdown-asset-rewriting / LaTeX-compile / live-watch logic plus `_renderBody`, - * driven purely by two methods: `_show(path)` and `_hide()`. It carries no + * markdown-asset-rewriting / LaTeX-compile / word-doc-convert / live-watch + * logic plus `_renderBody`, driven purely by two methods: `_show(path)` and + * `_hide()`. It carries no * navigation or page chrome of its own — subclasses (desktop `` * and mobile ``) wire visibility/path to those methods * and provide their own `render()` header. @@ -19,6 +20,10 @@ import './pdf-view.js'; // registers ; pdf.js itself is imported laz const IMG_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'avif']; const LATEX_EXTS = ['tex', 'latex']; +// Word-processor documents, converted to PDF server-side via LibreOffice +// (`?compile-docx=true`). Unlike LaTeX there is no readable source to fall +// back to: a failed or unavailable conversion leaves the binary state. +const WORD_EXTS = ['docx', 'doc', 'odt', 'rtf']; const TEXT_EXTS = [ 'txt', 'md', 'markdown', 'rs', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'py', 'json', 'yml', 'yaml', 'toml', 'sh', 'bash', 'zsh', 'fish', @@ -54,6 +59,7 @@ export function kindFor(path) { // (srcdoc + sandbox="allow-scripts", no allow-same-origin) — see _renderBody. if (ext === 'html' || ext === 'htm') return 'html'; if (LATEX_EXTS.includes(ext)) return 'latex'; + if (WORD_EXTS.includes(ext)) return 'docx'; if (TEXT_EXTS.includes(ext)) return 'text'; return 'binary'; } @@ -137,6 +143,7 @@ const VIEW_KINDS = { svg: 'an SVG image', html: 'a rendered HTML page', latex: 'a compiled LaTeX document', + docx: 'an office document converted to PDF', binary: 'a binary file, whose content is not displayed', }; @@ -391,7 +398,10 @@ export class FileViewerBase extends LightElement { /** * Download the current file. LaTeX sources always download the compiled PDF - * (`compile-latex=true`); every kind is served with `force_download=true` so + * (`compile-latex=true`); word documents instead download the **original** + * file — unlike a `.tex` source, a `.docx` is itself the editable document + * people want to keep or send, while the PDF is only the preview mechanism. + * Every kind is served with `force_download=true` so * the server sets `Content-Disposition: attachment` and the browser saves it * (with the server-supplied name) instead of rendering inline. */ @@ -488,6 +498,8 @@ export class FileViewerBase extends LightElement { if (oldUrl) URL.revokeObjectURL(oldUrl); } else if (this._kind === 'latex') { await this._loadLatex(path); + } else if (this._kind === 'docx') { + await this._loadDocx(path); } else if (this._kind === 'text' || this._kind === 'html') { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); @@ -551,6 +563,39 @@ export class FileViewerBase extends LightElement { this._content = await res.text(); } + /** + * Load a word-processor document (`.docx` / `.doc` / `.odt` / `.rtf`). + * Asks the server to convert it to PDF via LibreOffice; on any non-OK + * response (501 no LibreOffice, 504 timeout, 422 conversion error) there is + * no readable source to fall back to — the body is a zip — so the reason is + * kept in `_compileError` and `_renderBody` shows the binary state with the + * error block on top. + */ + async _loadDocx(path) { + const convertUrl = this._fileUrl(path, { 'compile-docx': 'true' }); + try { + const res = await fetch(convertUrl); + if (res.ok) { + const blob = await res.blob(); + // Swap URLs only after the new blob is ready so the preview never flickers. + const oldUrl = this._blobUrl; + this._blobUrl = URL.createObjectURL(blob); + if (oldUrl) URL.revokeObjectURL(oldUrl); + this._compileError = null; + return; + } + // The error body is a short plain-text reason (converter missing, + // timeout, or the captured soffice output) — shown verbatim, unlike the + // latex log which needs distilling. + let detail = ''; + try { detail = (await res.text()).trim(); } catch { /* ignore */ } + this._compileError = detail || `HTTP ${res.status}`; + } catch (e) { + this._compileError = e.message || String(e); + } + this._revokeBlobUrl(); + } + // ── History mode (git-versioned files) ───────────────────────────────────── /** @@ -901,6 +946,10 @@ export class FileViewerBase extends LightElement { // a native .pdf is rendered (see the note above). return html``; } + if (this._kind === 'docx' && this._blobUrl) { + // Successfully converted server-side (LibreOffice) — same render path. + return html``; + } if (this._kind === 'svg' && this._blobUrl) { // `allow-same-origin` (and nothing else) is required so the iframe can load // the blob: URL — those are only readable from their creating origin. With @@ -910,6 +959,23 @@ export class FileViewerBase extends LightElement { ${keyed(this._blobUrl, html``)} `; } + if (this._kind === 'docx') { + // Conversion failed or LibreOffice is not installed — unlike LaTeX + // there is no readable source to show (the file is a zip), so the + // reason sits in a foldable block over the download-only state. + return html` + ${this._compileError + ? html`
+  ${t('fv.docx_failed')} +
${this._compileError}
+
` + : nothing} +
+ + ${t('fv.binary_unavailable')} +
+ `; + } if (this._kind === 'binary') { return html`
diff --git a/web/i18n/en.js b/web/i18n/en.js index 72640aa..837e4bc 100644 --- a/web/i18n/en.js +++ b/web/i18n/en.js @@ -1049,6 +1049,7 @@ export default { 'fv.mode_source': 'Show source', 'fv.binary_unavailable': 'Preview not available for this file type.', 'fv.latex_failed': 'LaTeX compilation failed — showing source instead', + 'fv.docx_failed': 'Document conversion failed', 'fv.tab_view': 'View', 'fv.tab_edit': 'Edit', 'fv.dirty_badge': 'Unsaved changes', diff --git a/web/i18n/fr.js b/web/i18n/fr.js index 0a990f8..5f93ebd 100644 --- a/web/i18n/fr.js +++ b/web/i18n/fr.js @@ -1036,6 +1036,7 @@ export default { 'fv.mode_source': 'Afficher la source', 'fv.binary_unavailable': 'Aperçu non disponible pour ce type de fichier.', 'fv.latex_failed': 'Échec de la compilation LaTeX — affichage de la source à la place', + 'fv.docx_failed': 'Échec de la conversion du document', 'fv.tab_view': 'Afficher', 'fv.tab_edit': 'Modifier', 'fv.dirty_badge': 'Modifications non enregistrées', diff --git a/web/i18n/it.js b/web/i18n/it.js index c621f95..1f1fe46 100644 --- a/web/i18n/it.js +++ b/web/i18n/it.js @@ -1036,6 +1036,7 @@ export default { 'fv.mode_source': 'Mostra sorgente', 'fv.binary_unavailable': 'Anteprima non disponibile per questo tipo di file.', 'fv.latex_failed': 'Compilazione LaTeX fallita — mostra il sorgente', + 'fv.docx_failed': 'Conversione del documento non riuscita', 'fv.tab_view': 'Visualizza', 'fv.tab_edit': 'Modifica', 'fv.dirty_badge': 'Modifiche non salvate',