use std::collections::HashSet;
use std::fs;
use std::path::Path;
use crate::error::BuildError;
use crate::error::{ScrapsResult, anyhow::Context};
use crate::service::tera_render::{RenderOutput, resolve_template, user_template_glob};
use crate::usecase::build::model::backlinks_map::BacklinksMap;
use crate::usecase::build::model::html::HtmlMetadata;
use crate::usecase::build::model::list_view_configs::ListViewConfigs;
use crate::usecase::build::model::paging::Paging;
use crate::usecase::build::model::scrap_detail::ScrapDetails;
use crate::usecase::build::model::site_nav::SiteNav;
use rayon::prelude::*;
use scraps_libs::model::base_url::BaseUrl;
use scraps_libs::model::page_path::PagePath;
use scraps_libs::model::tag::Tag;
use tera::Tera;
use tracing::{Level, span};
use crate::usecase::build::html::templates;
use super::serde::index_scraps::{IndexScraps, IndexScrapsTera};
use super::serde::scrap_type::TypesTera;
pub struct IndexRender {
tera: Tera,
output: RenderOutput,
}
impl IndexRender {
pub fn new(static_dir_path: &Path, output: &RenderOutput) -> ScrapsResult<IndexRender> {
if let RenderOutput::Dir(output_dir_path) = output {
fs::create_dir_all(output_dir_path).context(BuildError::CreateDir)?;
}
let tera = templates::index(&user_template_glob(static_dir_path, "*.html"))?;
Ok(IndexRender {
tera,
output: output.clone(),
})
}
pub fn run(
&self,
base_url: &BaseUrl,
metadata: &HtmlMetadata,
list_view_configs: &ListViewConfigs,
scrap_details: &ScrapDetails,
backlinks_map: &BacklinksMap,
site_nav: &SiteNav,
) -> ScrapsResult<usize> {
let rows = {
let _span = span!(Level::INFO, "generate_index_rows").entered();
IndexScraps::new(scrap_details, backlinks_map)
};
let paginate_by = match list_view_configs.paging {
Paging::By(size) => size,
Paging::Not => 0,
};
let shell = {
let mut context = templates::context(base_url, metadata);
context.insert("paginate_by", &paginate_by);
context
};
self.render_scope(&shell, &rows.scope(|_| true), None, site_nav)?;
site_nav.tags.iter().par_bridge().try_for_each(|tag| {
let _span = span!(Level::INFO, "generate_tag_list").entered();
let keys: HashSet<String> = backlinks_map
.get_tag(tag)
.iter()
.map(|scrap| scrap.self_key().to_string())
.collect();
let list = rows.scope(|key| keys.contains(key));
self.render_scope(&shell, &list, Some(tag), site_nav)
})?;
Ok(1 + site_nav.tags.len())
}
fn render_scope(
&self,
shell: &tera::Context,
list: &IndexScrapsTera,
tag: Option<&Tag>,
site_nav: &SiteNav,
) -> ScrapsResult<()> {
let mut context = shell.clone();
templates::insert_site_nav(&mut context, site_nav, tag, true);
context.insert("scope_tag", &tag.map(Tag::to_string));
context.insert("scraps", list);
context.insert(
"types",
&TypesTera::counted_in(&site_nav.types, site_nav.scope_counts.of(tag)),
);
let output = match tag {
Some(tag) => self.output.join(PagePath::of_tag(tag).as_str()),
None => self.output.clone(),
};
let template_name = resolve_template(&self.tera, "index.html", "__builtins/index.html");
output.render(&self.tera, template_name, &context, "index.html")
}
}
#[cfg(test)]
mod tests {
use crate::test_fixtures::{TempScrapProject, temp_scrap_project};
use rstest::rstest;
use std::fs;
use url::Url;
use super::*;
use crate::usecase::build::model::scrap_detail::ScrapDetail;
use scraps_libs::lang::LangCode;
use scraps_libs::model::scrap::Scrap;
fn render(project: &TempScrapProject, scraps: &[Scrap], ts: &[Option<i64>]) -> usize {
let base_url = &BaseUrl::new(Url::parse("http://localhost:1112/").unwrap()).unwrap();
let metadata = HtmlMetadata::new(&LangCode::default(), "Scrap", &None, &None);
let link_targets = scraps_libs::html::LinkTargets::new(scraps);
let scrap_details = ScrapDetails::new(
scraps
.iter()
.zip(ts.iter().chain(std::iter::repeat(&None)))
.map(|(scrap, ts)| ScrapDetail::new(scrap, ts, base_url, &link_targets))
.collect(),
);
let backlinks_map = BacklinksMap::new(scraps);
let site_nav = SiteNav::new(scraps, &backlinks_map, true, chrono_tz::UTC, false);
IndexRender::new(
&project.static_dir,
&RenderOutput::Dir(project.output_dir.clone()),
)
.unwrap()
.run(
base_url,
&metadata,
&ListViewConfigs::new(&true, &Paging::By(2)),
&scrap_details,
&backlinks_map,
&site_nav,
)
.unwrap()
}
fn typed(title: &str, scrap_type: &str, body: &str) -> Scrap {
Scrap::new(
title,
&None,
&format!("---\ntype: {scrap_type}\n---\n{body}"),
)
}
#[rstest]
fn it_lists_every_scrap_in_updated_order(
#[from(temp_scrap_project)] project: TempScrapProject,
) {
project.add_static_file(
"index.html",
b"{{ paginate_by }}{% for row in scraps.rows %} {{ row.title }}:{{ row.backlinks_rank }}{% endfor %}",
);
let scraps = [
Scrap::new("old", &None, ""),
Scrap::new("new", &None, "[[old]]"),
Scrap::new("mid", &None, ""),
];
let pages = render(&project, &scraps, &[Some(0), Some(2), Some(1)]);
assert_eq!(pages, 1);
let home = fs::read_to_string(project.output_path("index.html")).unwrap();
assert_eq!(home, "2 new:1 mid:2 old:0");
assert!(!project.output_path("2.html").exists());
assert!(!project.output_path("backlinks").exists());
assert!(!project.output_path("all-scraps").exists());
}
#[rstest]
fn it_writes_a_list_for_each_tag(#[from(temp_scrap_project)] project: TempScrapProject) {
project.add_static_file(
"index.html",
b"{{ scope_tag }}|{% for row in scraps.rows %}{{ row.title }} {% endfor %}|{% for t in types %}{{ t.name }}={{ t.scraps_count }} {% endfor %}",
);
let scraps = [
typed("a", "Book", "#[[ai/ml]]"),
typed("b", "Blog", "#[[ai]]"),
typed("c", "Book", "#[[other]]"),
];
assert_eq!(render(&project, &scraps, &[]), 4);
let read = |path: &str| fs::read_to_string(project.output_path(path)).unwrap();
assert_eq!(read("tags/ai/index.html"), "ai|b a |Book=1 Blog=1 ");
assert_eq!(read("tags/ai/ml/index.html"), "ai/ml|a |Book=1 Blog=0 ");
assert_eq!(read("tags/other/index.html"), "other|c |Book=1 Blog=0 ");
assert!(!project.output_path("types").exists());
assert!(!project.output_path("tags/ai.html").exists());
}
#[rstest]
fn it_marks_each_row_for_the_page_to_filter_and_sort(
#[from(temp_scrap_project)] project: TempScrapProject,
) {
let scraps = [
typed("Rust book", "Book", "#[[Programming]]"),
Scrap::new("Ownership", &None, "#[[Programming]] [[Rust book]]"),
];
render(&project, &scraps, &[Some(1), Some(2)]);
let html = fs::read_to_string(project.output_path("tags/Programming/index.html")).unwrap();
assert!(html.contains(r#"data-type="Book""#), "{html}");
assert!(html.contains(r#"data-rank-backlinks="0""#), "{html}");
assert!(
html.contains(r#"aria-current="page""#) && html.contains("Programming"),
"{html}"
);
let home = fs::read_to_string(project.output_path("index.html")).unwrap();
assert!(home.contains(r#"data-type="Book""#), "{home}");
}
#[rstest]
fn it_offers_no_type_facet_when_no_scrap_has_a_type(
#[from(temp_scrap_project)] project: TempScrapProject,
) {
let scraps = [Scrap::new("untyped", &None, "#[[Blog]]")];
render(&project, &scraps, &[]);
let home = fs::read_to_string(project.output_path("index.html")).unwrap();
assert!(home.contains("facet-sort"), "{home}");
assert!(!home.contains("facet-type"), "{home}");
}
#[rstest]
fn fusejs_loads_as_es_module_in_builtin_index(
#[from(temp_scrap_project)] project: TempScrapProject,
) {
render(&project, &[Scrap::new("scrap1", &None, "# header1")], &[]);
let html = fs::read_to_string(project.output_path("index.html")).unwrap();
assert!(
!html.contains(r#"<script src="http://localhost:1112/js/fuse.min.js"#),
"Got HTML:\n{html}"
);
assert!(
html.contains(r#"import Fuse from "http://localhost:1112/js/fuse.min.js"#),
"Got HTML:\n{html}"
);
}
}