split metadata of files and markdown reader
This commit is contained in:
parent
c7f1f912f0
commit
b1c4bbdb27
10 changed files with 202 additions and 185 deletions
|
@ -6,7 +6,8 @@ use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
template::{InfosPage, NavBar},
|
template::{InfosPage, NavBar},
|
||||||
utils::{
|
utils::{
|
||||||
markdown::{File, TypeFileMetadata},
|
markdown::File,
|
||||||
|
metadata::TypeFileMetadata,
|
||||||
misc::{make_kw, read_file, Html},
|
misc::{make_kw, read_file, Html},
|
||||||
routes::blog::{build_rss, get_post, get_posts, Post, BLOG_DIR, MIME_TYPE_RSS, POST_DIR},
|
routes::blog::{build_rss, get_post, get_posts, Post, BLOG_DIR, MIME_TYPE_RSS, POST_DIR},
|
||||||
},
|
},
|
||||||
|
|
|
@ -7,7 +7,8 @@ use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
template::{InfosPage, NavBar},
|
template::{InfosPage, NavBar},
|
||||||
utils::{
|
utils::{
|
||||||
markdown::{File, TypeFileMetadata},
|
markdown::File,
|
||||||
|
metadata::TypeFileMetadata,
|
||||||
misc::{make_kw, read_file, Html},
|
misc::{make_kw, read_file, Html},
|
||||||
routes::contact::{find_links, remove_paragraphs},
|
routes::contact::{find_links, remove_paragraphs},
|
||||||
},
|
},
|
||||||
|
|
|
@ -8,7 +8,8 @@ use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
template::{InfosPage, NavBar},
|
template::{InfosPage, NavBar},
|
||||||
utils::{
|
utils::{
|
||||||
markdown::{File, TypeFileMetadata},
|
markdown::File,
|
||||||
|
metadata::TypeFileMetadata,
|
||||||
misc::{make_kw, read_file, Html},
|
misc::{make_kw, read_file, Html},
|
||||||
routes::cours::{excluded, get_filetree},
|
routes::cours::{excluded, get_filetree},
|
||||||
},
|
},
|
||||||
|
|
|
@ -6,7 +6,8 @@ use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
template::{InfosPage, NavBar},
|
template::{InfosPage, NavBar},
|
||||||
utils::{
|
utils::{
|
||||||
markdown::{File, TypeFileMetadata},
|
markdown::File,
|
||||||
|
metadata::TypeFileMetadata,
|
||||||
misc::{make_kw, read_file, Html},
|
misc::{make_kw, read_file, Html},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -7,7 +7,8 @@ use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
template::{InfosPage, NavBar},
|
template::{InfosPage, NavBar},
|
||||||
utils::{
|
utils::{
|
||||||
markdown::{File, TypeFileMetadata},
|
markdown::File,
|
||||||
|
metadata::TypeFileMetadata,
|
||||||
misc::{make_kw, read_file, Html},
|
misc::{make_kw, read_file, Html},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
use crate::utils::date::Date;
|
|
||||||
use base64::engine::general_purpose;
|
use base64::engine::general_purpose;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use comrak::nodes::{AstNode, NodeValue};
|
use comrak::nodes::{AstNode, NodeValue};
|
||||||
|
@ -6,114 +5,13 @@ use comrak::{format_html, parse_document, Arena, ComrakOptions, ListStyleType, O
|
||||||
use lol_html::html_content::ContentType;
|
use lol_html::html_content::ContentType;
|
||||||
use lol_html::{element, rewrite_str, HtmlRewriter, RewriteStrSettings, Settings};
|
use lol_html::{element, rewrite_str, HtmlRewriter, RewriteStrSettings, Settings};
|
||||||
use ramhorns::Content;
|
use ramhorns::Content;
|
||||||
use serde::{Deserialize, Deserializer};
|
|
||||||
use std::fmt::Debug;
|
use std::fmt::Debug;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// Metadata for blog posts
|
use crate::utils::metadata::TypeFileMetadata;
|
||||||
#[derive(Content, Clone, Debug, Default, Deserialize)]
|
|
||||||
pub struct FileMetadataBlog {
|
|
||||||
pub hardbreaks: Option<bool>,
|
|
||||||
pub title: Option<String>,
|
|
||||||
pub date: Option<Date>,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub publish: Option<bool>,
|
|
||||||
pub tags: Option<Vec<Tag>>,
|
|
||||||
pub toc: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A tag, related to post blog
|
use super::metadata::{get_metadata, FileMetadata, Metadata};
|
||||||
#[derive(Content, Debug, Clone)]
|
|
||||||
pub struct Tag {
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Deserialize<'a> for Tag {
|
|
||||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
||||||
where
|
|
||||||
D: Deserializer<'a>,
|
|
||||||
{
|
|
||||||
match <&str>::deserialize(deserializer) {
|
|
||||||
Ok(s) => match serde_yml::from_str(s) {
|
|
||||||
Ok(tag) => Ok(Self { name: tag }),
|
|
||||||
Err(e) => Err(serde::de::Error::custom(e)),
|
|
||||||
},
|
|
||||||
Err(e) => Err(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Metadata for contact entry
|
|
||||||
#[derive(Content, Debug, Default, Deserialize, Clone)]
|
|
||||||
pub struct FileMetadataContact {
|
|
||||||
pub title: String,
|
|
||||||
pub custom: Option<bool>,
|
|
||||||
pub user: Option<String>,
|
|
||||||
pub link: Option<String>,
|
|
||||||
pub newtab: Option<bool>,
|
|
||||||
pub description: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Metadata for index page
|
|
||||||
#[derive(Content, Debug, Default, Deserialize, Clone)]
|
|
||||||
pub struct FileMetadataIndex {
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub pronouns: Option<String>,
|
|
||||||
pub avatar: Option<String>,
|
|
||||||
pub avatar_caption: Option<String>,
|
|
||||||
pub avatar_style: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Metadata for portfolio cards
|
|
||||||
#[derive(Content, Debug, Default, Deserialize, Clone)]
|
|
||||||
pub struct FileMetadataPortfolio {
|
|
||||||
pub title: Option<String>,
|
|
||||||
pub link: Option<String>,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub language: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List of available metadata types
|
|
||||||
#[derive(Hash, PartialEq, Eq, Clone, Copy)]
|
|
||||||
pub enum TypeFileMetadata {
|
|
||||||
Blog,
|
|
||||||
Contact,
|
|
||||||
Generic,
|
|
||||||
Index,
|
|
||||||
Portfolio,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Structure who holds all the metadata the file have
|
|
||||||
/// Usually all fields are None except one
|
|
||||||
#[derive(Content, Debug, Default, Deserialize, Clone)]
|
|
||||||
pub struct FileMetadata {
|
|
||||||
pub hardbreaks: bool,
|
|
||||||
pub blog: Option<FileMetadataBlog>,
|
|
||||||
pub contact: Option<FileMetadataContact>,
|
|
||||||
pub index: Option<FileMetadataIndex>,
|
|
||||||
pub portfolio: Option<FileMetadataPortfolio>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::struct_excessive_bools)]
|
|
||||||
/// Global metadata
|
|
||||||
#[derive(Content, Debug, Clone)]
|
|
||||||
pub struct Metadata {
|
|
||||||
pub info: FileMetadata,
|
|
||||||
pub math: bool,
|
|
||||||
pub mermaid: bool,
|
|
||||||
pub syntax_highlight: bool,
|
|
||||||
pub mail_obfsucated: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Metadata {
|
|
||||||
/// Update current metadata boolean fields, keeping true ones
|
|
||||||
fn merge(&mut self, other: &Self) {
|
|
||||||
self.math = self.math || other.math;
|
|
||||||
self.mermaid = self.mermaid || other.mermaid;
|
|
||||||
self.syntax_highlight = self.syntax_highlight || other.syntax_highlight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// File description
|
/// File description
|
||||||
#[derive(Content, Debug, Clone)]
|
#[derive(Content, Debug, Clone)]
|
||||||
|
@ -324,80 +222,6 @@ pub fn read_md(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deserialize metadata based on a type
|
|
||||||
fn deserialize_metadata<T: Default + serde::de::DeserializeOwned>(text: &str) -> T {
|
|
||||||
serde_yml::from_str(text.trim().trim_matches(&['-'] as &[_])).unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch metadata from AST
|
|
||||||
pub fn get_metadata<'a>(root: &'a AstNode<'a>, mtype: TypeFileMetadata) -> FileMetadata {
|
|
||||||
root.children()
|
|
||||||
.map(|node| {
|
|
||||||
let generic = FileMetadata {
|
|
||||||
hardbreaks: true,
|
|
||||||
..FileMetadata::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
match &node.data.borrow().value {
|
|
||||||
// Extract metadata from frontmatter
|
|
||||||
NodeValue::FrontMatter(text) => match mtype {
|
|
||||||
TypeFileMetadata::Blog => {
|
|
||||||
let metadata: FileMetadataBlog = deserialize_metadata(text);
|
|
||||||
FileMetadata {
|
|
||||||
blog: Some(metadata.clone()),
|
|
||||||
hardbreaks: metadata.hardbreaks.unwrap_or_default(),
|
|
||||||
..FileMetadata::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TypeFileMetadata::Contact => {
|
|
||||||
let mut metadata: FileMetadataContact = deserialize_metadata(text);
|
|
||||||
// Trim descriptions
|
|
||||||
if let Some(desc) = &mut metadata.description {
|
|
||||||
desc.clone_from(&desc.trim().into());
|
|
||||||
}
|
|
||||||
FileMetadata {
|
|
||||||
contact: Some(metadata),
|
|
||||||
..FileMetadata::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TypeFileMetadata::Generic => generic,
|
|
||||||
TypeFileMetadata::Index => FileMetadata {
|
|
||||||
index: Some(deserialize_metadata(text)),
|
|
||||||
..FileMetadata::default()
|
|
||||||
},
|
|
||||||
TypeFileMetadata::Portfolio => FileMetadata {
|
|
||||||
portfolio: Some(deserialize_metadata(text)),
|
|
||||||
..FileMetadata::default()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_ => generic,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.next()
|
|
||||||
.map_or_else(
|
|
||||||
|| match mtype {
|
|
||||||
TypeFileMetadata::Blog => FileMetadata {
|
|
||||||
blog: Some(FileMetadataBlog::default()),
|
|
||||||
..FileMetadata::default()
|
|
||||||
},
|
|
||||||
TypeFileMetadata::Contact => FileMetadata {
|
|
||||||
contact: Some(FileMetadataContact::default()),
|
|
||||||
..FileMetadata::default()
|
|
||||||
},
|
|
||||||
TypeFileMetadata::Generic => FileMetadata::default(),
|
|
||||||
TypeFileMetadata::Index => FileMetadata {
|
|
||||||
index: Some(FileMetadataIndex::default()),
|
|
||||||
..FileMetadata::default()
|
|
||||||
},
|
|
||||||
TypeFileMetadata::Portfolio => FileMetadata {
|
|
||||||
portfolio: Some(FileMetadataPortfolio::default()),
|
|
||||||
..FileMetadata::default()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|data| data,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check whether mermaid diagrams are in the AST
|
/// Check whether mermaid diagrams are in the AST
|
||||||
fn check_mermaid<'a>(root: &'a AstNode<'a>, mermaid_str: &str) -> bool {
|
fn check_mermaid<'a>(root: &'a AstNode<'a>, mermaid_str: &str) -> bool {
|
||||||
root.children().any(|node| match &node.data.borrow().value {
|
root.children().any(|node| match &node.data.borrow().value {
|
||||||
|
|
183
src/utils/metadata.rs
Normal file
183
src/utils/metadata.rs
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
use crate::utils::date::Date;
|
||||||
|
use comrak::nodes::{AstNode, NodeValue};
|
||||||
|
use ramhorns::Content;
|
||||||
|
use serde::{Deserialize, Deserializer};
|
||||||
|
use std::fmt::Debug;
|
||||||
|
|
||||||
|
/// Metadata for blog posts
|
||||||
|
#[derive(Content, Clone, Debug, Default, Deserialize)]
|
||||||
|
pub struct FileMetadataBlog {
|
||||||
|
pub hardbreaks: Option<bool>,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub date: Option<Date>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub publish: Option<bool>,
|
||||||
|
pub tags: Option<Vec<Tag>>,
|
||||||
|
pub toc: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tag, related to post blog
|
||||||
|
#[derive(Content, Debug, Clone)]
|
||||||
|
pub struct Tag {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Deserialize<'a> for Tag {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'a>,
|
||||||
|
{
|
||||||
|
match <&str>::deserialize(deserializer) {
|
||||||
|
Ok(s) => match serde_yml::from_str(s) {
|
||||||
|
Ok(tag) => Ok(Self { name: tag }),
|
||||||
|
Err(e) => Err(serde::de::Error::custom(e)),
|
||||||
|
},
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata for contact entry
|
||||||
|
#[derive(Content, Debug, Default, Deserialize, Clone)]
|
||||||
|
pub struct FileMetadataContact {
|
||||||
|
pub title: String,
|
||||||
|
pub custom: Option<bool>,
|
||||||
|
pub user: Option<String>,
|
||||||
|
pub link: Option<String>,
|
||||||
|
pub newtab: Option<bool>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata for index page
|
||||||
|
#[derive(Content, Debug, Default, Deserialize, Clone)]
|
||||||
|
pub struct FileMetadataIndex {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub pronouns: Option<String>,
|
||||||
|
pub avatar: Option<String>,
|
||||||
|
pub avatar_caption: Option<String>,
|
||||||
|
pub avatar_style: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata for portfolio cards
|
||||||
|
#[derive(Content, Debug, Default, Deserialize, Clone)]
|
||||||
|
pub struct FileMetadataPortfolio {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub link: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub language: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List of available metadata types
|
||||||
|
#[derive(Hash, PartialEq, Eq, Clone, Copy)]
|
||||||
|
pub enum TypeFileMetadata {
|
||||||
|
Blog,
|
||||||
|
Contact,
|
||||||
|
Generic,
|
||||||
|
Index,
|
||||||
|
Portfolio,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Structure who holds all the metadata the file have
|
||||||
|
/// Usually all fields are None except one
|
||||||
|
#[derive(Content, Debug, Default, Deserialize, Clone)]
|
||||||
|
pub struct FileMetadata {
|
||||||
|
pub hardbreaks: bool,
|
||||||
|
pub blog: Option<FileMetadataBlog>,
|
||||||
|
pub contact: Option<FileMetadataContact>,
|
||||||
|
pub index: Option<FileMetadataIndex>,
|
||||||
|
pub portfolio: Option<FileMetadataPortfolio>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::struct_excessive_bools)]
|
||||||
|
/// Global metadata
|
||||||
|
#[derive(Content, Debug, Clone)]
|
||||||
|
pub struct Metadata {
|
||||||
|
pub info: FileMetadata,
|
||||||
|
pub math: bool,
|
||||||
|
pub mermaid: bool,
|
||||||
|
pub syntax_highlight: bool,
|
||||||
|
pub mail_obfsucated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Metadata {
|
||||||
|
/// Update current metadata boolean fields, keeping true ones
|
||||||
|
pub fn merge(&mut self, other: &Self) {
|
||||||
|
self.math = self.math || other.math;
|
||||||
|
self.mermaid = self.mermaid || other.mermaid;
|
||||||
|
self.syntax_highlight = self.syntax_highlight || other.syntax_highlight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialize metadata based on a type
|
||||||
|
fn deserialize_metadata<T: Default + serde::de::DeserializeOwned>(text: &str) -> T {
|
||||||
|
serde_yml::from_str(text.trim().trim_matches(&['-'] as &[_])).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch metadata from AST
|
||||||
|
pub fn get_metadata<'a>(root: &'a AstNode<'a>, mtype: TypeFileMetadata) -> FileMetadata {
|
||||||
|
root.children()
|
||||||
|
.map(|node| {
|
||||||
|
let generic = FileMetadata {
|
||||||
|
hardbreaks: true,
|
||||||
|
..FileMetadata::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
match &node.data.borrow().value {
|
||||||
|
// Extract metadata from frontmatter
|
||||||
|
NodeValue::FrontMatter(text) => match mtype {
|
||||||
|
TypeFileMetadata::Blog => {
|
||||||
|
let metadata: FileMetadataBlog = deserialize_metadata(text);
|
||||||
|
FileMetadata {
|
||||||
|
blog: Some(metadata.clone()),
|
||||||
|
hardbreaks: metadata.hardbreaks.unwrap_or_default(),
|
||||||
|
..FileMetadata::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TypeFileMetadata::Contact => {
|
||||||
|
let mut metadata: FileMetadataContact = deserialize_metadata(text);
|
||||||
|
// Trim descriptions
|
||||||
|
if let Some(desc) = &mut metadata.description {
|
||||||
|
desc.clone_from(&desc.trim().into());
|
||||||
|
}
|
||||||
|
FileMetadata {
|
||||||
|
contact: Some(metadata),
|
||||||
|
..FileMetadata::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TypeFileMetadata::Generic => generic,
|
||||||
|
TypeFileMetadata::Index => FileMetadata {
|
||||||
|
index: Some(deserialize_metadata(text)),
|
||||||
|
..FileMetadata::default()
|
||||||
|
},
|
||||||
|
TypeFileMetadata::Portfolio => FileMetadata {
|
||||||
|
portfolio: Some(deserialize_metadata(text)),
|
||||||
|
..FileMetadata::default()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_ => generic,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.next()
|
||||||
|
.map_or_else(
|
||||||
|
|| match mtype {
|
||||||
|
TypeFileMetadata::Blog => FileMetadata {
|
||||||
|
blog: Some(FileMetadataBlog::default()),
|
||||||
|
..FileMetadata::default()
|
||||||
|
},
|
||||||
|
TypeFileMetadata::Contact => FileMetadata {
|
||||||
|
contact: Some(FileMetadataContact::default()),
|
||||||
|
..FileMetadata::default()
|
||||||
|
},
|
||||||
|
TypeFileMetadata::Generic => FileMetadata::default(),
|
||||||
|
TypeFileMetadata::Index => FileMetadata {
|
||||||
|
index: Some(FileMetadataIndex::default()),
|
||||||
|
..FileMetadata::default()
|
||||||
|
},
|
||||||
|
TypeFileMetadata::Portfolio => FileMetadata {
|
||||||
|
portfolio: Some(FileMetadataPortfolio::default()),
|
||||||
|
..FileMetadata::default()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|data| data,
|
||||||
|
)
|
||||||
|
}
|
|
@ -11,7 +11,10 @@ use reqwest::Client;
|
||||||
|
|
||||||
use crate::config::FileConfiguration;
|
use crate::config::FileConfiguration;
|
||||||
|
|
||||||
use super::markdown::{read_md, File, FileMetadata, Metadata, TypeFileMetadata};
|
use super::{
|
||||||
|
markdown::{read_md, File},
|
||||||
|
metadata::{FileMetadata, Metadata, TypeFileMetadata},
|
||||||
|
};
|
||||||
|
|
||||||
#[cached]
|
#[cached]
|
||||||
pub fn get_reqwest_client() -> Client {
|
pub fn get_reqwest_client() -> Client {
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
pub mod date;
|
pub mod date;
|
||||||
pub mod github;
|
pub mod github;
|
||||||
pub mod markdown;
|
pub mod markdown;
|
||||||
|
pub mod metadata;
|
||||||
pub mod misc;
|
pub mod misc;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
|
|
|
@ -18,7 +18,8 @@ use crate::{
|
||||||
template::InfosPage,
|
template::InfosPage,
|
||||||
utils::{
|
utils::{
|
||||||
date::Date,
|
date::Date,
|
||||||
markdown::{get_metadata, get_options, File, FileMetadataBlog, TypeFileMetadata},
|
markdown::{get_options, File},
|
||||||
|
metadata::{get_metadata, FileMetadataBlog, TypeFileMetadata},
|
||||||
misc::{get_url, make_kw, read_file},
|
misc::{get_url, make_kw, read_file},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue