gazetta_core/model/
entry.rs

1//  Copyright (C) 2015 Steven Allen
2//
3//  This file is part of gazetta.
4//
5//  This program is free software: you can redistribute it and/or modify it under the terms of the
6//  GNU General Public License as published by the Free Software Foundation version 3 of the
7//  License.
8//
9//  This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
10//  without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See
11//  the GNU General Public License for more details.
12//
13//  You should have received a copy of the GNU General Public License along with this program.  If
14//  not, see <http://www.gnu.org/licenses/>.
15//
16
17use std::fs::File;
18use std::path::{Path, PathBuf};
19
20use chrono::FixedOffset;
21
22use crate::error::SourceError;
23use crate::model::index::Syndicate;
24
25use super::index::{self, Index};
26use super::yaml::{self, Yaml};
27use super::{DateTime, Meta};
28
29/// An entry in the website.
30///
31/// Note: *Pages* do not correspond one-to-one to Entries (due to pagination).
32#[derive(Debug, Clone)]
33pub struct Entry<EntryMeta>
34where
35    EntryMeta: Meta,
36{
37    /// The entry's title.
38    ///
39    /// This is separate from the general metadata for sorting and linking. All entries should have
40    /// titles.
41    pub title: String,
42
43    /// The entry's description.
44    ///
45    /// If present, this will be included in compact indices.
46    pub description: Option<String>,
47
48    /// The date & time at which the entry was created.
49    ///
50    /// This is separate from the general metadata for sorting.
51    pub date: Option<DateTime>,
52
53    /// The entry's last modification time.
54    ///
55    /// All entries have this. Usually this should be specified in the entry's metadata, but it can
56    /// also be derived from the associated file's metadata.
57    pub updated: DateTime,
58
59    /// The entries index options (if specified).
60    pub index: Option<Index>,
61
62    /// Indices into which this entry should be linked.
63    pub cc: Vec<String>,
64
65    /// Extra metadata.
66    pub meta: EntryMeta,
67
68    /// The entry name.
69    pub name: String,
70
71    /// The content
72    pub content: String,
73
74    /// Content format
75    pub format: String, // TODO: Use atoms (intern).
76}
77
78fn parse_datetime(date: &str) -> Result<DateTime, SourceError> {
79    Ok(if date.contains([' ', 't', 'T']) {
80        chrono::DateTime::<FixedOffset>::parse_from_rfc3339(date)
81            .map_err(|e| format!("invalid date: {e}"))?
82            .to_utc()
83    } else {
84        let date = chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d")
85            .map_err(|e| format!("invalid date: {e}"))?;
86        let datetime = chrono::NaiveDateTime::new(date, chrono::NaiveTime::default());
87        chrono::DateTime::from_naive_utc_and_offset(datetime, chrono::Utc)
88    })
89}
90
91impl<EntryMeta> Entry<EntryMeta>
92where
93    EntryMeta: Meta,
94{
95    pub fn from_file<P>(full_path: P, name: &str) -> Result<Self, SourceError>
96    where
97        P: AsRef<Path>,
98    {
99        Entry::_from_file(full_path.as_ref(), name)
100    }
101
102    fn _from_file(full_path: &Path, name: &str) -> Result<Self, SourceError> {
103        // Helpers
104        const U32_MAX_AS_I64: i64 = u32::MAX as i64;
105
106        fn resolve_path(base: &str, path: &str) -> Result<PathBuf, SourceError> {
107            use std::path::Component::*;
108            let path: &Path = path.as_ref();
109            let resolved = match path.components().next() {
110                Some(Prefix(_) | RootDir) => {
111                    return Err(SourceError::Config(
112                        format!("index for {base} specifies an absolute directory").into(),
113                    ));
114                }
115                None => {
116                    return Err(SourceError::Config(
117                        format!("index for {base} specifies an empty directory").into(),
118                    ));
119                }
120                Some(CurDir | ParentDir) => Path::new(base).join(path),
121                _ => path.into(),
122            };
123            let mut normalized = PathBuf::new();
124            for component in resolved.components() {
125                match component {
126                    Prefix(_) | RootDir => {
127                        return Err(SourceError::Config(
128                            format!("index for {base} specifies an absolute directory").into(),
129                        ));
130                    }
131                    CurDir => (),
132                    ParentDir => {
133                        if !normalized.pop() {
134                            return Err(SourceError::Config(
135                                format!(
136                                    "index for {base} specifies an invalid relative path: {}",
137                                    resolved.display(),
138                                )
139                                .into(),
140                            ));
141                        }
142                    }
143                    Normal(c) => normalized.push(c),
144                }
145            }
146            if normalized.components().next().is_none() {
147                return Err(SourceError::Config(
148                    format!("index for {base} specifies an empty directory").into(),
149                ));
150            }
151            Ok(normalized)
152        }
153
154        // Load metadata
155
156        let entry_file = File::open(full_path)?;
157        let (mut meta, content) = yaml::load_front(&entry_file)?;
158
159        let format = full_path
160            .extension()
161            .and_then(|e| e.to_str())
162            .unwrap_or("")
163            .to_owned();
164        let title = match meta.remove(&yaml::KEYS.title) {
165            Some(Yaml::String(title)) => title,
166            Some(..) => return Err("titles must be strings".into()),
167            None => return Err("entries must have titles".into()),
168        };
169        let description = match meta.remove(&yaml::KEYS.description) {
170            Some(Yaml::String(desc)) => Some(desc),
171            None => None,
172            Some(..) => return Err("invalid description type".into()),
173        };
174        let date = match meta.remove(&yaml::KEYS.date) {
175            Some(Yaml::String(date)) => Some(parse_datetime(&date)?),
176            Some(..) => return Err("date must be a string".into()),
177            None => None,
178        };
179        let updated = match meta.remove(&yaml::KEYS.updated) {
180            Some(Yaml::String(date)) => parse_datetime(&date)?,
181            Some(..) => return Err("date must be a string".into()),
182            None => match date {
183                Some(d) => d,
184                None => entry_file.metadata()?.modified()?.into(),
185            },
186        };
187        let index = match meta.remove(&yaml::KEYS.index) {
188            Some(Yaml::Boolean(b)) => b.then(|| Index {
189                paginate: None,
190                max: None,
191                compact: false,
192                sort: index::Sort::default(),
193                directories: vec![name.into()],
194                syndicate: None,
195            }),
196            Some(Yaml::String(dir)) => Some(Index {
197                paginate: None,
198                max: None,
199                compact: false,
200                sort: index::Sort::default(),
201                directories: vec![resolve_path(name, &dir)?],
202                syndicate: None,
203            }),
204            Some(Yaml::Array(array)) => Some(Index {
205                paginate: None,
206                max: None,
207                compact: false,
208                sort: index::Sort::default(),
209                directories: array
210                    .into_iter()
211                    .map(|i| match i {
212                        Yaml::String(dir) => resolve_path(name, &dir),
213                        _ => Err(SourceError::from("index directories must be strings")),
214                    })
215                    .collect::<Result<_, _>>()?,
216                syndicate: None,
217            }),
218            Some(Yaml::Hash(mut index)) => Some(Index {
219                paginate: match index.remove(&yaml::KEYS.paginate) {
220                    Some(Yaml::Integer(i @ 1..=U32_MAX_AS_I64)) => Some(i as u32),
221                    Some(Yaml::Boolean(false)) | None => None,
222                    Some(..) => return Err("invalid pagination setting".into()),
223                },
224                max: match index.remove(&yaml::KEYS.max) {
225                    Some(Yaml::Integer(i @ 1..=U32_MAX_AS_I64)) => Some(i as u32),
226                    Some(Yaml::Boolean(false)) | None => None,
227                    Some(..) => return Err("invalid max setting".into()),
228                },
229                syndicate: match index.remove(&yaml::KEYS.syndicate) {
230                    Some(Yaml::Integer(i @ 1..=U32_MAX_AS_I64)) => Some(Syndicate {
231                        max: Some(i as u32),
232                    }),
233                    Some(Yaml::Boolean(true)) => Some(Syndicate { max: None }),
234                    Some(Yaml::Boolean(false)) | None => None,
235                    Some(..) => return Err("invalid pagination setting".into()),
236                },
237                compact: match index.remove(&yaml::KEYS.compact) {
238                    Some(Yaml::Boolean(b)) => b,
239                    None => false,
240                    Some(..) => return Err("invalid compact setting".into()),
241                },
242                sort: match index.remove(&yaml::KEYS.sort) {
243                    Some(Yaml::String(key)) => key.parse()?,
244                    Some(..) => return Err("invalid sort value".into()),
245                    None => index::Sort::default(),
246                },
247                directories: match index.remove(&yaml::KEYS.directories) {
248                    Some(Yaml::Array(array)) => array
249                        .into_iter()
250                        .map(|i| match i {
251                            Yaml::String(dir) => resolve_path(name, &dir),
252                            _ => Err(SourceError::from(
253                                "index directories must be \
254                                 strings",
255                            )),
256                        })
257                        .collect::<Result<_, _>>()?,
258                    Some(Yaml::String(dir)) => vec![resolve_path(name, &dir)?],
259                    Some(..) => return Err("invalid directory list in index".into()),
260                    None => vec![name.into()],
261                },
262            }),
263            Some(..) => return Err("invalid index value".into()),
264            None => None,
265        };
266        let cc = match meta.remove(&yaml::KEYS.cc) {
267            Some(Yaml::String(cc)) => vec![cc],
268            Some(Yaml::Array(cc)) => cc
269                .into_iter()
270                .map(|v| match v {
271                    Yaml::String(ci) => Ok(ci),
272                    _ => Err(SourceError::from("invlaid cc value")),
273                })
274                .collect::<Result<_, _>>()?,
275            Some(..) => return Err("invalid cc value".into()),
276            None => Vec::new(),
277        };
278        let meta = EntryMeta::from_yaml(meta)?;
279        let name = name.to_owned();
280
281        Ok(Entry {
282            title,
283            description,
284            date,
285            updated,
286            index,
287            cc,
288            meta,
289            name,
290            content,
291            format,
292        })
293    }
294}
295
296/// A static entry.
297///
298/// Represents a static file/directory to be deployed.
299#[derive(Debug, Clone)]
300pub struct StaticEntry {
301    pub name: String,
302    pub source: PathBuf,
303}