v2
This commit is contained in:
parent
a03c1891c8
commit
a0ddaf89a9
5 changed files with 197 additions and 168 deletions
|
|
@ -14,5 +14,6 @@ restson = "1.5.0"
|
||||||
rpassword = "7.4.0"
|
rpassword = "7.4.0"
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
|
spond-macros = { version = "0.1.0", path = "../macros" }
|
||||||
tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] }
|
tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] }
|
||||||
url = "2.5.8"
|
url = "2.5.8"
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,34 @@
|
||||||
//use bon::Builder;
|
//use bon::Builder;
|
||||||
//use chrono::{DateTime,Utc};
|
use chrono::{DateTime,Utc};
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
//use restson::{RestClient, RestPath, Error};
|
//use restson::{RestClient, RestPath, Error};
|
||||||
//
|
//
|
||||||
//pub enum Order {
|
#[derive(Serialize)]
|
||||||
// Ascending,
|
pub enum Order {
|
||||||
// Descending,
|
Ascending,
|
||||||
//}
|
Descending,
|
||||||
//
|
}
|
||||||
//impl AsRef<str> for Order {
|
|
||||||
// fn as_ref(&self) -> &str {
|
impl AsRef<str> for Order {
|
||||||
// match self {
|
fn as_ref(&self) -> &str {
|
||||||
// Self::Ascending => "asc",
|
match self {
|
||||||
// Self::Descending => "desc",
|
Self::Ascending => "asc",
|
||||||
// }
|
Self::Descending => "desc",
|
||||||
// }
|
}
|
||||||
//}
|
}
|
||||||
//
|
}
|
||||||
//impl From<bool> for Order {
|
|
||||||
// fn from(ascending: bool) -> Self {
|
impl From<bool> for Order {
|
||||||
// if ascending {
|
fn from(ascending: bool) -> Self {
|
||||||
// Self::Ascending
|
if ascending {
|
||||||
// } else {
|
Self::Ascending
|
||||||
// Self::Descending
|
} else {
|
||||||
// }
|
Self::Descending
|
||||||
// }
|
}
|
||||||
//}
|
}
|
||||||
//
|
}
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct Spond {
|
pub struct Spond {
|
||||||
id: String,
|
id: String,
|
||||||
|
|
||||||
|
|
@ -46,29 +48,42 @@ pub struct Spond {
|
||||||
// min_end_timestamp=Option<DateTime<Utc>>,
|
// min_end_timestamp=Option<DateTime<Utc>>,
|
||||||
// max_end_timestamp=Option<DateTime<Utc>>,
|
// max_end_timestamp=Option<DateTime<Utc>>,
|
||||||
//) -> Get<(), Vec<Spond>> {
|
//) -> Get<(), Vec<Spond>> {
|
||||||
//
|
|
||||||
//}
|
|
||||||
//
|
|
||||||
//struct Sponds(Query);
|
|
||||||
//
|
|
||||||
//async pub fn sponds(
|
|
||||||
// #[builder(finish_fn)]
|
|
||||||
// client: &restson::RestClient, E
|
|
||||||
//
|
|
||||||
//
|
|
||||||
|
|
||||||
crate::get!(search(
|
//crate::get!(sponds(
|
||||||
|
// comments: bool,
|
||||||
|
// hidden: bool,
|
||||||
|
// add_profile_info: bool,
|
||||||
|
// scheduled,
|
||||||
|
// #[builder(into, default=Order::Ascending)] order: Order,
|
||||||
|
// #[builder(default=20)]max: usize,
|
||||||
|
// min_end_timestamp: Option<DateTime<Utc>>,
|
||||||
|
// max_end_timestamp: Option<DateTime<Utc>>,
|
||||||
|
// ),
|
||||||
|
// () => "sponds" -> Vec<Spond>);
|
||||||
|
|
||||||
|
#[spond_macros::endpoint((id:u128):"/spond/{id:032X}/info", (eid:u128, uid:u128): "/spond/{eid:032X}/response/{uid:032X}")]
|
||||||
|
pub async fn sponds(result: serde_json::Value,
|
||||||
|
#[query] comments: Option<bool>,
|
||||||
|
#[query] hidden: Option<bool>,
|
||||||
|
#[body] min_end_timestamp: Option<DateTime<Utc>>,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
crate::post!(post(
|
||||||
comments: bool,
|
comments: bool,
|
||||||
hidden: bool,
|
hidden: bool,
|
||||||
add_profile_info: bool,
|
add_profile_info: bool,
|
||||||
scheduled: bool,
|
scheduled: bool,
|
||||||
#[builder(into)] order: Order,
|
#[builder(into)] order: Order,
|
||||||
#[builder(default=20)]max: usize,
|
#[builder(default=20)]max: usize,
|
||||||
),
|
)
|
||||||
min_end_timestamp: Option<DateTime<Utc>>,
|
min_end_timestamp: Option<DateTime<Utc>>,
|
||||||
max_end_timestamp: Option<DateTime<Utc>>,
|
max_end_timestamp: Option<DateTime<Utc>>,
|
||||||
() => "sponds" -> Vec<Spond>);
|
() => "sponds" -> Vec<Spond>);
|
||||||
|
*/
|
||||||
//impl Search {
|
//impl Search {
|
||||||
// with_comments(
|
// with_comments(
|
||||||
//#[bon::builder]
|
//#[bon::builder]
|
||||||
|
|
|
||||||
|
|
@ -45,17 +45,29 @@ async fn main() -> Result<()> {
|
||||||
let client = Cli::parse().client().await?;
|
let client = Cli::parse().client().await?;
|
||||||
|
|
||||||
// https://api.spond.com/core/v1/sponds?includeComments=true&includeHidden=false&addProfileInfo=true&scheduled=true&order=asc&max=20&prevId=F94829E35A9B4A48A042646C8B658B01&minStartTimestamp=2026-04-11T09:45:00Z&minEndTimestamp=2026-02-26T23:00:00.001Z
|
// https://api.spond.com/core/v1/sponds?includeComments=true&includeHidden=false&addProfileInfo=true&scheduled=true&order=asc&max=20&prevId=F94829E35A9B4A48A042646C8B658B01&minStartTimestamp=2026-04-11T09:45:00Z&minEndTimestamp=2026-02-26T23:00:00.001Z
|
||||||
let query = [
|
if false {
|
||||||
("includeComments", "true"),
|
let query = [
|
||||||
("includeHidden", "false"),
|
("includeComments", "true"),
|
||||||
("addProfileInfo", "true"),
|
("includeHidden", "false"),
|
||||||
("scheduled", "true"),
|
("addProfileInfo", "true"),
|
||||||
("order", "asc"),
|
("scheduled", "true"),
|
||||||
("max", "20"),
|
("order", "asc"),
|
||||||
];
|
("max", "20"),
|
||||||
|
];
|
||||||
for spond in client.get_with::<_, Sponds>((), &query).await?.into_inner().0 {
|
|
||||||
println!("{spond:?}");
|
for spond in client.get_with::<_, Sponds>((), &query).await?.into_inner().0 {
|
||||||
|
println!("{spond:?}");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let request = api::sponds()
|
||||||
|
.comments(true)
|
||||||
|
.hidden(false)
|
||||||
|
.add_profile_info(false)
|
||||||
|
.scheduled(true)
|
||||||
|
;
|
||||||
|
for spond in request.call(&client).await? {
|
||||||
|
println!("{spond:?}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ macro_rules! get {
|
||||||
// Case 1: no query
|
// Case 1: no query
|
||||||
(
|
(
|
||||||
$name:ident,
|
$name:ident,
|
||||||
( $( $arg:ident : $arg_ty:ty ),* ) => $path:literal -> $out:ty $(,)?
|
( $( $arg:ident : $arg_ty:ty ),* $(,)? ) => $path:literal -> $out:ty
|
||||||
) => {
|
) => {
|
||||||
get!($name(), ( $( $arg : $arg_ty ),* ) => $path -> $out);
|
get!($name(), ( $( $arg : $arg_ty ),* ) => $path -> $out);
|
||||||
};
|
};
|
||||||
|
|
@ -11,7 +11,7 @@ macro_rules! get {
|
||||||
// Case 2: empty query ()
|
// Case 2: empty query ()
|
||||||
(
|
(
|
||||||
$name:ident (),
|
$name:ident (),
|
||||||
( $( $arg:ident : $arg_ty:ty ),* ) => $path:literal -> $out:ty $(,)?
|
( $( $arg:ident : $arg_ty:ty ),* $(,)? ) => $path:literal -> $out:ty
|
||||||
) => {
|
) => {
|
||||||
#[bon::builder]
|
#[bon::builder]
|
||||||
pub fn $name(
|
pub fn $name(
|
||||||
|
|
@ -35,42 +35,30 @@ macro_rules! get {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Case 3: query with flags / mixed types
|
// Case 3: query with optional attributes
|
||||||
(
|
(
|
||||||
$name:ident ( $( $query:tt ),* $(,)? ),
|
$name:ident ( $( $(#[$attr:meta])* $query_ident:ident $(: $query_ty:ty )? ),* $(,)? ),
|
||||||
( $( $arg:ident : $arg_ty:ty ),* ) => $path:literal -> $out:ty $(,)?
|
( $( $arg:ident : $arg_ty:ty ),* $(,)? ) => $path:literal -> $out:ty
|
||||||
) => {
|
) => {
|
||||||
#[bon::builder]
|
#[bon::builder]
|
||||||
pub fn $name(
|
pub fn $name(
|
||||||
#[builder(finish_fn)] client: &restson::RestClient,
|
#[builder(finish_fn)] client: &restson::RestClient,
|
||||||
$( #[builder(finish_fn)] $arg: $arg_ty, )*
|
$( #[builder(finish_fn)] $arg: $arg_ty, )*
|
||||||
$(
|
$(
|
||||||
get!(@query_field $query)
|
$(#[$attr])* $query_ident : $crate::get!( @query_type $( $query_ty )? ) ,
|
||||||
)*
|
)*
|
||||||
) -> impl std::future::Future<Output = Result<$out, restson::Error>> + '_ {
|
) -> impl std::future::Future<Output = Result<$out, restson::Error>> + '_ {
|
||||||
#[derive(serde::Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
struct Query<'a> {
|
|
||||||
$(
|
|
||||||
get!(@query_field_struct $query)
|
|
||||||
)*
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Query<'_> {
|
// Build Vec<(String,String)> dynamically using serde_json::to_string
|
||||||
fn as_pairs(&self) -> Vec<(&str, String)> {
|
let query_pairs: Vec<(String, String)> = vec![
|
||||||
let mut out = Vec::new();
|
|
||||||
$(
|
|
||||||
get!(@push_pair out, self, $query)
|
|
||||||
)*
|
|
||||||
out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let query = Query {
|
|
||||||
$(
|
$(
|
||||||
get!(@query_field_init $query)
|
{
|
||||||
)*
|
let val = serde_json::to_string(&$query_ident)
|
||||||
};
|
.expect("failed to serialize query param");
|
||||||
|
(stringify!($query_ident).to_string(), val)
|
||||||
|
}
|
||||||
|
),*
|
||||||
|
];
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct RP($out);
|
struct RP($out);
|
||||||
|
|
@ -83,28 +71,18 @@ macro_rules! get {
|
||||||
}
|
}
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
let result = client.get_with::<_, RP>(( $( $arg ),* ), &query.as_pairs()).await?;
|
// Convert Vec<(String,String)> to Vec<(&str,&str)>
|
||||||
|
let ref_pairs: Vec<(&str, &str)> = query_pairs
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let result = client.get_with::<_, RP>(( $( $arg ),* ), &ref_pairs).await?;
|
||||||
Ok(result.into_inner().0)
|
Ok(result.into_inner().0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Query helpers
|
( @query_type ) => { Option<bool> };
|
||||||
(@query_field $field:ident) => { $field: Option<bool>, };
|
( @query_type $ty:ty) => { $ty };
|
||||||
(@query_field $field:ident = $ty:ty) => { $field: $ty, };
|
|
||||||
|
|
||||||
(@query_field_struct $field:ident) => { $field: Option<bool>, };
|
|
||||||
(@query_field_struct $field:ident = $ty:ty) => { $field: $ty, };
|
|
||||||
|
|
||||||
(@query_field_init $field:ident) => { $field, };
|
|
||||||
(@query_field_init $field:ident = $ty:ty) => { $field, };
|
|
||||||
|
|
||||||
(@push_pair $vec:ident, $self:ident, $field:ident = $ty:ty) => {
|
|
||||||
$vec.push((stringify!($field), $self.$field.to_string()));
|
|
||||||
};
|
|
||||||
(@push_pair $vec:ident, $self:ident, $field:ident) => {
|
|
||||||
if let Some(v) = &$self.$field {
|
|
||||||
$vec.push((stringify!($field), v.to_string()));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,109 +1,132 @@
|
||||||
use proc_macro::TokenStream;
|
use proc_macro::TokenStream;
|
||||||
use quote::{quote, ToTokens};
|
use quote::quote;
|
||||||
use syn::{
|
use syn::{
|
||||||
parse_macro_input, Attribute, FnArg, ItemFn, Lit, Meta, Pat, PatType, Type, spanned::Spanned,
|
parse_macro_input, FnArg, ItemFn, LitStr, Pat, PatType, Type, Attribute, spanned::Spanned,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Endpoint macro
|
/// Path args parser for `#[endpoint((x:u128,y:String): "/path/{x}/{y}")]`
|
||||||
|
struct EndpointPath {
|
||||||
|
args: Vec<(syn::Ident, syn::Type)>,
|
||||||
|
path: LitStr,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl syn::parse::Parse for EndpointPath {
|
||||||
|
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
|
||||||
|
let content;
|
||||||
|
syn::parenthesized!(content in input);
|
||||||
|
|
||||||
|
let mut args = Vec::new();
|
||||||
|
while !content.is_empty() {
|
||||||
|
let ident: syn::Ident = content.parse()?;
|
||||||
|
content.parse::<syn::Token![:]>()?;
|
||||||
|
let ty: syn::Type = content.parse()?;
|
||||||
|
args.push((ident, ty));
|
||||||
|
|
||||||
|
if content.peek(syn::Token![,]) {
|
||||||
|
content.parse::<syn::Token![,]>()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input.parse::<syn::Token![:]>()?;
|
||||||
|
let path: LitStr = input.parse()?;
|
||||||
|
|
||||||
|
Ok(EndpointPath { args, path })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[proc_macro_attribute]
|
#[proc_macro_attribute]
|
||||||
pub fn endpoint(attr: TokenStream, item: TokenStream) -> TokenStream {
|
pub fn endpoint(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
// parse the endpoint attribute
|
let path_args = parse_macro_input!(attr as EndpointPath);
|
||||||
let attr = proc_macro2::TokenStream::from(attr);
|
|
||||||
let func = parse_macro_input!(item as ItemFn);
|
let func = parse_macro_input!(item as ItemFn);
|
||||||
|
|
||||||
let vis = &func.vis;
|
let vis = &func.vis;
|
||||||
let name = &func.sig.ident;
|
let name = &func.sig.ident;
|
||||||
let inputs = &func.sig.inputs;
|
|
||||||
let output = &func.sig.output;
|
let output = &func.sig.output;
|
||||||
|
|
||||||
// must be async
|
// Separate query/body args
|
||||||
if func.sig.asyncness.is_none() {
|
let mut query_fields = Vec::new();
|
||||||
return syn::Error::new(name.span(), "endpoint function must be async")
|
let mut body_fields = Vec::new();
|
||||||
.to_compile_error()
|
|
||||||
.into();
|
|
||||||
}
|
|
||||||
|
|
||||||
// defaults
|
for arg in &func.sig.inputs {
|
||||||
let mut method = quote! { GET };
|
if let FnArg::Typed(PatType { pat, ty, attrs, .. }) = arg {
|
||||||
let mut path = None;
|
let pat_ident = match &**pat {
|
||||||
|
Pat::Ident(pi) => &pi.ident,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
// parse #[endpoint(...)]
|
if attrs.iter().any(|a| a.path().is_ident("query")) {
|
||||||
if !attr.is_empty() {
|
query_fields.push((pat_ident.clone(), (*ty).clone(), attrs.clone()));
|
||||||
let attr_str = attr.to_string();
|
} else if attrs.iter().any(|a| a.path().is_ident("body")) {
|
||||||
// simple heuristic: if contains "POST", switch
|
body_fields.push((pat_ident.clone(), (*ty).clone(), attrs.clone()));
|
||||||
if attr_str.contains("POST") {
|
|
||||||
method = quote! { POST };
|
|
||||||
}
|
|
||||||
// simple heuristic: extract path in quotes
|
|
||||||
if let Some(start) = attr_str.find('"') {
|
|
||||||
if let Some(end) = attr_str[start+1..].find('"') {
|
|
||||||
path = Some(attr_str[start+1..start+1+end].to_string());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = match path {
|
// Path args
|
||||||
Some(p) => p,
|
let path_idents: Vec<_> = path_args.args.iter().map(|(i, _)| i).collect();
|
||||||
None => return syn::Error::new(name.span(), "endpoint path must be provided")
|
let path_types: Vec<_> = path_args.args.iter().map(|(_, t)| t).collect();
|
||||||
.to_compile_error()
|
let path_fmt = &path_args.path;
|
||||||
.into(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// process arguments
|
// Build query serialization
|
||||||
let mut client_arg = None;
|
let query_pairs = query_fields.iter().map(|(ident, _, _)| {
|
||||||
let mut other_args = Vec::new();
|
quote! {
|
||||||
|
if let Some(v) = &#ident {
|
||||||
for input in inputs {
|
query_pairs.push((stringify!(#ident), v.to_string()));
|
||||||
match input {
|
|
||||||
FnArg::Receiver(_) => continue, // skip self
|
|
||||||
FnArg::Typed(PatType { pat, ty, .. }) => {
|
|
||||||
if let Pat::Ident(ident) = &**pat {
|
|
||||||
if ident.ident == "client" {
|
|
||||||
client_arg = Some((ident.ident.clone(), ty));
|
|
||||||
} else {
|
|
||||||
other_args.push((ident.ident.clone(), ty));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
// generate tokens for function with builder
|
// Build body serialization
|
||||||
let arg_defs: Vec<proc_macro2::TokenStream> = other_args.iter().map(|(id, ty)| {
|
let body_pairs = body_fields.iter().map(|(ident, _, _)| {
|
||||||
// wrap Option<T> with #[builder(default)]
|
quote! {
|
||||||
if let Type::Path(tp) = ty.as_ref() {
|
body_map.insert(stringify!(#ident).to_string(), serde_json::to_value(&#ident)?);
|
||||||
let is_option = tp.path.segments.last().map(|seg| seg.ident == "Option").unwrap_or(false);
|
}
|
||||||
if is_option {
|
});
|
||||||
quote! { #[builder(default)] #id: #ty }
|
|
||||||
|
// Determine method
|
||||||
|
let method = if body_fields.is_empty() { quote! { GET } } else { quote! { POST } };
|
||||||
|
|
||||||
|
// Expand query/body fields for function signature
|
||||||
|
let query_sig = query_fields.iter().map(|(ident, ty, _attrs)| {
|
||||||
|
if let Type::Path(tp) = &**ty { // <-- dereference the Box<Type>
|
||||||
|
if tp.path.segments.last().unwrap().ident == "Option" {
|
||||||
|
quote! { #[builder(default)] #ident: #ty }
|
||||||
} else {
|
} else {
|
||||||
quote! { #id: #ty }
|
quote! { #ident: #ty }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
quote! { #id: #ty }
|
quote! { #ident: #ty }
|
||||||
}
|
}
|
||||||
}).collect();
|
});
|
||||||
|
|
||||||
let client_def = match client_arg {
|
let body_sig = body_fields.iter().map(|(ident, ty, _attrs)| {
|
||||||
Some((id, ty)) => quote! { #[builder(finish_fn)] #id: #ty },
|
quote! { #ident: #ty }
|
||||||
None => quote! { #[builder(finish_fn)] client: &restson::RestClient },
|
});
|
||||||
};
|
|
||||||
|
|
||||||
let call_args: Vec<proc_macro2::TokenStream> = other_args.iter().map(|(id, _)| {
|
|
||||||
quote! { #id }
|
|
||||||
}).collect();
|
|
||||||
|
|
||||||
let expanded = quote! {
|
let expanded = quote! {
|
||||||
#[bon::builder]
|
#[bon::builder]
|
||||||
#vis fn #name(
|
#vis fn #name(
|
||||||
#client_def,
|
#[builder(finish_fn)]
|
||||||
#(#arg_defs),*
|
client: &restson::RestClient,
|
||||||
) -> impl std::future::Future<Output = Result<_, restson::Error>> + '_ {
|
#( #[builder(finish_fn)] #path_idents: #path_types, )*
|
||||||
|
#( #query_sig, )*
|
||||||
|
#( #body_sig, )*
|
||||||
|
) -> impl std::future::Future<Output = Result<serde_json::Value, restson::Error>> + '_ {
|
||||||
|
let mut path = format!(#path_fmt, #( #path_idents = #path_idents ),*);
|
||||||
|
let mut query_pairs = Vec::new();
|
||||||
|
#( #query_pairs )*
|
||||||
|
let mut body_map = serde_json::Map::new();
|
||||||
|
#( #body_pairs )*
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
let result = client.get::<_, serde_json::Value>(#path).await?;
|
if body_map.is_empty() {
|
||||||
Ok(result)
|
client.request_with(#method, &path, &query_pairs, &()).await
|
||||||
|
} else {
|
||||||
|
client.request_with(#method, &path, &query_pairs, &body_map).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
expanded.into()
|
TokenStream::from(expanded)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue