212 Commits

Author SHA1 Message Date
bae0551895 Fix color picker not working 2024-02-28 21:16:24 +00:00
cfb498794f Fix images not setting properly 2024-02-28 16:58:44 +00:00
b0e37b56c0 Bump version 2024-02-26 10:42:46 +00:00
45f5b6261a Convert times to/from UTC 2024-02-26 10:26:07 +00:00
5f6326179c Move styles into Vite
Make sidebar work better
2024-02-25 09:50:10 +00:00
6254f91841 Bump version 2024-02-25 09:18:04 +00:00
60b90a61d4 Adjust permission check
Correct response code for oauth redirect
2024-02-25 09:09:00 +00:00
90f05758d0 Bypass self permission check for DMs 2024-02-24 22:27:29 +00:00
74b7b5d711 Remove glob patterns from static file includes 2024-02-24 17:56:27 +00:00
90550dc2c7 Add loader 2024-02-24 17:47:00 +00:00
79e6498245 Add overlay when data fetching 2024-02-24 17:31:39 +00:00
a8ef3d03f9 Add dashboard to build 2024-02-24 16:12:34 +00:00
53e13844f9 Add unit tests 2024-02-24 15:02:34 +00:00
dd7e681285 Update rust 2024-02-22 18:35:37 +00:00
6c20bf2a0f Bump version 2024-02-22 17:47:40 +00:00
15aa9ccffd Update help text 2024-02-22 17:42:29 +00:00
525471bcad Correct help text 2024-02-22 17:35:50 +00:00
86d53b63b6 Bump deps 2024-02-20 17:09:50 +00:00
d8f266852a Add remaining commands 2024-02-18 14:32:58 +00:00
76a286076b Link all top-level commands with macro recording/replaying logic 2024-02-18 13:24:37 +00:00
5e39e16060 Add option types for top-level commands 2024-02-18 11:04:43 +00:00
c1305cfb36 Extract trait 2024-02-17 20:25:14 +00:00
4823754955 Move all commands to their own files 2024-02-17 18:55:16 +00:00
eb92eacb90 Rearranged some commands
Working on a macro to automatically add option wrappers
2024-02-17 14:09:01 +00:00
d0833b7bca Add macro for extracting arguments 2024-02-16 20:09:32 +00:00
b81c3c80c1 Record some parameters for /remind 2024-02-15 17:28:43 +00:00
2f6d035efe Rename table references 2024-02-14 19:44:53 +00:00
96012ce43c Add migration script 2024-02-14 19:35:23 +00:00
fa7ec8731b Fix hook 2024-02-09 17:03:04 +00:00
def43bfa78 Refactor macros 2024-02-06 20:08:59 +00:00
e4e9af2bb4 Wip commit 2024-01-07 17:10:22 +00:00
cce0de7c75 wip bump versions 2023-12-22 19:12:42 +00:00
e7803b98e8 Merge pull request 'jude/react-dashboard' (#3) from jude/react-dashboard into current
Reviewed-on: #3
2023-12-22 16:58:30 +00:00
7aae246388 Remove submodule 2023-12-22 16:58:30 +00:00
a2d442bc54 Reset intervals correctly 2023-12-22 16:58:30 +00:00
59982df827 Correct merge errors 2023-12-22 16:58:30 +00:00
7a6372ed02 Update styles for notification flash 2023-12-22 16:58:30 +00:00
14a54471f7 Build dashboard 2023-12-22 16:58:30 +00:00
5d3b77f1cd Add metrics
Change dashboards to load an index.html file compiled otherwise
2023-12-22 16:58:30 +00:00
1d64c8bb79 Remove stat table 2023-12-22 16:58:03 +00:00
8ba0f02b98 Bump version 2023-11-12 10:00:46 +00:00
d36438c6ce Bump package lock. Add attachment serializer 2023-11-12 09:39:45 +00:00
e0c60e2ce3 Decode attachments correctly when patching a reminder 2023-11-11 15:05:35 +00:00
e7160215b0 Defer offset response 2023-11-11 13:36:40 +00:00
6eaa6f0f28 Bump version 2023-10-19 20:32:01 +01:00
9db0fa2513 Fix attachment decoding 2023-10-19 20:10:40 +01:00
ca13fd4fa7 Restructure code 2023-10-08 18:24:04 +01:00
55acc8fd16 Bump ver 2023-10-08 12:39:31 +01:00
145711fa5d Add version strings to files 2023-10-08 12:21:38 +01:00
5524215786 Bump ver 2023-10-07 16:10:01 +01:00
e8bd05893f Transmit guild name with patreon information 2023-10-07 16:08:25 +01:00
e3d3418f99 Change routing. Remove a macro 2023-10-05 18:54:53 +01:00
2681280a39 Fix interval parsing for different cases 2023-10-01 09:42:58 +01:00
00579428a1 Bump version 2023-09-25 18:20:22 +01:00
b8ef999710 Reload reminders after import 2023-09-25 18:17:19 +01:00
e8f84e281a Bump version 2023-09-24 14:53:16 +01:00
8ddff698e5 Show messages when imports succeed. 2023-09-24 14:14:21 +01:00
541633270c Fix margin on bottom of collapsed reminders 2023-09-24 13:58:24 +01:00
25286da5e0 Use transactions for certain routes 2023-09-24 13:57:27 +01:00
4bad1324b9 Restructure
Move some code out to other files. Add transaction guard
2023-09-24 13:11:53 +01:00
bd1462a00c Reposition "options"
Fix import/export
2023-09-23 23:38:16 +01:00
56ffc43616 Store intervals in templates 2023-09-23 22:47:21 +01:00
52cf642455 Send edit button to beta dashboard 2023-09-23 20:32:57 +01:00
0bf578357a Bump version 2023-09-23 18:31:51 +01:00
6e9eccb62e Update dependencies 2023-09-23 18:29:25 +01:00
6ea28284ce Bump ver 2023-09-23 18:24:39 +01:00
a6525f3052 Move button row down. Correct image sizes on some browsers 2023-09-23 18:14:01 +01:00
348639270d Move button row down 2023-09-23 18:05:26 +01:00
37177c2431 Update styles for mobile 2023-09-23 18:04:41 +01:00
8587bed703 Bump ver 2023-08-19 17:10:45 +01:00
6c9af1ae8e Correct styles on mobile burger 2023-08-19 17:09:46 +01:00
7695b7a476 Fix delete command 2023-08-19 14:35:07 +01:00
651da7b28e Improve some styles. Add an offline mode 2023-08-19 14:20:48 +01:00
eb086146bf Bump version 2023-08-16 17:05:18 +01:00
4ebd705e5e Add clearer indication of interval patreon requirements 2023-08-16 17:03:38 +01:00
5a85f1d83a Extract error sections to templates 2023-08-13 18:29:30 +01:00
68ba25886a Correct javascript comparisons 2023-08-11 13:19:31 +01:00
e25bf6b828 bump 2023-08-10 18:41:47 +01:00
5a386daa9d Fix expirations 2023-08-10 18:25:41 +01:00
0d4a02fb1e Bump ver 2023-08-08 17:48:49 +01:00
e135a74a9b Fix avatars not loading correctly 2023-08-08 17:44:40 +01:00
77f17c8dc2 Partially fix reminder usernames resetting 2023-08-07 21:50:11 +01:00
6a94f990cf Bump ver 2023-08-03 20:08:14 +01:00
3aa5bd37aa Fix duplicating reminder fields 2023-08-03 19:57:28 +01:00
fa83fed1af Fix interval updating 2023-08-03 19:50:15 +01:00
666cb7fa2f Fix padding etc. 2023-08-03 19:28:12 +01:00
a5678e15dc Fix styling on buttons
Prevent template buttons from wrapping by consuming more vertical space on middle-sized screens
2023-08-03 18:07:03 +01:00
9405cfcee9 Fix "Reminder needs content".
Certain fields were not being checked correctly for content.
2023-08-03 17:32:17 +01:00
cb25d02cdf Bump ver 2023-08-01 21:12:15 +01:00
bfe651a125 Change autocomplete to use a past date in the past 2023-08-01 20:13:05 +01:00
dc5e52d9ce Default datetime inputs to current date/time 2023-08-01 17:51:29 +01:00
229ada83e1 Fix cron username 2023-07-31 20:14:45 +01:00
13171d6744 Bump ver 2023-07-31 20:09:00 +01:00
2ad941c94c Fix not sending followup reminders 2023-07-31 20:07:54 +01:00
924d31e978 Bump ver 2023-07-31 20:05:43 +01:00
f9a1b23212 Update privacy 2023-07-31 19:28:23 +01:00
ae5795a7ea Update opcode handling 2023-07-31 19:25:06 +01:00
ee36c38eda Update manifest 2023-07-31 19:18:53 +01:00
eca7df3d9f Update style 2023-07-31 18:39:39 +01:00
902b7e1b4a Change reminder sending behaviour to keep reminders but flag them as sent 2023-07-31 18:39:27 +01:00
db1a53a797 Bump ver 2023-07-31 18:04:16 +01:00
3605d71b73 Suppress errors. Restyle 2023-07-31 17:59:38 +01:00
ea2cea573e Bump ver. Round failure rate. 2023-07-30 19:17:44 +01:00
d5fa8036e8 Add data to admin page for success/fail history 2023-07-30 19:09:48 +01:00
b8707bbc9a Fix deleting template making a call on empty template list 2023-07-30 17:16:37 +01:00
99eea16f62 Bump ver 2023-07-30 17:11:37 +01:00
88737302f3 Log reminder send status 2023-07-30 17:00:55 +01:00
213e3a5100 Fix styles. Feedback button 2023-07-30 15:50:46 +01:00
8fa1402ecc Bump ver 2023-07-30 15:42:46 +01:00
e63996bb61 Fix create template not testing for errors 2023-07-30 15:36:58 +01:00
9ede879630 Stats table migration 2023-07-30 15:28:26 +01:00
88e9826a62 Update terms. Fix issue with role picker 2023-07-30 15:26:51 +01:00
5d655c7e6d Update privacy policy 2023-07-30 15:16:34 +01:00
51c9d8a7ae Fix client error on selecting server with no channels 2023-07-30 15:11:34 +01:00
90df265114 Add handler for 50001 Missing Access 2023-07-30 14:13:20 +01:00
e65429aa9c Fix interval input styles 2023-07-30 13:22:57 +01:00
8d2232f0da Bump ver. Use Discord's error codes where possible to improve logging 2023-07-30 12:44:01 +01:00
a58b9866ea Reduce log level 2023-07-30 12:14:47 +01:00
b1f25be5d7 Use transparent background with dashboard logo 2023-07-29 17:13:05 +01:00
f0f9787326 Bump ver 2023-07-23 17:00:09 +01:00
302f5835e6 Fix wrapping on long server names 2023-07-23 16:30:15 +01:00
58c778632e Fix wrapping on long server names 2023-07-23 16:28:27 +01:00
5671fd462b Update contrast on the burger button. fix error thrown by update_select 2023-07-23 16:15:24 +01:00
5ac9733f15 Bump ver 2023-07-23 14:44:35 +01:00
01dc0334fd Fix arbitrary access to reminder list. 2023-07-23 14:29:59 +01:00
4a17aac15c Bump ver 2023-07-23 12:36:25 +01:00
8ce4fc9c6d Fix enable/disable button. Hide demo button 2023-07-23 12:16:09 +01:00
b4f07cfc1c Fix some mobile styles. Fix race condition in client side 2023-07-23 12:06:03 +01:00
8799089b2d Increase the size of reminder names. Restyle. 2023-07-22 15:09:06 +01:00
88c4830209 Fix dashboard embed fields 2023-07-22 13:34:18 +01:00
4dd3df5cc2 bump ver 2023-07-22 13:13:46 +01:00
369a325a46 bump ver 2023-07-22 10:46:33 +01:00
1a1a0fdefb show total reminders and intervals on admin dash 2023-07-10 09:59:11 +01:00
dda8bd3e10 Fix dead link. Hopefully extract mysql details from environment 2023-06-23 11:56:53 +01:00
edbfc92cb9 Add health check email notifications 2023-06-23 09:44:42 +01:00
6de11f09db Change graph periods 2023-06-21 15:36:05 +01:00
284bfcd9ad Split intervals 2023-06-21 15:24:43 +01:00
3d627b5bf0 Add charts 2023-06-21 15:09:24 +01:00
c3c0dbbbae Fetch upcoming schedule and backlog count 2023-06-21 13:26:28 +01:00
64dd81e941 Admin only routes 2023-06-21 10:54:20 +01:00
799298ca34 Add fail cutoff for reminder updating 2023-06-20 15:41:28 +01:00
fa542bb24f Clear up warning from new Rust version 2023-06-20 15:33:25 +01:00
e025d945cf Fix serious issue with adding days. Origin chrono v4.23 2023-06-20 15:30:44 +01:00
bb1c61d0b9 Fallback for reminder days 2023-06-20 14:44:05 +01:00
1519474f93 Report errors to server 2023-06-20 13:13:26 +01:00
9d8622f418 Add logout button 2023-06-20 08:50:12 +01:00
a66db37b33 update poise 2023-06-18 10:47:31 +01:00
c8c1a171d4 Bump version 2023-06-18 10:04:55 +01:00
88cfb829e3 Use conffiles 2023-06-17 12:49:01 +01:00
16be7a328e Correct permissions 2023-06-16 14:00:44 +01:00
04babf7930 updated some dashboard text. fixed authentication. hidden broken stuff 2023-06-16 13:38:42 +01:00
96bc09e8b5 correct authentication 2023-06-16 10:20:42 +01:00
976fb91ecc set default logs 2023-06-15 10:53:13 +01:00
1305b6e64e Bump version 2023-06-14 17:50:56 +01:00
cdfe44d958 Configure permissions properly on Rocket.toml. Make static path behave better 2023-06-14 13:29:48 +01:00
c824a36832 Corrected a number of apt packaging issues 2023-06-13 10:40:48 +01:00
c4bd2c1d18 bump dateparser requirement 2023-06-12 22:47:23 +01:00
561555ab7e updated tos and privacy 2023-05-27 16:40:41 +01:00
115fbd44cb update some frontend 2023-05-27 16:12:09 +01:00
aa931328b0 Support ephemeral reminder confirmations 2023-05-11 19:40:33 +01:00
4b42966284 Moved stuff around since threads are ridiculous 2023-05-11 18:33:06 +01:00
523ab7f03a Partial thread support 2023-05-11 18:32:58 +01:00
6e831c8253 Add migration for threads. Add ability to load .env from wd 2023-05-11 18:32:50 +01:00
4416e5d175 Remove need to supply webhook avatar 2023-05-08 17:32:59 +01:00
734a39a001 Change default Python location. Update build instructions. Add container build instructions 2023-05-08 17:04:51 +01:00
98191d29ee deb-related stuff 2023-05-07 21:08:59 +01:00
1c4c4a8b31 Add deb stuff. Correct dependency on database name 2023-05-07 20:59:07 +01:00
d496c81003 Correct typo in path 2023-05-07 20:38:08 +01:00
094d210f64 Fix orphaned channels issue again 2023-03-24 19:52:41 +00:00
314c72e132 Changed data import to add alongside rather than removing. 2023-03-24 19:41:34 +00:00
4e0163f2cb Rename some environment variables. Add partial deb metadata 2023-03-24 17:44:43 +00:00
e5b8c418af Merge remote-tracking branch 'origin/next' into next 2023-03-24 11:11:59 +00:00
3ef8584189 Use SQLx migrations 2023-03-24 11:11:51 +00:00
df2ad09c86 Update README.md 2023-01-21 12:25:24 +00:00
d70fb24eb1 Fix todo viewing not working for large entries
Was not checking the length of the item when trying to add it to the
dropdown, causing failures.
2023-01-06 17:08:09 +00:00
3150c7267d Add validating to length-validated fields on edit
Can't just replace edit logic with overwrite logic because partial editing is used in enabling/disabling. So need to replicate logic in a sensible way.
2022-12-18 13:38:43 +00:00
6e65e4ff3d update some help pages 2022-12-18 13:09:02 +00:00
67a4db2e9a Ensure interval updating is performed properly
Validate patreon status. Validate interval length against minimum. Update the reminder pane to reflect changes that were made. Properly deserialize.
2022-12-11 10:09:26 +00:00
e9bcb1973f Update web for daily intervals 2022-12-10 16:21:43 +00:00
9b87fd4258 Ver bump 2022-12-10 15:38:21 +00:00
a49a849917 Support daily intervals
Add new database column for interval_days. Update humantime to return days as a separate field.
2022-12-10 15:32:49 +00:00
aa74a7f9a3 Use timezones wherever possible.
Replace uses of NaiveDateTime with DateTime<Utc>. Use timezones in postman to update days correctly. Use chrono::Months to update months rather than using MySQL query.
2022-11-22 20:41:07 +00:00
08e4c6cb57 ver bump 2022-11-20 12:20:52 +00:00
6e087bd2dd Fix character counting on /look. Initial support for jumping over DST boundaries 2022-11-20 12:20:10 +00:00
e9792e6322 ver bump 2022-09-26 16:59:57 +01:00
130504b964 Add notice to macro initial run 2022-09-26 16:44:30 +01:00
2a8117d0c1 Revert multiline changes 2022-09-20 17:00:33 +01:00
94bfd39085 Patch compilation against live schema 2022-09-17 13:05:50 +01:00
40cd5f8a36 Patch compilation against live schema 2022-09-17 13:03:52 +01:00
133b00a2ce Patch compilation against live schema 2022-09-17 12:52:03 +01:00
57336f5c81 Change macro list to use fields to prevent going over limit
Add length checks for name and description
2022-09-17 12:37:58 +01:00
b62d24c024 Add an autocomplete for time hints
Shows the approximate time until a reminder will send in the autocomplete area.
2022-09-12 17:49:10 +01:00
8f8235a86e Move macro commands to own module
Lots of code here
2022-09-12 16:45:00 +01:00
c8f646a8fa Override timezone per command
Timezone option that will override the timezone on a per-command basis
2022-09-11 18:59:46 +01:00
ecaa382a1e Add join message 2022-09-11 17:38:53 +01:00
8991198fd3 Use autocomplete to ensure content box is shown 2022-09-11 15:24:02 +01:00
f20b95a482 Upgrade poise. Combine remind/multiline into one command 2022-09-08 17:58:05 +01:00
8dd7dc6409 Added command for multiline reminders 2022-09-07 18:27:13 +01:00
c799d10727 Move extra processes to user data setup 2022-09-03 16:19:59 +01:00
ceb6fb7b12 bump version 2022-09-03 15:49:05 +01:00
6708abdb0f Merge pull request #10 from reminder-bot/jellywx/fix-dm-reminders
group by channel instead of guild
2022-09-03 15:44:00 +01:00
a38f6024c1 Migrate natural commands 2022-09-03 15:40:29 +01:00
7d8748e3ef group by channel instead of guild 2022-08-19 09:04:12 +01:00
bb3386c4e8 migration for $r commands 2022-08-14 16:22:00 +01:00
25b84880a5 Don't send non-interval disabled reminders
Skip the sending logic as some users use disabled one-time reminders as presets
2022-08-04 19:06:29 +01:00
7b6e967a5d Block/allow DM reminders
Only affects slash commands but this is sort of a non-issue post September
2022-07-29 19:22:15 +01:00
2781f2923e Restrict reminder selection to one-per-guild during fetch loop 2022-07-28 19:20:15 +01:00
03f08f0a18 Update deps. Drop limiter on reminder query 2022-07-27 21:42:09 +01:00
79c86d43f2 Changed return types to results 2022-07-24 20:06:37 +01:00
e19af54caf Import todo lists. Export other data. 2022-07-22 23:30:45 +01:00
f4213c6a83 Cache channel in todo list command
Channel was not being cached, placing channel todos into the server todo list.
2022-07-02 08:31:17 +01:00
f56db14720 Webhook command
Add a command to view the webhook, as some users wish to use the webhook to edit past reminders.
2022-06-17 17:15:48 +01:00
314 changed files with 87650 additions and 10684 deletions

31
.gitignore vendored
View File

@ -1,7 +1,30 @@
/target
target
.env
/venv
.cargo
assets
out.json
/.idea
.idea
web/static/index.html
web/static/assets
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3013
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,32 +1,72 @@
[package]
name = "reminder_rs"
version = "1.6.0"
authors = ["jellywx <judesouthworth@pm.me>"]
edition = "2018"
workspaces = [".", "postman", "web", "entity", "migration"]
name = "reminder-rs"
version = "1.7.0-rc5"
authors = ["Jude Southworth <judesouthworth@pm.me>"]
edition = "2021"
license = "AGPL-3.0 only"
description = "Reminder Bot for Discord, now in Rust"
[dependencies]
poise = "0.2"
poise = "0.6.1"
dotenv = "0.15"
tokio = { version = "1", features = ["process", "full"] }
reqwest = "0.11"
regex = "1.4"
lazy-regex = "3.1"
regex = "1.10"
log = "0.4"
env_logger = "0.8"
env_logger = "0.11"
chrono = "0.4"
chrono-tz = { version = "0.5", features = ["serde"] }
chrono-tz = { version = "0.8", features = ["serde"] }
lazy_static = "1.4"
num-integer = "0.1"
serde = "1.0"
serde_json = "1.0"
serde_repr = "0.1"
rmp-serde = "0.15"
rand = "0.7"
rmp-serde = "1.1"
rand = "0.8"
levenshtein = "1.0"
base64 = "0.13.0"
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"] }
base64 = "0.21"
secrecy = "0.8.0"
[dependencies.postman]
path = "postman"
[dependencies.reminder_web]
path = "web"
[dependencies.extract_derive]
path = "extract_derive"
[dependencies.recordable_derive]
path = "recordable_derive"
[package.metadata.deb]
depends = "$auto, python3-dateparser (>= 1.0.0)"
suggests = "mysql-server-8.0, nginx"
maintainer-scripts = "debian"
assets = [
["target/release/reminder-rs", "usr/bin/reminder-rs", "755"],
["web/static/css/*", "lib/reminder-rs/static/css", "644"],
["web/static/favicon/*", "lib/reminder-rs/static/favicon", "644"],
["web/static/img/*", "lib/reminder-rs/static/img", "644"],
["web/static/js/*", "lib/reminder-rs/static/js", "644"],
["web/static/webfonts/*", "lib/reminder-rs/static/webfonts", "644"],
["web/static/site.webmanifest", "lib/reminder-rs/static/site.webmanifest", "644"],
["web/templates/**/*", "lib/reminder-rs/templates", "644"],
["reminder-dashboard/dist/static/assets/*", "lib/reminder-rs/static/assets", "644"],
["reminder-dashboard/dist/index.html", "lib/reminder-rs/static/index.html", "644"],
["conf/default.env", "etc/reminder-rs/config.env", "600"],
["conf/Rocket.toml", "etc/reminder-rs/Rocket.toml", "600"],
["healthcheck", "lib/reminder-rs/healthcheck", "755"],
["cron.d/reminder_health", "etc/cron.d/reminder_health", "644"],
# ["nginx/reminder-rs", "etc/nginx/sites-available/reminder-rs", "755"]
]
conf-files = [
"/etc/reminder-rs/config.env",
"/etc/reminder-rs/Rocket.toml",
]
[package.metadata.deb.systemd-units]
unit-scripts = "systemd"
start = false

9
Containerfile Normal file
View File

@ -0,0 +1,9 @@
FROM ubuntu:20.04
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH
RUN apt update && DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt install -y gcc gcc-multilib cmake pkg-config libssl-dev curl mysql-client-8.0
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --profile minimal --default-toolchain nightly
RUN cargo install cargo-deb

View File

@ -7,25 +7,36 @@ reminders are paid on the hosted version of the bot. Keep reading if you want to
You'll need rustc and cargo for compilation. To run, you'll need Python 3 still (due to no suitable replacement for dateparser in Rust)
### Compiling
Install build requirements:
`sudo apt install gcc gcc-multilib cmake libssl-dev build-essential`
### Build APT package
Install Rust from https://rustup.rs
Recommended method.
Reminder Bot can then be built by running `cargo build --release` in the top level directory. It is necessary to create a
folder called 'assets' containing an image file with its name specified in the environment as `WEBHOOK_AVATAR`, of
dimensions 128x128px to be used as the webhook avatar.
By default, this builds targeting Ubuntu 20.04. Modify the Containerfile if you wish to target a different platform. These instructions are written using `podman`, but `docker` should work too.
#### Compilation environment variables
These environment variables must be provided when compiling the bot
* `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
* `WEBHOOK_AVATAR` - accepts the name of an image file located in `$CARGO_MANIFEST_DIR/assets/` to be used as the avatar when creating webhooks. **IMPORTANT: image file must be 128x128 or smaller in size**
1. Install container software: `sudo apt install podman`.
2. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders`
3. Install SQLx CLI: `cargo install sqlx-cli`
4. From the source code directory, execute `sqlx migrate run`
5. Build container image: `podman build -t reminder-rs .`
6. Build with podman: `podman run --rm --network=host -v "$PWD":/mnt -w /mnt -e "DATABASE_URL=mysql://user@localhost/reminders" reminder-rs cargo deb`
### Setting up Python
Reminder Bot by default looks for a venv within it's working directory to run Python out of. To set up a venv, install `python3-venv` and run `python3 -m venv venv`. Then, run `source venv/bin/activate` to activate the venv, and do `pip install dateparser` to install the required library
### Environment Variables
### Compiling for other target
1. Install requirements:
`sudo apt install gcc gcc-multilib cmake libssl-dev build-essential python3-dateparser`
2. Install rustup from https://rustup.rs
3. Install the nightly toolchain: `rustup toolchain default nightly`
4. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders`.
5. Install `sqlx-cli`: `cargo install sqlx-cli`.
6. Run migrations: `sqlx migrate run`.
7. Set environment variables:
* `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
8. Build: `cargo build --release`
### Configuring
Reminder Bot reads a number of environment variables. Some are essential, and others have hardcoded fallbacks. Environment variables can be loaded from a .env file in the working directory.
__Required Variables__
@ -37,10 +48,5 @@ __Other Variables__
* `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor
* `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to subscribed users
* `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the subscription roles belong to
* `PYTHON_LOCATION` - default `venv/bin/python3`. Can be changed if your Python executable is located somewhere else
* `PYTHON_LOCATION` - default `/usr/bin/python3`. Can be changed if your Python executable is located somewhere else
* `THEME_COLOR` - default `8fb677`. Specifies the hex value of the color to use on info message embeds
* `DM_ENABLED` - default `1`, if `1`, Reminder Bot will respond to direct messages
### Todo List
* Convert aliases to macros

View File

@ -1,6 +1,6 @@
[default]
address = "0.0.0.0"
port = 5000
port = 18920
template_dir = "web/templates"
limits = { json = "10MiB" }
@ -11,18 +11,18 @@ secret_key = "tR8krio5FXTnnyIZNiJDXPondz0kI1v6X6BXZcBGIRY="
certs = "web/private/rsa_sha256_cert.pem"
key = "web/private/rsa_sha256_key.pem"
[rsa_sha256.tls]
[debug.rsa_sha256.tls]
certs = "web/private/rsa_sha256_cert.pem"
key = "web/private/rsa_sha256_key.pem"
[ecdsa_nistp256_sha256.tls]
[debug.ecdsa_nistp256_sha256.tls]
certs = "web/private/ecdsa_nistp256_sha256_cert.pem"
key = "web/private/ecdsa_nistp256_sha256_key_pkcs8.pem"
[ecdsa_nistp384_sha384.tls]
[debug.ecdsa_nistp384_sha384.tls]
certs = "web/private/ecdsa_nistp384_sha384_cert.pem"
key = "web/private/ecdsa_nistp384_sha384_key_pkcs8.pem"
[ed25519.tls]
[debug.ed25519.tls]
certs = "web/private/ed25519_cert.pem"
key = "eb/private/ed25519_key.pem"

BIN
assets/webhook.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

3
build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
println!("cargo:rerun-if-changed=migrations");
}

8
conf/Rocket.toml Normal file
View File

@ -0,0 +1,8 @@
[default]
address = "127.0.0.1"
port = 18920
template_dir = "/lib/reminder-rs/templates"
limits = { json = "10MiB" }
[release]
# secret_key = ""

19
conf/default.env Normal file
View File

@ -0,0 +1,19 @@
DATABASE_URL=
DISCORD_TOKEN=
PATREON_GUILD_ID=
PATREON_ROLE_ID=
LOCAL_TIMEZONE=
MIN_INTERVAL=
PYTHON_LOCATION=/usr/bin/python3
DONTRUN=
SECRET_KEY=
REMIND_INTERVAL=
OAUTH2_DISCORD_CALLBACK=
OAUTH2_CLIENT_ID=
OAUTH2_CLIENT_SECRET=
REPORT_EMAIL=
LOG_TO_DATABASE=1

1
cron.d/reminder_health Normal file
View File

@ -0,0 +1 @@
*/10 * * * * reminder /lib/reminder-rs/healthcheck

9
debian/postinst vendored Normal file
View File

@ -0,0 +1,9 @@
#!/bin/bash
set -e
id -u reminder &>/dev/null || useradd -r -M reminder
chown -R reminder /etc/reminder-rs
#DEBHELPER#

7
debian/postrm vendored Normal file
View File

@ -0,0 +1,7 @@
#!/bin/bash
set -e
id -u reminder &>/dev/null || userdel reminder
#DEBHELPER#

46
extract_derive/Cargo.lock generated Normal file
View File

@ -0,0 +1,46 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "extract_macro"
version = "0.1.0"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.78"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef"
dependencies = [
"proc-macro2",
]
[[package]]
name = "syn"
version = "2.0.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915aea9e586f80826ee59f8453c1101f9d1c4b3964cd2460185ee8e299ada496"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"

11
extract_derive/Cargo.toml Normal file
View File

@ -0,0 +1,11 @@
[package]
name = "extract_derive"
version = "0.1.0"
edition = "2021"
[lib]
proc-macro = true
[dependencies]
quote = "1.0.35"
syn = { version = "2.0.49", features = ["full"] }

53
extract_derive/src/lib.rs Normal file
View File

@ -0,0 +1,53 @@
use proc_macro::TokenStream;
use syn::{spanned::Spanned, Data, Fields};
#[proc_macro_derive(Extract)]
pub fn extract(input: TokenStream) -> TokenStream {
let ast = syn::parse_macro_input!(input);
impl_extract(&ast)
}
fn impl_extract(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
match &ast.data {
// Dispatch over struct: extract args directly from context
Data::Struct(st) => match &st.fields {
Fields::Named(fields) => {
let extracted = fields.named.iter().map(|field| {
let ident = &field.ident;
let ty = &field.ty;
quote::quote_spanned! {field.span()=>
#ident : crate::utils::extract_arg!(ctx, #ident, #ty)
}
});
TokenStream::from(quote::quote! {
impl Extract for #name {
fn extract(ctx: crate::ApplicationContext) -> Self {
Self {
#(#extracted,)*
}
}
}
})
}
Fields::Unit => TokenStream::from(quote::quote! {
impl Extract for #name {
fn extract(ctx: crate::ApplicationContext) -> Self {
Self {}
}
}
}),
_ => {
panic!("Only named/unit structs can derive Extract");
}
},
_ => {
panic!("Only structs can derive Extract");
}
}
}

13
healthcheck Executable file
View File

@ -0,0 +1,13 @@
#!/bin/bash
export $(grep -v '^#' /etc/reminder-rs/config.env | xargs -d '\n')
REGEX='mysql://([A-Za-z]+)@(.+)/(.+)'
[[ $DATABASE_URL =~ $REGEX ]]
VAR=$(mysql -u "${BASH_REMATCH[1]}" -h "${BASH_REMATCH[2]}" -N -D "${BASH_REMATCH[3]}" -e "SELECT COUNT(1) FROM reminders WHERE utc_time < NOW() - INTERVAL 10 MINUTE AND enabled = 1 AND status = 'pending'")
if [ "$VAR" -gt 0 ]
then
echo "This is to inform that there is a reminder backlog which must be resolved." | mail -s "Backlog: $VAR" "$REPORT_EMAIL"
fi

View File

@ -0,0 +1,229 @@
SET FOREIGN_KEY_CHECKS=0;
CREATE TABLE guilds (
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
guild BIGINT UNSIGNED UNIQUE NOT NULL,
name VARCHAR(100),
prefix VARCHAR(5) DEFAULT '$' NOT NULL,
timezone VARCHAR(32) DEFAULT 'UTC' NOT NULL,
default_channel_id INT UNSIGNED,
default_username VARCHAR(32) DEFAULT 'Reminder' NOT NULL,
default_avatar VARCHAR(512) DEFAULT 'https://raw.githubusercontent.com/reminder-bot/logos/master/Remind_Me_Bot_Logo_PPic.jpg' NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (default_channel_id) REFERENCES channels(id) ON DELETE SET NULL
);
CREATE TABLE channels (
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
channel BIGINT UNSIGNED UNIQUE NOT NULL,
name VARCHAR(100),
nudge SMALLINT NOT NULL DEFAULT 0,
blacklisted BOOL NOT NULL DEFAULT FALSE,
webhook_id BIGINT UNSIGNED UNIQUE,
webhook_token TEXT,
paused BOOL NOT NULL DEFAULT 0,
paused_until TIMESTAMP,
guild_id INT UNSIGNED,
PRIMARY KEY (id),
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE
);
CREATE TABLE users (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
user BIGINT UNSIGNED UNIQUE NOT NULL,
name VARCHAR(37) NOT NULL,
dm_channel INT UNSIGNED UNIQUE NOT NULL,
language VARCHAR(2) DEFAULT 'EN' NOT NULL,
timezone VARCHAR(32) DEFAULT 'UTC' NOT NULL,
meridian_time BOOLEAN DEFAULT 0 NOT NULL,
allowed_dm BOOLEAN DEFAULT 1 NOT NULL,
patreon BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (id),
FOREIGN KEY (dm_channel) REFERENCES channels(id) ON DELETE RESTRICT
);
CREATE TABLE roles (
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
role BIGINT UNSIGNED UNIQUE NOT NULL,
name VARCHAR(100),
guild_id INT UNSIGNED NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE
);
CREATE TABLE embeds (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
title VARCHAR(256) NOT NULL DEFAULT '',
description VARCHAR(2048) NOT NULL DEFAULT '',
image_url VARCHAR(512),
thumbnail_url VARCHAR(512),
footer VARCHAR(2048) NOT NULL DEFAULT '',
footer_icon VARCHAR(512),
color MEDIUMINT UNSIGNED NOT NULL DEFAULT 0x0,
PRIMARY KEY (id)
);
CREATE TABLE embed_fields (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
title VARCHAR(256) NOT NULL DEFAULT '',
value VARCHAR(1024) NOT NULL DEFAULT '',
inline BOOL NOT NULL DEFAULT 0,
embed_id INT UNSIGNED NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (embed_id) REFERENCES embeds(id) ON DELETE CASCADE
);
CREATE TABLE messages (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
content VARCHAR(2048) NOT NULL DEFAULT '',
tts BOOL NOT NULL DEFAULT 0,
embed_id INT UNSIGNED,
attachment MEDIUMBLOB,
attachment_name VARCHAR(260),
PRIMARY KEY (id),
FOREIGN KEY (embed_id) REFERENCES embeds(id) ON DELETE SET NULL
);
CREATE TABLE reminders (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
uid VARCHAR(64) UNIQUE NOT NULL,
name VARCHAR(24) NOT NULL DEFAULT 'Reminder',
message_id INT UNSIGNED NOT NULL,
channel_id INT UNSIGNED NOT NULL,
`time` INT UNSIGNED DEFAULT 0 NOT NULL,
`interval` INT UNSIGNED DEFAULT NULL,
expires TIMESTAMP DEFAULT NULL,
enabled BOOLEAN DEFAULT 1 NOT NULL,
avatar VARCHAR(512),
username VARCHAR(32),
method ENUM('remind', 'natural', 'dashboard', 'todo', 'countdown'),
set_at TIMESTAMP DEFAULT NOW(),
set_by INT UNSIGNED,
PRIMARY KEY (id),
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE RESTRICT,
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
FOREIGN KEY (set_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TRIGGER message_cleanup AFTER DELETE ON reminders
FOR EACH ROW
DELETE FROM messages WHERE id = OLD.message_id;
CREATE TRIGGER embed_cleanup AFTER DELETE ON messages
FOR EACH ROW
DELETE FROM embeds WHERE id = OLD.embed_id;
CREATE TABLE todos (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
user_id INT UNSIGNED,
guild_id INT UNSIGNED,
channel_id INT UNSIGNED,
value VARCHAR(2000) NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL
);
CREATE TABLE command_restrictions (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
role_id INT UNSIGNED NOT NULL,
command ENUM('todos', 'natural', 'remind', 'interval', 'timer', 'del', 'look', 'alias', 'countdown') NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
UNIQUE KEY (`role_id`, `command`)
);
CREATE TABLE timers (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
start_time TIMESTAMP NOT NULL DEFAULT NOW(),
name VARCHAR(32) NOT NULL,
owner BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (id)
);
CREATE TABLE events (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
`time` TIMESTAMP NOT NULL DEFAULT NOW(),
event_name ENUM('edit', 'enable', 'disable', 'delete') NOT NULL,
bulk_count INT UNSIGNED,
guild_id INT UNSIGNED NOT NULL,
user_id INT UNSIGNED,
reminder_id INT UNSIGNED,
PRIMARY KEY (id),
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (reminder_id) REFERENCES reminders(id) ON DELETE SET NULL
);
CREATE TABLE command_aliases (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
guild_id INT UNSIGNED NOT NULL,
name VARCHAR(12) NOT NULL,
command VARCHAR(2048) NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
UNIQUE KEY (`guild_id`, `name`)
);
CREATE TABLE guild_users (
guild INT UNSIGNED NOT NULL,
user INT UNSIGNED NOT NULL,
can_access BOOL NOT NULL DEFAULT 0,
FOREIGN KEY (guild) REFERENCES guilds(id) ON DELETE CASCADE,
FOREIGN KEY (user) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY (guild, user)
);
CREATE EVENT event_cleanup
ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 DAY
ON COMPLETION PRESERVE
DO DELETE FROM events WHERE `time` < DATE_SUB(NOW(), INTERVAL 5 DAY);

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,11 @@
CREATE TABLE macro (
id INT UNSIGNED AUTO_INCREMENT,
guild_id INT UNSIGNED NOT NULL,
name VARCHAR(100) NOT NULL,
description VARCHAR(100),
commands TEXT NOT NULL,
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
PRIMARY KEY (id)
);

View File

@ -0,0 +1,2 @@
ALTER TABLE reminders RENAME COLUMN `interval` TO `interval_seconds`;
ALTER TABLE reminders ADD COLUMN `interval_months` INT UNSIGNED DEFAULT NULL;

View File

@ -0,0 +1,49 @@
CREATE TABLE reminder_template (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(24) NOT NULL DEFAULT 'Reminder',
`guild_id` INT UNSIGNED NOT NULL,
`username` VARCHAR(32) DEFAULT NULL,
`avatar` VARCHAR(512) DEFAULT NULL,
`content` VARCHAR(2048) NOT NULL DEFAULT '',
`tts` BOOL NOT NULL DEFAULT 0,
`attachment` MEDIUMBLOB,
`attachment_name` VARCHAR(260),
`embed_title` VARCHAR(256) NOT NULL DEFAULT '',
`embed_description` VARCHAR(2048) NOT NULL DEFAULT '',
`embed_image_url` VARCHAR(512),
`embed_thumbnail_url` VARCHAR(512),
`embed_footer` VARCHAR(2048) NOT NULL DEFAULT '',
`embed_footer_url` VARCHAR(512),
`embed_author` VARCHAR(256) NOT NULL DEFAULT '',
`embed_author_url` VARCHAR(512),
`embed_color` INT UNSIGNED NOT NULL DEFAULT 0x0,
`embed_fields` JSON,
PRIMARY KEY (id),
FOREIGN KEY (`guild_id`) REFERENCES guilds (`id`) ON DELETE CASCADE
);
ALTER TABLE reminders ADD COLUMN embed_fields JSON;
update reminders
inner join embed_fields as E
on E.reminder_id = reminders.id
set embed_fields = (
select JSON_ARRAYAGG(
JSON_OBJECT(
'title', E.title,
'value', E.value,
'inline',
if(inline = 1, cast(TRUE as json), cast(FALSE as json))
)
)
from embed_fields
group by reminder_id
having reminder_id = reminders.id
);

View File

@ -0,0 +1 @@
ALTER TABLE reminders ADD COLUMN `interval_days` INT UNSIGNED DEFAULT NULL;

View File

@ -0,0 +1 @@
ALTER TABLE reminders ADD COLUMN `thread_id` BIGINT DEFAULT NULL;

View File

@ -0,0 +1 @@
ALTER TABLE guilds ADD COLUMN ephemeral_confirmations BOOL NOT NULL DEFAULT 0;

View File

@ -0,0 +1,2 @@
ALTER TABLE reminders MODIFY `name` VARCHAR(100) NOT NULL DEFAULT 'Reminder';
ALTER TABLE reminder_template MODIFY `name` VARCHAR(100) NOT NULL DEFAULT 'Reminder';

View File

@ -0,0 +1,9 @@
CREATE TABLE stat (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`utc_time` DATETIME NOT NULL DEFAULT NOW(),
`type` ENUM('reminder_sent', 'reminder_failed'),
`reminder_id` INT UNSIGNED,
`message` TEXT,
PRIMARY KEY (`id`)
);

View File

@ -0,0 +1,2 @@
ALTER TABLE reminders ADD COLUMN `status` ENUM ('pending', 'sent', 'failed', 'deleted') NOT NULL DEFAULT 'pending';
ALTER TABLE reminders ADD COLUMN `status_message` TEXT;

View File

@ -0,0 +1,3 @@
ALTER TABLE `reminder_template` ADD COLUMN `interval_seconds` INT UNSIGNED;
ALTER TABLE `reminder_template` ADD COLUMN `interval_days` INT UNSIGNED;
ALTER TABLE `reminder_template` ADD COLUMN `interval_months` INT UNSIGNED;

View File

@ -0,0 +1,50 @@
CREATE TABLE command_macro (
id INT UNSIGNED AUTO_INCREMENT,
guild_id INT UNSIGNED NOT NULL,
name VARCHAR(100) NOT NULL,
description VARCHAR(100),
commands JSON NOT NULL,
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
PRIMARY KEY (id)
);
# New JSON structure is {command_name: "Remind", "<option name>": "<option value>", ...}
INSERT INTO command_macro (guild_id, description, name, commands)
SELECT
guild_id,
description,
name,
(
SELECT JSON_ARRAYAGG(
(
SELECT JSON_OBJECTAGG(t2.name, t2.value)
FROM JSON_TABLE(
JSON_ARRAY_APPEND(t1.options, '$', JSON_OBJECT('name', 'command_name', 'value', t1.command_name)),
'$[*]' COLUMNS (
name VARCHAR(64) PATH '$.name' ERROR ON ERROR,
value TEXT PATH '$.value' ERROR ON ERROR
)) AS t2
)
)
FROM macro m2
JOIN JSON_TABLE(
commands,
'$[*]' COLUMNS (
command_name VARCHAR(64) PATH '$.command_name' ERROR ON ERROR,
options JSON PATH '$.options' ERROR ON ERROR
)) AS t1
WHERE m1.id = m2.id
)
FROM macro m1;
# # Check which commands are used in macros
# SELECT DISTINCT command_name
# FROM macro m2
# JOIN JSON_TABLE(
# commands,
# '$[*]' COLUMNS (
# command_name VARCHAR(64) PATH '$.command_name' ERROR ON ERROR,
# options JSON PATH '$.options' ERROR ON ERROR
# )) AS t1

7
models/Cargo.lock generated
View File

@ -1,7 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "models"
version = "0.1.0"

View File

@ -1,8 +0,0 @@
[package]
name = "models"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]

View File

@ -1,8 +0,0 @@
[package]
name = "entity"
version = "0.1.0"
edition = "2021"
[dependencies]
chrono-tz = "^0.6"
sea-orm = { version = "^0.8", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros"] }

View File

@ -1,60 +0,0 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "channel")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: i64,
pub guild_id: Option<i64>,
pub nudge: i32,
pub webhook_id: Option<i64>,
pub webhook_token: Option<String>,
pub paused: bool,
pub paused_until: Option<DateTimeUtc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::guild::Entity",
from = "Column::GuildId",
to = "super::guild::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Guild,
#[sea_orm(has_many = "super::user::Entity")]
User,
#[sea_orm(has_many = "super::reminder::Entity")]
Reminder,
#[sea_orm(has_many = "super::todo::Entity")]
Todo,
}
impl Related<super::guild::Entity> for Entity {
fn to() -> RelationDef {
Relation::Guild.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl Related<super::reminder::Entity> for Entity {
fn to() -> RelationDef {
Relation::Reminder.def()
}
}
impl Related<super::todo::Entity> for Entity {
fn to() -> RelationDef {
Relation::Todo.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -1,34 +0,0 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "command_macro")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub guild_id: i64,
pub name: String,
pub description: Option<String>,
pub commands: Option<Json>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::guild::Entity",
from = "Column::GuildId",
to = "super::guild::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Guild,
}
impl Related<super::guild::Entity> for Entity {
fn to() -> RelationDef {
Relation::Guild.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -1,48 +0,0 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "guild")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::reminder_template::Entity")]
ReminderTemplate,
#[sea_orm(has_many = "super::channel::Entity")]
Channel,
#[sea_orm(has_many = "super::todo::Entity")]
Todo,
#[sea_orm(has_many = "super::command_macro::Entity")]
CommandMacro,
}
impl Related<super::reminder_template::Entity> for Entity {
fn to() -> RelationDef {
Relation::ReminderTemplate.def()
}
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::Channel.def()
}
}
impl Related<super::todo::Entity> for Entity {
fn to() -> RelationDef {
Relation::Todo.def()
}
}
impl Related<super::command_macro::Entity> for Entity {
fn to() -> RelationDef {
Relation::CommandMacro.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -1 +0,0 @@

View File

@ -1,14 +0,0 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
pub mod prelude;
pub mod channel;
pub mod command_macro;
pub mod guild;
pub mod reminder;
pub mod reminder_template;
pub mod sea_orm_active_enums;
pub mod seaql_migrations;
pub mod timer;
pub mod todo;
pub mod user;

View File

@ -1,8 +0,0 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
pub use super::{
channel::Entity as Channel, command_macro::Entity as CommandMacro, guild::Entity as Guild,
reminder::Entity as Reminder, reminder_template::Entity as ReminderTemplate,
seaql_migrations::Entity as SeaqlMigrations, timer::Entity as Timer, todo::Entity as Todo,
user::Entity as User,
};

View File

@ -1,73 +0,0 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
use super::sea_orm_active_enums::Timezone;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "reminder")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub uid: String,
pub name: String,
pub channel_id: i64,
pub utc_time: DateTimeUtc,
pub timezone: Timezone,
pub interval_seconds: Option<i32>,
pub interval_months: Option<i32>,
pub enabled: bool,
pub expires: Option<DateTimeUtc>,
pub username: Option<String>,
pub avatar: Option<String>,
pub content: Option<String>,
pub tts: bool,
pub attachment: Option<Vec<u8>>,
pub attachment_name: Option<String>,
pub embed_title: Option<String>,
pub embed_description: Option<String>,
pub embed_image_url: Option<String>,
pub embed_thumbnail_url: Option<String>,
pub embed_footer: Option<String>,
pub embed_footer_url: Option<String>,
pub embed_author: Option<String>,
pub embed_author_url: Option<String>,
pub embed_color: Option<i32>,
pub embed_fields: Option<Json>,
pub set_at: DateTimeUtc,
pub set_by: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::channel::Entity",
from = "Column::ChannelId",
to = "super::channel::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Channel,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::SetBy",
to = "super::user::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
User,
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::Channel.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -1,48 +0,0 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "reminder_template")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub guild_id: i64,
pub name: String,
pub username: Option<String>,
pub avatar: Option<String>,
pub content: Option<String>,
pub tts: bool,
pub attachment: Option<Vec<u8>>,
pub attachment_name: Option<String>,
pub embed_title: Option<String>,
pub embed_description: Option<String>,
pub embed_image_url: Option<String>,
pub embed_thumbnail_url: Option<String>,
pub embed_footer: Option<String>,
pub embed_footer_url: Option<String>,
pub embed_author: Option<String>,
pub embed_author_url: Option<String>,
pub embed_color: Option<i32>,
pub embed_fields: Option<Json>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::guild::Entity",
from = "Column::GuildId",
to = "super::guild::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Guild,
}
impl Related<super::guild::Entity> for Entity {
fn to() -> RelationDef {
Relation::Guild.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

File diff suppressed because it is too large Load Diff

View File

@ -1,22 +0,0 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "seaql_migrations")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub version: String,
pub applied_at: i64,
}
#[derive(Copy, Clone, Debug, EnumIter)]
pub enum Relation {}
impl RelationTrait for Relation {
fn def(&self) -> RelationDef {
panic!("No RelationDef")
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -1,36 +0,0 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "timer")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub start_time: DateTimeUtc,
pub name: String,
pub user_id: Option<i64>,
pub guild_id: Option<i64>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::guild::Entity",
from = "Column::GuildId",
to = "super::guild::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Guild2,
#[sea_orm(
belongs_to = "super::guild::Entity",
from = "Column::UserId",
to = "super::guild::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Guild1,
}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -1,62 +0,0 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "todo")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub user_id: Option<i64>,
pub guild_id: Option<i64>,
pub channel_id: Option<i64>,
pub value: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::channel::Entity",
from = "Column::ChannelId",
to = "super::channel::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Channel,
#[sea_orm(
belongs_to = "super::guild::Entity",
from = "Column::GuildId",
to = "super::guild::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Guild,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
User,
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::Channel.def()
}
}
impl Related<super::guild::Entity> for Entity {
fn to() -> RelationDef {
Relation::Guild.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -1,50 +0,0 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
use super::sea_orm_active_enums::Timezone;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: i64,
pub dm_channel: i64,
pub timezone: Timezone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::channel::Entity",
from = "Column::DmChannel",
to = "super::channel::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Channel,
#[sea_orm(has_many = "super::reminder::Entity")]
Reminder,
#[sea_orm(has_many = "super::todo::Entity")]
Todo,
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::Channel.def()
}
}
impl Related<super::reminder::Entity> for Entity {
fn to() -> RelationDef {
Relation::Reminder.def()
}
}
impl Related<super::todo::Entity> for Entity {
fn to() -> RelationDef {
Relation::Todo.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

File diff suppressed because it is too large Load Diff

View File

@ -1,16 +0,0 @@
[package]
name = "migration"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "migration"
path = "src/lib.rs"
[dependencies]
entity = { path = "../entity" }
chrono-tz = "^0.6"
[dependencies.sea-orm-migration]
version = "^0.8.0"

View File

@ -1,37 +0,0 @@
# Running Migrator CLI
- Apply all pending migrations
```sh
cargo run
```
```sh
cargo run -- up
```
- Apply first 10 pending migrations
```sh
cargo run -- up -n 10
```
- Rollback last applied migrations
```sh
cargo run -- down
```
- Rollback last 10 applied migrations
```sh
cargo run -- down -n 10
```
- Drop all tables from the database, then reapply all migrations
```sh
cargo run -- fresh
```
- Rollback all applied migrations, then reapply all migrations
```sh
cargo run -- refresh
```
- Rollback all applied migrations
```sh
cargo run -- reset
```
- Check the status of all migrations
```sh
cargo run -- status
```

View File

@ -1,12 +0,0 @@
pub use sea_orm_migration::prelude::*;
mod m20220101_000001_create_table;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![Box::new(m20220101_000001_create_table::Migration)]
}
}

View File

@ -1,553 +0,0 @@
use chrono_tz::{Tz, TZ_VARIANTS};
use sea_orm_migration::prelude::*;
use crate::extension::postgres::Type;
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m20220101_000001_create_table"
}
}
#[derive(Iden)]
pub enum Guild {
Table,
Id,
}
#[derive(Iden)]
pub enum Channel {
Table,
Id,
GuildId,
Nudge,
WebhookId,
WebhookToken,
Paused,
PausedUntil,
}
#[derive(Iden)]
pub enum User {
Table,
Id,
DmChannel,
Timezone,
}
#[derive(Iden)]
pub enum Reminder {
Table,
Id,
Uid,
Name,
ChannelId,
UtcTime,
Timezone,
IntervalSeconds,
IntervalMonths,
Enabled,
Expires,
Username,
Avatar,
Content,
Tts,
Attachment,
AttachmentName,
EmbedTitle,
EmbedDescription,
EmbedImageUrl,
EmbedThumbnailUrl,
EmbedFooter,
EmbedFooterUrl,
EmbedAuthor,
EmbedAuthorUrl,
EmbedColor,
EmbedFields,
SetAt,
SetBy,
}
#[derive(Iden)]
pub enum ReminderTemplate {
Table,
Id,
GuildId,
Name,
Username,
Avatar,
Content,
Tts,
Attachment,
AttachmentName,
EmbedTitle,
EmbedDescription,
EmbedImageUrl,
EmbedThumbnailUrl,
EmbedFooter,
EmbedFooterUrl,
EmbedAuthor,
EmbedAuthorUrl,
EmbedColor,
EmbedFields,
}
#[derive(Iden)]
pub enum Timer {
Table,
Id,
StartTime,
Name,
UserId,
GuildId,
}
#[derive(Iden)]
pub enum Todo {
Table,
Id,
UserId,
GuildId,
ChannelId,
Value,
}
#[derive(Iden)]
pub enum CommandMacro {
Table,
Id,
GuildId,
Name,
Description,
Commands,
}
pub enum Timezone {
Type,
Tz(Tz),
}
impl Iden for Timezone {
fn unquoted(&self, s: &mut dyn Write) {
write!(
s,
"{}",
match self {
Self::Type => "timezone".to_string(),
Self::Tz(tz) => tz.to_string(),
}
)
.unwrap();
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_type(
Type::create()
.as_enum(Timezone::Type)
.values(TZ_VARIANTS.iter().map(|tz| Timezone::Tz(tz.to_owned())))
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(Guild::Table)
.if_not_exists()
.col(ColumnDef::new(Guild::Id).big_integer().not_null().primary_key())
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(Channel::Table)
.if_not_exists()
.col(ColumnDef::new(Channel::Id).big_integer().not_null().primary_key())
.col(ColumnDef::new(Channel::GuildId).big_integer())
.col(ColumnDef::new(Channel::Nudge).integer().not_null().default(0))
.col(ColumnDef::new(Channel::WebhookId).big_integer())
.col(ColumnDef::new(Channel::WebhookToken).string())
.col(ColumnDef::new(Channel::Paused).boolean().not_null().default(false))
.col(ColumnDef::new(Channel::PausedUntil).date_time())
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_channel_guild")
.from(Channel::Table, Channel::GuildId)
.to(Guild::Table, Guild::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(User::Table)
.if_not_exists()
.col(ColumnDef::new(User::Id).big_integer().not_null().primary_key())
.col(ColumnDef::new(User::DmChannel).big_integer().not_null())
.col(
ColumnDef::new(User::Timezone)
.custom(Timezone::Type)
.not_null()
.default("UTC"),
)
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_user_channel")
.from(User::Table, User::DmChannel)
.to(Channel::Table, Channel::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(Reminder::Table)
.if_not_exists()
.col(
ColumnDef::new(Reminder::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Reminder::Uid).string().char_len(64).not_null())
.col(
ColumnDef::new(Reminder::Name)
.string()
.char_len(24)
.default("Reminder")
.not_null(),
)
.col(ColumnDef::new(Reminder::ChannelId).big_integer().not_null())
.col(ColumnDef::new(Reminder::UtcTime).date_time().not_null())
.col(
ColumnDef::new(Reminder::Timezone)
.custom(Timezone::Type)
.not_null()
.default("UTC"),
)
.col(ColumnDef::new(Reminder::IntervalSeconds).integer())
.col(ColumnDef::new(Reminder::IntervalMonths).integer())
.col(ColumnDef::new(Reminder::Enabled).boolean().not_null().default(false))
.col(ColumnDef::new(Reminder::Expires).date_time())
.col(ColumnDef::new(Reminder::Username).string_len(32))
.col(ColumnDef::new(Reminder::Avatar).string_len(512))
.col(ColumnDef::new(Reminder::Content).string_len(2000))
.col(ColumnDef::new(Reminder::Tts).boolean().not_null().default(false))
.col(ColumnDef::new(Reminder::Attachment).binary_len(8 * 1024 * 1024))
.col(ColumnDef::new(Reminder::AttachmentName).string_len(260))
.col(ColumnDef::new(Reminder::EmbedTitle).string_len(256))
.col(ColumnDef::new(Reminder::EmbedDescription).string_len(4096))
.col(ColumnDef::new(Reminder::EmbedImageUrl).string_len(500))
.col(ColumnDef::new(Reminder::EmbedThumbnailUrl).string_len(500))
.col(ColumnDef::new(Reminder::EmbedFooter).string_len(2048))
.col(ColumnDef::new(Reminder::EmbedFooterUrl).string_len(500))
.col(ColumnDef::new(Reminder::EmbedAuthor).string_len(256))
.col(ColumnDef::new(Reminder::EmbedAuthorUrl).string_len(500))
.col(ColumnDef::new(Reminder::EmbedColor).integer())
.col(ColumnDef::new(Reminder::EmbedFields).json())
.col(ColumnDef::new(Reminder::SetAt).date_time().not_null().default("NOW()"))
.col(ColumnDef::new(Reminder::SetBy).big_integer().not_null())
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_reminder_channel")
.from(Reminder::Table, Reminder::ChannelId)
.to(Channel::Table, Channel::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_reminder_user")
.from(Reminder::Table, Reminder::SetBy)
.to(User::Table, User::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(ReminderTemplate::Table)
.if_not_exists()
.col(
ColumnDef::new(ReminderTemplate::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(ReminderTemplate::GuildId).big_integer().not_null())
.col(
ColumnDef::new(ReminderTemplate::Name)
.string()
.char_len(24)
.default("Reminder")
.not_null(),
)
.col(ColumnDef::new(ReminderTemplate::Username).string_len(32))
.col(ColumnDef::new(ReminderTemplate::Avatar).string_len(512))
.col(ColumnDef::new(ReminderTemplate::Content).string_len(2000))
.col(ColumnDef::new(ReminderTemplate::Tts).boolean().not_null().default(false))
.col(ColumnDef::new(ReminderTemplate::Attachment).binary_len(8 * 1024 * 1024))
.col(ColumnDef::new(ReminderTemplate::AttachmentName).string_len(260))
.col(ColumnDef::new(ReminderTemplate::EmbedTitle).string_len(256))
.col(ColumnDef::new(ReminderTemplate::EmbedDescription).string_len(4096))
.col(ColumnDef::new(ReminderTemplate::EmbedImageUrl).string_len(500))
.col(ColumnDef::new(ReminderTemplate::EmbedThumbnailUrl).string_len(500))
.col(ColumnDef::new(ReminderTemplate::EmbedFooter).string_len(2048))
.col(ColumnDef::new(ReminderTemplate::EmbedFooterUrl).string_len(500))
.col(ColumnDef::new(ReminderTemplate::EmbedAuthor).string_len(256))
.col(ColumnDef::new(ReminderTemplate::EmbedAuthorUrl).string_len(500))
.col(ColumnDef::new(ReminderTemplate::EmbedColor).integer())
.col(ColumnDef::new(ReminderTemplate::EmbedFields).json())
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_reminder_template_guild")
.from(ReminderTemplate::Table, ReminderTemplate::GuildId)
.to(Guild::Table, Guild::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(Timer::Table)
.if_not_exists()
.col(
ColumnDef::new(Timer::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Timer::StartTime).date_time().not_null().default("NOW()"))
.col(ColumnDef::new(Timer::Name).string_len(32).not_null().default("Timer"))
.col(ColumnDef::new(Timer::UserId).big_integer())
.col(ColumnDef::new(Timer::GuildId).big_integer())
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_timer_user")
.from(Timer::Table, Timer::UserId)
.to(Guild::Table, Guild::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_timer_guild")
.from(Timer::Table, Timer::GuildId)
.to(Guild::Table, Guild::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(Todo::Table)
.if_not_exists()
.col(
ColumnDef::new(Todo::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Todo::UserId).big_integer())
.col(ColumnDef::new(Todo::GuildId).big_integer())
.col(ColumnDef::new(Todo::ChannelId).big_integer())
.col(ColumnDef::new(Todo::Value).string_len(2000).not_null())
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_todo_user")
.from(Todo::Table, Todo::UserId)
.to(User::Table, User::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_todo_guild")
.from(Todo::Table, Todo::GuildId)
.to(Guild::Table, Guild::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_todo_channel")
.from(Todo::Table, Todo::ChannelId)
.to(Channel::Table, Channel::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(CommandMacro::Table)
.if_not_exists()
.col(
ColumnDef::new(CommandMacro::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(CommandMacro::GuildId).big_integer().not_null())
.col(ColumnDef::new(CommandMacro::Name).string_len(100).not_null())
.col(ColumnDef::new(CommandMacro::Description).string_len(100))
.col(ColumnDef::new(CommandMacro::Commands).json())
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_command_macro_guild")
.from(CommandMacro::Table, CommandMacro::GuildId)
.to(Guild::Table, Guild::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_foreign_key(
ForeignKey::drop().table(Channel::Table).name("fk_channel_guild").to_owned(),
)
.await?;
manager
.drop_foreign_key(
ForeignKey::drop().table(User::Table).name("fk_user_channel").to_owned(),
)
.await?;
manager
.drop_foreign_key(
ForeignKey::drop().table(Reminder::Table).name("fk_reminder_channel").to_owned(),
)
.await?;
manager
.drop_foreign_key(
ForeignKey::drop().table(Reminder::Table).name("fk_reminder_user").to_owned(),
)
.await?;
manager
.drop_foreign_key(
ForeignKey::drop()
.table(ReminderTemplate::Table)
.name("fk_reminder_template_guild")
.to_owned(),
)
.await?;
manager
.drop_foreign_key(
ForeignKey::drop().table(Timer::Table).name("fk_timer_user").to_owned(),
)
.await?;
manager
.drop_foreign_key(
ForeignKey::drop().table(Timer::Table).name("fk_timer_guild").to_owned(),
)
.await?;
manager
.drop_foreign_key(ForeignKey::drop().table(Todo::Table).name("fk_todo_user").to_owned())
.await?;
manager
.drop_foreign_key(
ForeignKey::drop().table(Todo::Table).name("fk_todo_guild").to_owned(),
)
.await?;
manager
.drop_foreign_key(
ForeignKey::drop().table(Todo::Table).name("fk_todo_channel").to_owned(),
)
.await?;
manager
.drop_foreign_key(
ForeignKey::drop()
.table(CommandMacro::Table)
.name("fk_command_macro_guild")
.to_owned(),
)
.await?;
manager.drop_table(Table::drop().table(Guild::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(Channel::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(User::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(Reminder::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(ReminderTemplate::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(Timer::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(Todo::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(CommandMacro::Table).to_owned()).await?;
manager.drop_type(Type::drop().name(Timezone::Type).to_owned()).await?;
Ok(())
}
}

View File

@ -1,6 +0,0 @@
use sea_orm_migration::prelude::*;
#[async_std::main]
async fn main() {
cli::run_cli(migration::Migrator).await;
}

View File

@ -1 +0,0 @@

41
nginx/reminder-rs Normal file
View File

@ -0,0 +1,41 @@
server {
server_name www.reminder-bot.com;
return 301 $scheme://reminder-bot.com$request_uri;
}
server {
listen 80;
server_name reminder-bot.com;
return 301 https://reminder-bot.com$request_uri;
}
server {
listen 443 ssl;
server_name reminder-bot.com;
ssl_certificate /etc/letsencrypt/live/reminder-bot.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/reminder-bot.com/privkey.pem;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
location / {
proxy_pass http://localhost:18920;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /static {
alias /var/www/reminder-rs/static;
expires 30d;
}
}

View File

@ -5,14 +5,12 @@ edition = "2021"
[dependencies]
tokio = { version = "1", features = ["process", "full"] }
regex = "1.4"
regex = "1.9"
log = "0.4"
env_logger = "0.8"
chrono = "0.4"
chrono-tz = { version = "0.5", features = ["serde"] }
chrono-tz = { version = "0.8", features = ["serde"] }
lazy_static = "1.4"
num-integer = "0.1"
serde = "1.0"
serde_json = "1.0"
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]}
serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]}
serenity = { version = "0.12", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }

View File

@ -1,4 +1,6 @@
use chrono::Duration;
use std::env;
use chrono::{DateTime, Days, Duration, Months};
use chrono_tz::Tz;
use lazy_static::lazy_static;
use log::{error, info, warn};
@ -6,11 +8,12 @@ use num_integer::Integer;
use regex::{Captures, Regex};
use serde::Deserialize;
use serenity::{
builder::CreateEmbed,
http::{CacheHttp, Http, HttpError, StatusCode},
all::{CreateAttachment, CreateEmbedFooter},
builder::{CreateEmbed, CreateEmbedAuthor, CreateMessage, ExecuteWebhook},
http::{CacheHttp, Http, HttpError},
model::{
channel::{Channel, Embed as SerenityEmbed},
id::ChannelId,
channel::Channel,
id::{ChannelId, MessageId},
webhook::Webhook,
},
Error, Result,
@ -30,6 +33,7 @@ lazy_static! {
Regex::new(r#"<<timefrom:(?P<time>\d+):(?P<format>.+)?>>"#).unwrap();
pub static ref TIMENOW_REGEX: Regex =
Regex::new(r#"<<timenow:(?P<timezone>(?:\w|/|_)+):(?P<format>.+)?>>"#).unwrap();
pub static ref LOG_TO_DATABASE: bool = env::var("LOG_TO_DATABASE").map_or(true, |v| v == "1");
}
fn fmt_displacement(format: &str, seconds: u64) -> String {
@ -62,7 +66,8 @@ pub fn substitute(string: &str) -> String {
let format = caps.name("format").map(|m| m.as_str());
if let (Some(final_time), Some(format)) = (final_time, format) {
let dt = NaiveDateTime::from_timestamp(final_time, 0);
match NaiveDateTime::from_timestamp_opt(final_time, 0) {
Some(dt) => {
let now = Utc::now().naive_utc();
let difference = {
@ -74,6 +79,10 @@ pub fn substitute(string: &str) -> String {
};
fmt_displacement(format, difference.num_seconds() as u64)
}
None => String::new(),
}
} else {
String::new()
}
@ -146,7 +155,7 @@ impl Embed {
embed.description = substitute(&embed.description);
embed.footer = substitute(&embed.footer);
embed.fields.iter_mut().for_each(|mut field| {
embed.fields.iter_mut().for_each(|field| {
field.title = substitute(&field.title);
field.value = substitute(&field.value);
});
@ -186,47 +195,39 @@ impl Embed {
impl Into<CreateEmbed> for Embed {
fn into(self) -> CreateEmbed {
let mut c = CreateEmbed::default();
let mut author = CreateEmbedAuthor::new(&self.author);
if let Some(author_icon) = &self.author_url {
author = author.icon_url(author_icon);
}
c.title(&self.title)
let mut footer = CreateEmbedFooter::new(&self.footer);
if let Some(footer_icon) = &self.footer_url {
footer = footer.icon_url(footer_icon);
}
let mut embed = CreateEmbed::default()
.title(&self.title)
.description(&self.description)
.color(self.color)
.author(|a| {
a.name(&self.author);
if let Some(author_icon) = &self.author_url {
a.icon_url(author_icon);
}
a
})
.footer(|f| {
f.text(&self.footer);
if let Some(footer_icon) = &self.footer_url {
f.icon_url(footer_icon);
}
f
});
.author(author)
.footer(footer);
for field in &self.fields.0 {
c.field(&field.title, &field.value, field.inline);
embed = embed.field(&field.title, &field.value, field.inline);
}
if let Some(image_url) = &self.image_url {
c.image(image_url);
embed = embed.image(image_url);
}
if let Some(thumbnail_url) = &self.thumbnail_url {
c.thumbnail(thumbnail_url);
embed = embed.thumbnail(thumbnail_url);
}
c
embed
}
}
#[derive(Debug)]
pub struct Reminder {
id: u32,
@ -244,11 +245,12 @@ pub struct Reminder {
attachment: Option<Vec<u8>>,
attachment_name: Option<String>,
utc_time: NaiveDateTime,
utc_time: DateTime<Utc>,
timezone: String,
restartable: bool,
expires: Option<NaiveDateTime>,
expires: Option<DateTime<Utc>>,
interval_seconds: Option<u32>,
interval_days: Option<u32>,
interval_months: Option<u32>,
avatar: Option<String>,
@ -282,6 +284,7 @@ SELECT
reminders.`restartable` AS restartable,
reminders.`expires` AS 'expires',
reminders.`interval_seconds` AS 'interval_seconds',
reminders.`interval_days` AS 'interval_days',
reminders.`interval_months` AS 'interval_months',
reminders.`avatar` AS avatar,
@ -293,8 +296,23 @@ INNER JOIN
ON
reminders.channel_id = channels.id
WHERE
reminders.`utc_time` < NOW()
LIMIT 25
reminders.`status` = 'pending' AND
reminders.`id` IN (
SELECT
MIN(id)
FROM
reminders
WHERE
reminders.`utc_time` <= NOW() AND
`status` = 'pending' AND
(
reminders.`interval_seconds` IS NOT NULL
OR reminders.`interval_months` IS NOT NULL
OR reminders.`interval_days` IS NOT NULL
OR reminders.enabled
)
GROUP BY channel_id
)
"#,
)
.fetch_all(pool)
@ -319,9 +337,7 @@ LIMIT 25
async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) {
let _ = sqlx::query!(
"
UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?
",
"UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?",
self.channel_id
)
.execute(pool)
@ -329,56 +345,71 @@ UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?
}
async fn refresh(&self, pool: impl Executor<'_, Database = Database> + Copy) {
if self.interval_seconds.is_some() || self.interval_months.is_some() {
let now = Utc::now().naive_local();
let mut updated_reminder_time = self.utc_time;
if let Some(interval) = self.interval_months {
match sqlx::query!(
// use the second date_add to force return value to datetime
"SELECT DATE_ADD(DATE_ADD(?, INTERVAL ? MONTH), INTERVAL 0 SECOND) AS new_time",
updated_reminder_time,
interval
)
.fetch_one(pool)
.await
if self.interval_seconds.is_some()
|| self.interval_months.is_some()
|| self.interval_days.is_some()
{
Ok(row) => match row.new_time {
Some(datetime) => {
updated_reminder_time = datetime;
// If all intervals are zero then dont care
if self.interval_seconds == Some(0)
&& self.interval_days == Some(0)
&& self.interval_months == Some(0)
{
self.set_sent(pool).await;
}
None => {
warn!("Could not update interval by months: got NULL");
updated_reminder_time += Duration::days(30);
let now = Utc::now();
let mut updated_reminder_time =
self.utc_time.with_timezone(&self.timezone.parse().unwrap_or(Tz::UTC));
let mut fail_count = 0;
while updated_reminder_time < now && fail_count < 4 {
if let Some(interval) = self.interval_months {
if interval != 0 {
updated_reminder_time = updated_reminder_time
.checked_add_months(Months::new(interval))
.unwrap_or_else(|| {
warn!(
"{}: Could not add {} months to a reminder",
interval, self.id
);
fail_count += 1;
updated_reminder_time
});
}
},
Err(e) => {
warn!("Could not update interval by months: {:?}", e);
// naively fallback to adding 30 days
updated_reminder_time += Duration::days(30);
}
if let Some(interval) = self.interval_days {
if interval != 0 {
updated_reminder_time = updated_reminder_time
.checked_add_days(Days::new(interval as u64))
.unwrap_or_else(|| {
warn!("{}: Could not add {} days to a reminder", self.id, interval);
fail_count += 1;
updated_reminder_time
})
}
}
if let Some(interval) = self.interval_seconds {
while updated_reminder_time < now {
updated_reminder_time += Duration::seconds(interval as i64);
}
}
if self.expires.map_or(false, |expires| {
NaiveDateTime::from_timestamp(updated_reminder_time.timestamp(), 0) > expires
}) {
self.force_delete(pool).await;
if fail_count >= 4 {
self.log_error(
"Failed to update 4 times and so is being deleted",
None::<&'static str>,
)
.await;
self.set_failed(pool, "Failed to update 4 times and so is being deleted").await;
} else if self.expires.map_or(false, |expires| updated_reminder_time > expires) {
self.set_sent(pool).await;
} else {
sqlx::query!(
"
UPDATE reminders SET `utc_time` = ? WHERE `id` = ?
",
updated_reminder_time,
"UPDATE reminders SET `utc_time` = ? WHERE `id` = ?",
updated_reminder_time.with_timezone(&Utc),
self.id
)
.execute(pool)
@ -386,15 +417,41 @@ UPDATE reminders SET `utc_time` = ? WHERE `id` = ?
.expect(&format!("Could not update time on Reminder {}", self.id));
}
} else {
self.force_delete(pool).await;
self.set_sent(pool).await;
}
}
async fn force_delete(&self, pool: impl Executor<'_, Database = Database> + Copy) {
async fn log_error(&self, error: &'static str, debug_info: Option<impl std::fmt::Debug>) {
let message = match debug_info {
Some(info) => format!(
"{}
{:?}",
error, info
),
None => error.to_string(),
};
error!("[Reminder {}] {}", self.id, message);
}
async fn log_success(&self) {}
async fn set_sent(&self, pool: impl Executor<'_, Database = Database> + Copy) {
sqlx::query!("UPDATE reminders SET `status` = 'sent' WHERE `id` = ?", self.id)
.execute(pool)
.await
.expect(&format!("Could not delete Reminder {}", self.id));
}
async fn set_failed(
&self,
pool: impl Executor<'_, Database = Database> + Copy,
message: &'static str,
) {
sqlx::query!(
"
DELETE FROM reminders WHERE `id` = ?
",
"UPDATE reminders SET `status` = 'failed', `status_message` = ? WHERE `id` = ?",
message,
self.id
)
.execute(pool)
@ -402,8 +459,8 @@ DELETE FROM reminders WHERE `id` = ?
.expect(&format!("Could not delete Reminder {}", self.id));
}
async fn pin_message<M: Into<u64>>(&self, message_id: M, http: impl AsRef<Http>) {
let _ = http.as_ref().pin_message(self.channel_id, message_id.into(), None).await;
async fn pin_message<M: Into<MessageId>>(&self, message_id: M, http: impl AsRef<Http>) {
let _ = http.as_ref().pin_message(self.channel_id.into(), message_id.into(), None).await;
}
pub async fn send(
@ -416,28 +473,24 @@ DELETE FROM reminders WHERE `id` = ?
reminder: &Reminder,
embed: Option<CreateEmbed>,
) -> Result<()> {
let channel = ChannelId(reminder.channel_id).to_channel(&cache_http).await;
let channel = ChannelId::new(reminder.channel_id).to_channel(&cache_http).await;
match channel {
Ok(Channel::Guild(channel)) => {
match channel
.send_message(&cache_http, |m| {
m.content(&reminder.content).tts(reminder.tts);
let mut message = CreateMessage::new().content(&reminder.content).tts(reminder.tts);
if let (Some(attachment), Some(name)) =
(&reminder.attachment, &reminder.attachment_name)
{
m.add_file((attachment as &[u8], name.as_str()));
message =
message.add_file(CreateAttachment::bytes(attachment as &[u8], name.as_str()));
}
if let Some(embed) = embed {
m.set_embed(embed);
message = message.embed(embed);
}
m
})
.await
{
match channel {
Ok(Channel::Guild(channel)) => {
match channel.send_message(&cache_http, message).await {
Ok(m) => {
if reminder.pin {
reminder.pin_message(m.id, cache_http.http()).await;
@ -449,24 +502,7 @@ DELETE FROM reminders WHERE `id` = ?
}
}
Ok(Channel::Private(channel)) => {
match channel
.send_message(&cache_http.http(), |m| {
m.content(&reminder.content).tts(reminder.tts);
if let (Some(attachment), Some(name)) =
(&reminder.attachment, &reminder.attachment_name)
{
m.add_file((attachment as &[u8], name.as_str()));
}
if let Some(embed) = embed {
m.set_embed(embed);
}
m
})
.await
{
match channel.send_message(&cache_http.http(), message).await {
Ok(m) => {
if reminder.pin {
reminder.pin_message(m.id, cache_http.http()).await;
@ -488,33 +524,31 @@ DELETE FROM reminders WHERE `id` = ?
webhook: Webhook,
embed: Option<CreateEmbed>,
) -> Result<()> {
match webhook
.execute(&cache_http.http(), reminder.pin || reminder.restartable, |w| {
w.content(&reminder.content).tts(reminder.tts);
let mut builder = ExecuteWebhook::new().content(&reminder.content).tts(reminder.tts);
if let Some(username) = &reminder.username {
w.username(username);
if !username.is_empty() {
builder = builder.username(username);
}
}
if let Some(avatar) = &reminder.avatar {
w.avatar_url(avatar);
builder = builder.avatar_url(avatar);
}
if let (Some(attachment), Some(name)) =
(&reminder.attachment, &reminder.attachment_name)
{
w.add_file((attachment as &[u8], name.as_str()));
builder =
builder.add_file(CreateAttachment::bytes(attachment as &[u8], name.as_str()));
}
if let Some(embed) = embed {
w.embeds(vec![SerenityEmbed::fake(|c| {
*c = embed;
c
})]);
builder = builder.embeds(vec![embed]);
}
w
})
match webhook
.execute(&cache_http.http(), reminder.pin || reminder.restartable, builder)
.await
{
Ok(m) => {
@ -537,9 +571,7 @@ DELETE FROM reminders WHERE `id` = ?
.map_or(true, |inner| inner >= Utc::now().naive_local()))
{
let _ = sqlx::query!(
"
UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?
",
"UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?",
self.channel_id
)
.execute(pool)
@ -550,13 +582,15 @@ UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?
let result = if let (Some(webhook_id), Some(webhook_token)) =
(self.webhook_id, &self.webhook_token)
{
let webhook_res =
cache_http.http().get_webhook_with_token(webhook_id, webhook_token).await;
let webhook_res = cache_http
.http()
.get_webhook_with_token(webhook_id.into(), webhook_token)
.await;
if let Ok(webhook) = webhook_res {
send_to_webhook(cache_http, &self, webhook, embed).await
} else {
warn!("Webhook vanished: {:?}", webhook_res);
warn!("Webhook vanished for reminder {}: {:?}", self.id, webhook_res);
self.reset_webhook(pool).await;
send_to_channel(cache_http, &self, embed).await
@ -566,24 +600,75 @@ UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?
};
if let Err(e) = result {
error!("Error sending {:?}: {:?}", self, e);
if let Error::Http(error) = e {
if error.status_code() == Some(StatusCode::NOT_FOUND) {
warn!("Seeing channel is deleted. Removing reminder");
self.force_delete(pool).await;
} else if let HttpError::UnsuccessfulRequest(error) = *error {
if error.error.code == 50007 {
warn!("User cannot receive DMs");
self.force_delete(pool).await;
} else {
if let HttpError::UnsuccessfulRequest(http_error) = error {
match http_error.error.code {
10003 => {
self.log_error(
"Could not be sent as channel does not exist",
None::<&'static str>,
)
.await;
self.set_failed(
pool,
"Could not be sent as channel does not exist",
)
.await;
}
10004 => {
self.log_error(
"Could not be sent as guild does not exist",
None::<&'static str>,
)
.await;
self.set_failed(pool, "Could not be sent as guild does not exist")
.await;
}
50001 => {
self.log_error(
"Could not be sent as missing access",
None::<&'static str>,
)
.await;
self.set_failed(pool, "Could not be sent as missing access").await;
}
50007 => {
self.log_error(
"Could not be sent as user has DMs disabled",
None::<&'static str>,
)
.await;
self.set_failed(pool, "Could not be sent as user has DMs disabled")
.await;
}
50013 => {
self.log_error(
"Could not be sent as permissions are invalid",
None::<&'static str>,
)
.await;
self.set_failed(
pool,
"Could not be sent as permissions are invalid",
)
.await;
}
_ => {
self.log_error("HTTP error sending reminder", Some(http_error))
.await;
self.refresh(pool).await;
}
}
} else {
self.log_error("(Likely) a parsing error", Some(error)).await;
self.refresh(pool).await;
}
} else {
self.log_error("Non-HTTP error", Some(e)).await;
self.refresh(pool).await;
}
} else {
self.log_success().await;
self.refresh(pool).await;
}
} else {

View File

@ -0,0 +1,11 @@
[package]
name = "recordable_derive"
version = "0.1.0"
edition = "2021"
[lib]
proc-macro = true
[dependencies]
quote = "1.0.35"
syn = { version = "2.0.49", features = ["full"] }

View File

@ -0,0 +1,42 @@
use proc_macro::TokenStream;
use syn::{spanned::Spanned, Data};
/// Macro to allow Recordable to be implemented on an enum by dispatching over each variant.
#[proc_macro_derive(Recordable)]
pub fn extract(input: TokenStream) -> TokenStream {
let ast = syn::parse_macro_input!(input);
impl_recordable(&ast)
}
fn impl_recordable(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
match &ast.data {
Data::Enum(en) => {
let extracted = en.variants.iter().map(|var| {
let ident = &var.ident;
quote::quote_spanned! {var.span()=>
Self::#ident (opt) => opt.run(ctx).await?
}
});
TokenStream::from(quote::quote! {
impl Recordable for #name {
async fn run(self, ctx: crate::Context<'_>) -> Result<(), crate::Error> {
match self {
#(#extracted,)*
}
Ok(())
}
}
})
}
_ => {
panic!("Only enums can derive Recordable");
}
}
}

View File

@ -0,0 +1,2 @@
printWidth = 100
tabWidth = 4

View File

@ -0,0 +1,19 @@
# reminder-dashboard
The re-re-rewrite of the dashboard.
## Why
The existing beta variant of the dashboard is written using vanilla JavaScript. This is fine,
but annoying to update. This would've been okay if I was more dedicated to updating the vanilla
JavaScript too, but I want to experiment with "new" things.
This also allows me to expand my frontend skills, which is relevant to part of my job.
## Developing
1. Run both `npm run dev` and `cargo run`
2. Symlink assets: assuming cloned
into `$HOME`, `ln -s $HOME/reminder-bot/reminder-dashboard/dist/index.html $HOME/reminder-bot/web/static/index.html`
and
`ln -s $HOME/reminder-bot/reminder-dashboard/dist/static/assets $HOME/reminder-bot/web/static/assets`

View File

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="EN">
<head>
<meta name="description" content="The most powerful Discord Reminders Bot">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8">
<meta name="yandex-verification" content="bb77b8681eb64a90"/>
<meta name="google-site-verification" content="7h7UVTeEe0AOzHiH3cFtsqMULYGN-zCZdMT_YCkW1Ho"/>
<!-- <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *; font-src fonts.gstatic.com 'self'"> -->
<!-- favicon -->
<link rel="apple-touch-icon" sizes="180x180"
href="/static/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32"
href="/static/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16"
href="/static/favicon/favicon-16x16.png">
<link rel="manifest" href="/static/site.webmanifest">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="theme-color" content="#ffffff">
<title>Reminder Bot | Dashboard</title>
<!-- styles -->
<link rel="stylesheet" href="/static/css/bulma.min.css">
<link rel="stylesheet" href="/static/css/fa.css">
<link rel="stylesheet" href="/static/css/font.css">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/dtsel.css">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

5421
reminder-dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,33 @@
{
"name": "example",
"private": true,
"type": "module",
"scripts": {
"dev": "vite build --watch --mode development",
"build": "vite build",
"preview": "vite preview",
"package": "mkdir -p reminder-dashboard/usr/share/reminder-dashboard && vite build --mode production --outDir reminder-dashboard/usr/share/reminder-dashboard && dpkg-deb --build reminder-dashboard"
},
"dependencies": {
"axios": "^1.5.1",
"bulma": "^0.9.4",
"luxon": "^3.4.3",
"preact": "^10.13.1",
"react-colorful": "^5.6.1",
"react-query": "^3.39.3",
"tributejs": "^5.1.3",
"use-debounce": "^10.0.0",
"wouter": "^3.0"
},
"devDependencies": {
"@preact/preset-vite": "^2.5.0",
"@types/luxon": "^3.3.2",
"eslint": "^8.50.0",
"eslint-config-preact": "^1.3.0",
"prettier": "^3.0.3",
"react-datepicker": "^4.21.0",
"sass": "^1.71.1",
"typescript": "^5.2.2",
"vite": "^5.1"
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,91 @@
.date-selector-wrapper {
width: 200px;
padding: 3px;
background-color: #fff;
box-shadow: 1px 1px 10px 1px #5c5c5c;
position: absolute;
font-size: 12px;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
-o-user-select: none;
/* user-select: none; */
}
.cal-header, .cal-row {
display: flex;
width: 100%;
height: 30px;
line-height: 30px;
text-align: center;
}
.cal-cell, .cal-nav {
cursor: pointer;
}
.cal-day-names {
height: 25px;
line-height: 25px;
}
.cal-day-names .cal-cell {
cursor: default;
font-weight: bold;
}
.cal-cell-prev, .cal-cell-next {
color: #777;
}
.cal-months .cal-row, .cal-years .cal-row {
height: 60px;
line-height: 60px;
}
.cal-nav-prev, .cal-nav-next {
flex: 0.15;
}
.cal-nav-current {
flex: 0.75;
font-weight: bold;
}
.cal-months .cal-cell, .cal-years .cal-cell {
flex: 0.25;
}
.cal-days .cal-cell {
flex: 0.143;
}
.cal-value {
color: #fff;
background-color: #286090;
}
.cal-cell:hover, .cal-nav:hover {
background-color: #eee;
}
.cal-value:hover {
background-color: #204d74;
}
/* time footer */
.cal-time {
display: flex;
justify-content: flex-start;
height: 27px;
line-height: 27px;
}
.cal-time-label, .cal-time-value {
flex: 0.12;
text-align: center;
}
.cal-time-slider {
flex: 0.77;
background-image: linear-gradient(to right, #d1d8dd, #d1d8dd);
background-repeat: no-repeat;
background-size: 100% 1px;
background-position: left 50%;
height: 100%;
}
.cal-time-slider input {
width: 100%;
-webkit-appearance: none;
background: 0 0;
cursor: pointer;
height: 100%;
outline: 0;
user-select: auto;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,63 @@
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 300;
src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightItalic'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZMkids18E.ttf) format('truetype');
font-display: swap;
}
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 400;
src: local('Source Sans Pro Italic'), local('SourceSansPro-Italic'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xK1dSBYKcSV-LCoeQqfX1RYOo3qPZ7nsDc.ttf) format('truetype');
font-display: swap;
}
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 600;
src: local('Source Sans Pro SemiBold Italic'), local('SourceSansPro-SemiBoldItalic'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZY4lCds18E.ttf) format('truetype');
font-display: swap;
}
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKydSBYKcSV-LCoeQqfX1RYOo3ik4zwlxdr.ttf) format('truetype');
font-display: swap;
}
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xK3dSBYKcSV-LCoeQqfX1RYOo3qOK7g.ttf) format('truetype');
font-display: swap;
}
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
src: local('Source Sans Pro SemiBold'), local('SourceSansPro-SemiBold'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKydSBYKcSV-LCoeQqfX1RYOo3i54rwlxdr.ttf) format('truetype');
font-display: swap;
}
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 700;
src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwlxdr.ttf) format('truetype');
font-display: swap;
}
@font-face {
font-family: 'Ubuntu';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/ubuntu/v15/4iCs6KVjbNBYlgo6eA.ttf) format('truetype');
font-display: swap;
}
@font-face {
font-family: 'Ubuntu';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/ubuntu/v15/4iCv6KVjbNBYlgoCxCvTtw.ttf) format('truetype');
font-display: swap;
}

View File

@ -0,0 +1,888 @@
* {
font-family: "Ubuntu Bold", "Ubuntu", sans-serif;
}
button {
font-weight: 700;
}
/* override styles for when the div is collapsed */
div.reminderContent.is-collapsed .column.discord-frame {
display: none;
}
div.reminderContent.is-collapsed .column.settings {
display: none;
}
div.reminderContent.is-collapsed .reminder-settings {
margin-bottom: 0;
}
div.reminderContent.is-collapsed .button-row {
display: none;
}
div.reminderContent.is-collapsed .button-row-edit {
display: none;
}
div.reminderContent.is-collapsed .reminder-topbar {
padding-bottom: 0;
}
div.reminderContent.is-collapsed .invert-collapses {
display: inline-flex;
}
div.reminderContent .invert-collapses {
display: none;
}
div.reminderContent.is-collapsed input[name="name"] {
display: inline-flex;
flex-grow: 1;
border: none;
background: none;
box-shadow: none;
opacity: 1;
}
div.reminderContent.is-collapsed .hide-box {
display: inline-flex;
}
div.reminderContent.is-collapsed .hide-box i {
transform: rotate(90deg);
}
/* END */
/* dashboard styles */
.hide-box {
border: none;
background: none;
}
.hide-box:focus {
outline: none;
box-shadow: none !important;
}
.channel-bar {
display: flex;
justify-content: center;
flex-direction: column;
font-weight: bold;
}
button.inline-btn {
height: 100%;
padding: 5px;
}
button.change-color {
position: absolute;
left: calc(-1rem - 40px);
}
button.disable-enable[data-action="enable"]:after {
content: "Enable";
}
button.disable-enable[data-action="disable"]:after {
content: "Disable";
}
.media-content {
overflow-x: visible;
}
div.discord-embed {
position: relative;
}
div.split-controls {
display: flex;
flex-direction: column;
justify-content: space-between;
flex-grow: 2;
}
.reminder-topbar > div {
padding-left: 6px;
padding-right: 6px;
}
.settings {
display: flex;
flex-direction: column;
}
.name-bar {
flex-grow: 1;
flex-shrink: 1;
}
.hide-button-bar {
flex-grow: 0;
flex-shrink: 0;
}
.patreon-only {
padding-bottom: 16px;
}
.tts-row {
padding-bottom: 10px;
}
.reminder-topbar {
display: flex;
margin-bottom: 0 !important;
}
.reminder-settings {
margin-top: 0 !important;
}
.reminder-settings > .column {
flex-grow: 0;
flex-shrink: 0;
flex-basis: 50%;
}
div.reminderContent {
margin-top: 10px;
margin-bottom: 10px;
padding: 14px;
background-color: #f5f5f5;
border-radius: 8px;
}
/* Interval inputs */
div.interval-group {
height: unset !important;
}
div.interval-group .clear:focus {
outline: none;
box-shadow: none !important;
}
div.interval-group .no-break {
text-wrap: avoid;
white-space: nowrap;
}
div.interval-group .clear {
border: none;
background: none;
padding: 1px;
margin-right: -3px;
}
div.interval-group > .interval-group-left input {
-webkit-appearance: none;
border-style: none;
background-color: #eee;
font-size: 1rem;
font-family: monospace;
}
div.interval-group > .interval-group-left input.w2 {
width: 3ch;
}
div.interval-group > .interval-group-left input.w3 {
width: 3ch;
}
div.interval-group {
display: flex;
flex-direction: row;
justify-content: space-between;
}
/* !Interval inputs */
.left-pad {
padding-left: 1rem;
padding-right: 0.2rem;
}
.notification {
padding-right: 1.5rem;
}
div.inset-content {
margin-left: 10%;
margin-right: 10%;
}
div.flash-message {
position: fixed;
width: calc(100% - 32px);
margin: 16px !important;
z-index: 99;
bottom: 0;
display: none;
}
div.flash-message.is-active {
display: block;
}
body {
min-height: 100vh;
}
span.spacer {
width: 10px;
}
nav .dashboard-button {
background: white ;
}
span.patreon-color {
color: #f96854;
}
p.pageTitle {
margin-left: 12px;
}
#welcome > div {
height: 100%;
padding-top: 30vh;
}
div#pageNavbar {
background-color: #363636;
}
div#pageNavbar a {
color: #fff;
text-align: center;
}
.navbar-burger {
flex-shrink: 0;
}
.navbar-item.pageTitle {
flex-shrink: 1;
white-space: nowrap;
overflow: hidden;
}
.dashboard-burger, .dashboard-burger:active, .dashboard-burger.is-active {
background-color: #adc99c !important;
border-radius: 14px;
padding: 6px;
background-clip: content-box;
}
div#pageNavbar a:hover {
background-color: #4a4a4a;
}
img.rounded-corners {
border-radius: 12px;
}
div.brand {
text-align: center;
height: 52px;
background-color: #8fb677;
}
img.dashboard-brand {
text-align: center;
height: 100%;
width: auto;
}
div.dashboard-sidebar {
background-color: #363636;
width: 230px !important;
padding-right: 0;
}
ul.guildList {
flex-grow: 1;
flex-shrink: 1;
overflow: auto;
}
div.dashboard-sidebar:not(.mobile-sidebar) .aside-footer {
flex-shrink: 0;
flex-grow: 0;
position: fixed;
bottom: 0;
width: 226px;
}
div.dashboard-sidebar svg {
flex-shrink: 0;
}
div.mobile-sidebar {
z-index: 100;
min-height: 100vh;
position: absolute;
top: 0;
display: none;
flex-direction: column;
}
#expandAll {
width: 60px;
}
div.mobile-sidebar .aside-footer {
margin-top: auto;
}
div.mobile-sidebar.is-active {
display: flex;
}
aside.menu {
display: flex;
flex-direction: column;
flex-grow: 1;
}
div.dashboard-frame {
min-height: 100vh;
margin-bottom: 0 !important;
}
.embed-field-box[data-inlined="0"] .inline-btn > i {
transform: rotate(90deg);
}
.embed-field-box[data-inlined="0"] {
min-width: 100%;
}
.embed-field-box[data-inlined="1"] {
min-width: auto;
}
.menu a {
color: #fff;
}
.menu .menu-label {
color: #bbb;
}
.menu {
padding-left: 4px;
}
.dashboard-navbar {
background-color: #8fb677 !important;
position: absolute;
top: 0;
width: 100%;
}
textarea.autoresize {
resize: none;
}
textarea, input {
width: 100%;
}
input.default-width {
width: initial;
}
.message-input:placeholder-shown {
font-style: italic;
background-color: #40444b;
color: #fff;
}
.message-input {
border: none;
background-color: rgba(0, 0, 0, 0);
color: #fff;
}
.time-input {
border-top: none;
border-left: none;
border-right: none;
border-bottom-style: solid;
background-color: #40444b;
color: #fff;
width: 120px;
font-size: 0.875rem;
}
.message-input::placeholder {
color: #72767b;
}
.discord-title {
font-weight: bold;
font-size: 1rem;
margin: 4px 0 4px 0;
}
.discord-description {
font-size: 0.875rem;
}
.discord-username {
font-size: 1rem;
font-weight: bold;
margin-bottom: 4px;
width: initial;
}
.discord-message-header {
white-space: nowrap;
margin-bottom: 8px;
}
.discord-content {
margin-bottom: 8px;
}
.customizable img {
background-color: #72767b;
border-radius: 8px;
}
.customizable.is-20x20 img {
width: 20px;
height: 20px;
}
.customizable.is-24x24 img {
width: 24px;
height: 24px;
}
.customizable.is-400x300 img {
margin-top: 10px;
width: 100%;
height: 100px;
}
.customizable.is-32x32 img {
width: 32px;
height: 32px;
}
.customizable.thumbnail img {
width: 100px;
height: 100px;
}
.customizable input.imageInput {
display: none;
position: absolute;
top: 0;
left: 36px;
width: 400px;
}
.customizable.thumbnail input.imageInput {
display: none;
position: absolute;
top: 0;
left: -400px;
width: 400px;
}
.customizable input.is-active {
display: block !important;
}
.discord-frame {
color: #fff;
padding: 10px;
border-radius: 8px;
background-color: #36393f;
}
.discord-embed {
padding: 8px 16px 16px 12px;
margin: 0 20px 4px 0;
border-radius: 4px;
border-left: 4px solid #fff;
background-color: #2f3136;
}
.embed-author-box {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.embed-author-box > .a {
flex: initial;
}
.embed-author-box > .b {
flex: auto;
}
.embed-footer-box {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.embed-author-box .image {
margin: 0 8px 0 0 !important;
}
.embed-footer-box .image {
margin: 0 8px 0 0 !important;
}
.discord-embed-author {
display: inline-block;
font-size: 0.875rem;
font-weight: bold;
}
.discord-embed-footer {
font-size: 0.75rem;
}
.embed-body {
display: flex;
}
.embed-body > .a {
flex-grow: 1;
flex-shrink: 1;
flex-basis: auto;
margin-right: 4px;
}
.embed-body input, .embed-body textarea {
min-width: 0;
}
.embed-body > .b {
flex-grow: 0;
flex-shrink: 0;
flex-basis: auto;
}
.discord-field-title, .discord-field-value {
max-width: 120px;
}
.discord-field-title {
font-weight: bold;
}
.embed-field-box {
margin: 12px 8px 0 0;
max-width: 120px;
flex: initial;
}
.field-input {
font-size: 0.875rem;
width: 120px;
}
.embed-multifield-box {
display: flex;
max-width: 100%;
flex-wrap: wrap;
}
.channel-select {
font-size: 1.125rem;
margin-bottom: 4px;
margin-left: 48px;
display: inline-flex;
font-weight: bold;
color: #6e89da;
width: auto;
border-radius: 2px;
border-bottom: 1px solid #fff;
}
.channel-selector {
width: 100%;
}
.select {
width: 100%;
}
li.highlight {
margin-bottom: 0 !important;
}
.button-row {
display: flex;
}
.button-row .button-row-reminder {
flex-grow: 0;
padding: 2px;
}
.button-row-template {
display: flex;
flex-grow: 1;
justify-content: space-between;
}
.button-row .button-row-template > div {
padding: 2px;
}
@media only screen and (max-width: 1023px) {
p.title.pageTitle {
display: none;
}
.dashboard-frame {
margin-top: 4rem !important;
}
.customizable.thumbnail img {
width: 60px;
height: 60px;
}
}
@media only screen and (max-width: 768px) {
.button-row {
display: flex;
flex-direction: column;
}
.button-row .button-row-reminder {
width: 100%;
}
.button-row .button-row-template > div {
flex-basis: 0;
flex-grow: 1;
}
.button-row button {
width: 100%;
}
.reminder-settings {
margin-bottom: 0 !important;
}
.tts-row {
padding-bottom: 0;
}
}
/* loader */
#loader {
position: fixed;
top: 0;
background-color: rgba(255, 255, 255, 0.8);
width: 100vw;
z-index: 999;
}
#loader .title {
font-size: 6rem;
}
/* END */
div.reminderError {
margin: 10px;
padding: 14px;
background-color: #f5f5f5;
border-radius: 8px;
}
div.reminderError .errorHead {
display: flex;
flex-direction: row;
}
div.reminderError .errorIcon {
padding: 8px;
border-radius: 4px;
margin-right: 12px;
}
div.reminderError .errorIcon .fas {
display: none
}
div.reminderError[data-case="deleted"] .errorIcon {
background-color: #e7e5e4;
}
div.reminderError[data-case="failed"] .errorIcon {
background-color: #fecaca;
}
div.reminderError[data-case="sent"] .errorIcon {
background-color: #d9f99d;
}
div.reminderError[data-case="deleted"] .errorIcon .fas.fa-trash {
display: block;
}
div.reminderError[data-case="failed"] .errorIcon .fas.fa-exclamation-triangle {
display: block;
}
div.reminderError[data-case="sent"] .errorIcon .fas.fa-check {
display: block;
}
div.reminderError .errorHead .reminderName {
font-size: 1rem;
display: flex;
flex-direction: column;
justify-content: center;
color: rgb(54, 54, 54);
flex-grow: 1;
}
div.reminderError .errorHead .reminderTime {
font-size: 1rem;
display: flex;
flex-direction: column;
flex-shrink: 1;
justify-content: center;
color: rgb(54, 54, 54);
background-color: #ffffff;
padding: 8px;
border-radius: 4px;
border-color: #e5e5e5;
border-width: 1px;
border-style: solid;
}
div.reminderError .reminderMessage {
font-size: 1rem;
display: flex;
flex-direction: column;
justify-content: center;
color: rgb(54, 54, 54);
flex-grow: 1;
font-style: italic;
}
/* other stuff */
.half-rem {
width: 0.5rem;
}
.pad-left {
width: 12px;
}
#dead {
display: none;
}
.colorpicker-container {
display: flex;
justify-content: center;
}
.create-reminder {
margin: 0 12px 12px 12px;
}
.button.is-success:not(.is-outlined) {
color: white;
}
.button.is-outlined.is-success {
background-color: white;
}
a.switch-pane {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.guild-submenu {
display: none;
}
.guild-submenu li {
font-size: 0.8rem;
}
a.switch-pane.is-active ~ .guild-submenu {
display: block;
}
.feedback {
background-color: #5865F2;
}
.is-locked {
pointer-events: none;
}
.is-locked > :not(.patreon-invert) {
opacity: 0.4;
}
.is-locked .patreon-invert {
display: block;
}
.patreon-invert {
display: none;
}
.is-locked .foreground {
pointer-events: auto;
}
.is-locked .field:last-of-type {
display: none;
}
.stat-row {
display: flex;
flex-direction: row;
}
.stat-box {
flex-grow: 1;
border-radius: 6px;
background-color: #fcfcfc;
border-color: #efefef;
border-style: solid;
border-width: 1px;
margin: 4px;
padding: 4px;
}
.figure {
text-align: center;
}
.figure-num {
font-size: 2rem;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@ -0,0 +1,131 @@
document.addEventListener("DOMContentLoaded", () => {
fetch("/admin/data")
.then((resp) => resp.json())
.then((data) => {
document.querySelector("#backlog").textContent = data.backlog;
document.querySelector("#reminders").textContent = data.count.reminders;
document.querySelector("#intervals").textContent = data.count.intervals;
let historySent = data.historyLong.sent.reduce(
(iv, frame) => iv + frame.count,
0
);
let historyFailed = data.historyLong.failed.reduce(
(iv, frame) => iv + frame.count,
0
);
let rate = historyFailed / (historySent + historyFailed);
let formatted = Math.round(rate * 10000) / 100;
document.querySelector("#historySent").textContent = historySent;
document.querySelector("#historyFailed").textContent = historyFailed;
document.querySelector("#failRate").textContent = `${formatted}%`;
new Chart(document.getElementById("schedule"), {
type: "bar",
data: {
labels: [
...data.scheduleShort.once,
...data.scheduleShort.interval,
].map((row) => luxon.DateTime.fromISO(row.time_key)),
datasets: [
{
label: "Reminders",
data: data.scheduleShort.once.map((row) => row.count),
},
{
label: "Intervals",
data: data.scheduleShort.interval.map((row) => row.count),
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
type: "time",
time: {
unit: "minute",
},
},
y: {
stacked: true,
},
},
},
});
new Chart(document.getElementById("scheduleLong"), {
type: "bar",
data: {
labels: [
...data.scheduleLong.once,
...data.scheduleLong.interval,
].map((row) => luxon.DateTime.fromISO(row.time_key)),
datasets: [
{
label: "Reminders",
data: data.scheduleLong.once.map((row) => row.count),
},
{
label: "Intervals",
data: data.scheduleLong.interval.map((row) => row.count),
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
type: "time",
time: {
unit: "day",
},
},
y: {
stacked: true,
},
},
},
});
new Chart(document.getElementById("historyLong"), {
type: "bar",
data: {
labels: [...data.historyLong.sent, ...data.historyLong.failed].map(
(row) => luxon.DateTime.fromISO(row.time_key)
),
datasets: [
{
label: "Success",
data: data.historyLong.sent.map((row) => row.count),
},
{
label: "Fail",
data: data.historyLong.failed.map((row) => row.count),
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
type: "time",
time: {
unit: "day",
},
},
y: {
stacked: true,
},
},
},
});
});
});

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,7 @@
/*!
* chartjs-adapter-luxon v1.3.1
* https://www.chartjs.org
* (c) 2023 chartjs-adapter-luxon Contributors
* Released under the MIT license
*/
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(require("chart.js"),require("luxon")):"function"==typeof define&&define.amd?define(["chart.js","luxon"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).Chart,e.luxon)}(this,(function(e,t){"use strict";const n={datetime:t.DateTime.DATETIME_MED_WITH_SECONDS,millisecond:"h:mm:ss.SSS a",second:t.DateTime.TIME_WITH_SECONDS,minute:t.DateTime.TIME_SIMPLE,hour:{hour:"numeric"},day:{day:"numeric",month:"short"},week:"DD",month:{month:"short",year:"numeric"},quarter:"'Q'q - yyyy",year:{year:"numeric"}};e._adapters._date.override({_id:"luxon",_create:function(e){return t.DateTime.fromMillis(e,this.options)},init(e){this.options.locale||(this.options.locale=e.locale)},formats:function(){return n},parse:function(e,n){const i=this.options,r=typeof e;return null===e||"undefined"===r?null:("number"===r?e=this._create(e):"string"===r?e="string"==typeof n?t.DateTime.fromFormat(e,n,i):t.DateTime.fromISO(e,i):e instanceof Date?e=t.DateTime.fromJSDate(e,i):"object"!==r||e instanceof t.DateTime||(e=t.DateTime.fromObject(e,i)),e.isValid?e.valueOf():null)},format:function(e,t){const n=this._create(e);return"string"==typeof t?n.toFormat(t):n.toLocaleString(t)},add:function(e,t,n){const i={};return i[n]=t,this._create(e).plus(i).valueOf()},diff:function(e,t,n){return this._create(e).diff(this._create(t)).as(n).valueOf()},startOf:function(e,t,n){if("isoWeek"===t){n=Math.trunc(Math.min(Math.max(0,n),6));const t=this._create(e);return t.minus({days:(t.weekday-n+7)%7}).startOf("day").valueOf()}return t?this._create(e).startOf(t).valueOf():e},endOf:function(e,t){return this._create(e).endOf(t).valueOf()}})}));

View File

@ -0,0 +1,931 @@
(function () {
"use strict";
var BODYTYPES = ["DAYS", "MONTHS", "YEARS"];
var MONTHS = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
];
var WEEKDAYS = [
"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
];
/** @typedef {Object.<string, Function[]>} Handlers */
/** @typedef {function(String, Function): null} AddHandler */
/** @typedef {("DAYS"|"MONTHS"|"YEARS")} BodyType */
/** @typedef {string|number} StringNum */
/** @typedef {Object.<string, StringNum>} StringNumObj */
/**
* The local state
* @typedef {Object} InstanceState
* @property {Date} value
* @property {Number} year
* @property {Number} month
* @property {Number} day
* @property {Number} time
* @property {Number} hours
* @property {Number} minutes
* @property {Number} seconds
* @property {BodyType} bodyType
* @property {Boolean} visible
* @property {Number} cancelBlur
*/
/**
* @typedef {Object} Config
* @property {String} dateFormat
* @property {String} timeFormat
* @property {Boolean} showDate
* @property {Boolean} showTime
* @property {Number} paddingX
* @property {Number} paddingY
* @property {BodyType} defaultView
* @property {"TOP"|"BOTTOM"} direction
*/
/**
* @class
* @param {HTMLElement} elem
* @param {Config} config
*/
function DTS(elem, config) {
var config = config || {};
/** @type {Config} */
var defaultConfig = {
defaultView: BODYTYPES[0],
dateFormat: "yyyy-mm-dd",
timeFormat: "HH:MM:SS",
showDate: true,
showTime: false,
paddingX: 5,
paddingY: 5,
direction: 'TOP'
}
if (!elem) {
throw TypeError("input element or selector required for contructor");
}
if (Object.getPrototypeOf(elem) === String.prototype) {
var _elem = document.querySelectorAll(elem);
if (!_elem[0]){
throw Error('"' + elem + '" not found.');
}
elem = _elem[0];
}
this.config = setDefaults(config, defaultConfig);
this.dateFormat = this.config.dateFormat;
this.timeFormat = this.config.timeFormat;
this.dateFormatRegEx = new RegExp("yyyy|yy|mm|dd", "gi");
this.timeFormatRegEx = new RegExp("hh|mm|ss|a", "gi");
this.inputElem = elem;
this.dtbox = null;
this.setup();
}
DTS.prototype.setup = function () {
var handler = this.inputElemHandler.bind(this);
this.inputElem.addEventListener("focus", handler, false)
this.inputElem.addEventListener("blur", handler, false);
}
DTS.prototype.inputElemHandler = function (e) {
if (e.type == "focus") {
if (!this.dtbox) {
this.dtbox = new DTBox(e.target, this);
}
this.dtbox.visible = true;
} else if (e.type == "blur" && this.dtbox && this.dtbox.visible) {
var self = this;
setTimeout(function () {
if (self.dtbox.cancelBlur > 0) {
self.dtbox.cancelBlur -= 1;
} else {
self.dtbox.visible = false;
self.inputElem.blur();
}
}, 100);
}
}
/**
* @class
* @param {HTMLElement} elem
* @param {DTS} settings
*/
function DTBox(elem, settings) {
/** @type {DTBox} */
var self = this;
/** @type {Handlers} */
var handlers = {};
/** @type {InstanceState} */
var localState = {};
/**
* @param {String} key
* @param {*} default_val
*/
function getterSetter(key, default_val) {
return {
get: function () {
var val = localState[key];
return val === undefined ? default_val : val;
},
set: function (val) {
var prevState = self.state;
var _handlers = handlers[key] || [];
localState[key] = val;
for (var i = 0; i < _handlers.length; i++) {
_handlers[i].bind(self)(localState, prevState);
}
},
};
};
/** @type {AddHandler} */
function addHandler(key, handlerFn) {
if (!key || !handlerFn) {
return false;
}
if (!handlers[key]) {
handlers[key] = [];
}
handlers[key].push(handlerFn);
}
Object.defineProperties(this, {
visible: getterSetter("visible", false),
bodyType: getterSetter("bodyType", settings.config.defaultView),
value: getterSetter("value"),
year: getterSetter("year", 0),
month: getterSetter("month", 0),
day: getterSetter("day", 0),
hours: getterSetter("hours", 0),
minutes: getterSetter("minutes", 0),
seconds: getterSetter("seconds", 0),
cancelBlur: getterSetter("cancelBlur", 0),
addHandler: {value: addHandler},
month_long: {
get: function () {
return MONTHS[self.month];
},
},
month_short: {
get: function () {
return self.month_long.slice(0, 3);
},
},
state: {
get: function () {
return Object.assign({}, localState);
},
},
time: {
get: function() {
var hours = self.hours * 60 * 60 * 1000;
var minutes = self.minutes * 60 * 1000;
var seconds = self.seconds * 1000;
return hours + minutes + seconds;
}
},
});
this.el = {};
this.settings = settings;
this.elem = elem;
this.setup();
}
DTBox.prototype.setup = function () {
Object.defineProperties(this.el, {
wrapper: { value: null, configurable: true },
header: { value: null, configurable: true },
body: { value: null, configurable: true },
footer: { value: null, configurable: true }
});
this.setupWrapper();
if (this.settings.config.showDate) {
this.setupHeader();
this.setupBody();
}
if (this.settings.config.showTime) {
this.setupFooter();
}
var self = this;
this.addHandler("visible", function (state, prevState) {
if (state.visible && !prevState.visible){
document.body.appendChild(this.el.wrapper);
var parts = self.elem.value.split(/\s*,\s*/);
var startDate = undefined;
var startTime = 0;
if (self.settings.config.showDate) {
startDate = parseDate(parts[0], self.settings);
}
if (self.settings.config.showTime) {
startTime = parseTime(parts[parts.length-1], self.settings);
startTime = startTime || 0;
}
if (!(startDate && startDate.getTime())) {
startDate = new Date();
startDate = new Date(
startDate.getFullYear(),
startDate.getMonth(),
startDate.getDate()
);
}
var value = new Date(startDate.getTime() + startTime);
self.value = value;
self.year = value.getFullYear();
self.month = value.getMonth();
self.day = value.getDate();
self.hours = value.getHours();
self.minutes = value.getMinutes();
self.seconds = value.getSeconds();
if (self.settings.config.showDate) {
self.setHeaderContent();
self.setBodyContent();
}
if (self.settings.config.showTime) {
self.setFooterContent();
}
} else if (!state.visible && prevState.visible) {
document.body.removeChild(this.el.wrapper);
}
});
}
DTBox.prototype.setupWrapper = function () {
if (!this.el.wrapper) {
var el = document.createElement("div");
el.classList.add("date-selector-wrapper");
Object.defineProperty(this.el, "wrapper", { value: el });
}
var self = this;
var htmlRoot = document.getElementsByTagName('html')[0];
function setPosition(e){
var minTopSpace = 300;
var box = getOffset(self.elem);
var config = self.settings.config;
var paddingY = config.paddingY || 5;
var paddingX = config.paddingX || 5;
var top = box.top + self.elem.offsetHeight + paddingY;
var left = box.left + paddingX;
var bottom = htmlRoot.clientHeight - box.top + paddingY;
self.el.wrapper.style.left = `${left}px`;
if (box.top > minTopSpace && config.direction != 'BOTTOM') {
self.el.wrapper.style.bottom = `${bottom}px`;
self.el.wrapper.style.top = '';
} else {
self.el.wrapper.style.top = `${top}px`;
self.el.wrapper.style.bottom = '';
}
}
function handler(e) {
self.cancelBlur += 1;
setTimeout(function(){
self.elem.focus();
}, 50);
}
setPosition();
this.setPosition = setPosition;
this.el.wrapper.addEventListener("mousedown", handler, false);
this.el.wrapper.addEventListener("touchstart", handler, false);
window.addEventListener('resize', this.setPosition);
}
DTBox.prototype.setupHeader = function () {
if (!this.el.header) {
var row = document.createElement("div");
var classes = ["cal-nav-prev", "cal-nav-current", "cal-nav-next"];
row.classList.add("cal-header");
for (var i = 0; i < 3; i++) {
var cell = document.createElement("div");
cell.classList.add("cal-nav", classes[i]);
cell.onclick = this.onHeaderChange.bind(this);
row.appendChild(cell);
}
row.children[0].innerHTML = "&lt;";
row.children[2].innerHTML = "&gt;";
Object.defineProperty(this.el, "header", { value: row });
tryAppendChild(row, this.el.wrapper);
}
this.setHeaderContent();
}
DTBox.prototype.setHeaderContent = function () {
var content = this.year;
if ("DAYS" == this.bodyType) {
content = this.month_long + " " + content;
} else if ("YEARS" == this.bodyType) {
var start = this.year + 10 - (this.year % 10);
content = start - 10 + "-" + (start - 1);
}
this.el.header.children[1].innerText = content;
}
DTBox.prototype.setupBody = function () {
if (!this.el.body) {
var el = document.createElement("div");
el.classList.add("cal-body");
Object.defineProperty(this.el, "body", { value: el });
tryAppendChild(el, this.el.wrapper);
}
var toAppend = null;
function makeGrid(rows, cols, className, firstRowClass, clickHandler) {
var grid = document.createElement("div");
grid.classList.add(className);
for (var i = 1; i < rows + 1; i++) {
var row = document.createElement("div");
row.classList.add("cal-row", "cal-row-" + i);
if (i == 1 && firstRowClass) {
row.classList.add(firstRowClass);
}
for (var j = 1; j < cols + 1; j++) {
var col = document.createElement("div");
col.classList.add("cal-cell", "cal-col-" + j);
col.onclick = clickHandler;
row.appendChild(col);
}
grid.appendChild(row);
}
return grid;
}
if ("DAYS" == this.bodyType) {
toAppend = this.el.body.calDays;
if (!toAppend) {
toAppend = makeGrid(7, 7, "cal-days", "cal-day-names", this.onDateSelected.bind(this));
for (var i = 0; i < 7; i++) {
var cell = toAppend.children[0].children[i];
cell.innerText = WEEKDAYS[i].slice(0, 2);
cell.onclick = null;
}
this.el.body.calDays = toAppend;
}
} else if ("MONTHS" == this.bodyType) {
toAppend = this.el.body.calMonths;
if (!toAppend) {
toAppend = makeGrid(3, 4, "cal-months", null, this.onMonthSelected.bind(this));
for (var i = 0; i < 3; i++) {
for (var j = 0; j < 4; j++) {
var monthShort = MONTHS[4 * i + j].slice(0, 3);
toAppend.children[i].children[j].innerText = monthShort;
}
}
this.el.body.calMonths = toAppend;
}
} else if ("YEARS" == this.bodyType) {
toAppend = this.el.body.calYears;
if (!toAppend) {
toAppend = makeGrid(3, 4, "cal-years", null, this.onYearSelected.bind(this));
this.el.body.calYears = toAppend;
}
}
empty(this.el.body);
tryAppendChild(toAppend, this.el.body);
this.setBodyContent();
}
DTBox.prototype.setBodyContent = function () {
var grid = this.el.body.children[0];
var classes = ["cal-cell-prev", "cal-cell-next", "cal-value"];
if ("DAYS" == this.bodyType) {
var oneDayMilliSecs = 24 * 60 * 60 * 1000;
var start = new Date(this.year, this.month, 1);
var adjusted = new Date(start.getTime() - oneDayMilliSecs * start.getDay());
grid.children[6].style.display = "";
for (var i = 1; i < 7; i++) {
for (var j = 0; j < 7; j++) {
var cell = grid.children[i].children[j];
var month = adjusted.getMonth();
var date = adjusted.getDate();
cell.innerText = date;
cell.classList.remove(classes[0], classes[1], classes[2]);
if (month != this.month) {
if (i == 6 && j == 0) {
grid.children[6].style.display = "none";
break;
}
cell.classList.add(month < this.month ? classes[0] : classes[1]);
} else if (isEqualDate(adjusted, this.value)){
cell.classList.add(classes[2]);
}
adjusted = new Date(adjusted.getTime() + oneDayMilliSecs);
}
}
} else if ("YEARS" == this.bodyType) {
var year = this.year - (this.year % 10) - 1;
for (i = 0; i < 3; i++) {
for (j = 0; j < 4; j++) {
grid.children[i].children[j].innerText = year;
year += 1;
}
}
grid.children[0].children[0].classList.add(classes[0]);
grid.children[2].children[3].classList.add(classes[1]);
}
}
/** @param {Event} e */
DTBox.prototype.onTimeChange = function(e) {
e.stopPropagation();
if (e.type == 'mousedown') {
this.cancelBlur += 1;
return;
}
var el = e.target;
this[el.name] = parseInt(el.value) || 0;
this.setupFooter();
if (e.type == 'change') {
var self = this;
setTimeout(function(){
self.elem.focus();
}, 50);
}
this.setInputValue();
}
DTBox.prototype.setupFooter = function() {
if (!this.el.footer) {
var footer = document.createElement("div");
var handler = this.onTimeChange.bind(this);
var self = this;
function makeRow(label, name, range, changeHandler) {
var row = document.createElement("div");
row.classList.add('cal-time');
var labelCol = row.appendChild(document.createElement("div"));
labelCol.classList.add('cal-time-label');
labelCol.innerText = label;
var valueCol = row.appendChild(document.createElement("div"));
valueCol.classList.add('cal-time-value');
valueCol.innerText = '00';
var inputCol = row.appendChild(document.createElement("div"));
var slider = inputCol.appendChild(document.createElement("input"));
Object.assign(slider, {step:1, min:0, max:range, name:name, type:'range'});
Object.defineProperty(footer, name, {value: slider});
inputCol.classList.add('cal-time-slider');
slider.onchange = changeHandler;
slider.oninput = changeHandler;
slider.onmousedown = changeHandler;
self[name] = self[name] || parseInt(slider.value) || 0;
footer.appendChild(row)
}
makeRow('HH:', 'hours', 23, handler);
makeRow('MM:', 'minutes', 59, handler);
makeRow('SS:', 'seconds', 59, handler);
footer.classList.add("cal-footer");
Object.defineProperty(this.el, "footer", { value: footer });
tryAppendChild(footer, this.el.wrapper);
}
this.setFooterContent();
}
DTBox.prototype.setFooterContent = function() {
if (this.el.footer) {
var footer = this.el.footer;
footer.hours.value = this.hours;
footer.children[0].children[1].innerText = padded(this.hours, 2);
footer.minutes.value = this.minutes;
footer.children[1].children[1].innerText = padded(this.minutes, 2);
footer.seconds.value = this.seconds;
footer.children[2].children[1].innerText = padded(this.seconds, 2);
}
}
DTBox.prototype.setInputValue = function() {
var date = new Date(this.year, this.month, this.day);
var strings = [];
if (this.settings.config.showDate) {
strings.push(renderDate(date, this.settings));
}
if (this.settings.config.showTime) {
var joined = new Date(date.getTime() + this.time);
strings.push(renderTime(joined, this.settings));
}
this.elem.value = strings.join(', ');
}
DTBox.prototype.onDateSelected = function (e) {
var row = e.target.parentNode;
var date = parseInt(e.target.innerText);
if (!(row.nextSibling && row.nextSibling.nextSibling) && date < 8) {
this.month += 1;
} else if (!(row.previousSibling && row.previousSibling.previousSibling) && date > 7) {
this.month -= 1;
}
this.day = parseInt(e.target.innerText);
this.value = new Date(this.year, this.month, this.day);
this.setInputValue();
this.setHeaderContent();
this.setBodyContent();
}
/** @param {Event} e */
DTBox.prototype.onMonthSelected = function (e) {
var col = 0;
var row = 2;
var cell = e.target;
if (cell.parentNode.nextSibling){
row = cell.parentNode.previousSibling ? 1: 0;
}
if (cell.previousSibling) {
col = 3;
if (cell.nextSibling) {
col = cell.previousSibling.previousSibling ? 2 : 1;
}
}
this.month = 4 * row + col;
this.bodyType = "DAYS";
this.setHeaderContent();
this.setupBody();
}
/** @param {Event} e */
DTBox.prototype.onYearSelected = function (e) {
this.year = parseInt(e.target.innerText);
this.bodyType = "MONTHS";
this.setHeaderContent();
this.setupBody();
}
/** @param {Event} e */
DTBox.prototype.onHeaderChange = function (e) {
var cell = e.target;
if (cell.previousSibling && cell.nextSibling) {
var idx = BODYTYPES.indexOf(this.bodyType);
if (idx < 0 || !BODYTYPES[idx + 1]) {
return;
}
this.bodyType = BODYTYPES[idx + 1];
this.setupBody();
} else {
var sign = cell.previousSibling ? 1 : -1;
switch (this.bodyType) {
case "DAYS":
this.month += sign * 1;
break;
case "MONTHS":
this.year += sign * 1;
break;
case "YEARS":
this.year += sign * 10;
}
if (this.month > 11 || this.month < 0) {
this.year += Math.floor(this.month / 11);
this.month = this.month > 11 ? 0 : 11;
}
}
this.setHeaderContent();
this.setBodyContent();
}
/**
* @param {HTMLElement} elem
* @returns {{left:number, top:number}}
*/
function getOffset(elem) {
var box = elem.getBoundingClientRect();
var left = window.pageXOffset !== undefined ? window.pageXOffset :
(document.documentElement || document.body.parentNode || document.body).scrollLeft;
var top = window.pageYOffset !== undefined ? window.pageYOffset :
(document.documentElement || document.body.parentNode || document.body).scrollTop;
return { left: box.left + left, top: box.top + top };
}
function empty(e) {
for (; e.children.length; ) e.removeChild(e.children[0]);
}
function tryAppendChild(newChild, refNode) {
try {
refNode.appendChild(newChild);
return newChild;
} catch (e) {
console.trace(e);
}
}
/** @class */
function hookFuncs() {
/** @type {Handlers} */
this._funcs = {};
}
/**
* @param {string} key
* @param {Function} func
*/
hookFuncs.prototype.add = function(key, func){
if (!this._funcs[key]){
this._funcs[key] = [];
}
this._funcs[key].push(func)
}
/**
* @param {String} key
* @returns {Function[]} handlers
*/
hookFuncs.prototype.get = function(key){
return this._funcs[key] ? this._funcs[key] : [];
}
/**
* @param {Array.<string>} arr
* @param {String} string
* @returns {Array.<string>} sorted string
*/
function sortByStringIndex(arr, string) {
return arr.sort(function(a, b){
var h = string.indexOf(a);
var l = string.indexOf(b);
var rank = 0;
if (h < l) {
rank = -1;
} else if (l < h) {
rank = 1;
} else if (a.length > b.length) {
rank = -1;
} else if (b.length > a.length) {
rank = 1;
}
return rank;
});
}
/**
* Remove keys from array that are not in format
* @param {string[]} keys
* @param {string} format
* @returns {string[]} new filtered array
*/
function filterFormatKeys(keys, format) {
var out = [];
var formatIdx = 0;
for (var i = 0; i<keys.length; i++) {
var key = keys[i];
if (format.slice(formatIdx).indexOf(key) > -1) {
formatIdx += key.length;
out.push(key);
}
}
return out;
}
/**
* @template {StringNumObj} FormatObj
* @param {string} value
* @param {string} format
* @param {FormatObj} formatObj
* @param {function(Object.<string, hookFuncs>): null} setHooks
* @returns {FormatObj} formatObj
*/
function parseData(value, format, formatObj, setHooks) {
var hooks = {
canSkip: new hookFuncs(),
updateValue: new hookFuncs(),
}
var keys = sortByStringIndex(Object.keys(formatObj), format);
var filterdKeys = filterFormatKeys(keys, format);
var vstart = 0; // value start
if (setHooks) {
setHooks(hooks);
}
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
var fstart = format.indexOf(key);
var _vstart = vstart; // next value start
var val = null;
var canSkip = false;
var funcs = hooks.canSkip.get(key);
vstart = vstart || fstart;
for (var j = 0; j < funcs.length; j++) {
if (funcs[j](formatObj)){
canSkip = true;
break;
}
}
if (fstart > -1 && !canSkip) {
var sep = null;
var stop = vstart + key.length;
var fnext = -1;
var nextKeyIdx = i + 1;
_vstart += key.length; // set next value start if current key is found
// get next format token used to determine separator
while (fnext == -1 && nextKeyIdx < keys.length){
var nextKey = keys[nextKeyIdx];
nextKeyIdx += 1;
if (filterdKeys.indexOf(nextKey) === -1) {
continue;
}
fnext = nextKey ? format.indexOf(nextKey) : -1; // next format start
}
if (fnext > -1){
sep = format.slice(stop, fnext);
if (sep) {
var _stop = value.slice(vstart).indexOf(sep);
if (_stop && _stop > -1){
stop = _stop + vstart;
_vstart = stop + sep.length;
}
}
}
val = parseInt(value.slice(vstart, stop));
var funcs = hooks.updateValue.get(key);
for (var k = 0; k < funcs.length; k++) {
val = funcs[k](val, formatObj, vstart, stop);
}
}
formatObj[key] = { index: vstart, value: val };
vstart = _vstart; // set next value start
}
return formatObj;
}
/**
* @param {String} value
* @param {DTS} settings
* @returns {Date} date object
*/
function parseDate(value, settings) {
/** @type {{yyyy:number=, yy:number=, mm:number=, dd:number=}} */
var formatObj = {yyyy:null, yy:null, mm:null, dd:null};
var format = ((settings.dateFormat) || '').toLowerCase();
if (!format) {
throw new TypeError('dateFormat not found (' + settings.dateFormat + ')');
}
var formatObj = parseData(value, format, formatObj, function(hooks){
hooks.canSkip.add("yy", function(data){
return data["yyyy"].value;
});
hooks.updateValue.add("yy", function(val){
return 100 * Math.floor(new Date().getFullYear() / 100) + val;
});
});
var year = formatObj["yyyy"].value || formatObj["yy"].value;
var month = formatObj["mm"].value - 1;
var date = formatObj["dd"].value;
var result = new Date(year, month, date);
return result;
}
/**
* @param {String} value
* @param {DTS} settings
* @returns {Number} time in milliseconds <= (24 * 60 * 60 * 1000) - 1
*/
function parseTime(value, settings) {
var format = ((settings.timeFormat) || '').toLowerCase();
if (!format) {
throw new TypeError('timeFormat not found (' + settings.timeFormat + ')');
}
/** @type {{hh:number=, mm:number=, ss:number=, a:string=}} */
var formatObj = {hh:null, mm:null, ss:null, a:null};
var formatObj = parseData(value, format, formatObj, function(hooks){
hooks.updateValue.add("a", function(val, data, start, stop){
return value.slice(start, start + 2);
});
});
var hours = formatObj["hh"].value;
var minutes = formatObj["mm"].value;
var seconds = formatObj["ss"].value;
var am_pm = formatObj["a"].value;
var am_pm_lower = am_pm ? am_pm.toLowerCase() : am_pm;
if (am_pm && ["am", "pm"].indexOf(am_pm_lower) > -1){
if (am_pm_lower == 'am' && hours == 12){
hours = 0;
} else if (am_pm_lower == 'pm') {
hours += 12;
}
}
var time = hours * 60 * 60 * 1000 + minutes * 60 * 1000 + seconds * 1000;
return time;
}
/**
* @param {Date} value
* @param {DTS} settings
* @returns {String} date string
*/
function renderDate(value, settings) {
var format = settings.dateFormat.toLowerCase();
var date = value.getDate();
var month = value.getMonth() + 1;
var year = value.getFullYear();
var yearShort = year % 100;
var formatObj = {
dd: date < 10 ? "0" + date : date,
mm: month < 10 ? "0" + month : month,
yyyy: year,
yy: yearShort < 10 ? "0" + yearShort : yearShort
};
var str = format.replace(settings.dateFormatRegEx, function (found) {
return formatObj[found];
});
return str;
}
/**
* @param {Date} value
* @param {DTS} settings
* @returns {String} date string
*/
function renderTime(value, settings) {
var Format = settings.timeFormat;
var format = Format.toLowerCase();
var hours = value.getHours();
var minutes = value.getMinutes();
var seconds = value.getSeconds();
var am_pm = null;
var hh_am_pm = null;
if (format.indexOf('a') > -1) {
am_pm = hours >= 12 ? 'pm' : 'am';
am_pm = Format.indexOf('A') > -1 ? am_pm.toUpperCase() : am_pm;
hh_am_pm = hours == 0 ? '12' : (hours > 12 ? hours%12 : hours);
}
var formatObj = {
hh: am_pm ? hh_am_pm : (hours < 10 ? "0" + hours : hours),
mm: minutes < 10 ? "0" + minutes : minutes,
ss: seconds < 10 ? "0" + seconds : seconds,
a: am_pm,
};
var str = format.replace(settings.timeFormatRegEx, function (found) {
return formatObj[found];
});
return str;
}
/**
* checks if two dates are equal
* @param {Date} date1
* @param {Date} date2
* @returns {Boolean} true or false
*/
function isEqualDate(date1, date2) {
if (!(date1 && date2)) return false;
return (date1.getFullYear() == date2.getFullYear() &&
date1.getMonth() == date2.getMonth() &&
date1.getDate() == date2.getDate());
}
/**
* @param {Number} val
* @param {Number} pad
* @param {*} default_val
* @returns {String} padded string
*/
function padded(val, pad, default_val) {
var default_val = default_val || 0;
var valStr = '' + (parseInt(val) || default_val);
var diff = Math.max(pad, valStr.length) - valStr.length;
return ('' + default_val).repeat(diff) + valStr;
}
/**
* @template X
* @template Y
* @param {X} obj
* @param {Y} objDefaults
* @returns {X|Y} merged object
*/
function setDefaults(obj, objDefaults) {
var keys = Object.keys(objDefaults);
for (var i=0; i<keys.length; i++) {
var key = keys[i];
if (!Object.prototype.hasOwnProperty.call(obj, key)) {
obj[key] = objDefaults[key];
}
}
return obj;
}
window.dtsel = Object.create({},{
DTS: { value: DTS },
DTObj: { value: DTBox },
fn: {
value: Object.defineProperties({}, {
empty: { value: empty },
appendAfter: {
value: function (newElem, refNode) {
refNode.parentNode.insertBefore(newElem, refNode.nextSibling);
},
},
getOffset: { value: getOffset },
parseDate: { value: parseDate },
renderDate: { value: renderDate },
parseTime: {value: parseTime},
renderTime: {value: renderTime},
setDefaults: {value: setDefaults},
}),
},
});
})();

View File

@ -0,0 +1,23 @@
function collapse_all() {
document.querySelectorAll("div.reminderContent:not(.creator)").forEach((el) => {
el.classList.add("is-collapsed");
});
}
function expand_all() {
document.querySelectorAll("div.reminderContent:not(.creator)").forEach((el) => {
el.classList.remove("is-collapsed");
});
}
const expandAll = document.querySelector("#expandAll");
expandAll.addEventListener("change", (ev) => {
if (ev.target.value === "expand") {
expand_all();
} else if (ev.target.value === "collapse") {
collapse_all();
}
ev.target.value = "";
});

Some files were not shown because too many files have changed in this diff Show More