fields are now json and work. fix for intervals. moved some code together

This commit is contained in:
jude 2022-04-07 17:13:02 +01:00
parent d946ef1dca
commit 85d27c5bba
13 changed files with 270 additions and 539 deletions

6
Cargo.lock generated
View File

@ -1943,6 +1943,8 @@ dependencies = [
"log", "log",
"num-integer", "num-integer",
"regex", "regex",
"serde",
"serde_json",
"serenity", "serenity",
"sqlx", "sqlx",
"tokio", "tokio",
@ -2153,6 +2155,7 @@ dependencies = [
"rocket", "rocket",
"rocket_dyn_templates", "rocket_dyn_templates",
"serde", "serde",
"serde_json",
"serenity", "serenity",
"sqlx", "sqlx",
] ]
@ -2761,6 +2764,8 @@ dependencies = [
"rand 0.8.5", "rand 0.8.5",
"rsa", "rsa",
"rustls 0.19.1", "rustls 0.19.1",
"serde",
"serde_json",
"sha-1 0.9.8", "sha-1 0.9.8",
"sha2 0.9.9", "sha2 0.9.9",
"smallvec", "smallvec",
@ -2786,6 +2791,7 @@ dependencies = [
"once_cell", "once_cell",
"proc-macro2", "proc-macro2",
"quote", "quote",
"serde_json",
"sha2 0.9.9", "sha2 0.9.9",
"sqlx-core", "sqlx-core",
"sqlx-rt", "sqlx-rt",

View File

@ -29,3 +29,5 @@ CREATE TABLE reminder_template (
FOREIGN KEY (`guild_id`) REFERENCES channels (`id`) ON DELETE CASCADE FOREIGN KEY (`guild_id`) REFERENCES channels (`id`) ON DELETE CASCADE
); );
ALTER TABLE reminders ADD COLUMN embed_fields JSON;

View File

@ -12,7 +12,9 @@ chrono = "0.4"
chrono-tz = { version = "0.5", features = ["serde"] } chrono-tz = { version = "0.5", features = ["serde"] }
lazy_static = "1.4" lazy_static = "1.4"
num-integer = "0.1" num-integer = "0.1"
sqlx = { version = "0.5.10", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]} serde = "1.0"
serde_json = "1.0"
sqlx = { version = "0.5.10", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]}
[dependencies.serenity] [dependencies.serenity]
git = "https://github.com/serenity-rs/serenity" git = "https://github.com/serenity-rs/serenity"

View File

@ -4,6 +4,7 @@ use lazy_static::lazy_static;
use log::{error, info, warn}; use log::{error, info, warn};
use num_integer::Integer; use num_integer::Integer;
use regex::{Captures, Regex}; use regex::{Captures, Regex};
use serde::Deserialize;
use serenity::{ use serenity::{
builder::CreateEmbed, builder::CreateEmbed,
http::{CacheHttp, Http, StatusCode}, http::{CacheHttp, Http, StatusCode},
@ -15,7 +16,10 @@ use serenity::{
Error, Result, Error, Result,
}; };
use sqlx::{ use sqlx::{
types::chrono::{NaiveDateTime, Utc}, types::{
chrono::{NaiveDateTime, Utc},
Json,
},
Executor, Executor,
}; };
@ -94,11 +98,6 @@ pub fn substitute(string: &str) -> String {
} }
struct Embed { struct Embed {
inner: EmbedInner,
fields: Vec<EmbedField>,
}
struct EmbedInner {
title: String, title: String,
description: String, description: String,
image_url: Option<String>, image_url: Option<String>,
@ -108,8 +107,10 @@ struct EmbedInner {
author: String, author: String,
author_url: Option<String>, author_url: Option<String>,
color: u32, color: u32,
fields: Json<Vec<EmbedField>>,
} }
#[derive(Deserialize)]
struct EmbedField { struct EmbedField {
title: String, title: String,
value: String, value: String,
@ -121,76 +122,54 @@ impl Embed {
pool: impl Executor<'_, Database = Database> + Copy, pool: impl Executor<'_, Database = Database> + Copy,
id: u32, id: u32,
) -> Option<Self> { ) -> Option<Self> {
let mut inner = sqlx::query_as_unchecked!( let mut embed = sqlx::query_as!(
EmbedInner, Self,
" r#"
SELECT SELECT
`embed_title` AS title, `embed_title` AS title,
`embed_description` AS description, `embed_description` AS description,
`embed_image_url` AS image_url, `embed_image_url` AS image_url,
`embed_thumbnail_url` AS thumbnail_url, `embed_thumbnail_url` AS thumbnail_url,
`embed_footer` AS footer, `embed_footer` AS footer,
`embed_footer_url` AS footer_url, `embed_footer_url` AS footer_url,
`embed_author` AS author, `embed_author` AS author,
`embed_author_url` AS author_url, `embed_author_url` AS author_url,
`embed_color` AS color `embed_color` AS color,
FROM IFNULL(`embed_fields`, '[]') AS "fields:_"
reminders FROM reminders
WHERE WHERE `id` = ?"#,
`id` = ?
",
id id
) )
.fetch_one(pool) .fetch_one(pool)
.await .await
.unwrap(); .unwrap();
inner.title = substitute(&inner.title); embed.title = substitute(&embed.title);
inner.description = substitute(&inner.description); embed.description = substitute(&embed.description);
inner.footer = substitute(&inner.footer); embed.footer = substitute(&embed.footer);
let mut fields = sqlx::query_as_unchecked!( embed.fields.iter_mut().for_each(|mut field| {
EmbedField,
"
SELECT
title,
value,
inline
FROM
embed_fields
WHERE
reminder_id = ?
",
id
)
.fetch_all(pool)
.await
.unwrap();
fields.iter_mut().for_each(|mut field| {
field.title = substitute(&field.title); field.title = substitute(&field.title);
field.value = substitute(&field.value); field.value = substitute(&field.value);
}); });
let e = Embed { inner, fields }; if embed.has_content() {
Some(embed)
if e.has_content() {
Some(e)
} else { } else {
None None
} }
} }
pub fn has_content(&self) -> bool { pub fn has_content(&self) -> bool {
if self.inner.title.is_empty() if self.title.is_empty()
&& self.inner.description.is_empty() && self.description.is_empty()
&& self.inner.image_url.is_none() && self.image_url.is_none()
&& self.inner.thumbnail_url.is_none() && self.thumbnail_url.is_none()
&& self.inner.footer.is_empty() && self.footer.is_empty()
&& self.inner.footer_url.is_none() && self.footer_url.is_none()
&& self.inner.author.is_empty() && self.author.is_empty()
&& self.inner.author_url.is_none() && self.author_url.is_none()
&& self.fields.is_empty() && self.fields.0.is_empty()
{ {
false false
} else { } else {
@ -203,37 +182,37 @@ impl Into<CreateEmbed> for Embed {
fn into(self) -> CreateEmbed { fn into(self) -> CreateEmbed {
let mut c = CreateEmbed::default(); let mut c = CreateEmbed::default();
c.title(&self.inner.title) c.title(&self.title)
.description(&self.inner.description) .description(&self.description)
.color(self.inner.color) .color(self.color)
.author(|a| { .author(|a| {
a.name(&self.inner.author); a.name(&self.author);
if let Some(author_icon) = &self.inner.author_url { if let Some(author_icon) = &self.author_url {
a.icon_url(author_icon); a.icon_url(author_icon);
} }
a a
}) })
.footer(|f| { .footer(|f| {
f.text(&self.inner.footer); f.text(&self.footer);
if let Some(footer_icon) = &self.inner.footer_url { if let Some(footer_icon) = &self.footer_url {
f.icon_url(footer_icon); f.icon_url(footer_icon);
} }
f f
}); });
for field in &self.fields { for field in &self.fields.0 {
c.field(&field.title, &field.value, field.inline); c.field(&field.title, &field.value, field.inline);
} }
if let Some(image_url) = &self.inner.image_url { if let Some(image_url) = &self.image_url {
c.image(image_url); c.image(image_url);
} }
if let Some(thumbnail_url) = &self.inner.thumbnail_url { if let Some(thumbnail_url) = &self.thumbnail_url {
c.thumbnail(thumbnail_url); c.thumbnail(thumbnail_url);
} }

View File

@ -12,7 +12,8 @@ oauth2 = "4"
log = "0.4" log = "0.4"
reqwest = "0.11" reqwest = "0.11"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono"] } serde_json = "1.0"
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] }
chrono = "0.4" chrono = "0.4"
chrono-tz = "0.5" chrono-tz = "0.5"
lazy_static = "1.4.0" lazy_static = "1.4.0"

View File

@ -10,9 +10,9 @@ pub const MAX_EMBED_AUTHOR_LENGTH: usize = 256;
pub const MAX_EMBED_FOOTER_LENGTH: usize = 2048; pub const MAX_EMBED_FOOTER_LENGTH: usize = 2048;
pub const MAX_URL_LENGTH: usize = 512; pub const MAX_URL_LENGTH: usize = 512;
pub const MAX_USERNAME_LENGTH: usize = 100; pub const MAX_USERNAME_LENGTH: usize = 100;
pub const MAX_EMBED_FIELDS: usize = 25;
pub const MAX_EMBED_FIELD_TITLE_LENGTH: usize = 256; pub const MAX_EMBED_FIELD_TITLE_LENGTH: usize = 256;
pub const MAX_EMBED_FIELD_VALUE_LENGTH: usize = 1024; pub const MAX_EMBED_FIELD_VALUE_LENGTH: usize = 1024;
pub const MAX_EMBED_FIELDS: usize = 25;
pub const MINUTE: usize = 60; pub const MINUTE: usize = 60;
pub const HOUR: usize = 60 * MINUTE; pub const HOUR: usize = 60 * MINUTE;

View File

@ -9,7 +9,11 @@ mod routes;
use std::{collections::HashMap, env}; use std::{collections::HashMap, env};
use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl}; use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl};
use rocket::{fs::FileServer, tokio::sync::broadcast::Sender}; use rocket::{
fs::FileServer,
serde::json::{json, Json, Value as JsonValue},
tokio::sync::broadcast::Sender,
};
use rocket_dyn_templates::Template; use rocket_dyn_templates::Template;
use serenity::{ use serenity::{
client::Context, client::Context,
@ -46,6 +50,11 @@ async fn not_found() -> Template {
Template::render("errors/404", &map) Template::render("errors/404", &map)
} }
#[catch(422)]
async fn unprocessable_entity() -> JsonValue {
json!({"error": "Invalid request.", "errors": ["Invalid request."]})
}
#[catch(500)] #[catch(500)]
async fn internal_server_error() -> Template { async fn internal_server_error() -> Template {
let map: HashMap<String, String> = HashMap::new(); let map: HashMap<String, String> = HashMap::new();
@ -69,7 +78,16 @@ pub async fn initialize(
rocket::build() rocket::build()
.attach(Template::fairing()) .attach(Template::fairing())
.register("/", catchers![not_authorized, forbidden, not_found, internal_server_error]) .register(
"/",
catchers![
not_authorized,
forbidden,
not_found,
internal_server_error,
unprocessable_entity
],
)
.manage(oauth2_client) .manage(oauth2_client)
.manage(reqwest_client) .manage(reqwest_client)
.manage(serenity_context) .manage(serenity_context)
@ -105,10 +123,6 @@ pub async fn initialize(
routes::dashboard::user::get_user_info, routes::dashboard::user::get_user_info,
routes::dashboard::user::update_user_info, routes::dashboard::user::update_user_info,
routes::dashboard::user::get_user_guilds, routes::dashboard::user::get_user_guilds,
routes::dashboard::user::create_reminder,
routes::dashboard::user::get_reminders,
routes::dashboard::user::overwrite_reminder,
routes::dashboard::user::delete_reminder,
routes::dashboard::guild::get_guild_channels, routes::dashboard::guild::get_guild_channels,
routes::dashboard::guild::get_guild_roles, routes::dashboard::guild::get_guild_roles,
routes::dashboard::guild::create_reminder, routes::dashboard::guild::create_reminder,

View File

@ -19,12 +19,13 @@ use crate::{
check_guild_subscription, check_subscription, check_guild_subscription, check_subscription,
consts::{ consts::{
DAY, DISCORD_CDN, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, DAY, DISCORD_CDN, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH,
MAX_EMBED_DESCRIPTION_LENGTH, MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH, MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH,
MAX_EMBED_FIELD_VALUE_LENGTH, MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH,
MAX_URL_LENGTH, MAX_USERNAME_LENGTH, MIN_INTERVAL, MAX_URL_LENGTH, MAX_USERNAME_LENGTH, MIN_INTERVAL,
}, },
routes::dashboard::{ routes::dashboard::{
create_database_channel, generate_uid, name_default, DeleteReminder, JsonReminder, create_database_channel, generate_uid, name_default, DeleteReminder, PatchReminder,
PatchReminder, Reminder, Reminder,
}, },
}; };
@ -133,7 +134,7 @@ pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Conte
#[post("/api/guild/<id>/reminders", data = "<reminder>")] #[post("/api/guild/<id>/reminders", data = "<reminder>")]
pub async fn create_reminder( pub async fn create_reminder(
id: u64, id: u64,
reminder: Json<JsonReminder>, reminder: Json<Reminder>,
cookies: &CookieJar<'_>, cookies: &CookieJar<'_>,
serenity_context: &State<Context>, serenity_context: &State<Context>,
pool: &State<Pool<MySql>>, pool: &State<Pool<MySql>>,
@ -180,6 +181,13 @@ pub async fn create_reminder(
check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title); check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author); check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author);
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer); check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer);
check_length_opt!(MAX_EMBED_FIELDS, reminder.embed_fields);
if let Some(fields) = &reminder.embed_fields {
for field in &fields.0 {
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
}
}
check_length_opt!(MAX_USERNAME_LENGTH, reminder.username); check_length_opt!(MAX_USERNAME_LENGTH, reminder.username);
check_length_opt!( check_length_opt!(
MAX_URL_LENGTH, MAX_URL_LENGTH,
@ -245,6 +253,7 @@ pub async fn create_reminder(
embed_image_url, embed_image_url,
embed_thumbnail_url, embed_thumbnail_url,
embed_title, embed_title,
embed_fields,
enabled, enabled,
expires, expires,
interval_seconds, interval_seconds,
@ -255,7 +264,7 @@ pub async fn create_reminder(
tts, tts,
username, username,
`utc_time` `utc_time`
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
new_uid, new_uid,
attachment_data, attachment_data,
reminder.attachment_name, reminder.attachment_name,
@ -271,6 +280,7 @@ pub async fn create_reminder(
reminder.embed_image_url, reminder.embed_image_url,
reminder.embed_thumbnail_url, reminder.embed_thumbnail_url,
reminder.embed_title, reminder.embed_title,
reminder.embed_fields,
reminder.enabled, reminder.enabled,
reminder.expires, reminder.expires,
reminder.interval_seconds, reminder.interval_seconds,
@ -302,6 +312,7 @@ pub async fn create_reminder(
reminders.embed_image_url, reminders.embed_image_url,
reminders.embed_thumbnail_url, reminders.embed_thumbnail_url,
reminders.embed_title, reminders.embed_title,
reminders.embed_fields,
reminders.enabled, reminders.enabled,
reminders.expires, reminders.expires,
reminders.interval_seconds, reminders.interval_seconds,
@ -324,7 +335,7 @@ pub async fn create_reminder(
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
warn!("Failed to complete SQL query: {:?}", e); warn!("Failed to complete SQL query: {:?}", e);
json!({"error": "Could not load reminders"}) json!({"error": "Could not load reminder"})
}), }),
Err(e) => { Err(e) => {
@ -365,6 +376,7 @@ pub async fn get_reminders(id: u64, ctx: &State<Context>, pool: &State<Pool<MySq
reminders.embed_image_url, reminders.embed_image_url,
reminders.embed_thumbnail_url, reminders.embed_thumbnail_url,
reminders.embed_title, reminders.embed_title,
reminders.embed_fields,
reminders.enabled, reminders.enabled,
reminders.expires, reminders.expires,
reminders.interval_seconds, reminders.interval_seconds,
@ -421,6 +433,7 @@ pub async fn edit_reminder(
embed_image_url, embed_image_url,
embed_thumbnail_url, embed_thumbnail_url,
embed_title, embed_title,
embed_fields,
enabled, enabled,
expires, expires,
interval_seconds, interval_seconds,
@ -507,6 +520,7 @@ pub async fn edit_reminder(
reminders.embed_image_url, reminders.embed_image_url,
reminders.embed_thumbnail_url, reminders.embed_thumbnail_url,
reminders.embed_title, reminders.embed_title,
reminders.embed_fields,
reminders.enabled, reminders.enabled,
reminders.expires, reminders.expires,
reminders.interval_seconds, reminders.interval_seconds,

View File

@ -6,7 +6,7 @@ use rocket::{http::CookieJar, response::Redirect};
use rocket_dyn_templates::Template; use rocket_dyn_templates::Template;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serenity::{http::Http, model::id::ChannelId}; use serenity::{http::Http, model::id::ChannelId};
use sqlx::Executor; use sqlx::{types::Json, Executor};
use crate::{ use crate::{
consts::{CHARACTERS, DEFAULT_AVATAR}, consts::{CHARACTERS, DEFAULT_AVATAR},
@ -30,39 +30,7 @@ fn channel_default() -> u64 {
pub struct EmbedField { pub struct EmbedField {
title: String, title: String,
value: String, value: String,
} inline: bool,
#[derive(Serialize, Deserialize)]
pub struct JsonReminder {
attachment: Option<String>,
attachment_name: Option<String>,
avatar: Option<String>,
#[serde(with = "string")]
channel: u64,
content: String,
embed_author: String,
embed_author_url: Option<String>,
embed_color: u32,
embed_description: String,
embed_footer: String,
embed_footer_url: Option<String>,
embed_image_url: Option<String>,
embed_thumbnail_url: Option<String>,
embed_title: String,
embed_fields: Vec<EmbedField>,
enabled: bool,
expires: Option<NaiveDateTime>,
interval_seconds: Option<u32>,
interval_months: Option<u32>,
#[serde(default = "name_default")]
name: String,
pin: bool,
restartable: bool,
tts: bool,
#[serde(default)]
uid: String,
username: Option<String>,
utc_time: NaiveDateTime,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@ -82,6 +50,7 @@ pub struct Reminder {
embed_image_url: Option<String>, embed_image_url: Option<String>,
embed_thumbnail_url: Option<String>, embed_thumbnail_url: Option<String>,
embed_title: String, embed_title: String,
embed_fields: Option<Json<Vec<EmbedField>>>,
enabled: bool, enabled: bool,
expires: Option<NaiveDateTime>, expires: Option<NaiveDateTime>,
interval_seconds: Option<u32>, interval_seconds: Option<u32>,
@ -130,7 +99,7 @@ pub struct PatchReminder {
#[serde(default)] #[serde(default)]
embed_title: Unset<String>, embed_title: Unset<String>,
#[serde(default)] #[serde(default)]
embed_fields: Unset<EmbedField>, embed_fields: Unset<Json<Vec<EmbedField>>>,
#[serde(default)] #[serde(default)]
enabled: Unset<bool>, enabled: Unset<bool>,
#[serde(default)] #[serde(default)]

View File

@ -11,14 +11,13 @@ use serde::{Deserialize, Serialize};
use serenity::{ use serenity::{
client::Context, client::Context,
model::{ model::{
id::{GuildId, RoleId, UserId}, id::{GuildId, RoleId},
permissions::Permissions, permissions::Permissions,
}, },
}; };
use sqlx::{MySql, Pool}; use sqlx::{MySql, Pool};
use super::Reminder; use crate::consts::DISCORD_API;
use crate::{consts::DISCORD_API, routes::dashboard::DeleteReminder};
#[derive(Serialize)] #[derive(Serialize)]
struct UserInfo { struct UserInfo {
@ -164,241 +163,3 @@ pub async fn get_user_guilds(cookies: &CookieJar<'_>, reqwest_client: &State<Cli
json!({"error": "Not authorized"}) json!({"error": "Not authorized"})
} }
} }
#[post("/api/user/reminders", data = "<reminder>")]
pub async fn create_reminder(
reminder: Json<Reminder>,
_ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
match sqlx::query!(
"INSERT INTO reminders (
avatar,
content,
embed_author,
embed_author_url,
embed_color,
embed_description,
embed_footer,
embed_footer_url,
embed_image_url,
embed_thumbnail_url,
embed_title,
enabled,
expires,
interval_seconds,
interval_months,
name,
pin,
restartable,
tts,
username,
`utc_time`
) VALUES (
avatar = ?,
content = ?,
embed_author = ?,
embed_author_url = ?,
embed_color = ?,
embed_description = ?,
embed_footer = ?,
embed_footer_url = ?,
embed_image_url = ?,
embed_thumbnail_url = ?,
embed_title = ?,
enabled = ?,
expires = ?,
interval_seconds = ?,
interval_months = ?,
name = ?,
pin = ?,
restartable = ?,
tts = ?,
username = ?,
`utc_time` = ?
)",
reminder.avatar,
reminder.content,
reminder.embed_author,
reminder.embed_author_url,
reminder.embed_color,
reminder.embed_description,
reminder.embed_footer,
reminder.embed_footer_url,
reminder.embed_image_url,
reminder.embed_thumbnail_url,
reminder.embed_title,
reminder.enabled,
reminder.expires,
reminder.interval_seconds,
reminder.interval_months,
reminder.name,
reminder.pin,
reminder.restartable,
reminder.tts,
reminder.username,
reminder.utc_time,
)
.execute(pool.inner())
.await
{
Ok(_) => {
json!({})
}
Err(e) => {
warn!("Error in `create_reminder`: {:?}", e);
json!({"error": "Could not create reminder"})
}
}
}
#[get("/api/user/reminders")]
pub async fn get_reminders(
pool: &State<Pool<MySql>>,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
) -> JsonValue {
if let Some(user_id) =
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten()
{
let query_res = sqlx::query!(
"SELECT channel FROM channels INNER JOIN users ON users.dm_channel = channels.id WHERE users.user = ?",
user_id
)
.fetch_one(pool.inner())
.await;
let dm_channel = if let Ok(query) = query_res {
Some(query.channel)
} else {
if let Ok(dm_channel) = UserId(user_id).create_dm_channel(&ctx.inner()).await {
Some(dm_channel.id.as_u64().to_owned())
} else {
None
}
};
if let Some(channel_id) = dm_channel {
let reminders = sqlx::query_as!(
Reminder,
r#"SELECT
reminders.attachment,
reminders.attachment_name,
reminders.avatar,
channels.channel,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
reminders.embed_color,
reminders.embed_description,
reminders.embed_footer,
reminders.embed_footer_url,
reminders.embed_image_url,
reminders.embed_thumbnail_url,
reminders.embed_title,
reminders.enabled as "enabled:_",
reminders.expires,
reminders.interval_seconds,
reminders.interval_months,
reminders.name,
reminders.pin as "pin:_",
reminders.restartable as "restartable:_",
reminders.tts as "tts:_",
reminders.uid,
reminders.username,
reminders.utc_time
FROM reminders INNER JOIN channels ON channels.id = reminders.channel_id WHERE channels.channel = ?"#,
channel_id
)
.fetch_all(pool.inner())
.await
.unwrap_or(vec![]);
json!(reminders)
} else {
json!({"error": "User's DM channel could not be determined"})
}
} else {
json!({"error": "Not authorized"})
}
}
#[put("/api/user/reminders", data = "<reminder>")]
pub async fn overwrite_reminder(reminder: Json<Reminder>, pool: &State<Pool<MySql>>) -> JsonValue {
match sqlx::query!(
"UPDATE reminders SET
avatar = ?,
content = ?,
embed_author = ?,
embed_author_url = ?,
embed_color = ?,
embed_description = ?,
embed_footer = ?,
embed_footer_url = ?,
embed_image_url = ?,
embed_thumbnail_url = ?,
embed_title = ?,
enabled = ?,
expires = ?,
interval_seconds = ?,
interval_months = ?,
name = ?,
pin = ?,
restartable = ?,
tts = ?,
username = ?,
`utc_time` = ?
WHERE uid = ?",
reminder.avatar,
reminder.content,
reminder.embed_author,
reminder.embed_author_url,
reminder.embed_color,
reminder.embed_description,
reminder.embed_footer,
reminder.embed_footer_url,
reminder.embed_image_url,
reminder.embed_thumbnail_url,
reminder.embed_title,
reminder.enabled,
reminder.expires,
reminder.interval_seconds,
reminder.interval_months,
reminder.name,
reminder.pin,
reminder.restartable,
reminder.tts,
reminder.username,
reminder.utc_time,
reminder.uid
)
.execute(pool.inner())
.await
{
Ok(_) => {
json!({})
}
Err(e) => {
warn!("Error in `overwrite_reminder`: {:?}", e);
json!({"error": "Could not modify reminder"})
}
}
}
#[delete("/api/user/reminders", data = "<reminder>")]
pub async fn delete_reminder(
reminder: Json<DeleteReminder>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
if sqlx::query!("DELETE FROM reminders WHERE uid = ?", reminder.uid)
.execute(pool.inner())
.await
.is_ok()
{
json!({})
} else {
json!({"error": "Could not delete reminder"})
}
}

View File

@ -5,8 +5,6 @@ function get_interval(element) {
let minutes = element.querySelector('input[name="interval_minutes"]').value; let minutes = element.querySelector('input[name="interval_minutes"]').value;
let seconds = element.querySelector('input[name="interval_seconds"]').value; let seconds = element.querySelector('input[name="interval_seconds"]').value;
console.log(minutes);
return { return {
months: parseInt(months) || null, months: parseInt(months) || null,
seconds: seconds:
@ -53,7 +51,7 @@ function update_interval(element) {
} }
} }
let $intervalGroup = document.querySelector(".interval-group"); const $intervalGroup = document.querySelector(".interval-group");
document.querySelector(".interval-group").addEventListener( document.querySelector(".interval-group").addEventListener(
"blur", "blur",
@ -73,9 +71,13 @@ document.addEventListener("remindersLoaded", (event) => {
for (reminder of event.detail) { for (reminder of event.detail) {
let $intervalGroup = reminder.node.querySelector(".interval-group"); let $intervalGroup = reminder.node.querySelector(".interval-group");
$intervalGroup.addEventListener("blur", (ev) => { $intervalGroup.addEventListener(
if (ev.target.nodeName !== "BUTTON") update_interval($intervalGroup); "blur",
}); (ev) => {
if (ev.target.nodeName !== "BUTTON") update_interval($intervalGroup);
},
true
);
$intervalGroup.querySelector("button.clear").addEventListener("click", () => { $intervalGroup.querySelector("button.clear").addEventListener("click", () => {
$intervalGroup.querySelectorAll("input").forEach((el) => { $intervalGroup.querySelectorAll("input").forEach((el) => {

View File

@ -5,10 +5,10 @@ const $colorPickerModal = document.querySelector("div#pickColorModal");
const $colorPickerInput = $colorPickerModal.querySelector("input"); const $colorPickerInput = $colorPickerModal.querySelector("input");
const $deleteReminderBtn = document.querySelector("#delete-reminder-confirm"); const $deleteReminderBtn = document.querySelector("#delete-reminder-confirm");
const $reminderTemplate = document.querySelector("template#guildReminder"); const $reminderTemplate = document.querySelector("template#guildReminder");
const $embedFieldTemplate = document.querySelector("template#embedFieldTemplate");
let channels; let channels;
let roles; let roles;
let guild_id;
function colorToInt(r, g, b) { function colorToInt(r, g, b) {
return (r << 16) + (g << 8) + b; return (r << 16) + (g << 8) + b;
@ -118,10 +118,10 @@ async function fetch_reminders(guild_id) {
for (let reminder of data) { for (let reminder of data) {
let newFrame = $reminderTemplate.content.cloneNode(true); let newFrame = $reminderTemplate.content.cloneNode(true);
newFrame.querySelector(".reminderContent").dataset.uid = newFrame.querySelector(".reminderContent").dataset["uid"] =
reminder["uid"]; reminder["uid"];
render_reminder(reminder, newFrame); deserialize_reminder(reminder, newFrame);
$reminderBox.appendChild(newFrame); $reminderBox.appendChild(newFrame);
@ -137,7 +137,87 @@ async function fetch_reminders(guild_id) {
}); });
} }
function render_reminder(reminder, frame) { async function serialize_reminder(node) {
let interval = get_interval(node);
let rgb_color = window.getComputedStyle(
node.querySelector("div.discord-embed")
).borderLeftColor;
let rgb = rgb_color.match(/\d+/g);
let color = colorToInt(parseInt(rgb[0]), parseInt(rgb[1]), parseInt(rgb[2]));
let utc_time = luxon.DateTime.fromISO(
node.querySelector('input[name="time"]').value
).setZone("UTC");
if (utc_time.invalid) {
return { error: "Time provided invalid." };
}
let fields = [
...node.querySelectorAll("div.embed-multifield-box div.embed-field-box"),
]
.map((el) => {
return {
title: el.querySelector("textarea#embedFieldTitle").value,
value: el.querySelector("textarea#embedFieldValue").value,
inline: el.dataset["inlined"] === "1",
};
})
.filter(({ title, value, inline }) => title.length + value.length > 0);
let attachment = null;
let attachment_name = null;
if (node.querySelector('input[name="attachment"]').files.length > 0) {
let file = node.querySelector('input[name="attachment"]').files[0];
attachment = await new Promise((resolve) => {
let fileReader = new FileReader();
fileReader.onload = (e) => resolve(fileReader.result);
fileReader.readAsDataURL(file);
});
attachment = attachment.split(",")[1];
attachment_name = file.name;
}
const reminderContent = node.closest(".reminderContent");
return {
// if we're creating a reminder, ignore this field
uid: reminderContent !== null ? reminderContent.dataset["uid"] : "",
// if we're editing a reminder, ignore this field
enabled: reminderContent !== null ? null : true,
restartable: false,
attachment: attachment,
attachment_name: attachment_name,
avatar: has_source(node.querySelector("img.discord-avatar").src),
channel: node.querySelector("select.channel-selector").value,
content: node.querySelector('textarea[name="content"]').value,
embed_author_url: has_source(node.querySelector("img.embed_author_url").src),
embed_author: node.querySelector('textarea[name="embed_author"]').value,
embed_color: color,
embed_description: node.querySelector('textarea[name="embed_description"]').value,
embed_footer: node.querySelector('textarea[name="embed_footer"]').value,
embed_footer_url: has_source(node.querySelector("img.embed_footer_url").src),
embed_image_url: has_source(node.querySelector("img.embed_image_url").src),
embed_thumbnail_url: has_source(
node.querySelector("img.embed_thumbnail_url").src
),
embed_title: node.querySelector('textarea[name="embed_title"]').value,
embed_fields: fields,
expires: null,
interval_seconds: interval.seconds,
interval_months: interval.months,
name: node.querySelector('input[name="name"]').value,
pin: node.querySelector('input[name="pin"]').checked,
tts: node.querySelector('input[name="tts"]').checked,
username: node.querySelector('input[name="username"]').value,
utc_time: utc_time.toFormat("yyyy-LL-dd'T'HH:mm:ss"),
};
}
function deserialize_reminder(reminder, frame) {
// populate channels // populate channels
set_channels(frame.querySelector("select.channel-selector")); set_channels(frame.querySelector("select.channel-selector"));
@ -161,10 +241,25 @@ function render_reminder(reminder, frame) {
} }
} }
const lastChild = frame.querySelector("div.embed-multifield-box .embed-field-box");
for (let field of reminder["embed_fields"]) {
let embed_field = $embedFieldTemplate.content.cloneNode(true);
embed_field.querySelector("textarea.discord-field-title").value = field["title"];
embed_field.querySelector("textarea.discord-field-value").value = field["value"];
embed_field.querySelector(".embed-field-box").dataset["inlined"] = field["inline"]
? "1"
: "0";
frame
.querySelector("div.embed-multifield-box")
.insertBefore(embed_field, lastChild);
}
if (reminder["interval_seconds"] !== null) update_interval(frame); if (reminder["interval_seconds"] !== null) update_interval(frame);
let $enableBtn = frame.querySelector(".disable-enable"); let $enableBtn = frame.querySelector(".disable-enable");
$enableBtn.dataset.action = reminder["enabled"] ? "disable" : "enable"; $enableBtn.dataset["action"] = reminder["enabled"] ? "disable" : "enable";
let timeInput = frame.querySelector('input[name="time"]'); let timeInput = frame.querySelector('input[name="time"]');
let localTime = luxon.DateTime.fromISO(reminder["utc_time"], { zone: "UTC" }).setZone( let localTime = luxon.DateTime.fromISO(reminder["utc_time"], { zone: "UTC" }).setZone(
@ -223,7 +318,7 @@ document.addEventListener("remindersLoaded", (event) => {
const enableBtn = node.querySelector(".disable-enable"); const enableBtn = node.querySelector(".disable-enable");
enableBtn.addEventListener("click", () => { enableBtn.addEventListener("click", () => {
let enable = enableBtn.dataset.action === "enable"; let enable = enableBtn.dataset["action"] === "enable";
fetch(`/dashboard/api/guild/${guild}/reminders`, { fetch(`/dashboard/api/guild/${guild}/reminders`, {
method: "PATCH", method: "PATCH",
@ -237,7 +332,9 @@ document.addEventListener("remindersLoaded", (event) => {
if (data.error) { if (data.error) {
show_error(data.error); show_error(data.error);
} else { } else {
enableBtn.dataset.action = data["enabled"] ? "enable" : "disable"; enableBtn.dataset["action"] = data["enabled"]
? "enable"
: "disable";
} }
}); });
}); });
@ -249,66 +346,17 @@ document.addEventListener("remindersLoaded", (event) => {
const $saveBtn = node.querySelector("button.save-btn"); const $saveBtn = node.querySelector("button.save-btn");
$saveBtn.addEventListener("click", (event) => { $saveBtn.addEventListener("click", async (event) => {
$saveBtn.querySelector("span.icon > i").classList = [ $saveBtn.querySelector("span.icon > i").classList = [
"fas fa-spinner fa-spin", "fas fa-spinner fa-spin",
]; ];
let interval = get_interval(node); let reminder = await serialize_reminder(node);
if (reminder.error) {
show_error(reminder.error);
return;
}
let rgb_color = window.getComputedStyle(
node.querySelector("div.discord-embed")
).borderLeftColor;
let rgb = rgb_color.match(/\d+/g);
let color = colorToInt(parseInt(rgb[0]), parseInt(rgb[1]), parseInt(rgb[2]));
let utc_time = luxon.DateTime.fromISO(
node.querySelector('input[name="time"]').value
).setZone("UTC");
let fields = node.querySelectorAll(".embed-field-box", (el) => {
return {
title: el.querySelector('input[name="embed_field_title[]"]').value,
value: el.querySelector('input[name="embed_field_value[]"]').value,
};
});
let reminder = {
uid: node.closest(".reminderContent").dataset["uid"],
avatar: has_source(node.querySelector("img.discord-avatar").src),
channel: node.querySelector("select.channel-selector").value,
content: node.querySelector('textarea[name="content"]').value,
embed_author_url: has_source(
node.querySelector("img.embed_author_url").src
),
embed_author: node.querySelector('textarea[name="embed_author"]').value,
embed_color: color,
embed_description: node.querySelector(
'textarea[name="embed_description"]'
).value,
embed_footer: node.querySelector('textarea[name="embed_footer"]').value,
embed_footer_url: has_source(
node.querySelector("img.embed_footer_url").src
),
embed_image_url: has_source(
node.querySelector("img.embed_image_url").src
),
embed_thumbnail_url: has_source(
node.querySelector("img.embed_thumbnail_url").src
),
embed_title: node.querySelector('textarea[name="embed_title"]').value,
embed_fields: fields,
expires: null,
interval_seconds: interval.seconds,
interval_months: interval.months,
name: node.querySelector('input[name="name"]').value,
pin: node.querySelector('input[name="pin"]').checked,
tts: node.querySelector('input[name="tts"]').checked,
username: node.querySelector('input[name="username"]').value,
utc_time: utc_time.toFormat("yyyy-LL-dd'T'HH:mm:ss"),
};
// send to server
let guild = document.querySelector(".guildList a.is-active").dataset["guild"]; let guild = document.querySelector(".guildList a.is-active").dataset["guild"];
fetch(`/dashboard/api/guild/${guild}/reminders`, { fetch(`/dashboard/api/guild/${guild}/reminders`, {
@ -319,7 +367,9 @@ document.addEventListener("remindersLoaded", (event) => {
body: JSON.stringify(reminder), body: JSON.stringify(reminder),
}) })
.then((response) => response.json()) .then((response) => response.json())
.then((data) => console.log(data)); .then((data) => {
for (let error of data.errors) show_error(error);
});
$saveBtn.querySelector("span.icon > i").classList = ["fas fa-check"]; $saveBtn.querySelector("span.icon > i").classList = ["fas fa-check"];
@ -384,7 +434,7 @@ document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll(".navbar-burger").forEach((el) => { document.querySelectorAll(".navbar-burger").forEach((el) => {
el.addEventListener("click", () => { el.addEventListener("click", () => {
const target = el.dataset.target; const target = el.dataset["target"];
const $target = document.getElementById(target); const $target = document.getElementById(target);
el.classList.toggle("is-active"); el.classList.toggle("is-active");
@ -434,8 +484,6 @@ document.addEventListener("DOMContentLoaded", () => {
$anchor.addEventListener("click", async (e) => { $anchor.addEventListener("click", async (e) => {
e.preventDefault(); e.preventDefault();
guild_id = guild.id;
const event = new CustomEvent("guildSwitched", { const event = new CustomEvent("guildSwitched", {
detail: { detail: {
guild_name: guild.name, guild_name: guild.name,
@ -488,35 +536,6 @@ let $createBtn = $createReminder.querySelector("button#createReminder");
$createBtn.addEventListener("click", async () => { $createBtn.addEventListener("click", async () => {
$createBtn.querySelector("span.icon > i").classList = ["fas fa-spinner fa-spin"]; $createBtn.querySelector("span.icon > i").classList = ["fas fa-spinner fa-spin"];
let interval = get_interval($createReminder);
let rgb_color = window.getComputedStyle(
$createReminder.querySelector("div.discord-embed")
).borderLeftColor;
let rgb = rgb_color.match(/\d+/g);
let color = colorToInt(parseInt(rgb[0]), parseInt(rgb[1]), parseInt(rgb[2]));
let utc_time = luxon.DateTime.fromISO(
$createReminder.querySelector('input[name="time"]').value
).setZone("UTC");
if (utc_time.invalid) {
show_error("Time provided invalid.");
$createBtn.querySelector("span.icon > i").classList = ["fas fa-sparkles"];
return;
}
let fields = [
...$createReminder.querySelectorAll(
"div.embed-multifield-box div.embed-field-box"
),
].map((el) => {
return {
title: el.querySelector("textarea#embedFieldTitle").value,
value: el.querySelector("textarea#embedFieldValue").value,
};
});
let attachment = null; let attachment = null;
let attachment_name = null; let attachment_name = null;
@ -532,44 +551,12 @@ $createBtn.addEventListener("click", async () => {
attachment_name = file.name; attachment_name = file.name;
} }
let reminder = { let reminder = await serialize_reminder($createReminder);
attachment: attachment, if (reminder.error) {
attachment_name: attachment_name, show_error(reminder.error);
avatar: has_source($createReminder.querySelector("img.discord-avatar").src), return;
channel: $createReminder.querySelector("select.channel-selector").value, }
content: $createReminder.querySelector("textarea#messageContent").value,
embed_author_url: has_source(
$createReminder.querySelector("img.embed_author_url").src
),
embed_author: $createReminder.querySelector("textarea#embedAuthor").value,
embed_color: color,
embed_description: $createReminder.querySelector("textarea#embedDescription")
.value,
embed_footer: $createReminder.querySelector("textarea#embedFooter").value,
embed_footer_url: has_source(
$createReminder.querySelector("img.embed_footer_url").src
),
embed_image_url: has_source(
$createReminder.querySelector("img.embed_image_url").src
),
embed_thumbnail_url: has_source(
$createReminder.querySelector("img.embed_thumbnail_url").src
),
embed_title: $createReminder.querySelector("textarea#embedTitle").value,
embed_fields: fields,
enabled: true,
expires: null,
interval_seconds: interval.seconds,
interval_months: interval.months,
name: $createReminder.querySelector('input[name="name"]').value,
pin: $createReminder.querySelector('input[name="pin"]').checked,
restartable: false,
tts: $createReminder.querySelector('input[name="tts"]').checked,
username: $createReminder.querySelector("input#reminderUsername").value,
utc_time: utc_time.toFormat("yyyy-LL-dd'T'HH:mm:ss"),
};
// send to server
let guild = document.querySelector(".guildList a.is-active").dataset["guild"]; let guild = document.querySelector(".guildList a.is-active").dataset["guild"];
fetch(`/dashboard/api/guild/${guild}/reminders`, { fetch(`/dashboard/api/guild/${guild}/reminders`, {
@ -589,7 +576,7 @@ $createBtn.addEventListener("click", async () => {
newFrame.querySelector(".reminderContent").dataset["uid"] = data["uid"]; newFrame.querySelector(".reminderContent").dataset["uid"] = data["uid"];
render_reminder(data, newFrame); deserialize_reminder(data, newFrame);
$reminderBox.appendChild(newFrame); $reminderBox.appendChild(newFrame);
@ -682,19 +669,10 @@ document.addEventListener("remindersLoaded", () => {
window.getComputedStyle($discordFrame).borderLeftColor; window.getComputedStyle($discordFrame).borderLeftColor;
}); });
}); });
document.querySelectorAll(".embed-field-box button.inline-btn").forEach((el) => {
el.addEventListener("click", (ev) => {
let inlined = ev.target.closest(".embed-field-box").dataset["inlined"];
ev.target.closest(".embed-field-box").dataset["inlined"] =
inlined === "1" ? "0" : "1";
});
});
}); });
function check_embed_fields() { function check_embed_fields() {
document.querySelectorAll(".embed-field-box").forEach((element) => { document.querySelectorAll(".embed-field-box").forEach((element) => {
const $template = document.querySelector("template#embedFieldTemplate");
const $titleInput = element.querySelector(".discord-field-title"); const $titleInput = element.querySelector(".discord-field-title");
const $valueInput = element.querySelector(".discord-field-value"); const $valueInput = element.querySelector(".discord-field-value");
@ -726,16 +704,7 @@ function check_embed_fields() {
$valueInput.value !== "" && $valueInput.value !== "" &&
element.nextElementSibling === null element.nextElementSibling === null
) { ) {
const $clone = $template.content.cloneNode(true); const $clone = $embedFieldTemplate.content.cloneNode(true);
$clone
.querySelector(".embed-field-box button.inline-btn")
.addEventListener("click", (ev) => {
let inlined =
ev.target.closest(".embed-field-box").dataset["inlined"];
ev.target.closest(".embed-field-box").dataset["inlined"] =
inlined == "1" ? "0" : "1";
});
element.parentElement.append($clone); element.parentElement.append($clone);
} }
}); });
@ -746,16 +715,7 @@ function check_embed_fields() {
$valueInput.value !== "" && $valueInput.value !== "" &&
element.nextElementSibling === null element.nextElementSibling === null
) { ) {
const $clone = $template.content.cloneNode(true); const $clone = $embedFieldTemplate.content.cloneNode(true);
$clone
.querySelector(".embed-field-box button.inline-btn")
.addEventListener("click", (ev) => {
let inlined =
ev.target.closest(".embed-field-box").dataset["inlined"];
ev.target.closest(".embed-field-box").dataset["inlined"] =
inlined == "1" ? "0" : "1";
});
element.parentElement.append($clone); element.parentElement.append($clone);
} }
}); });
@ -780,3 +740,11 @@ document.addEventListener("DOMNodeInserted", () => {
check_embed_fields(); check_embed_fields();
resize_textareas(); resize_textareas();
}); });
document.addEventListener("click", (ev) => {
if (ev.target.closest("button.inline-btn") !== null) {
let inlined = ev.target.closest(".embed-field-box").dataset["inlined"];
ev.target.closest(".embed-field-box").dataset["inlined"] =
inlined == "1" ? "0" : "1";
}
});

View File

@ -140,6 +140,23 @@
<button class="modal-close is-large close-modal" aria-label="close"></button> <button class="modal-close is-large close-modal" aria-label="close"></button>
</div> </div>
<div class="modal" id="dataManagerModal">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<label class="modal-card-title" for="urlInput">Import/Export Manager <a href="/help/iemanager"><span><i class="fa fa-question-circle"></i></span></a></label>
<button class="delete close-modal" aria-label="close"></button>
</header>
<section class="modal-card-body">
<div class="has-text-centered">
<button class="button is-success is-outlined" id="import-data">Import Data</button>
<button class="button is-success" id="export-data">Export Data</button>
</div>
</section>
</div>
<button class="modal-close is-large close-modal" aria-label="close"></button>
</div>
<div class="modal" id="deleteReminderModal"> <div class="modal" id="deleteReminderModal">
<div class="modal-background"></div> <div class="modal-background"></div>
<div class="modal-card"> <div class="modal-card">
@ -182,12 +199,15 @@
<ul class="menu-list guildList"> <ul class="menu-list guildList">
</ul> </ul>
<div class="aside-footer" style="position: fixed; bottom: 0;"> <div class="aside-footer" style="position: fixed; bottom: 0; width: 226px;">
<p class="menu-label"> <p class="menu-label">
Settings Settings
</p> </p>
<ul class="menu-list"> <ul class="menu-list">
<li> <li>
<a class="show-modal" data-modal="dataManagerModal">
<span class="icon"><i class="fas fa-exchange"></i></span> Import/Export
</a>
<a class="show-modal" data-modal="chooseTimezoneModal"> <a class="show-modal" data-modal="chooseTimezoneModal">
<span class="icon"><i class="fas fa-map-marked"></i></span> Timezone <span class="icon"><i class="fas fa-map-marked"></i></span> Timezone
</a> </a>
@ -211,16 +231,6 @@
</g> </g>
</svg> </svg>
<aside class="menu" style="display: flex; flex-direction: column; flex-grow: 1;"> <aside class="menu" style="display: flex; flex-direction: column; flex-grow: 1;">
<p class="menu-label">
Personal
</p>
<ul class="menu-list">
<li>
<a class="switch-pane" data-pane="personal">
<span class="icon"><i class="fas fa-map-pin"></i></span> @%username%
</a>
</li>
</ul>
<p class="menu-label"> <p class="menu-label">
Servers Servers
</p> </p>
@ -233,6 +243,9 @@
</p> </p>
<ul class="menu-list"> <ul class="menu-list">
<li> <li>
<a class="show-modal" data-modal="dataManagerModal">
<span class="icon"><i class="fas fa-exchange"></i></span> Import/Export
</a>
<a class="show-modal" data-modal="chooseTimezoneModal"> <a class="show-modal" data-modal="chooseTimezoneModal">
<span class="icon"><i class="fas fa-map-marked"></i></span> Timezone <span class="icon"><i class="fas fa-map-marked"></i></span> Timezone
</a> </a>