Rewrite for better module handling
This commit is contained in:
parent
b3771ba87a
commit
73672d8c96
11 changed files with 332 additions and 96 deletions
|
@ -7,7 +7,8 @@
|
|||
|
||||
# axum-folder-router
|
||||
|
||||
```folder_router``` is a procedural macro for the Axum web framework that automatically generates router boilerplate based on your file structure. It simplifies route organization by using filesystem conventions to define your API routes.
|
||||
```folder_router``` is a procedural macro for the Axum web framework that automatically generates router boilerplate based on your file structure.
|
||||
It simplifies route organization by using filesystem conventions to define your API routes.
|
||||
|
||||
## Usage
|
||||
|
||||
|
|
5
examples/advanced/api/files/[...path]/route.rs
Normal file
5
examples/advanced/api/files/[...path]/route.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
use axum::{extract::Path, response::IntoResponse};
|
||||
|
||||
pub async fn get(Path(path): Path<String>) -> impl IntoResponse {
|
||||
format!("Requested file path: {}", path)
|
||||
}
|
9
examples/advanced/api/files/route.rs
Normal file
9
examples/advanced/api/files/route.rs
Normal file
|
@ -0,0 +1,9 @@
|
|||
use axum::response::{Html, IntoResponse};
|
||||
|
||||
pub async fn get() -> impl IntoResponse {
|
||||
Html("<h1>Hello World!</h1>").into_response()
|
||||
}
|
||||
|
||||
pub async fn post() -> impl IntoResponse {
|
||||
"Posted successfully".into_response()
|
||||
}
|
9
examples/advanced/api/route.rs
Normal file
9
examples/advanced/api/route.rs
Normal file
|
@ -0,0 +1,9 @@
|
|||
use axum::response::{Html, IntoResponse};
|
||||
|
||||
pub async fn get() -> impl IntoResponse {
|
||||
Html("<h1>Hello World!</h1>").into_response()
|
||||
}
|
||||
|
||||
pub async fn post() -> impl IntoResponse {
|
||||
"Posted successfully".into_response()
|
||||
}
|
5
examples/advanced/api/users/[id]/route.rs
Normal file
5
examples/advanced/api/users/[id]/route.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
use axum::{extract::Path, response::IntoResponse};
|
||||
|
||||
pub async fn get(Path(id): Path<String>) -> impl IntoResponse {
|
||||
format!("User ID: {}", id)
|
||||
}
|
9
examples/advanced/api/users/route.rs
Normal file
9
examples/advanced/api/users/route.rs
Normal file
|
@ -0,0 +1,9 @@
|
|||
use axum::response::{Html, IntoResponse};
|
||||
|
||||
pub async fn get() -> impl IntoResponse {
|
||||
Html("<h1>Hello World!</h1>").into_response()
|
||||
}
|
||||
|
||||
pub async fn post() -> impl IntoResponse {
|
||||
"Posted successfully".into_response()
|
||||
}
|
28
examples/advanced/main.rs
Normal file
28
examples/advanced/main.rs
Normal file
|
@ -0,0 +1,28 @@
|
|||
use axum::Router;
|
||||
use axum_folder_router::folder_router;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct AppState {
|
||||
_foo: String,
|
||||
}
|
||||
|
||||
folder_router!("examples/advanced/api", AppState);
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Create app state
|
||||
let app_state = AppState {
|
||||
_foo: "".to_string(),
|
||||
};
|
||||
|
||||
// Generate the router using the macro
|
||||
let folder_router: Router<AppState> = folder_router();
|
||||
|
||||
// Build the router and provide the state
|
||||
let app: Router<()> = folder_router.with_state(app_state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
|
||||
println!("Listening on http://{}", listener.local_addr()?);
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
|
@ -5,6 +5,5 @@ pub async fn get() -> impl IntoResponse {
|
|||
}
|
||||
|
||||
pub async fn post() -> impl IntoResponse {
|
||||
"Posted successfully".into_response()
|
||||
"Posted successfully".into_response()
|
||||
}
|
||||
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
use anyhow;
|
||||
use axum::Router;
|
||||
use axum_folder_router::folder_router;
|
||||
use tokio;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct AppState {
|
||||
_foo: String,
|
||||
}
|
||||
|
||||
folder_router!("./examples/simple/api", AppState);
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Create app state
|
||||
|
@ -16,7 +16,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
};
|
||||
|
||||
// Generate the router using the macro
|
||||
let folder_router: Router<AppState> = folder_router!("./examples/simple/api", AppState);
|
||||
let folder_router: Router<AppState> = folder_router();
|
||||
|
||||
// Build the router and provide the state
|
||||
let app: Router<()> = folder_router.with_state(app_state);
|
||||
|
|
23
rustfmt.toml
Normal file
23
rustfmt.toml
Normal file
|
@ -0,0 +1,23 @@
|
|||
|
||||
edition = "2024"
|
||||
max_width = 100
|
||||
tab_spaces = 4
|
||||
|
||||
|
||||
# unstable
|
||||
format_macro_bodies = true
|
||||
format_macro_matchers = true
|
||||
format_strings = true
|
||||
group_imports = "StdExternalCrate"
|
||||
imports_granularity = "Crate"
|
||||
imports_layout = "HorizontalVertical"
|
||||
overflow_delimited_expr = true
|
||||
reorder_impl_items = true
|
||||
struct_field_align_threshold = 4
|
||||
struct_lit_single_line = false
|
||||
trailing_comma = "Vertical"
|
||||
unstable_features = true
|
||||
use_field_init_shorthand = true
|
||||
use_try_shorthand = true
|
||||
wrap_comments = true
|
||||
# format_brace_macros = true
|
306
src/lib.rs
306
src/lib.rs
|
@ -1,6 +1,9 @@
|
|||
//! # ```axum_folder_router``` Macro Documentation
|
||||
//!
|
||||
//! [folder_router!] is a procedural macro for the Axum web framework that automatically generates router boilerplate based on your file structure. It simplifies route organization by using filesystem conventions to define your API routes.
|
||||
//! [folder_router!] is a procedural macro for the Axum web framework that
|
||||
//! automatically generates router boilerplate based on your file structure. It
|
||||
//! simplifies route organization by using filesystem conventions to define your
|
||||
//! API routes.
|
||||
//!
|
||||
//! ## Installation
|
||||
//!
|
||||
|
@ -9,12 +12,13 @@
|
|||
//! ```toml
|
||||
//! [dependencies]
|
||||
//! axum_folder_router = "0.1.0"
|
||||
//! axum = "0.7"
|
||||
//! axum = "0.8"
|
||||
//! ```
|
||||
//!
|
||||
//! ## Basic Usage
|
||||
//!
|
||||
//! The macro scans a directory for ```route.rs``` files and automatically creates an Axum router based on the file structure:
|
||||
//! The macro scans a directory for ```route.rs``` files and automatically
|
||||
//! creates an Axum router based on the file structure:
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
#![doc = include_str!("../examples/simple/main.rs")]
|
||||
|
@ -23,7 +27,6 @@
|
|||
//! ## File Structure Convention
|
||||
//!
|
||||
//! The macro converts your file structure into routes:
|
||||
//!
|
||||
//! ```text
|
||||
//! src/api/
|
||||
//! ├── route.rs -> "/"
|
||||
|
@ -43,7 +46,6 @@
|
|||
//! ## Route Handlers
|
||||
//!
|
||||
//! Inside each ```route.rs``` file, define async functions named after HTTP methods:
|
||||
//!
|
||||
//! ```rust
|
||||
#![doc = include_str!("../examples/simple/api/route.rs")]
|
||||
//! ```
|
||||
|
@ -64,13 +66,11 @@
|
|||
//! ### Path Parameters
|
||||
//!
|
||||
//! Dynamic path segments are defined using brackets:
|
||||
//!
|
||||
//! ```text
|
||||
//! src/api/users/[id]/route.rs -> "/users/{id}"
|
||||
//! ```
|
||||
//!
|
||||
//! Inside the route handler:
|
||||
//!
|
||||
//! ```rust
|
||||
//! use axum::{
|
||||
//! extract::Path,
|
||||
|
@ -85,11 +85,9 @@
|
|||
//! ### Catch-all Parameters
|
||||
//!
|
||||
//! Use the spread syntax for catch-all segments:
|
||||
//!
|
||||
//! ```text
|
||||
//! src/api/files/[...path]/route.rs -> "/files/*path"
|
||||
//! ```
|
||||
//!
|
||||
//! ```rust
|
||||
//! use axum::{
|
||||
//! extract::Path,
|
||||
|
@ -105,7 +103,6 @@
|
|||
//!
|
||||
//! The state type provided to the macro is available in all route handlers:
|
||||
//! All routes share the same state type, though you can use ```FromRef``` for more granular state extraction.
|
||||
//!
|
||||
//! ```rust
|
||||
//! use axum::{
|
||||
//! extract::State,
|
||||
|
@ -123,13 +120,22 @@
|
|||
//! ## Limitations
|
||||
//!
|
||||
//! - **Compile-time Only**: The routing is determined at compile time, so dynamic route registration isn't supported.
|
||||
//!
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use proc_macro::TokenStream;
|
||||
use quote::{format_ident, quote};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use syn::{Ident, LitStr, Result, Token, parse::Parse, parse::ParseStream, parse_macro_input};
|
||||
use syn::{
|
||||
Ident,
|
||||
LitStr,
|
||||
Result,
|
||||
Token,
|
||||
parse::{Parse, ParseStream},
|
||||
parse_macro_input,
|
||||
};
|
||||
|
||||
struct FolderRouterArgs {
|
||||
path: String,
|
||||
|
@ -149,25 +155,51 @@ impl Parse for FolderRouterArgs {
|
|||
}
|
||||
}
|
||||
|
||||
/// Creates an Axum router by scanning a directory for `route.rs` files.
|
||||
// A struct representing a directory in the module tree
|
||||
#[derive(Debug)]
|
||||
struct ModuleDir {
|
||||
name: String,
|
||||
has_route: bool,
|
||||
children: HashMap<String, ModuleDir>,
|
||||
}
|
||||
|
||||
impl ModuleDir {
|
||||
fn new(name: &str) -> Self {
|
||||
ModuleDir {
|
||||
name: name.to_string(),
|
||||
has_route: false,
|
||||
children: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an Axum router module tree & creation function
|
||||
/// by scanning a directory for `route.rs` files.
|
||||
///
|
||||
/// # Parameters
|
||||
///
|
||||
/// * `path` - A string literal pointing to the API directory, relative to the Cargo manifest directory
|
||||
/// * `state_type` - The type name of your application state that will be shared across all routes
|
||||
/// * `path` - A string literal pointing to the API directory, relative to the
|
||||
/// Cargo manifest directory
|
||||
/// * `state_type` - The type name of your application state that will be shared
|
||||
/// across all routes
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// # use axum_folder_router::folder_router;
|
||||
/// use axum_folder_router::folder_router;
|
||||
/// # #[derive(Debug, Clone)]
|
||||
/// # struct AppState ();
|
||||
/// #
|
||||
/// let router = folder_router!("./src/api", AppState);
|
||||
/// folder_router!("./src/api", AppState);
|
||||
/// #
|
||||
/// fn main() {
|
||||
/// let router = folder_router();
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// This will scan all `route.rs` files in the `./src/api` directory and its subdirectories,
|
||||
/// automatically mapping their path structure to URL routes with the specified state type.
|
||||
/// This will scan all `route.rs` files in the `./src/api` directory and its
|
||||
/// subdirectories, automatically mapping their path structure to URL routes
|
||||
/// with the specified state type.
|
||||
#[proc_macro]
|
||||
pub fn folder_router(input: TokenStream) -> TokenStream {
|
||||
let args = parse_macro_input!(input as FolderRouterArgs);
|
||||
|
@ -182,26 +214,23 @@ pub fn folder_router(input: TokenStream) -> TokenStream {
|
|||
let mut routes = Vec::new();
|
||||
collect_route_files(&base_dir, &base_dir, &mut routes);
|
||||
|
||||
// Generate module definitions and route registrations
|
||||
let mut module_defs = Vec::new();
|
||||
let mut route_registrations = Vec::new();
|
||||
|
||||
for (route_path, rel_path) in routes {
|
||||
// Generate module name and axum path
|
||||
let (axum_path, mod_name) = path_to_route_info(&rel_path);
|
||||
let mod_ident = format_ident!("{}", mod_name);
|
||||
|
||||
// Create module path for include!
|
||||
let rel_file_path = route_path.strip_prefix(&manifest_dir).unwrap();
|
||||
let rel_file_str = rel_file_path.to_string_lossy().to_string();
|
||||
|
||||
// Add module definition
|
||||
module_defs.push(quote! {
|
||||
#[allow(warnings)]
|
||||
mod #mod_ident {
|
||||
include!(concat!(env!("CARGO_MANIFEST_DIR"), "/", #rel_file_str));
|
||||
// Build module tree
|
||||
let mut root = ModuleDir::new("__folder_router");
|
||||
for (route_path, rel_path) in &routes {
|
||||
add_to_module_tree(&mut root, rel_path, route_path);
|
||||
}
|
||||
});
|
||||
|
||||
// Generate module tree
|
||||
let root_mod_ident = format_ident!("{}", root.name);
|
||||
|
||||
let base_path_lit = LitStr::new(base_dir.to_str().unwrap(), proc_macro2::Span::call_site());
|
||||
let mod_hierarchy = generate_module_hierarchy(&root);
|
||||
|
||||
// Generate route registrations
|
||||
let mut route_registrations = Vec::new();
|
||||
for (route_path, rel_path) in routes {
|
||||
// Generate module path and axum path
|
||||
let (axum_path, mod_path) = path_to_module_path(&rel_path);
|
||||
|
||||
// Read the file content to find HTTP methods
|
||||
let file_content = fs::read_to_string(&route_path).unwrap_or_default();
|
||||
|
@ -217,14 +246,15 @@ pub fn folder_router(input: TokenStream) -> TokenStream {
|
|||
|
||||
if !method_registrations.is_empty() {
|
||||
let (_first_method, first_method_ident) = &method_registrations[0];
|
||||
let mod_path_tokens = generate_mod_path_tokens(&mod_path);
|
||||
|
||||
let mut builder = quote! {
|
||||
axum::routing::#first_method_ident(#mod_ident::#first_method_ident)
|
||||
axum::routing::#first_method_ident(#root_mod_ident::#mod_path_tokens::#first_method_ident)
|
||||
};
|
||||
|
||||
for (_method, method_ident) in &method_registrations[1..] {
|
||||
builder = quote! {
|
||||
#builder.#method_ident(#mod_ident::#method_ident)
|
||||
#builder.#method_ident(#root_mod_ident::#mod_path_tokens::#method_ident)
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -237,9 +267,12 @@ pub fn folder_router(input: TokenStream) -> TokenStream {
|
|||
|
||||
// Generate the final code
|
||||
let expanded = quote! {
|
||||
{
|
||||
#(#module_defs)*
|
||||
#[path = #base_path_lit]
|
||||
mod #root_mod_ident {
|
||||
#mod_hierarchy
|
||||
}
|
||||
|
||||
fn folder_router() -> axum::Router::<#state_type> {
|
||||
let mut router = axum::Router::<#state_type>::new();
|
||||
#(#route_registrations)*
|
||||
router
|
||||
|
@ -249,7 +282,149 @@ pub fn folder_router(input: TokenStream) -> TokenStream {
|
|||
expanded.into()
|
||||
}
|
||||
|
||||
// Recursively collect route.rs files
|
||||
// Add a path to the module tree
|
||||
fn add_to_module_tree(root: &mut ModuleDir, rel_path: &Path, _route_path: &Path) {
|
||||
let mut current = root;
|
||||
|
||||
let components: Vec<_> = rel_path
|
||||
.components()
|
||||
.map(|c| c.as_os_str().to_string_lossy().to_string())
|
||||
.collect();
|
||||
|
||||
// Handle special case for root route.rs
|
||||
if components.is_empty() {
|
||||
current.has_route = true;
|
||||
return;
|
||||
}
|
||||
|
||||
for (i, component) in components.iter().enumerate() {
|
||||
// For the file itself (route.rs), we just mark the directory as having a route
|
||||
if i == components.len() - 1 && component == "route.rs" {
|
||||
current.has_route = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// For directories, add them to the tree
|
||||
let dir_name = component.clone();
|
||||
if !current.children.contains_key(&dir_name) {
|
||||
current
|
||||
.children
|
||||
.insert(dir_name.clone(), ModuleDir::new(&dir_name));
|
||||
}
|
||||
|
||||
current = current.children.get_mut(&dir_name).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// Generate module hierarchy code
|
||||
fn generate_module_hierarchy(dir: &ModuleDir) -> proc_macro2::TokenStream {
|
||||
let mut result = proc_macro2::TokenStream::new();
|
||||
|
||||
// panic!("{:?}", dir);
|
||||
// Add route.rs module if this directory has one
|
||||
if dir.has_route {
|
||||
let route_mod = quote! {
|
||||
#[path = "route.rs"]
|
||||
pub mod route;
|
||||
};
|
||||
result.extend(route_mod);
|
||||
}
|
||||
|
||||
// Add subdirectories
|
||||
for child in dir.children.values() {
|
||||
let child_name = format_ident!("{}", normalize_module_name(&child.name));
|
||||
let child_path_lit = LitStr::new(&child.name, proc_macro2::Span::call_site());
|
||||
let child_content = generate_module_hierarchy(child);
|
||||
|
||||
let child_mod = quote! {
|
||||
#[path = #child_path_lit]
|
||||
pub mod #child_name {
|
||||
#child_content
|
||||
}
|
||||
};
|
||||
|
||||
result.extend(child_mod);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// Generate tokens for a module path
|
||||
fn generate_mod_path_tokens(mod_path: &[String]) -> proc_macro2::TokenStream {
|
||||
let mut result = proc_macro2::TokenStream::new();
|
||||
|
||||
for (i, segment) in mod_path.iter().enumerate() {
|
||||
let segment_ident = format_ident!("{}", segment);
|
||||
|
||||
if i == 0 {
|
||||
result = quote! { #segment_ident };
|
||||
} else {
|
||||
result = quote! { #result::#segment_ident };
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// Normalize a path segment for use as a module name
|
||||
fn normalize_module_name(name: &str) -> String {
|
||||
if name.starts_with('[') && name.ends_with(']') {
|
||||
let inner = &name[1..name.len() - 1];
|
||||
if let Some(stripped) = inner.strip_prefix("...") {
|
||||
format!("___{}", stripped)
|
||||
} else {
|
||||
format!("__{}", inner)
|
||||
}
|
||||
} else {
|
||||
name.replace(['-', '.'], "_")
|
||||
}
|
||||
}
|
||||
|
||||
// Convert a relative path to module path segments and axum route path
|
||||
fn path_to_module_path(rel_path: &Path) -> (String, Vec<String>) {
|
||||
let mut axum_path = String::new();
|
||||
let mut mod_path = Vec::new();
|
||||
|
||||
let components: Vec<_> = rel_path
|
||||
.components()
|
||||
.map(|c| c.as_os_str().to_string_lossy().to_string())
|
||||
.collect();
|
||||
|
||||
// Handle root route
|
||||
if components.is_empty() {
|
||||
return ("/".to_string(), vec!["route".to_string()]);
|
||||
}
|
||||
|
||||
for (i, segment) in components.iter().enumerate() {
|
||||
if i == components.len() - 1 && segment == "route.rs" {
|
||||
mod_path.push("route".to_string());
|
||||
} else if segment.starts_with('[') && segment.ends_with(']') {
|
||||
let inner = &segment[1..segment.len() - 1];
|
||||
if let Some(param) = inner.strip_prefix("...") {
|
||||
axum_path.push_str(&format!("/{{*{}}}", param));
|
||||
mod_path.push(format!("___{}", param));
|
||||
} else {
|
||||
axum_path.push_str(&format!("/{{{}}}", inner));
|
||||
mod_path.push(format!("__{}", inner));
|
||||
}
|
||||
} else if segment != "route.rs" {
|
||||
// Skip the actual route.rs file
|
||||
axum_path.push('/');
|
||||
axum_path.push_str(segment);
|
||||
mod_path.push(normalize_module_name(segment));
|
||||
} else {
|
||||
println!("blub");
|
||||
}
|
||||
}
|
||||
|
||||
if axum_path.is_empty() {
|
||||
axum_path = "/".to_string();
|
||||
}
|
||||
|
||||
(axum_path, mod_path)
|
||||
}
|
||||
|
||||
// Recursively collect route.rs files (unchanged from your original)
|
||||
fn collect_route_files(base_dir: &Path, current_dir: &Path, routes: &mut Vec<(PathBuf, PathBuf)>) {
|
||||
if let Ok(entries) = fs::read_dir(current_dir) {
|
||||
for entry in entries.filter_map(std::result::Result::ok) {
|
||||
|
@ -258,43 +433,16 @@ fn collect_route_files(base_dir: &Path, current_dir: &Path, routes: &mut Vec<(Pa
|
|||
if path.is_dir() {
|
||||
collect_route_files(base_dir, &path, routes);
|
||||
} else if path.file_name().unwrap_or_default() == "route.rs" {
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Ok(rel_dir) = parent.strip_prefix(base_dir) {
|
||||
if let Ok(rel_dir) = path.strip_prefix(base_dir) {
|
||||
routes.push((path.clone(), rel_dir.to_path_buf()));
|
||||
}
|
||||
}
|
||||
|
||||
// if let Some(parent) = path.parent() {
|
||||
// if let Ok(rel_dir) = parent.strip_prefix(base_dir) {
|
||||
// routes.push((path.clone(), rel_dir.to_path_buf()));
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert a relative path to (axum_path, mod_name)
|
||||
fn path_to_route_info(rel_path: &Path) -> (String, String) {
|
||||
if rel_path.components().count() == 0 {
|
||||
return ("/".to_string(), "root".to_string());
|
||||
}
|
||||
|
||||
let mut axum_path = String::new();
|
||||
let mut mod_name = String::new();
|
||||
|
||||
for segment in rel_path.iter() {
|
||||
let s = segment.to_str().unwrap_or_default();
|
||||
if s.starts_with('[') && s.ends_with(']') {
|
||||
let inner = &s[1..s.len() - 1];
|
||||
if let Some(param) = inner.strip_prefix("...") {
|
||||
axum_path.push_str(&format!("/*{}", param));
|
||||
mod_name.push_str(&format!("__{}", param));
|
||||
} else {
|
||||
axum_path.push_str(&format!("/{{{}}}", inner));
|
||||
mod_name.push_str(&format!("__{}", inner));
|
||||
}
|
||||
} else {
|
||||
axum_path.push('/');
|
||||
axum_path.push_str(s);
|
||||
mod_name.push_str("__");
|
||||
mod_name.push_str(s);
|
||||
}
|
||||
}
|
||||
|
||||
(axum_path, mod_name.trim_start_matches('_').to_string())
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue