diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index d5b5598..8777dab 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -18,32 +18,32 @@ jobs: steps: - uses: actions/checkout@v3 - uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + targets: wasm32-unknown-unknown + components: rustc-dev, clippy, rustfmt - uses: Swatinem/rust-cache@v2 - # make sure all code has been formatted with rustfmt - - run: rustup component add rustfmt - name: check rustfmt run: cargo fmt -- --check --color always - # run clippy to verify we have no warnings - - run: rustup component add clippy - run: cargo fetch - name: cargo clippy - run: cargo clippy --all-features --all-targets -- -D warnings + run: cargo clippy --all-features --all-targets -- --no-deps -D warnings test: name: Tests strategy: matrix: - os: [ - macOS-12, - # windows-2022, - ubuntu-22.04, - ] + os: [macOS-12, windows-2022, ubuntu-22.04] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 - uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + targets: wasm32-unknown-unknown + components: rustc-dev, clippy, rustfmt - uses: Swatinem/rust-cache@v2 - run: cargo fetch - name: cargo test build @@ -51,18 +51,18 @@ jobs: - name: run tests run: ./test.sh - # msrv-check: - # name: Minimum Stable Rust Version Check - # runs-on: ubuntu-22.04 - # steps: - # - uses: actions/checkout@v3 - # - uses: dtolnay/rust-toolchain@master - # with: - # toolchain: "1.65.0" - # - uses: Swatinem/rust-cache@v2 - # - run: cargo fetch - # - name: cargo check - # run: cargo check --all-targets + msrv-check: + name: Minimum Stable Rust Version Check + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.65.0" + - uses: Swatinem/rust-cache@v2 + - run: cargo fetch + - name: cargo check + run: cargo check --all-targets build-aarch64-apple-darwin: name: Build aarch64-apple-darwin @@ -97,22 +97,6 @@ jobs: # - uses: Swatinem/rust-cache@v2 # - run: cargo run --release --target x86_64-unknown-linux-musl -- generate --fail about.hbs - # Build `mdBook` documentation and upload it as a temporary build artifact - # doc-book: - # name: Build the book - # runs-on: ubuntu-22.04 - # steps: - # - uses: actions/checkout@v3 - # - run: | - # set -e - # curl -L https://github.com/rust-lang-nursery/mdBook/releases/download/v0.4.21/mdbook-v0.4.21-x86_64-unknown-linux-gnu.tar.gz | tar xzf - - # echo `pwd` >> $GITHUB_PATH - # - run: (cd docs && mdbook build) - # - uses: actions/upload-artifact@v1 - # with: - # name: doc-book - # path: docs/book - publish-check: name: Publish Check runs-on: ubuntu-22.04 @@ -178,28 +162,3 @@ jobs: files: "archival*" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # publish: - # name: Publish Docs - # needs: [doc-book] - # runs-on: ubuntu-22.04 - # if: github.event_name == 'push' && github.ref == 'refs/heads/main' - # steps: - # - name: Download book - # uses: actions/download-artifact@v1 - # with: - # name: doc-book - # - name: Assemble gh-pages - # run: | - # mv doc-book gh-pages - # # If this is a push to the main branch push to the `gh-pages` using a - # # deploy key. Note that a deploy key is necessary for now because otherwise - # # using the default token for github actions doesn't actually trigger a page - # # rebuild. - # - name: Push to gh-pages - # # Uses a rust script to setup and push to the gh-pages branch - # run: curl -LsSf https://git.io/fhJ8n | rustc - && (cd gh-pages && ../rust_out) - # env: - # GITHUB_DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} - # BUILD_REPOSITORY_ID: ${{ github.repository }} - # BUILD_SOURCEVERSION: ${{ github.sha }} diff --git a/Cargo.lock b/Cargo.lock index 5d34b60..a7ebea9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1296,6 +1296,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-integer" version = "0.1.46" @@ -2188,13 +2194,14 @@ dependencies = [ [[package]] name = "time" -version = "0.3.31" +version = "0.3.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f657ba42c3f86e7680e53c8cd3af8abbe56b5491790b46e22e19c0d57463583e" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", "itoa", "libc", + "num-conv", "num_threads", "powerfmt", "serde", @@ -2210,10 +2217,11 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.16" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26197e33420244aeb70c3e8c78376ca46571bc4e701e4791c2cd9f57dcb3a43f" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" dependencies = [ + "num-conv", "time-core", ] diff --git a/Cargo.toml b/Cargo.toml index 8075adc..b5c3dbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,6 @@ binary = [ "dep:rand", "dep:base64", "dep:urlencoding", - "dep:serde_json", ] import-csv = ["dep:csv"] stdlib-fs = ["dep:notify", "dep:fs_extra", "dep:walkdir"] @@ -66,7 +65,7 @@ reqwest = { version = "0.11.24", features = [ "rustls-tls", "json", ], default-features = false, optional = true } -serde_json = { version = "1.0.113", optional = true } +serde_json = { version = "1.0.113" } home = { version = "0.5.9", optional = true } rsa = { version = "0.9.6", optional = true, features = ["sha2"] } rand = { version = "0.8.5", optional = true } @@ -93,7 +92,7 @@ thiserror = "1.0.56" zip = { version = "0.6.6", default-features = false, features = ["deflate"] } toml_datetime = "0.6.5" tracing = "0.1.37" -time = { version = "0.3.31", features = ["local-offset"] } +time = { version = "0.3.36", features = ["local-offset"] } semver = "1.0.22" once_cell = "1.19.0" data-encoding = "2.5.0" diff --git a/events.d.ts b/events.d.ts index b494d1b..dfcf64c 100644 --- a/events.d.ts +++ b/events.d.ts @@ -19,9 +19,23 @@ export type File = { "name": (string | null); "filename": string; "mime": string; - "display_type": string; + "display_type": "image"|"audio"|"video"|"upload"; "url": string; }; +export type MetaValue = ({ + "String": string; +} | { + "Number": F64; +} | { + "Boolean": boolean; +} | { + "DateTime": DateTime; +} | { + "Array": MetaValue[]; +} | { + "Map": Record; +}); +export type Meta = Record; export type FieldValue = ({ "String": string; } | { @@ -30,10 +44,14 @@ export type FieldValue = ({ "Number": F64; } | { "Date": DateTime; +} | { + "Objects": Record[]; } | { "Boolean": boolean; } | { "File": File; +} | { + "Meta": Meta; }); export type AddObjectValue = { "path": ValuePath; @@ -53,7 +71,8 @@ export type EditFieldEvent = { "object": string; "filename": string; "path": ValuePath; - "value": FieldValue; + "field": string; + "value": (FieldValue | null); }; export type EditOrderEvent = { "object": string; diff --git a/pre-commit.sh b/pre-commit.sh index 465e555..920e18c 100755 --- a/pre-commit.sh +++ b/pre-commit.sh @@ -4,5 +4,5 @@ set -e cd $(dirname "$0") -cargo clippy --all-features --all-targets -- -D warnings +cargo clippy --all-features --all-targets -- --no-deps -D warnings ./test.sh diff --git a/src/binary/command/import.rs b/src/binary/command/import.rs index 796c5c5..3527f5a 100644 --- a/src/binary/command/import.rs +++ b/src/binary/command/import.rs @@ -234,7 +234,7 @@ impl BinaryCommand for Command { // Find the specified child, if present Some( child_path - .get_child_definition(obj_def) + .get_definition(obj_def) .map_err(|_| ImportError::InvalidField(child_path.to_string()))?, ) } else { @@ -367,7 +367,7 @@ impl Command { })) .map_err(|e| ImportError::WriteError(e.to_string()))?; if let ArchivalEventResponse::Index(i) = r { - current_path = current_path.join(ValuePathComponent::Index(i)); + current_path = current_path.append(ValuePathComponent::Index(i)); } else { panic!("archival did not return an index for an inserted child"); } @@ -396,9 +396,6 @@ impl Command { name }; if let Some(value) = row.get(from_name) { - let field_path = current_path - .clone() - .join(ValuePathComponent::Key(name.to_string())); // Validate type let value = FieldValue::from_string(name, field_type, value.to_string()) .map_err(|e| ImportError::ParseError(e.to_string()))?; @@ -406,8 +403,9 @@ impl Command { .send_event_no_rebuild(ArchivalEvent::EditField(EditFieldEvent { object: object.to_string(), filename: filename.to_string(), - path: field_path, - value, + path: current_path.clone(), + value: Some(value), + field: name.to_string(), })) .map_err(|e| ImportError::WriteError(e.to_string()))?; } else { diff --git a/src/binary/command/upload.rs b/src/binary/command/upload.rs index 7c4473f..19a60ca 100644 --- a/src/binary/command/upload.rs +++ b/src/binary/command/upload.rs @@ -138,7 +138,7 @@ impl BinaryCommand for Command { let sha = archival.sha_for_file(file_path)?; let upload_url = format!("{}/upload/{}/{}", API_URL, archival_site, sha); let mime = mime_guess::from_path(file_path); - let mut file = File::from_mime(mime); + let mut file = File::from_mime_guess(mime); file.sha = sha; file.filename = file_path .file_name() @@ -196,8 +196,9 @@ impl BinaryCommand for Command { archival.send_event_no_rebuild(ArchivalEvent::EditField(EditFieldEvent { object: object_type.clone(), filename: object_name.clone(), - path: field_path.clone(), - value: field_data.clone(), + path: ValuePath::empty(), + value: Some(field_data.clone()), + field: field.to_string(), }))?; if let FieldValue::File(fd) = field_data { println!( diff --git a/src/constants.rs b/src/constants.rs index 5c95203..2ea9794 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -6,6 +6,7 @@ pub const OBJECTS_DIR_NAME: &str = "objects"; pub const BUILD_DIR_NAME: &str = "dist"; pub const STATIC_DIR_NAME: &str = "public"; pub const LAYOUT_DIR_NAME: &str = "layout"; +pub const NESTED_TYPES: [&str; 5] = ["meta", "upload", "video", "audio", "image"]; #[cfg(debug_assertions)] pub const UPLOADS_URL: &str = "http://localhost:7777"; #[cfg(not(debug_assertions))] diff --git a/src/events.rs b/src/events.rs index f716614..2639049 100644 --- a/src/events.rs +++ b/src/events.rs @@ -4,7 +4,7 @@ use typescript_type_def::TypeDef; use crate::{value_path::ValuePath, FieldValue}; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(TypeDef))] pub enum ArchivalEvent { AddObject(AddObjectEvent), @@ -14,22 +14,23 @@ pub enum ArchivalEvent { AddChild(ChildEvent), RemoveChild(ChildEvent), } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(TypeDef))] pub enum ArchivalEventResponse { None, Index(usize), } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(TypeDef))] pub struct EditFieldEvent { pub object: String, pub filename: String, pub path: ValuePath, - pub value: FieldValue, + pub field: String, + pub value: Option, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(TypeDef))] pub struct EditOrderEvent { pub object: String, @@ -37,21 +38,21 @@ pub struct EditOrderEvent { pub order: i32, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(TypeDef))] pub struct DeleteObjectEvent { pub object: String, pub filename: String, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(TypeDef))] pub struct AddObjectValue { pub path: ValuePath, pub value: FieldValue, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(TypeDef))] pub struct AddObjectEvent { pub object: String, @@ -60,7 +61,7 @@ pub struct AddObjectEvent { pub values: Vec, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(TypeDef))] pub struct ChildEvent { pub object: String, diff --git a/src/fields/date_time.rs b/src/fields/date_time.rs index e327c2a..74058cc 100644 --- a/src/fields/date_time.rs +++ b/src/fields/date_time.rs @@ -9,7 +9,7 @@ use std::{ }; use time::{format_description, UtcOffset}; -#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, PartialOrd)] #[cfg_attr(feature = "typescript", derive(typescript_type_def::TypeDef))] pub struct DateTime { #[serde(skip)] @@ -99,7 +99,23 @@ impl DateTime { Ok(date_str) } - pub fn as_datetime(&self) -> model::DateTime { + pub fn bounce(&mut self) { + let date_str = Self::parse_date_string(self.raw.to_string()) + .unwrap_or_else(|_| format!("Invalid date value {}", self.raw)); + self.inner = Some( + model::DateTime::from_str(&date_str) + .unwrap_or_else(|| panic!("Invalid date value {}", self.raw)), + ) + } + + pub fn borrowed_as_datetime(&self) -> &model::DateTime { + if self.inner.is_none() { + panic!("cannot borrow datetime before it is bounced"); + } + return self.inner.as_ref().unwrap(); + } + + pub fn as_liquid_datetime(&self) -> model::DateTime { if let Some(inner) = self.inner { inner } else { diff --git a/src/fields/field_type.rs b/src/fields/field_type.rs index 1f982ef..00c180e 100644 --- a/src/fields/field_type.rs +++ b/src/fields/field_type.rs @@ -1,8 +1,9 @@ use serde::{Deserialize, Serialize}; use std::fmt::{Debug, Display}; -use std::str::FromStr; use thiserror::Error; +use crate::manifest::EditorTypes; + #[derive(Error, Debug, Clone)] pub enum InvalidFieldError { #[error("unrecognized type {0}")] @@ -31,6 +32,22 @@ pub enum InvalidFieldError { UnsupportedStringValue(String), #[error("type {0} was not provided a value and has no default")] NoDefaultForType(String), + #[error("field '{0}' failed validator '{1}'")] + FailedValidation(String, String), +} + +#[cfg(feature = "typescript")] +mod typedefs { + use typescript_type_def::{ + type_expr::{Ident, NativeTypeInfo, TypeExpr, TypeInfo}, + TypeDef, + }; + pub struct AliasTypeDef; + impl TypeDef for AliasTypeDef { + const INFO: TypeInfo = TypeInfo::Native(NativeTypeInfo { + r#ref: TypeExpr::ident(Ident("[FieldType, string]")), + }); + } } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] @@ -45,24 +62,11 @@ pub enum FieldType { Video, Upload, Audio, -} - -impl FromStr for FieldType { - type Err = InvalidFieldError; - fn from_str(string: &str) -> Result { - match string { - "string" => Ok(FieldType::String), - "number" => Ok(FieldType::Number), - "date" => Ok(FieldType::Date), - "markdown" => Ok(FieldType::Markdown), - "boolean" => Ok(FieldType::Boolean), - "image" => Ok(FieldType::Image), - "video" => Ok(FieldType::Video), - "audio" => Ok(FieldType::Audio), - "upload" => Ok(FieldType::Upload), - _ => Err(InvalidFieldError::UnrecognizedType(string.to_string())), - } - } + Meta, + Alias( + #[cfg_attr(feature = "typescript", type_def(type_of = "typedefs::AliasTypeDef"))] + Box<(FieldType, String)>, + ), } impl FieldType { @@ -77,6 +81,35 @@ impl FieldType { Self::Video => "video", Self::Audio => "audio", Self::Upload => "upload", + Self::Meta => "meta", + Self::Alias(a) => a.0.to_str(), + } + } + pub fn from_str( + string: &str, + editor_types: &EditorTypes, + ) -> Result { + match string { + "string" => Ok(FieldType::String), + "number" => Ok(FieldType::Number), + "date" => Ok(FieldType::Date), + "markdown" => Ok(FieldType::Markdown), + "boolean" => Ok(FieldType::Boolean), + "image" => Ok(FieldType::Image), + "video" => Ok(FieldType::Video), + "audio" => Ok(FieldType::Audio), + "upload" => Ok(FieldType::Upload), + "meta" => Ok(FieldType::Meta), + t => { + if let Some(et) = editor_types.get(t) { + Ok(FieldType::Alias(Box::new(( + FieldType::from_str(&et.alias_of, editor_types)?, + t.to_string(), + )))) + } else { + Err(InvalidFieldError::UnrecognizedType(string.to_string())) + } + } } } } diff --git a/src/fields/field_value.rs b/src/fields/field_value.rs index 7c84a2f..c79bafb 100644 --- a/src/fields/field_value.rs +++ b/src/fields/field_value.rs @@ -1,9 +1,11 @@ use super::file::File; +use super::meta::Meta; use super::DateTime; use super::{FieldType, InvalidFieldError}; use comrak::{markdown_to_html, ComrakOptions}; use liquid::{model, ValueView}; use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; use std::{ collections::HashMap, error::Error, @@ -21,6 +23,35 @@ pub enum FieldValueError { pub type ObjectValues = HashMap; +#[cfg(feature = "typescript")] +mod typedefs { + use typescript_type_def::{ + type_expr::{Ident, NativeTypeInfo, TypeExpr, TypeInfo}, + TypeDef, + }; + pub struct ObjectValuesTypeDef; + impl TypeDef for ObjectValuesTypeDef { + const INFO: TypeInfo = TypeInfo::Native(NativeTypeInfo { + r#ref: TypeExpr::ident(Ident("Record[]")), + }); + } +} + +macro_rules! compare_values { + ($left:ident, $right:ident, $($t:path),*) => { + match $left { + $($t(lv) => { + if let $t(rv) = $right { + lv.partial_cmp(rv) + } else { + None + } + })* + _ => None + } + } +} + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] #[cfg_attr(feature = "typescript", derive(typescript_type_def::TypeDef))] pub enum FieldValue { @@ -28,16 +59,37 @@ pub enum FieldValue { Markdown(String), Number(f64), Date(DateTime), - #[cfg_attr(feature = "typescript", serde(skip))] - Objects(Vec), + // Workaround for circular type: https://github.com/dbeckwith/rust-typescript-type-def/issues/18#issuecomment-2078469020 + Objects( + #[cfg_attr( + feature = "typescript", + type_def(type_of = "typedefs::ObjectValuesTypeDef") + )] + Vec, + ), Boolean(bool), File(File), + Meta(Meta), } fn err(f_type: &FieldType, value: String) -> FieldValueError { FieldValueError::InvalidValue(f_type.to_string(), value.to_owned()) } impl FieldValue { + // Note that this comparison just skips fields that cannot be compared and + // returns None. + pub fn compare(&self, to: &FieldValue) -> Option { + compare_values!( + self, + to, + Self::String, + Self::Markdown, + Self::Number, + Self::Date, + Self::Boolean, + Self::File + ) + } pub fn val_with_type(f_type: &FieldType, value: String) -> Result> { let t_val = toml::Value::try_from(&value)?; Ok(match f_type { @@ -81,13 +133,18 @@ impl FieldValue { let f_info = t_val.as_table().ok_or_else(|| err(f_type, value))?; Self::File(File::download().fill_from_map(f_info)) } + FieldType::Meta => { + let f_info = t_val.as_table().ok_or_else(|| err(f_type, value))?; + Self::Meta(Meta::from(f_info)) + } + FieldType::Alias(a) => Self::val_with_type(&a.0, value)?, }) } #[cfg(test)] pub fn liquid_date(&self) -> model::DateTime { match self { - FieldValue::Date(d) => d.as_datetime(), + FieldValue::Date(d) => d.as_liquid_datetime(), _ => panic!("Not a date"), } } @@ -106,7 +163,7 @@ impl From<&FieldValue> for Option { FieldValue::Markdown(v) => Some(toml::Value::String(v.to_owned())), FieldValue::Number(n) => Some(toml::Value::Float(*n)), FieldValue::Date(d) => { - let d = d.as_datetime(); + let d = d.as_liquid_datetime(); Some(toml::Value::Datetime(toml_datetime::Datetime { date: Some(toml_datetime::Date { year: d.year() as u16, @@ -137,6 +194,7 @@ impl From<&FieldValue> for Option { .collect(), )), FieldValue::File(f) => Some(toml::Value::Table(f.to_toml())), + FieldValue::Meta(m) => Some(toml::Value::Table(m.to_toml())), } } } @@ -165,6 +223,7 @@ impl ValueView for FieldValue { FieldValue::Objects(_) => "objects", FieldValue::Boolean(_) => "boolean", FieldValue::File(_) => "file", + FieldValue::Meta(_) => "meta", } } /// Interpret as a string. @@ -186,7 +245,7 @@ impl ValueView for FieldValue { FieldValue::String(s) => Some(model::ScalarCow::new(s)), FieldValue::Number(n) => Some(model::ScalarCow::new(*n)), // TODO: should be able to return a datetime value here - FieldValue::Date(d) => Some(model::ScalarCow::new((*d).as_datetime())), + FieldValue::Date(d) => Some(model::ScalarCow::new((*d).as_liquid_datetime())), FieldValue::Markdown(s) => Some(model::ScalarCow::new(markdown_to_html( s, &ComrakOptions::default(), @@ -194,6 +253,7 @@ impl ValueView for FieldValue { FieldValue::Boolean(b) => Some(model::ScalarCow::new(*b)), FieldValue::Objects(_) => None, FieldValue::File(_f) => None, + FieldValue::Meta(_m) => None, } } fn as_array(&self) -> Option<&dyn model::ArrayView> { @@ -205,6 +265,7 @@ impl ValueView for FieldValue { fn as_object(&self) -> Option<&dyn model::ObjectView> { match self { FieldValue::File(f) => Some(f), + FieldValue::Meta(m) => Some(m), _ => None, } } @@ -218,6 +279,7 @@ impl ValueView for FieldValue { FieldValue::Boolean(_) => self.as_scalar().to_value(), FieldValue::Objects(_) => self.as_array().to_value(), FieldValue::File(_) => self.as_object().to_value(), + FieldValue::Meta(_) => self.as_object().to_value(), } } } @@ -273,6 +335,7 @@ impl FieldValue { )), } } + #[instrument(skip(value))] pub fn from_toml( key: &String, @@ -373,6 +436,14 @@ impl FieldValue { } })?), )), + FieldType::Meta => Ok(FieldValue::Meta(Meta::from(value.as_table().ok_or_else( + || InvalidFieldError::TypeMismatch { + field: key.to_owned(), + field_type: field_type.to_string(), + value: value.to_string(), + }, + )?))), + FieldType::Alias(a) => Self::from_toml(key, &a.0, value), } } @@ -381,31 +452,34 @@ impl FieldValue { FieldValue::String(s) => s.clone(), FieldValue::Markdown(n) => n.clone(), FieldValue::Number(n) => n.to_string(), - FieldValue::Date(d) => d.as_datetime().to_rfc2822(), + FieldValue::Date(d) => d.as_liquid_datetime().to_rfc2822(), FieldValue::Boolean(b) => b.to_string(), FieldValue::Objects(o) => format!("{:?}", o), FieldValue::File(f) => format!("{:?}", f.to_map(true)), + FieldValue::Meta(m) => format!("{:?}", serde_json::Value::from(m)), } } } +fn default_val(f_type: &FieldType) -> FieldValue { + match f_type { + FieldType::String => FieldValue::String("".to_string()), + FieldType::Number => FieldValue::Number(0.0), + FieldType::Date => FieldValue::Date(DateTime::now()), + FieldType::Markdown => FieldValue::Markdown("".to_string()), + FieldType::Boolean => FieldValue::Boolean(false), + FieldType::Image => FieldValue::File(File::image()), + FieldType::Video => FieldValue::File(File::video()), + FieldType::Audio => FieldValue::File(File::audio()), + FieldType::Upload => FieldValue::File(File::download()), + FieldType::Meta => FieldValue::Meta(Meta::default()), + FieldType::Alias(a) => default_val(&a.0), + } +} pub fn def_to_values(def: &HashMap) -> HashMap { let mut vals = HashMap::new(); for (key, f_type) in def { - vals.insert( - key.to_string(), - match f_type { - FieldType::String => FieldValue::String("".to_string()), - FieldType::Number => FieldValue::Number(0.0), - FieldType::Date => FieldValue::Date(DateTime::now()), - FieldType::Markdown => FieldValue::Markdown("".to_string()), - FieldType::Boolean => FieldValue::Boolean(false), - FieldType::Image => FieldValue::File(File::image()), - FieldType::Video => FieldValue::File(File::video()), - FieldType::Audio => FieldValue::File(File::audio()), - FieldType::Upload => FieldValue::File(File::download()), - }, - ); + vals.insert(key.to_string(), default_val(f_type)); } vals } diff --git a/src/fields/file.rs b/src/fields/file.rs index 74b8482..75cbb3f 100644 --- a/src/fields/file.rs +++ b/src/fields/file.rs @@ -1,8 +1,8 @@ use crate::fields::FieldConfig; use liquid::{ObjectView, ValueView}; -use mime_guess::MimeGuess; +use mime_guess::{mime::FromStrError, Mime, MimeGuess}; use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, fmt::Display}; +use std::{collections::HashMap, fmt::Display, str::FromStr}; use tracing::warn; #[derive(Debug, Clone, PartialEq)] @@ -49,13 +49,31 @@ impl From<&str> for DisplayType { } } -#[derive(Debug, ObjectView, ValueView, Deserialize, Serialize, Clone, PartialEq)] +#[cfg(feature = "typescript")] +mod typedefs { + use typescript_type_def::{ + type_expr::{Ident, NativeTypeInfo, TypeExpr, TypeInfo}, + TypeDef, + }; + pub struct DisplayTypeType; + impl TypeDef for DisplayTypeType { + const INFO: TypeInfo = TypeInfo::Native(NativeTypeInfo { + r#ref: TypeExpr::ident(Ident("\"image\"|\"audio\"|\"video\"|\"upload\"")), + }); + } +} + +#[derive(Debug, ObjectView, ValueView, Deserialize, Serialize, Clone, PartialEq, PartialOrd)] #[cfg_attr(feature = "typescript", derive(typescript_type_def::TypeDef))] pub struct File { pub sha: String, pub name: Option, pub filename: String, pub mime: String, + #[cfg_attr( + feature = "typescript", + type_def(type_of = "typedefs::DisplayTypeType") + )] pub display_type: String, pub url: String, } @@ -77,7 +95,18 @@ impl File { display_type: display_type.to_string(), } } - pub fn from_mime(mime: MimeGuess) -> Self { + pub fn from_mime(mime_str: &str) -> Result { + let mime = Mime::from_str(mime_str)?; + let mut f = match mime.type_() { + mime_guess::mime::VIDEO => Self::video(), + mime_guess::mime::AUDIO => Self::audio(), + mime_guess::mime::IMAGE => Self::image(), + _ => Self::download(), + }; + f.mime = mime.to_string(); + Ok(f) + } + pub fn from_mime_guess(mime: MimeGuess) -> Self { let m_type = mime.first_or_octet_stream(); let mut f = match m_type.type_() { mime_guess::mime::VIDEO => Self::video(), @@ -88,6 +117,16 @@ impl File { f.mime = m_type.to_string(); f } + pub fn get_key(&self, str: &str) -> Option<&String> { + match str { + "sha" => Some(&self.sha), + "name" => self.name.as_ref(), + "filename" => Some(&self.filename), + "mime" => Some(&self.mime), + "display_type" => Some(&self.display_type), + _ => None, + } + } pub fn fill_from_map(mut self, map: &toml::map::Map) -> Self { for (k, v) in map { match &k[..] { @@ -164,6 +203,9 @@ impl File { let config = FieldConfig::get(); format!("{}/{}", config.uploads_url, sha) } + pub fn update_url(&mut self) { + self.url = Self::_url(&self.sha); + } pub fn url(&self) -> String { Self::_url(&self.sha) } diff --git a/src/fields/meta.rs b/src/fields/meta.rs new file mode 100644 index 0000000..6af492a --- /dev/null +++ b/src/fields/meta.rs @@ -0,0 +1,397 @@ +use liquid_core::model; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, fmt::Display}; + +use super::DateTime; + +#[cfg(feature = "typescript")] +mod typedefs { + use typescript_type_def::{ + type_expr::{Ident, NativeTypeInfo, TypeExpr, TypeInfo}, + TypeDef, + }; + pub struct MetaArrTypeDef; + impl TypeDef for MetaArrTypeDef { + const INFO: TypeInfo = TypeInfo::Native(NativeTypeInfo { + r#ref: TypeExpr::ident(Ident("MetaValue[]")), + }); + } + + pub struct MetaTypeDef; + impl TypeDef for MetaTypeDef { + const INFO: TypeInfo = TypeInfo::Native(NativeTypeInfo { + r#ref: TypeExpr::ident(Ident("Record")), + }); + } +} + +#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)] +#[cfg_attr(feature = "typescript", derive(typescript_type_def::TypeDef))] +pub struct Meta(pub HashMap); + +impl Display for Meta { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self.to_toml()) + } +} + +impl Meta { + pub fn to_toml(&self) -> toml::map::Map { + let mut m = toml::map::Map::new(); + for (k, v) in &self.0 { + m.insert(k.to_string(), v.to_toml()); + } + m + } + pub fn get_value(&self, key: &str) -> Option<&MetaValue> { + self.0.get(key) + } +} + +impl From<&MetaValue> for model::Value { + fn from(value: &MetaValue) -> Self { + match value { + MetaValue::String(s) => model::Value::scalar(s.to_string()), + MetaValue::Number(v) => model::Value::scalar(*v), + MetaValue::Boolean(v) => model::Value::scalar(*v), + MetaValue::DateTime(d) => model::Value::scalar(d.as_liquid_datetime()), + MetaValue::Array(v) => model::Value::array( + v.iter() + .map(model::Value::from) + .collect::>(), + ), + MetaValue::Map(m) => model::Value::Object(model::Object::from(m)), + } + } +} +impl From<&Meta> for model::Object { + fn from(value: &Meta) -> Self { + let mut m = model::Object::new(); + for (k, v) in &value.0 { + m.insert(k.into(), v.into()); + } + m + } +} + +impl From<&Meta> for serde_json::Value { + fn from(value: &Meta) -> Self { + let mut m = serde_json::Map::::new(); + for (k, v) in &value.0 { + m.insert(k.to_string(), v.into()); + } + serde_json::Value::Object(m) + } +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +#[cfg_attr(feature = "typescript", derive(typescript_type_def::TypeDef))] +pub enum MetaValue { + String(String), + Number(f64), + Boolean(bool), + DateTime(DateTime), + Array( + #[cfg_attr(feature = "typescript", type_def(type_of = "typedefs::MetaArrTypeDef"))] + Vec, + ), + // Workaround for circular type: https://github.com/dbeckwith/rust-typescript-type-def/issues/18#issuecomment-2078469020 + Map(#[cfg_attr(feature = "typescript", type_def(type_of = "typedefs::MetaTypeDef"))] Meta), +} + +impl MetaValue { + pub fn to_toml(&self) -> toml::Value { + match self { + Self::String(s) => toml::Value::String(s.to_string()), + Self::Number(v) => toml::Value::Float(*v), + Self::Boolean(v) => toml::Value::Boolean(*v), + Self::DateTime(d) => { + let dt = *d.as_liquid_datetime(); + let date = dt.date(); + let time = dt.time(); + let offset = dt.offset(); + toml::Value::Datetime(toml_datetime::Datetime { + date: Some(toml_datetime::Date { + year: date.year() as u16, + month: date.month() as u8, + day: date.day(), + }), + time: Some(toml_datetime::Time { + hour: time.hour(), + minute: time.minute(), + second: time.second(), + nanosecond: time.nanosecond(), + }), + offset: Some(toml_datetime::Offset::Custom { + minutes: offset.whole_minutes(), + }), + }) + } + Self::Array(v) => toml::Value::Array(v.iter().map(|n| n.to_toml()).collect()), + Self::Map(m) => toml::Value::Table(m.to_toml()), + } + } +} + +// JSON + +impl From<&MetaValue> for serde_json::Value { + fn from(value: &MetaValue) -> Self { + match value { + MetaValue::String(s) => s.to_string().into(), + MetaValue::Number(v) => (*v).into(), + MetaValue::Boolean(v) => (*v).into(), + MetaValue::DateTime(d) => d.to_string().into(), + MetaValue::Array(v) => v.iter().collect::(), + MetaValue::Map(m) => m.into(), + } + } +} + +// TOML + +impl From<&toml::Value> for MetaValue { + fn from(value: &toml::Value) -> Self { + match value { + toml::Value::String(v) => MetaValue::String(v.to_string()), + toml::Value::Integer(v) => MetaValue::Number(*v as f64), + toml::Value::Float(v) => MetaValue::Number(*v), + toml::Value::Array(v) => MetaValue::Array(v.iter().map(|n| n.into()).collect()), + toml::Value::Boolean(v) => MetaValue::Boolean(*v), + toml::Value::Table(v) => MetaValue::Map(v.into()), + toml::Value::Datetime(v) => MetaValue::DateTime(DateTime::from_toml(v).unwrap()), + } + } +} + +impl From<&toml::map::Map> for Meta { + fn from(value: &toml::map::Map) -> Self { + let mut meta = HashMap::new(); + for (k, v) in value { + meta.insert(k.to_string(), MetaValue::from(v)); + } + Self(meta) + } +} + +// Liquid + +impl model::ValueView for Meta { + fn as_scalar(&self) -> Option> { + None + } + + fn is_scalar(&self) -> bool { + false + } + + fn as_array(&self) -> Option<&dyn model::ArrayView> { + None + } + + fn is_array(&self) -> bool { + false + } + + fn as_object(&self) -> Option<&dyn model::ObjectView> { + Some(self) + } + + fn is_object(&self) -> bool { + true + } + + fn as_state(&self) -> Option { + None + } + + fn is_state(&self) -> bool { + false + } + + fn is_nil(&self) -> bool { + false + } + + fn as_debug(&self) -> &dyn std::fmt::Debug { + &self.0 + } + + fn render(&self) -> model::DisplayCow<'_> { + todo!() + } + + fn source(&self) -> model::DisplayCow<'_> { + todo!() + } + + fn type_name(&self) -> &'static str { + "meta" + } + + fn query_state(&self, _state: model::State) -> bool { + false + } + + fn to_kstr(&self) -> model::KStringCow<'_> { + format!("{:?}", self.0).into() + } + + fn to_value(&self) -> model::Value { + let mut m = model::Object::new(); + for (k, v) in &self.0 { + m.insert(k.into(), v.into()); + } + model::Value::Object(m) + } +} + +impl model::ObjectView for Meta { + fn as_value(&self) -> &dyn model::ValueView { + self + } + + fn size(&self) -> i64 { + self.0.len() as i64 + } + + fn keys<'k>(&'k self) -> Box> + 'k> { + Box::new(self.0.keys().map(|k| k.into())) + } + + fn values<'k>(&'k self) -> Box + 'k> { + Box::new(self.keys().map(|k| self.get(&k).unwrap())) + } + + fn iter<'k>( + &'k self, + ) -> Box, &'k dyn model::ValueView)> + 'k> { + todo!() + } + + fn contains_key(&self, index: &str) -> bool { + self.0.contains_key(index) + } + + fn get<'s>(&'s self, index: &str) -> Option<&'s dyn model::ValueView> { + if let Some(o) = self.0.get(index) { + Some(o) + } else { + None + } + } +} + +impl model::ArrayView for MetaValue { + fn first(&self) -> Option<&dyn model::ValueView> { + self.get(0) + } + + fn last(&self) -> Option<&dyn model::ValueView> { + self.get(-1) + } + + fn as_value(&self) -> &dyn model::ValueView { + self + } + + fn size(&self) -> i64 { + if let MetaValue::Array(arr) = self { + arr.len() as i64 + } else { + 0 + } + } + + fn values<'k>(&'k self) -> Box + 'k> { + if let MetaValue::Array(arr) = self { + Box::new(arr.values()) + } else { + panic!("values called on non-array MetaValue") + } + } + + fn contains_key(&self, index: i64) -> bool { + if let MetaValue::Array(arr) = self { + arr.contains_key(index) + } else { + false + } + } + + fn get(&self, index: i64) -> Option<&dyn model::ValueView> { + if let MetaValue::Array(arr) = self { + arr.get(index) + } else { + None + } + } +} + +impl model::ValueView for MetaValue { + fn as_scalar(&self) -> Option> { + match self { + MetaValue::String(s) => Some(s.to_string().into()), + MetaValue::Number(v) => Some((*v).into()), + MetaValue::Boolean(v) => Some((*v).into()), + MetaValue::DateTime(d) => Some(d.as_liquid_datetime().into()), + _ => None, + } + } + + fn as_array(&self) -> Option<&dyn model::ArrayView> { + if let MetaValue::Array(v) = self { + Some(v) + } else { + None + } + } + fn as_object(&self) -> Option<&dyn model::ObjectView> { + if let MetaValue::Map(m) = self { + Some(m) + } else { + None + } + } + + fn as_debug(&self) -> &dyn std::fmt::Debug { + self + } + + fn render(&self) -> model::DisplayCow<'_> { + match self { + MetaValue::String(s) => s.render(), + MetaValue::Number(v) => v.render(), + MetaValue::Boolean(v) => v.render(), + MetaValue::DateTime(d) => d.borrowed_as_datetime().render(), + _ => todo!("MetaValue render not implemented for non-scalar values"), + } + } + + fn source(&self) -> model::DisplayCow<'_> { + todo!("MetaValue source not implemented") + } + + fn type_name(&self) -> &'static str { + match self { + MetaValue::String(_) => "meta:string", + MetaValue::Number(_) => "meta:number", + MetaValue::Boolean(_) => "meta:boolean", + MetaValue::DateTime(_) => "meta:datetime", + MetaValue::Array(_) => "meta:array", + MetaValue::Map(_) => "meta:map", + } + } + + fn query_state(&self, _state: model::State) -> bool { + false + } + + fn to_kstr(&self) -> model::KStringCow<'_> { + format!("{:?}", self).into() + } + + fn to_value(&self) -> model::Value { + self.into() + } +} diff --git a/src/fields/mod.rs b/src/fields/mod.rs index 0750585..dc17264 100644 --- a/src/fields/mod.rs +++ b/src/fields/mod.rs @@ -2,10 +2,12 @@ mod date_time; pub(crate) mod field_type; pub(crate) mod field_value; mod file; +pub(crate) mod meta; pub use date_time::DateTime; pub use field_type::{FieldType, InvalidFieldError}; pub use field_value::{FieldValue, ObjectValues}; pub use file::File; +pub use meta::MetaValue; use once_cell::sync::Lazy; use std::sync::{Mutex, MutexGuard}; diff --git a/src/lib.rs b/src/lib.rs index 87f5409..e7d7c3b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -164,7 +164,14 @@ impl Archival { obj_type )))?; let table: toml::Table = toml::from_str(&contents)?; - let _ = Object::from_table(obj_def, Path::new(filename), &table)?; + // Note that this also fails when custom validation fails. + let _ = Object::from_table( + obj_def, + Path::new(filename), + &table, + &self.site.manifest.editor_types, + false, + )?; // Object is valid, write it self.fs_mutex .with_fs(|fs| fs.write_str(&self.object_path_impl(obj_type, filename, fs)?, contents)) @@ -266,6 +273,7 @@ impl Archival { pub fn get_objects(&self) -> Result, Box> { self.fs_mutex.with_fs(|fs| self.site.get_objects(fs)) } + pub fn get_objects_sorted( &self, sort: impl Fn(&Object, &Object) -> Ordering, @@ -276,7 +284,10 @@ impl Archival { fn edit_field(&self, event: EditFieldEvent) -> Result> { self.write_object(&event.object, &event.filename, |existing| { - event.path.set_in_object(existing, event.value); + event + .path + .append((&event.field).into()) + .set_in_object(existing, event.value); Ok(existing) })?; Ok(ArchivalEventResponse::None) @@ -320,7 +331,7 @@ impl Archival { filename: &str, obj_cb: impl FnOnce(&mut Object) -> Result<&mut Object, Box>, ) -> Result<(), Box> { - debug!("write {}", filename); + debug!("write {}", obj_type); self.fs_mutex.with_fs(|fs| { let path = self.object_path_impl(obj_type, filename, fs)?; let contents = self.modify_object_file(obj_type, filename, obj_cb, fs)?; @@ -475,8 +486,9 @@ mod lib { archival.send_event(ArchivalEvent::EditField(EditFieldEvent { object: "section".to_string(), filename: "first".to_string(), - path: ValuePath::default().join(value_path::ValuePathComponent::key("name")), - value: FieldValue::String("This is the new title".to_string()), + path: ValuePath::empty(), + field: "name".to_string(), + value: Some(FieldValue::String("This is the new name".to_string())), }))?; // Sending an event should result in an updated fs let index_html = archival @@ -484,7 +496,7 @@ mod lib { .with_fs(|fs| fs.read_to_string(&archival.site.manifest.build_dir.join("index.html")))? .unwrap(); println!("index: {}", index_html); - assert!(index_html.contains("This is the new title")); + assert!(index_html.contains("This is the new name")); Ok(()) } @@ -575,7 +587,7 @@ mod lib { .send_event(ArchivalEvent::AddChild(ChildEvent { object: "post".to_string(), filename: "a-post".to_string(), - path: ValuePath::default().join(ValuePathComponent::key("links")), + path: ValuePath::default().append(ValuePathComponent::key("links")), })) .unwrap(); let objects = archival.get_objects()?; @@ -623,8 +635,8 @@ mod lib { object: "post".to_string(), filename: "a-post".to_string(), path: ValuePath::default() - .join(ValuePathComponent::key("links")) - .join(ValuePathComponent::Index(0)), + .append(ValuePathComponent::key("links")) + .append(ValuePathComponent::Index(0)), })) .unwrap(); // Sending an event should result in an updated fs @@ -663,6 +675,7 @@ mod lib { assert!(output.contains("archival_site = \"test\"")); assert!(output.contains("site_url = \"test.com\"")); // Doesn't fill defaults + assert!(!output.contains("objects_dir")); assert!(!output.contains("objects")); // Does show non-defaults assert!(output.contains("prebuild = [\"test\"]")); diff --git a/src/manifest.rs b/src/manifest.rs index e115bb9..cfb0e23 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -1,27 +1,170 @@ -use serde::{Deserialize, Serialize}; +use regex::Regex; +use serde::{de::Visitor, Deserialize, Deserializer, Serialize, Serializer}; use std::{ + collections::HashMap, error::Error, - fmt, + fmt::{self, Display}, + ops::Deref, path::{Path, PathBuf}, }; use toml::{Table, Value}; -use crate::{constants::LAYOUT_DIR_NAME, file_system::FileSystemAPI, FieldConfig}; +use crate::{ + constants::{LAYOUT_DIR_NAME, NESTED_TYPES}, + file_system::FileSystemAPI, + object::ValuePath, + FieldConfig, +}; use super::constants::{ BUILD_DIR_NAME, OBJECTS_DIR_NAME, OBJECT_DEFINITION_FILE_NAME, PAGES_DIR_NAME, STATIC_DIR_NAME, }; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum InvalidManifestError { + #[error("Invalid Site Path")] + InvalidSitePath, + #[error("Failed Parsing Manifest File")] + FailedParsing, + #[error("Manifest Field {0} was of an unrecognized type.")] + BadType(String), + #[error("Validator {0} was not a valid regular expression ({1}).")] + InvalidValidator(String, String), + #[error("Manifest Missing Required Field: {0}")] + MissingRequired(String), + #[error("Bad Path '{1}' for Field {0}")] + BadPath(Value, String), + #[error("Cannot define a nested validator for type {0} ({1})")] + InvalidNestedValidator(String, String), + #[error("Invalid Manifest value '{1}' for field {0}.")] + InvalidField(Value, String), +} + #[derive(Debug, Clone)] -struct InvalidManifestError; -impl Error for InvalidManifestError {} -impl fmt::Display for InvalidManifestError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "invalid manifest") +#[cfg_attr(feature = "typescript", derive(typescript_type_def::TypeDef))] +pub struct Validator(#[cfg_attr(feature = "typescript", type_def(type_of = "String"))] Regex); + +impl Validator { + pub fn new(regex: &str, name: &str) -> Result { + Ok(Self(Regex::new(regex).map_err(|e| { + InvalidManifestError::InvalidValidator(name.to_string(), e.to_string()) + })?)) + } + pub fn validate(&self, input: &str) -> bool { + self.0.is_match(input) + } +} + +impl Display for Validator { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0.as_str()) + } +} + +impl Deref for Validator { + type Target = Regex; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Serialize for Validator { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.0.as_str()) + } +} +struct ValidatorVisitor; +impl<'de> Visitor<'de> for ValidatorVisitor { + type Value = Validator; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a regular expression") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + let re = Regex::new(v).map_err(|e| E::custom(format!("Invalid Regex {}: {}", v, e)))?; + Ok(Validator(re)) + } +} +impl<'de> Deserialize<'de> for Validator { + fn deserialize>(deserializer: D) -> Result { + deserializer.deserialize_str(ValidatorVisitor) + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "typescript", derive(typescript_type_def::TypeDef))] +pub struct ManifestEditorTypePathValidator { + pub path: ValuePath, + pub validate: Validator, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "typescript", derive(typescript_type_def::TypeDef))] +pub enum ManifestEditorTypeValidator { + Value(Validator), + Path(ManifestEditorTypePathValidator), +} + +impl Display for ManifestEditorTypeValidator { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + Self::Path(p) => { + format!("{}: {}", p.path, p.validate) + } + Self::Value(v) => v.to_string(), + } + ) + } +} + +impl From<&ManifestEditorTypeValidator> for toml::Value { + fn from(value: &ManifestEditorTypeValidator) -> Self { + match value { + ManifestEditorTypeValidator::Value(v) => toml::Value::String(v.to_string()), + ManifestEditorTypeValidator::Path(p) => { + let mut map = toml::map::Map::new(); + map.insert("path".to_string(), toml::Value::String(p.path.to_string())); + map.insert( + "validate".to_string(), + toml::Value::String(p.validate.to_string()), + ); + map.into() + } + } + } +} +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[cfg_attr(feature = "typescript", derive(typescript_type_def::TypeDef))] +pub struct ManifestEditorType { + pub alias_of: String, + pub validate: Vec, + pub editor_url: String, +} + +impl From<&ManifestEditorType> for toml::Value { + fn from(value: &ManifestEditorType) -> Self { + let mut map = toml::map::Map::new(); + map.insert( + "validate".into(), + toml::Value::Array(value.validate.iter().map(|v| v.into()).collect()), + ); + map.insert("editor_url".into(), value.editor_url.to_string().into()); + map.into() } } -#[derive(Debug, Default, Deserialize, Serialize)] +pub type EditorTypes = HashMap; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[cfg_attr(feature = "typescript", derive(typescript_type_def::TypeDef))] pub struct Manifest { #[serde(skip)] @@ -37,6 +180,7 @@ pub struct Manifest { pub static_dir: PathBuf, pub layout_dir: PathBuf, pub uploads_url: Option, + pub editor_types: EditorTypes, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -46,6 +190,7 @@ pub enum ManifestField { ArchivalVersion, SiteUrl, ArchivalSite, + ObjectDefinitionFile, ObjectsDir, Prebuild, PagesDir, @@ -53,6 +198,7 @@ pub enum ManifestField { StaticDir, LayoutDir, CdnUrl, + EditorTypes, } impl ManifestField { @@ -61,6 +207,7 @@ impl ManifestField { ManifestField::ArchivalVersion => "archival_version", ManifestField::ArchivalSite => "archival_site", ManifestField::SiteUrl => "site_url", + ManifestField::ObjectDefinitionFile => "object_file", ManifestField::ObjectsDir => "objects", ManifestField::Prebuild => "prebuild", ManifestField::PagesDir => "pages", @@ -68,6 +215,7 @@ impl ManifestField { ManifestField::StaticDir => "static_dir", ManifestField::LayoutDir => "layout_dir", ManifestField::CdnUrl => "uploads_url", + ManifestField::EditorTypes => "editor_types", } } } @@ -87,6 +235,7 @@ impl fmt::Display for Manifest { static files: {} layout dir: {} build dir: {} + {} "#, self.archival_version .as_ref() @@ -101,7 +250,37 @@ impl fmt::Display for Manifest { self.pages_dir.display(), self.static_dir.display(), self.layout_dir.display(), - self.build_dir.display() + self.build_dir.display(), + if !self.editor_types.is_empty() { + format!( + "editor types:\n{}", + self.editor_types + .iter() + .map(|(tn, i)| { + format!( + " {}: {}{}", + tn, + i.alias_of, + if i.validate.is_empty() { + "".to_string() + } else { + format!( + " ({})", + i.validate + .iter() + .map(|v| v.to_string()) + .collect::>() + .join(",") + ) + } + ) + }) + .collect::>() + .join("\n") + ) + } else { + "".to_string() + } ) } } @@ -121,12 +300,20 @@ impl Manifest { build_dir: root.join(BUILD_DIR_NAME), static_dir: root.join(STATIC_DIR_NAME), layout_dir: root.join(LAYOUT_DIR_NAME), + editor_types: HashMap::new(), } } fn is_default(&self, field: &ManifestField) -> bool { let str_value = self.field_as_string(field); match field { ManifestField::Prebuild => str_value == "[]", + ManifestField::ObjectDefinitionFile => { + str_value + == self + .root + .join(OBJECT_DEFINITION_FILE_NAME) + .to_string_lossy() + } ManifestField::ObjectsDir => { str_value == self.root.join(OBJECTS_DIR_NAME).to_string_lossy() } @@ -145,19 +332,14 @@ impl Manifest { _ => str_value.is_empty(), } } - pub fn from_file( - manifest_path: &Path, - fs: &impl FileSystemAPI, - ) -> Result> { - let root = manifest_path.parent().ok_or(InvalidManifestError)?; + pub fn from_string(root: &Path, string: String) -> Result> { let mut manifest = Manifest::default(root); - let string = fs.read_to_string(manifest_path)?.unwrap_or_default(); let values: Table = toml::from_str(&string)?; - let path_or_err = |value: Value| -> Result { + let path_or_err = |value: Value, field: &str| -> Result { if let Some(string) = value.as_str() { return Ok(root.join(string)); } - Err(InvalidManifestError) + Err(InvalidManifestError::BadPath(value, field.to_string())) }; for (key, value) in values.into_iter() { match key.as_str() { @@ -174,22 +356,40 @@ impl Manifest { .collect() }) } - "pages" => manifest.pages_dir = path_or_err(value)?, - "objects" => manifest.objects_dir = path_or_err(value)?, - "build_dir" => manifest.build_dir = path_or_err(value)?, - "static_dir" => manifest.static_dir = path_or_err(value)?, - "layout_dir" => manifest.layout_dir = path_or_err(value)?, + "pages" => manifest.pages_dir = path_or_err(value, "pages")?, + "objects" => manifest.objects_dir = path_or_err(value, "objects")?, + "build_dir" => manifest.build_dir = path_or_err(value, "build_dir")?, + "static_dir" => manifest.static_dir = path_or_err(value, "static_dir")?, + "layout_dir" => manifest.layout_dir = path_or_err(value, "layout_dir")?, + "object_file" => { + manifest.object_definition_file = path_or_err(value, "object_file")? + } + "editor_types" => manifest.parse_editor_types(value).unwrap(), _ => {} } } Ok(manifest) } + pub fn from_file( + manifest_path: &Path, + fs: &impl FileSystemAPI, + ) -> Result> { + let root = manifest_path + .parent() + .ok_or(InvalidManifestError::InvalidSitePath)?; + let string = fs.read_to_string(manifest_path)?.unwrap_or_default(); + Manifest::from_string(root, string) + } + fn toml_field(&self, field: &ManifestField) -> Option { match field { ManifestField::ArchivalVersion => self.archival_version.to_owned().map(Value::String), ManifestField::ArchivalSite => self.archival_site.to_owned().map(Value::String), ManifestField::SiteUrl => self.site_url.to_owned().map(Value::String), + ManifestField::ObjectDefinitionFile => Some(Value::String( + self.object_definition_file.to_string_lossy().to_string(), + )), ManifestField::CdnUrl => self.uploads_url.to_owned().map(Value::String), ManifestField::Prebuild => { if self.prebuild.is_empty() { @@ -218,13 +418,117 @@ impl Manifest { ManifestField::LayoutDir => { Some(Value::String(self.layout_dir.to_string_lossy().to_string())) } + ManifestField::EditorTypes => { + let mut map = toml::map::Map::new(); + for (type_name, type_val) in &self.editor_types { + map.insert(type_name.into(), type_val.into()); + } + Some(Value::Table(map)) + } } } + fn parse_editor_types(&mut self, types: toml::Value) -> Result<(), InvalidManifestError> { + let types = match types { + toml::Value::Table(t) => t, + _ => return Err(InvalidManifestError::FailedParsing), + }; + let mut editor_types = HashMap::new(); + for (type_name, info) in types { + let mut editor_type = ManifestEditorType::default(); + let info_map = match info { + toml::Value::Table(t) => t, + _ => return Err(InvalidManifestError::FailedParsing), + }; + editor_type.alias_of = info_map + .get("type") + .ok_or_else(|| InvalidManifestError::MissingRequired(format!("{type_name}.type")))? + .as_str() + .ok_or_else(|| { + InvalidManifestError::MissingRequired(format!("{type_name}.type (string)")) + })? + .to_string(); + if let Some(editor_url) = info_map.get("editor_url") { + editor_type.editor_url = editor_url + .as_str() + .ok_or_else(|| { + InvalidManifestError::InvalidField( + editor_url.to_owned(), + "editor_url".to_string(), + ) + })? + .to_string(); + } + if let Some(validator_val) = info_map.get("validate") { + let is_nested_type = NESTED_TYPES.contains(&&editor_type.alias_of[..]); + editor_type.validate = match validator_val { + toml::Value::Array(arr) => arr + .iter() + .map(|val| match val { + toml::Value::Table(t) => { + if !is_nested_type { + return Err(InvalidManifestError::InvalidNestedValidator( + editor_type.alias_of.to_string(), + type_name.to_string(), + )); + } + let path = ValuePath::from_string( + t.get("path") + .ok_or_else(|| { + InvalidManifestError::MissingRequired(format!( + "{type_name}.validate.path" + )) + })? + .as_str() + .ok_or_else(|| { + InvalidManifestError::MissingRequired(format!( + "{type_name}.validate.path (string)" + )) + })?, + ); + let validate_string = t + .get("validate") + .ok_or_else(|| { + InvalidManifestError::MissingRequired(format!( + "{type_name}.validate.validate" + )) + })? + .as_str() + .ok_or_else(|| { + InvalidManifestError::MissingRequired(format!( + "{type_name}.validate.validate (string)" + )) + })? + .to_string(); + Ok(ManifestEditorTypeValidator::Path( + ManifestEditorTypePathValidator { + path, + validate: Validator::new(&validate_string, &type_name)?, + }, + )) + } + toml::Value::String(s) => Ok(ManifestEditorTypeValidator::Value( + Validator::new(s, &type_name)?, + )), + _ => Err(InvalidManifestError::BadType("validate (item)".to_string())), + }) + .collect::, _>>()?, + _ => return Err(InvalidManifestError::BadType("validate (root)".to_string())), + }; + } + editor_types.insert(type_name, editor_type); + } + self.editor_types = editor_types; + Ok(()) + } + pub fn set(&mut self, field: &ManifestField, value: String) { match field { ManifestField::ArchivalVersion => self.archival_version = Some(value), ManifestField::ArchivalSite => self.archival_site = Some(value), + ManifestField::ObjectDefinitionFile => { + self.object_definition_file = PathBuf::from(value) + } ManifestField::SiteUrl => self.site_url = Some(value), ManifestField::CdnUrl => self.uploads_url = Some(value), ManifestField::Prebuild => { @@ -235,6 +539,9 @@ impl Manifest { ManifestField::BuildDir => self.build_dir = PathBuf::from(value), ManifestField::StaticDir => self.static_dir = PathBuf::from(value), ManifestField::LayoutDir => self.layout_dir = PathBuf::from(value), + ManifestField::EditorTypes => { + todo!("EditorTypes are not modifiable via events") + } } } @@ -243,6 +550,7 @@ impl Manifest { Some(fv) => match fv { Value::Array(a) => toml::to_string(&a).unwrap_or_default(), Value::String(s) => s, + Value::Table(t) => toml::to_string(&t).unwrap_or_default(), _ => panic!("unsupported manifest field type"), }, None => String::default(), @@ -256,12 +564,14 @@ impl Manifest { ManifestField::SiteUrl, ManifestField::CdnUrl, ManifestField::Prebuild, + ManifestField::ObjectDefinitionFile, ManifestField::ArchivalSite, ManifestField::PagesDir, ManifestField::ObjectsDir, ManifestField::BuildDir, ManifestField::StaticDir, ManifestField::ObjectsDir, + ManifestField::EditorTypes, ] } @@ -291,3 +601,99 @@ impl Manifest { .collect() } } + +#[cfg(test)] +mod tests { + + use super::*; + + fn full_manifest_content() -> &'static str { + "archival_version = '0.6.0' + archival_site = 'jesse' + site_url = 'https://jesse.onarchival.dev' + object_file = 'm_objects.toml' + prebuild = ['echo \"HELLO!\"'] + objects = 'm_objects' + pages = 'm_pages' + build_dir = 'm_dist' + static_dir = 'm_public' + layout_dir = 'm_layout' + uploads_url = 'https://uploads.archival.dev' + [editor_types.day] + type = 'date' + validate = ['\\d{2}/\\d{2}/\\d{4}'] + [editor_types.custom] + type = 'meta' + editor_url = 'https://editor.archival.dev/editors/json/editor.html' + [[editor_types.custom.validate]] + path = 'field_a' + validate = '.+' + [[editor_types.custom.validate]] + path = 'field_b' + validate = '.+' + " + } + + #[test] + fn manifest_parsing() -> Result<(), Box> { + let m = Manifest::from_string(Path::new(""), full_manifest_content().to_string())?; + println!("M: {:?}", m); + assert_eq!(m.archival_version, Some("0.6.0".to_string())); + assert_eq!(m.archival_site, Some("jesse".to_string())); + assert_eq!(m.site_url, Some("https://jesse.onarchival.dev".to_string())); + assert_eq!( + m.object_definition_file, + Path::new("m_objects.toml").to_path_buf() + ); + assert_eq!(m.objects_dir, Path::new("m_objects").to_path_buf()); + assert_eq!(m.pages_dir, Path::new("m_pages").to_path_buf()); + assert_eq!(m.build_dir, Path::new("m_dist").to_path_buf()); + assert_eq!(m.static_dir, Path::new("m_public").to_path_buf()); + assert_eq!(m.layout_dir, Path::new("m_layout").to_path_buf()); + assert_eq!( + m.uploads_url, + Some("https://uploads.archival.dev".to_string()) + ); + assert_eq!(m.prebuild.len(), 1); + let t1 = &m.editor_types["day"]; + assert_eq!(t1.alias_of, "date"); + assert_eq!(t1.validate.len(), 1); + assert!(matches!( + t1.validate[0], + ManifestEditorTypeValidator::Value(_) + )); + if let ManifestEditorTypeValidator::Value(v) = &t1.validate[0] { + assert_eq!(v.to_string(), "\\d{2}/\\d{2}/\\d{4}"); + } + let t2 = &m.editor_types["custom"]; + assert_eq!(t2.alias_of, "meta"); + assert_eq!( + t2.editor_url, + "https://editor.archival.dev/editors/json/editor.html" + ); + assert_eq!(t2.validate.len(), 2); + assert!(matches!( + t2.validate[0], + ManifestEditorTypeValidator::Path(_) + )); + if let ManifestEditorTypeValidator::Path(v) = &t2.validate[0] { + assert_eq!(v.path.to_string(), "field_a"); + assert_eq!(v.validate.to_string(), ".+"); + } + assert!(matches!( + t2.validate[1], + ManifestEditorTypeValidator::Path(_) + )); + if let ManifestEditorTypeValidator::Path(v) = &t2.validate[1] { + assert_eq!(v.path.to_string(), "field_b"); + assert_eq!(v.validate.to_string(), ".+"); + } + let manifest_output = m.to_toml()?; + println!("MTOML {}", manifest_output); + assert!(manifest_output.contains("[editor_types.day]")); + assert!(manifest_output.contains("[editor_types.custom]")); + assert!(manifest_output.contains("editor_url = \"")); + assert!(manifest_output.contains("[[editor_types.custom.validate]]")); + Ok(()) + } +} diff --git a/src/object/mod.rs b/src/object/mod.rs index 71695ee..24cd204 100644 --- a/src/object/mod.rs +++ b/src/object/mod.rs @@ -1,7 +1,8 @@ -pub use crate::value_path::ValuePath; +pub use crate::value_path::{ValuePath, ValuePathComponent}; use crate::{ events::AddObjectValue, - fields::{FieldValue, InvalidFieldError, ObjectValues}, + fields::{FieldType, FieldValue, InvalidFieldError, ObjectValues}, + manifest::{EditorTypes, ManifestEditorTypeValidator}, object_definition::ObjectDefinition, reserved_fields::{self, is_reserved_field}, }; @@ -10,7 +11,7 @@ use liquid::{ ObjectView, ValueView, }; use serde::{Deserialize, Serialize}; -use std::{error::Error, fmt::Debug, path::Path}; +use std::{collections::HashMap, error::Error, fmt::Debug, path::Path}; use toml::Table; use tracing::{instrument, warn}; mod object_entry; @@ -27,27 +28,83 @@ pub struct Object { } impl Object { + #[instrument] + pub fn validate( + field_type: &FieldType, + field_value: &FieldValue, + custom_types: &EditorTypes, + ) -> Result<(), InvalidFieldError> { + // You can only define a validator via editor_types, which will always + // create an alias type + if let FieldType::Alias(a) = field_type { + if let Some(custom_type) = custom_types.get(&a.1) { + for validator in &custom_type.validate { + match validator { + ManifestEditorTypeValidator::Path(p) => { + if let Ok(validated_value) = p.path.get_value(field_value) { + if !p.validate.validate(&validated_value.to_string()) { + return Err(InvalidFieldError::FailedValidation( + validated_value.to_string(), + p.validate.to_string(), + )); + } + } else { + // Value not found - if our validator passes + // with an empty string, this is ok. Otherwise + // this is an error. + if !p.validate.validate("") { + return Err(InvalidFieldError::FailedValidation( + "(not found)".to_string(), + p.validate.to_string(), + )); + } + } + } + ManifestEditorTypeValidator::Value(v) => { + if !v.validate(&field_value.to_string()) { + return Err(InvalidFieldError::FailedValidation( + field_value.to_string(), + v.to_string(), + )); + } + } + } + } + } + } + Ok(()) + } + #[instrument(skip(definition, table))] pub fn values_from_table( file: &Path, table: &Table, definition: &ObjectDefinition, + custom_types: &EditorTypes, + skip_validation: bool, ) -> Result> { // liquid-rust only supports strict parsing. This is reasonable but we // also want to allow empty root keys, so we fill in defaults for any // missing definition keys let mut values = definition.empty_object(); - for (key, value) in table { - if let Some(field_type) = definition.fields.get(&key.to_string()) { - // Primitive values - let field_value = FieldValue::from_toml(key, field_type, value)?; - values.insert(key.to_string(), field_value); - } else if let Some(child_def) = definition.children.get(&key.to_string()) { + for (type_name, value) in table { + let def_key = custom_types + .get(type_name) + .map(|i| &i.alias_of) + .unwrap_or(type_name); + if let Some(field_type) = definition.fields.get(def_key) { + // Values + let field_value = FieldValue::from_toml(def_key, field_type, value)?; + if !skip_validation { + Object::validate(field_type, &field_value, custom_types)?; + } + values.insert(def_key.to_string(), field_value); + } else if let Some(child_def) = definition.children.get(&def_key.to_string()) { // Children let m_objects = value .as_array() .ok_or_else(|| InvalidFieldError::NotAnArray { - key: key.to_string(), + key: def_key.to_string(), value: value.to_string(), })?; let mut objects: Vec = Vec::new(); @@ -56,17 +113,24 @@ impl Object { object .as_table() .ok_or_else(|| InvalidFieldError::InvalidChild { - key: key.to_owned(), + key: def_key.to_owned(), index, child: value.to_string(), })?; - let object = Object::values_from_table(file, table, child_def)?; + let object = Object::values_from_table( + file, + table, + child_def, + custom_types, + skip_validation, + )?; objects.push(object); } let field_value = FieldValue::Objects(objects); - values.insert(key.to_string(), field_value); - } else if !is_reserved_field(key) { - warn!("{}: unknown field {}", file.display(), key); + + values.insert(def_key.to_string(), field_value); + } else if !is_reserved_field(def_key) { + warn!("{}: unknown field {}", file.display(), def_key); } } Ok(values) @@ -77,8 +141,11 @@ impl Object { definition: &ObjectDefinition, file: &Path, table: &Table, + custom_types: &EditorTypes, + skip_validation: bool, ) -> Result> { - let values = Object::values_from_table(file, table, definition)?; + let values = + Object::values_from_table(file, table, definition, custom_types, skip_validation)?; let mut order = -1; if let Some(t_order) = table.get(reserved_fields::ORDER) { if let Some(int_order) = t_order.as_integer() { @@ -108,7 +175,8 @@ impl Object { defaults: Vec, ) -> Result> { let path = Path::new(&definition.name).join(filename); - let values = Object::values_from_table(&path, &Table::new(), definition)?; + let values = + Object::values_from_table(&path, &Table::new(), definition, &HashMap::new(), true)?; let mut object = Self { filename: filename.to_owned(), object_name: definition.name.clone(), @@ -117,7 +185,7 @@ impl Object { values, }; for default in defaults { - default.path.set_in_object(&mut object, default.value); + default.path.set_in_object(&mut object, Some(default.value)); } Ok(object) } @@ -154,6 +222,8 @@ impl Object { #[cfg(test)] mod tests { + use std::collections::HashMap; + use crate::{ fields::DateTime, object_definition::tests::artist_and_example_definition_str, FieldConfig, }; @@ -177,13 +247,17 @@ mod tests { #[test] fn object_parsing() -> Result<(), Box> { - let defs = - ObjectDefinition::from_table(&toml::from_str(artist_and_example_definition_str())?)?; + let defs = ObjectDefinition::from_table( + &toml::from_str(artist_and_example_definition_str())?, + &HashMap::new(), + )?; let table: Table = toml::from_str(artist_object_str())?; let obj = Object::from_table( defs.get("artist").unwrap(), Path::new("tormenta-rey"), &table, + &HashMap::new(), + false, )?; assert_eq!(obj.order, 1); assert_eq!(obj.object_name, "artist"); diff --git a/src/object_definition.rs b/src/object_definition.rs index bee6a5c..e3a7cf7 100644 --- a/src/object_definition.rs +++ b/src/object_definition.rs @@ -1,16 +1,30 @@ use crate::{ fields::{field_type::InvalidFieldError, FieldType, ObjectValues}, + manifest::EditorTypes, reserved_fields::{self, is_reserved_field, reserved_field_from_str, ReservedFieldError}, FieldValue, }; use serde::{Deserialize, Serialize}; -use std::str::FromStr; use std::{collections::HashMap, error::Error, fmt::Debug}; use toml::Table; use tracing::instrument; pub type ObjectDefinitions = HashMap; +#[cfg(feature = "typescript")] +mod typedefs { + use typescript_type_def::{ + type_expr::{Ident, NativeTypeInfo, TypeExpr, TypeInfo}, + TypeDef, + }; + pub struct ObjectDefinitionChildrenDef; + impl TypeDef for ObjectDefinitionChildrenDef { + const INFO: TypeInfo = TypeInfo::Native(NativeTypeInfo { + r#ref: TypeExpr::ident(Ident("Record")), + }); + } +} + #[derive(Debug, Deserialize, Serialize, Clone)] #[cfg_attr(feature = "typescript", derive(typescript_type_def::TypeDef))] pub struct ObjectDefinition { @@ -18,12 +32,19 @@ pub struct ObjectDefinition { pub fields: HashMap, pub field_order: Vec, pub template: Option, - #[cfg_attr(feature = "typescript", serde(skip))] + #[cfg_attr( + feature = "typescript", + type_def(type_of = "typedefs::ObjectDefinitionChildrenDef") + )] pub children: HashMap, } impl ObjectDefinition { - pub fn new(name: &str, definition: &Table) -> Result> { + pub fn new( + name: &str, + definition: &Table, + editor_types: &EditorTypes, + ) -> Result> { if is_reserved_field(name) { return Err(InvalidFieldError::ReservedObjectNameError(name.to_string()).into()); } @@ -39,9 +60,10 @@ impl ObjectDefinition { obj_def.field_order.push(key.to_string()); } if let Some(child_table) = m_value.as_table() { - obj_def - .children - .insert(key.clone(), ObjectDefinition::new(key, child_table)?); + obj_def.children.insert( + key.clone(), + ObjectDefinition::new(key, child_table, editor_types)?, + ); } else if let Some(value) = m_value.as_str() { if key == reserved_fields::TEMPLATE { obj_def.template = Some(value.to_string()); @@ -52,17 +74,23 @@ impl ObjectDefinition { } else { obj_def .fields - .insert(key.clone(), FieldType::from_str(value)?); + .insert(key.clone(), FieldType::from_str(value, editor_types)?); } } } Ok(obj_def) } - pub fn from_table(table: &Table) -> Result, Box> { + pub fn from_table( + table: &Table, + editor_types: &EditorTypes, + ) -> Result, Box> { let mut objects: HashMap = HashMap::new(); for (name, m_def) in table.into_iter() { if let Some(def) = m_def.as_table() { - objects.insert(name.clone(), ObjectDefinition::new(name, def)?); + objects.insert( + name.clone(), + ObjectDefinition::new(name, def, editor_types)?, + ); } } Ok(objects) @@ -86,6 +114,7 @@ pub mod tests { pub fn artist_and_example_definition_str() -> &'static str { "[artist] name = \"string\" + meta = \"meta\" template = \"artist\" [artist.tour_dates] date = \"date\" @@ -104,7 +133,7 @@ pub mod tests { #[test] fn parsing() -> Result<(), Box> { let table: Table = toml::from_str(artist_and_example_definition_str())?; - let defs = ObjectDefinition::from_table(&table)?; + let defs = ObjectDefinition::from_table(&table, &HashMap::new())?; println!("{:?}", defs); @@ -112,11 +141,12 @@ pub mod tests { assert!(defs.contains_key("artist")); assert!(defs.contains_key("example")); let artist = defs.get("artist").unwrap(); - assert_eq!(artist.field_order.len(), 4); + assert_eq!(artist.field_order.len(), 5); assert_eq!(artist.field_order[0], "name".to_string()); - assert_eq!(artist.field_order[1], "tour_dates".to_string()); - assert_eq!(artist.field_order[2], "videos".to_string()); - assert_eq!(artist.field_order[3], "numbers".to_string()); + assert_eq!(artist.field_order[1], "meta".to_string()); + assert_eq!(artist.field_order[2], "tour_dates".to_string()); + assert_eq!(artist.field_order[3], "videos".to_string()); + assert_eq!(artist.field_order[4], "numbers".to_string()); assert!(!artist.field_order.contains(&"template".to_string())); assert!(artist.fields.contains_key("name")); assert_eq!(artist.fields.get("name").unwrap(), &FieldType::String); diff --git a/src/page.rs b/src/page.rs index a4845bd..0e49b65 100644 --- a/src/page.rs +++ b/src/page.rs @@ -188,6 +188,7 @@ impl<'a> Page<'a> { "order": template_info.object.order, "path": template_info.object.path, })); + println!("OV: {:?}", object_vals); let mut context = liquid::object!({ template_info.definition.name.to_owned(): object_vals }); @@ -224,7 +225,7 @@ impl<'a> Page<'a> { mod tests { use crate::{ - fields::{DateTime, FieldType, FieldValue}, + fields::{meta::Meta, DateTime, FieldType, FieldValue, MetaValue}, liquid_parser, MemoryFileSystem, }; @@ -250,6 +251,19 @@ mod tests { "name".to_string(), FieldValue::String("Tormenta Rey".to_string()), ), + ( + "meta".to_string(), + FieldValue::Meta(Meta(HashMap::from([ + ("number".to_string(), MetaValue::Number(42.26)), + ( + "deep".to_string(), + MetaValue::Map(Meta(HashMap::from([( + "deep".to_string(), + MetaValue::Array(vec![MetaValue::String("HELLO!".to_string())]), + )]))), + ), + ]))), + ), ("numbers".to_string(), FieldValue::Objects(numbers_objects)), ( "tour_dates".to_string(), @@ -348,6 +362,8 @@ mod tests { } fn artist_template_content() -> &'static str { "name: {{artist.name}} + metanum: {{artist.meta.number}} + deep: {{artist.meta.deep.deep | first}} {% for number in artist.numbers %} number: {{number.number}} {% endfor %} @@ -391,6 +407,7 @@ mod tests { let liquid_parser = liquid_parser::get(None, None, &MemoryFileSystem::default())?; let objects_map = get_objects_map(); let object = objects_map["artist"].into_iter().next().unwrap(); + println!("OBJ: {:?}", object); let artist_def = artist_definition(); let page = Page::new_with_template( "tormenta-rey".to_string(), @@ -403,7 +420,12 @@ mod tests { let rendered = page.render(&liquid_parser, &objects_map)?; println!("rendered: {}", rendered); assert!(rendered.contains("name: Tormenta Rey"), "root field"); + assert!( + rendered.contains("metanum: 42.26"), + "child meta number field" + ); assert!(rendered.contains("number: 2.57"), "child number field"); + assert!(rendered.contains("deep: HELLO!"), "child deep field"); assert!(rendered.contains("date: Dec 22, 22"), "child date field"); assert!(rendered.contains("link: foo.com"), "child string field"); Ok(()) diff --git a/src/site.rs b/src/site.rs index 189e384..cab9e36 100644 --- a/src/site.rs +++ b/src/site.rs @@ -71,18 +71,19 @@ impl Site { #[instrument(skip(fs))] pub fn load(fs: &impl FileSystemAPI) -> Result> { // Load our manifest (should it exist) - let manifest = match Manifest::from_file(Path::new(MANIFEST_FILE_NAME), fs) { - Ok(m) => { - // When loading a manifest, check its compatibility. - if let Some(manifest_version) = &m.archival_version { - let (compat, message) = check_compatibility(manifest_version); - if !compat { - return Err(ArchivalError::new(&message).into()); - } + let manifest_path = Path::new(MANIFEST_FILE_NAME); + let manifest = if fs.exists(manifest_path)? { + let manifest = Manifest::from_file(manifest_path, fs)?; + // When loading a manifest, check its compatibility. + if let Some(manifest_version) = &manifest.archival_version { + let (compat, message) = check_compatibility(manifest_version); + if !compat { + return Err(ArchivalError::new(&message).into()); } - m } - Err(_) => Manifest::default(Path::new("")), + manifest + } else { + Manifest::default(Path::new("")) }; let odf = Path::new(&manifest.object_definition_file); if !fs.exists(odf)? { @@ -96,7 +97,7 @@ impl Site { // Load our object definitions debug!("loading definition {}", odf.display()); let objects_table = read_toml(odf, fs)?; - let objects = ObjectDefinition::from_table(&objects_table)?; + let objects = ObjectDefinition::from_table(&objects_table, &manifest.editor_types)?; Ok(Site { manifest, object_definitions: objects, @@ -213,6 +214,10 @@ impl Site { object_def, Path::new(path.with_extension("").file_name().unwrap()), &obj_table, + &self.manifest.editor_types, + // Skip validation when populating the cache, we may have new + // objects with invalid unset keys + true, )?; cache.insert(path.to_path_buf(), o.clone()); Ok(o) diff --git a/src/value_path.rs b/src/value_path.rs index d444937..7c68606 100644 --- a/src/value_path.rs +++ b/src/value_path.rs @@ -1,8 +1,9 @@ use crate::{ - fields::{field_value, FieldType, FieldValue, ObjectValues}, + fields::{field_value, meta::Meta, FieldType, FieldValue, MetaValue, ObjectValues}, object::Object, ObjectDefinition, }; +use liquid::ValueView; use serde::{Deserialize, Serialize}; use std::fmt::Display; use thiserror::Error; @@ -30,6 +31,35 @@ impl ValuePathComponent { pub fn key(name: &str) -> Self { Self::Key(name.to_string()) } + pub fn as_key(vp: Option) -> Option { + match vp { + Some(vp) => match vp { + ValuePathComponent::Key(k) => Some(k), + ValuePathComponent::Index(_) => None, + }, + None => None, + } + } + pub fn as_index(vp: Option) -> Option { + match vp { + Some(vp) => match vp { + ValuePathComponent::Key(_) => None, + ValuePathComponent::Index(i) => Some(i), + }, + None => None, + } + } +} + +impl From<&String> for ValuePathComponent { + fn from(value: &String) -> Self { + ValuePathComponent::Key(value.to_string()) + } +} +impl From for ValuePathComponent { + fn from(value: usize) -> Self { + ValuePathComponent::Index(value) + } } impl Display for ValuePathComponent { @@ -61,27 +91,173 @@ impl Display for ValuePath { } } +pub enum FoundValue<'a> { + Meta(&'a MetaValue), + String(&'a String), +} + +impl Display for FoundValue<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::String(s) => write!(f, "{}", s), + Self::Meta(mv) => write!(f, "{}", mv.render()), + } + } +} + +impl FromIterator for ValuePath { + fn from_iter>(iter: T) -> Self { + Self { + path: iter.into_iter().collect(), + } + } +} + impl ValuePath { + pub fn empty() -> Self { + Self { path: vec![] } + } pub fn from_string(string: &str) -> Self { let mut vpv: Vec = vec![]; - for part in string.split('.') { - match part.parse::() { - Ok(index) => vpv.push(ValuePathComponent::Index(index)), - Err(_) => vpv.push(ValuePathComponent::Key(part.to_string())), + if !string.is_empty() { + for part in string.split('.') { + match part.parse::() { + Ok(index) => vpv.push(ValuePathComponent::Index(index)), + Err(_) => vpv.push(ValuePathComponent::Key(part.to_string())), + } } } Self { path: vpv } } + pub fn is_empty(&self) -> bool { + self.path.is_empty() + } - pub fn join(mut self, component: ValuePathComponent) -> Self { + pub fn append(mut self, component: ValuePathComponent) -> Self { self.path.push(component); self } + pub fn concat(mut self, path: ValuePath) -> Self { + for p in path.path { + self = self.append(p); + } + self + } + + pub fn first(&self) -> ValuePath { + let first = self + .path + .first() + .expect("called .first on an empty value_path"); + ValuePath { + path: vec![first.clone()], + } + } + + pub fn unshift(&mut self) -> Option { + if !self.path.is_empty() { + Some(self.path.remove(0)) + } else { + None + } + } pub fn pop(&mut self) -> Option { self.path.pop() } + pub fn get_in_meta<'a>(&self, meta: &'a Meta) -> Option<&'a MetaValue> { + let mut i_path = self.path.iter().map(|v| match v { + ValuePathComponent::Index(i) => ValuePathComponent::Index(*i), + ValuePathComponent::Key(k) => ValuePathComponent::Key(k.to_owned()), + }); + let path = if let Some(ValuePathComponent::Key(k)) = i_path.next() { + k + } else { + return None; + }; + let mut last_val = meta.get_value(&path)?; + for cmp in i_path { + match cmp { + ValuePathComponent::Index(i) => { + if let MetaValue::Array(a) = last_val { + if let Some(f) = a.get(i) { + last_val = f; + continue; + } + } + return None; + } + ValuePathComponent::Key(k) => match last_val { + MetaValue::Map(m) => { + if let Some(f) = m.get_value(&k) { + last_val = f; + continue; + } + } + _ => { + return None; + } + }, + } + } + Some(last_val) + } + + pub fn get_value<'a>(&self, field: &'a FieldValue) -> Result, ValuePathError> { + let mut i_path = self.path.iter().map(|v| match v { + ValuePathComponent::Index(i) => ValuePathComponent::Index(*i), + ValuePathComponent::Key(k) => ValuePathComponent::Key(k.to_owned()), + }); + let mut last_val = field; + while let Some(cmp) = &i_path.next() { + match cmp { + ValuePathComponent::Index(i) => { + if let FieldValue::Objects(o) = field { + if let Some(v) = o.get(*i) { + if let Some(ValuePathComponent::Key(k)) = i_path.next() { + if let Some(fv) = v.get(&k) { + last_val = fv; + continue; + } + } + } + } + return Err(ValuePathError::NotFound( + self.to_string(), + field.to_string(), + )); + } + ValuePathComponent::Key(k) => match last_val { + FieldValue::Meta(m) => { + let c = cmp.clone(); + return ValuePath::from_iter(vec![c].into_iter().chain(i_path)) + .get_in_meta(m) + .map(FoundValue::Meta) + .ok_or_else(|| { + ValuePathError::NotFound(self.to_string(), field.to_string()) + }); + } + FieldValue::File(f) => { + return f.get_key(k).map(FoundValue::String).ok_or_else(|| { + ValuePathError::NotFound(self.to_string(), field.to_string()) + }) + } + _ => { + return Err(ValuePathError::NotFound( + self.to_string(), + field.to_string(), + )) + } + }, + } + } + Err(ValuePathError::NotFound( + self.to_string(), + field.to_string(), + )) + } + pub fn get_in_object<'a>(&self, object: &'a Object) -> Option<&'a FieldValue> { let mut i_path = self.path.iter().map(|v| match v { ValuePathComponent::Index(i) => ValuePathComponent::Index(*i), @@ -115,6 +291,64 @@ impl ValuePath { last_val } + pub fn get_children<'a>(&self, object: &'a Object) -> Option<&'a Vec> { + let mut i_path = self.path.iter().map(|v| match v { + ValuePathComponent::Index(i) => ValuePathComponent::Index(*i), + ValuePathComponent::Key(k) => ValuePathComponent::Key(k.to_owned()), + }); + let mut last_val = &object.values; + while let Some(cmp) = i_path.next() { + if let ValuePathComponent::Key(key) = cmp { + if let Some(FieldValue::Objects(children)) = last_val.get(&key) { + let next = i_path.next(); + if next.is_none() { + // Reached the end of the path, return the + // children here. + return Some(children); + } else if let Some(ValuePathComponent::Index(k)) = next { + // Path continues, recurse. + if let Some(c) = children.get(k) { + last_val = c; + continue; + } + } else { + // Path continues but next item is not an index, + // so this is not a valid path. Return nothing. + return None; + } + } + } + break; + } + None + } + + pub fn get_object_values<'a>(&self, object: &'a Object) -> Option<&'a ObjectValues> { + let mut i_path = self.path.iter().map(|v| match v { + ValuePathComponent::Index(i) => ValuePathComponent::Index(*i), + ValuePathComponent::Key(k) => ValuePathComponent::Key(k.to_owned()), + }); + let mut last_val = &object.values; + while let Some(cmp) = i_path.next() { + if let ValuePathComponent::Key(k) = cmp { + if let Some(FieldValue::Objects(children)) = last_val.get(&k) { + if let Some(ValuePathComponent::Index(idx)) = i_path.next() { + if let Some(child) = children.get(idx) { + last_val = child; + } + } else { + panic!("invalid value path {} for {:?}", self, object); + } + } else { + return None; + } + } else { + panic!("invalid value path {} for {:?}", self, object); + } + } + Some(last_val) + } + pub fn get_field_definition<'a>( &self, def: &'a ObjectDefinition, @@ -148,7 +382,7 @@ impl ValuePath { )) } - pub fn get_child_definition<'a>( + pub fn get_definition<'a>( &self, def: &'a ObjectDefinition, ) -> Result<&'a ObjectDefinition, ValuePathError> { @@ -173,7 +407,7 @@ impl ValuePath { object: &mut Object, obj_def: &ObjectDefinition, ) -> Result { - let child_def = self.get_child_definition(obj_def)?; + let child_def = self.get_definition(obj_def)?; let new_child = field_value::def_to_values(&child_def.fields); self.modify_children(object, |children| { children.push(new_child); @@ -238,7 +472,7 @@ impl ValuePath { } } - pub fn set_in_object(&self, object: &mut Object, value: FieldValue) { + pub fn set_in_object(&self, object: &mut Object, value: Option) { let mut i_path = self.path.iter().map(|v| match v { ValuePathComponent::Index(i) => ValuePathComponent::Index(*i), ValuePathComponent::Key(k) => ValuePathComponent::Key(k.to_owned()), @@ -252,7 +486,10 @@ impl ValuePath { last_val = object.values.get_mut(&k); continue; } else { - object.values.insert(k, value); + match value { + Some(value) => object.values.insert(k, value), + None => object.values.remove(&k), + }; break; } } @@ -268,7 +505,10 @@ impl ValuePath { last_val = child.get_mut(&k); continue; } else { - child.insert(k, value); + match value { + Some(value) => child.insert(k, value), + None => child.remove(&k), + }; } } }