pub mod docx;
pub mod epub;
pub mod markdown;
pub mod tex;
use std::path::{Path, PathBuf};
use anyhow::Result;
use crate::project::ProjectLayout;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::{Node, NodeKind};
pub fn assemble_typst_source(
layout: &ProjectLayout,
hierarchy: &Hierarchy,
root_id: Option<uuid::Uuid>,
) -> Result<String> {
assemble_typst_source_filtered(layout, hierarchy, root_id, None, None)
}
pub fn assemble_typst_source_filtered(
layout: &ProjectLayout,
hierarchy: &Hierarchy,
root_id: Option<uuid::Uuid>,
status_floor_idx: Option<usize>,
tag_filter: Option<&str>,
) -> Result<String> {
assemble_typst_source_profiled(layout, hierarchy, root_id, status_floor_idx, tag_filter, &[])
}
pub fn assemble_typst_source_profiled(
layout: &ProjectLayout,
hierarchy: &Hierarchy,
root_id: Option<uuid::Uuid>,
status_floor_idx: Option<usize>,
tag_filter: Option<&str>,
profiles: &[(String, String)],
) -> Result<String> {
let variables = crate::config::Config::load_layered(&layout.config_path())
.map(|c| c.docs.variables)
.unwrap_or_default();
let tag_filter_norm =
tag_filter.map(|t| t.trim().to_ascii_lowercase());
let mut out = String::new();
let candidates: Vec<&Node> = if let Some(root_id) = root_id {
hierarchy
.collect_subtree(root_id)
.into_iter()
.filter_map(|id| hierarchy.get(id))
.collect()
} else {
hierarchy.flatten().into_iter().map(|(n, _)| n).collect()
};
let timeline_chapter_ids: std::collections::HashSet<uuid::Uuid> = hierarchy
.iter()
.filter(|n| {
n.kind == NodeKind::Chapter
&& n.system_tag.as_deref()
== Some(crate::store::SYSTEM_TAG_BOOK_TIMELINE)
})
.map(|n| n.id)
.collect();
for node in candidates {
if node.kind != NodeKind::Paragraph {
continue;
}
if node.event.is_some() {
continue;
}
if let Some(parent_id) = node.parent_id {
if timeline_chapter_ids.contains(&parent_id) {
continue;
}
}
if let Some(floor) = status_floor_idx {
let idx = status_ladder_index(node.status.as_deref());
if idx < floor {
continue;
}
}
if let Some(needle) = tag_filter_norm.as_deref() {
let has = node
.tags
.iter()
.any(|t| t.to_ascii_lowercase() == needle);
if !has {
continue;
}
}
if !profile_matches(&node.tags, profiles) {
continue;
}
let Some(rel) = node.file.as_ref() else {
continue;
};
let abs = layout.root.join(rel);
let mut body = std::fs::read_to_string(&abs)?;
if !variables.is_empty() {
for (k, v) in &variables {
body = body.replace(&format!("{{{{{k}}}}}"), v);
}
}
if !out.is_empty() && !out.ends_with("\n\n") {
if out.ends_with('\n') {
out.push('\n');
} else {
out.push_str("\n\n");
}
}
out.push_str(&body);
if !body.ends_with('\n') {
out.push('\n');
}
}
Ok(out)
}
fn profile_matches(tags: &[String], profiles: &[(String, String)]) -> bool {
for (dim, want) in profiles {
let prefix = format!("profile:{}:", dim.to_ascii_lowercase());
let vals: Vec<String> = tags
.iter()
.filter_map(|t| t.to_ascii_lowercase().strip_prefix(&prefix).map(str::to_string))
.collect();
if vals.is_empty() {
continue; }
if !vals.iter().any(|v| v == &want.to_ascii_lowercase()) {
return false; }
}
true
}
fn status_ladder_index(s: Option<&str>) -> usize {
let Some(s) = s else { return 0 };
match s.trim().to_ascii_lowercase().as_str() {
"none" | "" => 0,
"napkin" => 1,
"first" => 2,
"second" => 3,
"third" => 4,
"final" => 5,
"ready" => 6,
_ => 0,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Artefact {
Markdown(String),
Tex(String),
Epub(Vec<u8>),
}
impl Artefact {
pub fn extension(&self) -> &'static str {
match self {
Artefact::Markdown(_) => "md",
Artefact::Tex(_) => "tex",
Artefact::Epub(_) => "epub",
}
}
pub fn write_to(&self, path: &Path) -> Result<()> {
match self {
Artefact::Markdown(s) | Artefact::Tex(s) => {
std::fs::write(path, s.as_bytes())?;
}
Artefact::Epub(bytes) => {
std::fs::write(path, bytes)?;
}
}
Ok(())
}
}
pub fn build_markdown(combined: &str) -> Artefact {
Artefact::Markdown(markdown::typst_to_markdown(combined))
}
pub fn build_tex(combined: &str) -> Artefact {
Artefact::Tex(tex::typst_to_tex(combined))
}
pub fn build_epub(markdown_src: &str, title: &str) -> Result<Artefact> {
let bytes = epub::write_epub(markdown_src, title)?;
Ok(Artefact::Epub(bytes))
}
pub fn with_artefact_extension(path: &Path, artefact: &Artefact) -> PathBuf {
path.with_extension(artefact.extension())
}
#[cfg(test)]
mod profile_tests {
use super::profile_matches;
fn tags(ts: &[&str]) -> Vec<String> {
ts.iter().map(|s| s.to_string()).collect()
}
fn prof(ps: &[(&str, &str)]) -> Vec<(String, String)> {
ps.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
#[test]
fn tdoc3_profile_matching() {
assert!(profile_matches(&tags(&["profile:edition:enterprise"]), &[]));
assert!(profile_matches(&tags(&["para:code"]), &prof(&[("edition", "enterprise")])));
assert!(profile_matches(&tags(&["profile:edition:enterprise"]), &prof(&[("edition", "Enterprise")])));
assert!(!profile_matches(&tags(&["profile:edition:community"]), &prof(&[("edition", "enterprise")])));
assert!(profile_matches(
&tags(&["profile:audience:beginner", "profile:audience:expert"]),
&prof(&[("audience", "expert")])
));
assert!(profile_matches(
&tags(&["profile:edition:enterprise"]),
&prof(&[("edition", "enterprise"), ("audience", "expert")])
));
}
}