aboutsummaryrefslogtreecommitdiff
path: root/backend/src
diff options
context:
space:
mode:
authorGravatar Chris Xiong <chirs241097@gmail.com> 2024-07-24 23:40:11 -0400
committerGravatar Chris Xiong <chirs241097@gmail.com> 2024-07-24 23:40:11 -0400
commitb736068ee7b82e05c2ede8bc48ace7ffa4709e29 (patch)
treeeaa3cce9fdd9973043a7a55613584f90f6598a20 /backend/src
downloadnotekins-b736068ee7b82e05c2ede8bc48ace7ffa4709e29.tar.xz
Initial commit.
Diffstat (limited to 'backend/src')
-rw-r--r--backend/src/config.rs82
-rw-r--r--backend/src/main.rs160
-rw-r--r--backend/src/monolith.rs238
-rw-r--r--backend/src/render.rs346
4 files changed, 826 insertions, 0 deletions
diff --git a/backend/src/config.rs b/backend/src/config.rs
new file mode 100644
index 0000000..a1085ba
--- /dev/null
+++ b/backend/src/config.rs
@@ -0,0 +1,82 @@
+use std::collections::hash_map::HashMap;
+use std::fs::File;
+use std::io::Read;
+use std::fmt::{Display, Formatter, Error};
+
+#[derive(Clone)]
+pub enum ConfigValue {
+ Str(String),
+ UInt(usize)
+}
+
+impl Display for ConfigValue {
+ fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> {
+ match self {
+ ConfigValue::Str(s) => write!(f, "{}", s),
+ ConfigValue::UInt(u) => write!(f, "{}", u)
+ }
+ }
+}
+
+struct ConfigItem {
+ default_value: ConfigValue,
+ parse_func: Box<dyn Fn(String) -> Option<ConfigValue>>,
+}
+
+pub struct Config {
+ v: HashMap<String, ConfigValue>,
+}
+
+fn stripped_str(s: String) -> Option<ConfigValue> {
+ Some(ConfigValue::Str(String::from(s.trim())))
+}
+
+fn parse_usize(s: String) -> Option<ConfigValue> {
+ if let Ok(r) = usize::from_str_radix(&s, 10) {
+ Some(ConfigValue::UInt(r))
+ } else { None }
+}
+
+pub fn split_at_first<'a, 'b>(s: &'a str, sp: &'b str) -> (&'a str, &'a str) {
+ if let Some(p) = s.find(sp) {
+ (&s[.. p], &s[p + sp.len()..])
+ } else { (s, "") }
+}
+
+impl Config {
+ fn items() -> HashMap<&'static str, ConfigItem> {
+ HashMap::from([
+ ("SERVED_DATA_ROOT", ConfigItem{default_value: ConfigValue::Str(String::from("")), parse_func: Box::new(stripped_str)}),
+ ("POSTS_PER_PAGE", ConfigItem{default_value: ConfigValue::UInt(20), parse_func: Box::new(parse_usize)}),
+ ("DISPLAY_TIMEZONE", ConfigItem{default_value: ConfigValue::Str(String::from("UTC")), parse_func: Box::new(stripped_str)}),
+ ("VERSION_STRING", ConfigItem{default_value: ConfigValue::Str(String::from("1.0")), parse_func: Box::new(stripped_str)}),
+ ])
+ }
+ pub fn parse_config(filename: &str) -> Config {
+ let mut v = HashMap::from_iter(
+ Config::items().iter()
+ .map(|(k, v)| {(String::from(*k), v.default_value.clone())})
+ );
+ let mut f = File::open(filename).unwrap();
+ let mut s = String::new();
+ if let Ok(_) = f.read_to_string(&mut s) {
+ for l in s.split("\n") {
+ let (sname, svalue) = split_at_first(l, "=");
+ if let Some(ConfigItem{default_value: _, parse_func}) = Config::items().get(sname) {
+ if let Some(value) = parse_func(String::from(svalue)) {
+ v.insert(String::from(sname), value);
+ }
+ }
+ }
+ }
+ Config { v }
+ }
+ pub fn get(&self, key: &str) -> ConfigValue {
+ self.v.get(&String::from(key)).unwrap().clone()
+ }
+ pub fn get_str(&self, key: &str) -> String {
+ if let ConfigValue::Str(s) = self.get(key) {
+ s
+ } else { String::from("") }
+ }
+}
diff --git a/backend/src/main.rs b/backend/src/main.rs
new file mode 100644
index 0000000..0474f89
--- /dev/null
+++ b/backend/src/main.rs
@@ -0,0 +1,160 @@
+mod config;
+mod monolith;
+mod render;
+
+use std::env;
+use std::collections::hash_map::{HashMap, RandomState};
+
+enum CGIStatusCode {
+ S200,
+ C400,
+ C404,
+ C405
+}
+
+type CGIHTTPHeaders = Vec<(String, String)>;
+
+struct CGIResponse {
+ status: CGIStatusCode,
+ headers: CGIHTTPHeaders,
+ body: String
+}
+
+fn cgi_handle_request(conf: &config::Config) -> Result<CGIResponse, CGIResponse> {
+ let headers = vec![
+ (String::from("Allow"), String::from("GET")),
+ (String::from("Content-type"), String::from("text/html"))
+ ];
+ let mkerr = |status| CGIResponse {
+ status,
+ headers: headers.clone(),
+ body: String::from("")
+ };
+ let request_method = env::var("REQUEST_METHOD").map_err(|_| mkerr(CGIStatusCode::C400))?;
+ if request_method != "GET" {
+ return Err(mkerr(CGIStatusCode::C405));
+ }
+ let query: HashMap<_, _, RandomState> =
+ if let Ok(query_string) = env::var("QUERY_STRING") {
+ if query_string.len() > 0 {
+ HashMap::from_iter(query_string.split('&').map(|qi| {
+ let (k, v) = config::split_at_first(qi, "=");
+ (String::from(k), String::from(v))
+ }))
+ } else { HashMap::from([(String::from("page"), String::from("1"))]) }
+ } else { HashMap::from([(String::from("page"), String::from("1"))]) };
+ let m = monolith::Monolith::new(String::from("posts.monolith"));
+ if let Some(ps) = query.get("page") {
+ let p = usize::from_str_radix(&ps, 10).map_err(|_| mkerr(CGIStatusCode::C400))?.checked_sub(1).ok_or(mkerr(CGIStatusCode::C404))?;
+ let ps = m.get_page_posts(p).ok_or(mkerr(CGIStatusCode::C404))?;
+ let r = render::Renderer::load("./template");
+ return Ok(CGIResponse {
+ status: CGIStatusCode::S200,
+ headers,
+ body: r.render_page(ps, p, m.get_page_count(), conf)
+ });
+ } else if let Some(ds) = query.get("post") {
+ let d = i64::from_str_radix(&ds, 10).map_err(|_| mkerr(CGIStatusCode::C400))?;
+ let p = m.get_post_2(d).ok_or(mkerr(CGIStatusCode::C404))?;
+ let r = render::Renderer::load("./template");
+ return Ok(CGIResponse {
+ status: CGIStatusCode::S200,
+ headers,
+ body: r.render_single_post(p, conf)
+ });
+ }
+ Err(mkerr(CGIStatusCode::C400))
+}
+
+fn cgimain(conf: config::Config) -> Result<(), &'static str> {
+ let r = match cgi_handle_request(&conf) {
+ Ok(r) => r,
+ Err(r) => r
+ };
+ let (status, status_str) = match r.status {
+ CGIStatusCode::S200 => (200, "OK"),
+ CGIStatusCode::C400 => (400, "Bad Request"),
+ CGIStatusCode::C404 => (404, "Not Found"),
+ CGIStatusCode::C405 => (405, "Method Not Allowed")
+ };
+ print!("Status: {} {}\r\n", status, status_str);
+ r.headers.iter().for_each(|(f, v)| print!("{}: {}\r\n", f, v));
+ print!("\r\n");
+ if status < 400 {
+ print!("{}", r.body);
+ } else {
+ let rdr = render::Renderer::load("./template");
+ print!("{}", rdr.render_error(status, String::from(status_str), &conf));
+ }
+ Ok(())
+}
+
+fn dbgmain(conf: config::Config) -> Result<(), &'static str> {
+ eprintln!("in debug mode");
+ eprintln!("notekins version {}", conf.get("VERSION_STRING"));
+ let mut m = monolith::Monolith::new(String::from("posts.monolith"));
+ let mut args = env::args();
+ args.next();
+ if let Some(dbgop) = args.next() {
+ match dbgop.as_str() {
+ "get_post" => {
+ let tss = args.next().ok_or("missing timestamp")?;
+ let ts = i64::from_str_radix(&tss, 10).map_err(|_| "invalid timestamp")?;
+ m.load_index();
+ let p = m.get_post(ts).ok_or("post not found")?;
+ monolith::test_print_post(&p);
+ Ok(())
+ },
+ "get_post2" => {
+ let tss = args.next().ok_or("missing timestamp")?;
+ let ts = i64::from_str_radix(&tss, 10).map_err(|_| "invalid timestamp")?;
+ let p = m.get_post_2(ts).ok_or("post not found")?;
+ monolith::test_print_post(&p);
+ Ok(())
+ },
+ "get_page" => {
+ let pgs = args.next().ok_or("missing page")?;
+ let pg = usize::from_str_radix(&pgs, 10).map_err(|_| "invalid page")?;
+ let ps = m.get_page_posts(pg).ok_or("page out of range")?;
+ for p in ps {
+ monolith::test_print_post(&p);
+ }
+ Ok(())
+ },
+ "render_page" => {
+ let pgs = args.next().ok_or("missing page")?;
+ let pg = usize::from_str_radix(&pgs, 10).map_err(|_| "invalid page")?;
+ let ps = m.get_page_posts(pg).ok_or("page out of range")?;
+ let r = render::Renderer::load("./template");
+ println!("{}", r.render_page(ps, pg, m.get_page_count(), &conf));
+ Ok(())
+ },
+ "render_post" => {
+ let tss = args.next().ok_or("missing timestamp")?;
+ let ts = i64::from_str_radix(&tss, 10).map_err(|_| "invalid timestamp")?;
+ let p = m.get_post_2(ts).ok_or("post not found")?;
+ let r = render::Renderer::load("./template");
+ println!("{}", r.render_single_post(p, &conf));
+ Ok(())
+ },
+ _ => Err("unsupported debug option")
+ }
+ } else {
+ m.load_index();
+ let dates = m.get_all_dates();
+ for d in dates {
+ let p = m.get_post(d);
+ println!("{:?}", p)
+ }
+ Ok(())
+ }
+}
+
+fn main() -> Result<(), &'static str> {
+ let conf = config::Config::parse_config("notekins.conf");
+ if let Ok(_) = env::var("SERVER_SOFTWARE") {
+ cgimain(conf)
+ } else {
+ dbgmain(conf)
+ }
+}
diff --git a/backend/src/monolith.rs b/backend/src/monolith.rs
new file mode 100644
index 0000000..a471138
--- /dev/null
+++ b/backend/src/monolith.rs
@@ -0,0 +1,238 @@
+use std::fs::File;
+use std::vec::Vec;
+use std::io::Read;
+use memmap::Mmap;
+
+fn consume_str(buf: &[u8]) -> (String, usize) {
+ let nulp = buf.iter().position(|x| *x == 0u8).unwrap();
+ let s = String::from_utf8_lossy(&buf[..nulp]);
+ (s.to_string(), nulp)
+}
+
+#[derive(Debug)]
+pub enum MediaInstance {
+ Image {thmb: String, orig: String},
+ Video
+}
+
+impl MediaInstance {
+ fn consume(buf: &[u8]) -> (MediaInstance, usize) {
+ match buf[0] as char {
+ 'I' => {
+ let b = &buf[1..];
+ let (thmb, p1) = consume_str(b);
+ let b = &b[p1 + 1..];
+ let (orig, p2) = consume_str(b);
+ (MediaInstance::Image{thmb, orig}, p1 + p2 + 3)
+ },
+ 'V' => {
+ (MediaInstance::Video, 1)
+ }
+ _ => panic!("Invalid media type")
+ }
+ }
+}
+
+#[derive(Debug)]
+pub struct Post {
+ pub content: String,
+ pub date: i64,
+ pub media: Vec<MediaInstance>,
+ pub tags: Vec<String>
+}
+
+pub fn test_print_post(p: &Post) {
+ println!("{}", p.content);
+ println!("{}", p.date);
+ for m in &p.media {
+ match m {
+ MediaInstance::Image{thmb, orig} => println!("Image {} {}", thmb, orig),
+ MediaInstance::Video => println!("Video")
+ }
+ }
+ for t in &p.tags {
+ println!("{}", t);
+ }
+}
+
+impl Post {
+ fn consume(buf: &[u8]) -> (Post, usize) {
+ let (content, p) = consume_str(buf);
+ let mut l = p + 1;
+ let b = &buf[p + 1..];
+ let date = i64::from_le_bytes(b[..8].try_into().unwrap());
+ l += 8;
+ let b = &b[8..];
+ let mut media = vec![];
+ let mut tags = vec![];
+ let nmedia = b[0];
+ let mut b = &b[1..];
+ l += 1;
+ for _ in 0..nmedia {
+ let (m, ml) = MediaInstance::consume(b);
+ media.push(m);
+ b = &b[ml..];
+ l += ml;
+ }
+ let ntags = b[0];
+ let mut b = &b[1..];
+ l += 1;
+ for _ in 0..ntags {
+ let (t, p) = consume_str(b);
+ tags.push(t);
+ b = &b[p + 1..];
+ l+= p + 1;
+ }
+ (Post{content, date, media, tags}, l)
+ }
+}
+
+pub struct Monolith {
+ filen: String,
+ idxfn: String,
+ pdxfn: String,
+ post_ranges: Vec<(i64, usize, usize)>,
+}
+
+impl Monolith {
+ pub fn new(filen: String) -> Monolith {
+ let idxfn = filen.clone() + ".idx";
+ let pdxfn = filen.clone() + ".pdx";
+ Monolith {
+ filen,
+ idxfn,
+ pdxfn,
+ post_ranges: vec![]
+ }
+ }
+
+ pub fn load_index(&mut self) {
+ let mut f = File::open(&self.idxfn).unwrap();
+ let mut last_r: u64 = 0;
+ self.post_ranges.clear();
+ loop {
+ let mut buf: [u8; 16] = [0; 16];
+ match f.read_exact(&mut buf) {
+ Ok(_) => (),
+ Err(e) => match e.kind() {
+ std::io::ErrorKind::UnexpectedEof => break,
+ _ => panic!("unexpected error {}", e)
+ }
+ }
+ let t = i64::from_le_bytes(buf[..8].try_into().unwrap());
+ let l = last_r;
+ let r = u64::from_le_bytes(buf[8..].try_into().unwrap());
+ self.post_ranges.push((t, l as usize, r as usize));
+ last_r = r;
+ }
+ }
+
+ fn find_post(&self, date: i64) -> Option<(usize, usize)> {
+ if let Ok(p) = self.post_ranges.binary_search_by(|p| p.0.cmp(&date)) {
+ let (_, l, r) = self.post_ranges[p];
+ Some((l, r))
+ } else { None }
+ }
+
+ fn find_post_2(&self, date: i64) -> Option<(usize, usize)> {
+ let f = File::open(&self.idxfn).unwrap();
+ let map = unsafe { Mmap::map(&f) }.unwrap();
+ let len = map.len();
+ let nposts = len / 16;
+ let mut l = 0;
+ let mut r = nposts;
+ let mut s = r;
+ let mut postidx = None;
+ while l < r {
+ let m = l + s / 2;
+ let b = &map[m * 16..];
+ let cdate = i64::from_le_bytes(b[..8].try_into().unwrap());
+ l = if cdate < date { m + 1 } else { l };
+ r = if cdate > date { m } else { r };
+ s = r - l;
+ if cdate == date {
+ postidx = Some(m);
+ break;
+ }
+ }
+ if let Some(postidx) = postidx {
+ let b = &map[postidx * 16 + 8..];
+ let r = u64::from_le_bytes(b[..8].try_into().unwrap());
+ let l = if postidx == 0 {
+ 0
+ } else {
+ let b = &map[postidx * 16 - 8..];
+ u64::from_le_bytes(b[..8].try_into().unwrap())
+ };
+ Some((l as usize, r as usize))
+ } else { None }
+ }
+
+ fn get_page_range(&self, page: usize) -> Option<(usize, usize)> {
+ let f = File::open(&self.pdxfn).unwrap();
+ let map = unsafe { Mmap::map(&f) }.unwrap();
+ let len = map.len();
+ let npages = len / 8;
+ if page >= npages {
+ return None;
+ }
+ let b = &map[page * 8..];
+ let r = u64::from_le_bytes(b[..8].try_into().unwrap());
+ let l = if page == npages - 1 {
+ 0
+ } else {
+ let b = &map[(page + 1) * 8..];
+ u64::from_le_bytes(b[..8].try_into().unwrap())
+ };
+ Some((l as usize, r as usize))
+ }
+
+ fn get_posts_in_range(&self, l: usize, r: usize) -> Vec<Post> {
+ let f = File::open(&self.filen).unwrap();
+ let map = unsafe { Mmap::map(&f) }.unwrap();
+ let mut remaining = &map[l..r];
+ let mut ret = vec![];
+ loop {
+ if remaining.len() == 0 {
+ break;
+ }
+ let (post, len) = Post::consume(remaining);
+ ret.push(post);
+ remaining = &remaining[len + 1..];
+ }
+ ret
+ }
+
+ pub fn get_all_dates(&self) -> Vec<i64> {
+ self.post_ranges.iter().map(|x| x.0).collect()
+ }
+
+ pub fn get_post(&self, date: i64) -> Option<Post> {
+ if let Some((l, r)) = self.find_post(date) {
+ let f = File::open(&self.filen).unwrap();
+ let map = unsafe { Mmap::map(&f) }.unwrap();
+ Some(Post::consume(&map[l..r]).0)
+ } else { None }
+ }
+
+ pub fn get_post_2(&self, date: i64) -> Option<Post> {
+ if let Some((l, r)) = self.find_post_2(date) {
+ let f = File::open(&self.filen).unwrap();
+ let map = unsafe { Mmap::map(&f) }.unwrap();
+ Some(Post::consume(&map[l..r]).0)
+ } else { None }
+ }
+
+ pub fn get_page_posts(&self, page: usize) -> Option<Vec<Post>> {
+ if let Some((l, r)) = self.get_page_range(page) {
+ Some(self.get_posts_in_range(l, r))
+ } else {
+ None
+ }
+ }
+
+ pub fn get_page_count(&self) -> usize {
+ let m = std::fs::metadata(&self.pdxfn).unwrap();
+ (m.len() / 8) as usize
+ }
+}
diff --git a/backend/src/render.rs b/backend/src/render.rs
new file mode 100644
index 0000000..76b5490
--- /dev/null
+++ b/backend/src/render.rs
@@ -0,0 +1,346 @@
+use regex::Regex;
+use chrono::DateTime;
+use tzfile::Tz;
+
+use std::path::Path;
+use std::fs::File;
+use std::io::Read;
+use std::borrow::Cow;
+
+use crate::monolith::MediaInstance;
+use crate::monolith::Post;
+use crate::config::Config;
+
+pub struct Renderer {
+ paget: String,
+ pagert: String,
+ postt: String,
+ media_contt: String,
+ media_imgt: String,
+ errort: String
+}
+
+#[derive(Clone)]
+struct SubstitutionContext<'p> {
+ ps: Option<&'p Vec<Post>>,
+ p: Option<&'p Post>,
+ curmedia: Option<usize>,
+ curpage: Option<usize>,
+ maxpage: Option<usize>,
+ single_post: bool,
+ error: Option<usize>,
+ error_str: Option<String>
+}
+
+impl Renderer {
+ pub fn load<P: AsRef<Path>>(template_dir: P) -> Renderer {
+ let template_dir = template_dir.as_ref();
+ let mut f = File::open(template_dir.join("page.template")).unwrap();
+ let mut paget = String::from("");
+ f.read_to_string(&mut paget).unwrap();
+ let mut f = File::open(template_dir.join("pager.template")).unwrap();
+ let mut pagert = String::from("");
+ f.read_to_string(&mut pagert).unwrap();
+ let mut f = File::open(template_dir.join("post.template")).unwrap();
+ let mut postt = String::from("");
+ f.read_to_string(&mut postt).unwrap();
+ let mut f = File::open(template_dir.join("media-container.template")).unwrap();
+ let mut media_contt = String::from("");
+ f.read_to_string(&mut media_contt).unwrap();
+ let mut f = File::open(template_dir.join("media-image.template")).unwrap();
+ let mut media_imgt = String::from("");
+ f.read_to_string(&mut media_imgt).unwrap();
+ let mut f = File::open(template_dir.join("error.template")).unwrap();
+ let mut errort = String::from("");
+ f.read_to_string(&mut errort).unwrap();
+ Renderer{paget, pagert, postt, media_contt, media_imgt, errort}
+ }
+ fn resolve_current_page(sub: &str, curpage: Option<usize>, maxpage: Option<usize>) -> String {
+ if curpage.is_none() || maxpage.is_none() {
+ return String::from("")
+ }
+ let curpage = curpage.unwrap() + 1;
+ let maxpage = maxpage.unwrap();
+ let p: Vec<&str> = sub.split('/').collect();
+ if p.len() < 2 {
+ curpage.to_string()
+ } else {
+ let d = isize::from_str_radix(p[1], 10).unwrap_or_default();
+ if let Some(v) = curpage.checked_add_signed(d) {
+ if (v == 0) || (v > maxpage) {
+ String::from("")
+ } else {
+ v.to_string()
+ }
+ } else { String::from("") }
+ }
+ }
+
+ fn resolve_current_page_url(sub: &str, curpage: Option<usize>, maxpage: Option<usize>) -> String {
+ if curpage.is_none() || maxpage.is_none() {
+ return String::from("");
+ }
+ let curpage = curpage.unwrap() + 1;
+ let maxpage = maxpage.unwrap();
+ let mut p = sub.split('/');
+ p.next();
+ let d = isize::from_str_radix(p.next().unwrap_or_default(), 10).unwrap_or_default();
+ let default = p.next().unwrap_or_default();
+ if let Some(v) = curpage.checked_add_signed(d) {
+ if (v == 0) || (v > maxpage) {
+ String::from(default)
+ } else {
+ String::from("?page=") + &v.to_string()
+ }
+ } else { String::from(default) }
+ }
+
+ fn resolve_post_content_plain(sub: &str, p: Option<&Post>) -> String {
+ if p.is_none() {
+ return String::from("");
+ }
+ let p = p.unwrap();
+ let mut params = sub.split('/');
+ params.next();
+ let c = usize::from_str_radix(params.next().unwrap_or_default(), 10).unwrap_or_default();
+ let re = Regex::new("<[^>]+>").unwrap();
+ let plain = re.replace_all(&p.content, "");
+ let taken: String = plain.chars().take(c).collect();
+ if taken.len() < plain.len() {
+ taken + " ..."
+ } else { taken }
+ }
+
+ fn resolve_img_thumb_url(p: Option<&Post>, curmedia: Option<usize>, conf: &Config) -> String {
+ if p.is_none() {
+ return String::from("");
+ }
+ let p = p.unwrap();
+ if let Some(curmedia) = curmedia {
+ if let MediaInstance::Image{thmb, orig: _} = &p.media[curmedia] {
+ return conf.get_str("SERVED_DATA_ROOT") + "/" + thmb;
+ }
+ }
+ String::from("")
+ }
+
+ fn resolve_img_orig_url(p: Option<&Post>, curmedia: Option<usize>, conf: &Config) -> String {
+ if p.is_none() {
+ return String::from("");
+ }
+ let p = p.unwrap();
+ if let Some(curmedia) = curmedia {
+ if let MediaInstance::Image{thmb: _, orig} = &p.media[curmedia] {
+ return conf.get_str("SERVED_DATA_ROOT") + "/" + orig;
+ }
+ }
+ String::from("")
+ }
+
+ fn resolve_max_page(maxpage: Option<usize>) -> String {
+ if let Some(maxpage) = maxpage {
+ maxpage.to_string()
+ } else {
+ String::from("")
+ }
+ }
+
+ fn resolve_post_content(p: Option<&Post>) -> String {
+ if let Some(p) = p {
+ p.content.clone()
+ } else { String::from("") }
+ }
+
+ fn resolve_post_date_formatted(p: Option<&Post>, conf: &Config) -> String {
+ if p.is_none() {
+ return String::from("");
+ }
+ let p = p.unwrap();
+ if let Some(dt) = DateTime::from_timestamp(p.date, 0) {
+ if let Ok(tz) = Tz::named(&conf.get_str("DISPLAY_TIMEZONE")) {
+ dt.with_timezone(&&tz).to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
+ } else { dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true) }
+ } else { String::from("") }
+ }
+
+ fn resolve_post_date_timestamp(p: Option<&Post>) -> String {
+ if let Some(p) = p {
+ p.date.to_string()
+ } else { String::from("") }
+ }
+
+ fn resolve_post_tags(p: Option<&Post>) -> String {
+ if let Some(p) = p {
+ String::from(p.tags.iter().fold(String::from(""), |s, t| s + "#" + &t + " ").trim_end())
+ } else { String::from("") }
+ }
+
+ fn render_post(&self, sc: &SubstitutionContext, conf: &Config) -> String {
+ self.substitute(&self.postt, sc, conf)
+ }
+
+ fn render_posts(&self, sc: &SubstitutionContext, conf: &Config) -> String {
+ if let Some(ps) = sc.ps {
+ let s = ps.iter().rev().fold(String::from(""), |r, p| {
+ let psc = SubstitutionContext {
+ p: Some(&p),
+ .. sc.clone()
+ };
+ r + &self.render_post(&psc, conf)
+ });
+ return s;
+ }
+ String::from("")
+ }
+
+ fn render_pager(&self, sc: &SubstitutionContext, conf: &Config) -> String {
+ if sc.single_post {
+ String::from("")
+ } else {
+ self.substitute(&self.pagert, sc, conf)
+ }
+ }
+
+ fn render_media_instance(&self, sc: &SubstitutionContext, conf: &Config) -> String {
+ if let Some(curmedia) = sc.curmedia {
+ if let Some(p) = sc.p {
+ if curmedia < p.media.len() {
+ if let MediaInstance::Image{thmb: _, orig: _} = p.media[curmedia] {
+ return self.substitute(&self.media_imgt, sc, conf);
+ }
+ }
+ }
+ }
+ String::from("")
+ }
+
+ fn render_media(&self, sc: &SubstitutionContext, conf: &Config) -> String {
+ if let Some(p) = sc.p {
+ let s = (0..p.media.len()).fold(String::from(""), |r, midx| {
+ let nsc = SubstitutionContext {
+ curmedia: Some(midx),
+ .. sc.clone()
+ };
+ r + &self.render_media_instance(&nsc, conf)
+ });
+ return s;
+ }
+ String::from("")
+ }
+
+ fn resolve_media_container(&self, sc: &SubstitutionContext, conf: &Config) -> String {
+ if let Some(p) = sc.p {
+ if p.media.len() > 0 {
+ return self.substitute(&self.media_contt, sc, conf);
+ }
+ }
+ String::from("")
+ }
+
+ fn resolve_error_status(sc: &SubstitutionContext) -> String {
+ if let Some(err) = sc.error {
+ err.to_string()
+ } else {
+ String::from("")
+ }
+ }
+
+ fn resolve_error_description(sc: &SubstitutionContext) -> String {
+ if let Some(errs) = &sc.error_str {
+ String::from(errs)
+ } else {
+ String::from("")
+ }
+ }
+
+ fn resolve_notekins_version(conf: &Config) -> String {
+ conf.get_str("VERSION_STRING")
+ }
+
+ fn resolve_substitution(&self, sub: &str, sc: &SubstitutionContext, conf: &Config) -> String {
+ if sub.starts_with("CURRENT_PAGE_URL") {
+ Self::resolve_current_page_url(sub, sc.curpage, sc.maxpage)
+ } else if sub.starts_with("CURRENT_PAGE") {
+ Self::resolve_current_page(sub, sc.curpage, sc.maxpage)
+ } else if sub.starts_with("POST_CONTENT_PLAIN") {
+ Self::resolve_post_content_plain(sub, sc.p)
+ } else {
+ match sub {
+ "IMG_THUMB_URL" => Self::resolve_img_thumb_url(sc.p, sc.curmedia, conf),
+ "IMG_ORIG_URL" => Self::resolve_img_orig_url(sc.p, sc.curmedia, conf),
+ "POSTS" => self.render_posts(sc, conf),
+ "MAX_PAGE" => Self::resolve_max_page(sc.maxpage),
+ "PAGER" => self.render_pager(sc, conf),
+ "MEDIA" => self.render_media(sc, conf),
+ "MEDIA_CONTAINER" => self.resolve_media_container(sc, conf),
+ "POST_CONTENT" => Self::resolve_post_content(sc.p),
+ "POST_DATE_FORMATTED" => Self::resolve_post_date_formatted(sc.p, conf),
+ "POST_DATE_TIMESTAMP" => Self::resolve_post_date_timestamp(sc.p),
+ "POST_TAGS" => Self::resolve_post_tags(sc.p),
+ "ERROR_STATUS" => Self::resolve_error_status(sc),
+ "ERROR_DESCRIPTION" => Self::resolve_error_description(sc),
+ "NOTEKINS_VERSION" => Self::resolve_notekins_version(conf),
+ _ => {
+ eprintln!("unknown substitution string {}", sub);
+ String::from("")
+ }
+ }
+ }
+ }
+
+ fn substitute(&self, template: &str, sc: &SubstitutionContext, conf: &Config) -> String {
+ let mut sp: Vec<Cow<'_, str>> = template.split('@').map(|x| Cow::Borrowed(x)).collect();
+ for sub in sp.iter_mut().skip(1).step_by(2) {
+ let subbed = self.resolve_substitution(sub, sc, conf);
+ *sub = Cow::Owned(subbed);
+ }
+ sp.iter().fold(String::from(""), |r, s| r + &s)
+ }
+
+ fn render_page_internal(&self, sc: &SubstitutionContext, conf: &Config) -> String {
+ self.substitute(&self.paget, sc, conf)
+ }
+
+ pub fn render_page(&self, posts: Vec<Post>, curpage: usize, maxpage: usize, conf: &Config) -> String {
+ let sc = SubstitutionContext {
+ ps: Some(&posts),
+ p: None,
+ curmedia: None,
+ curpage: Some(curpage),
+ maxpage: Some(maxpage),
+ single_post: false,
+ error: None,
+ error_str: None
+ };
+ self.render_page_internal(&sc, conf)
+ }
+
+ pub fn render_single_post(&self, post: Post, conf: &Config) -> String {
+ let ps = vec![post];
+ let sc = SubstitutionContext {
+ ps: Some(&ps),
+ p: Some(&ps[0]),
+ curmedia: None,
+ curpage: None,
+ maxpage: None,
+ single_post: true,
+ error: None,
+ error_str: None
+ };
+ self.render_page_internal(&sc, conf)
+ }
+
+ pub fn render_error(&self, err: usize, errs: String, conf: &Config) -> String {
+ let sc = SubstitutionContext {
+ ps: None,
+ p: None,
+ curmedia: None,
+ curpage: None,
+ maxpage: None,
+ single_post: false,
+ error: Some(err),
+ error_str: Some(errs)
+ };
+ self.substitute(&self.errort, &sc, &conf)
+ }
+}