mirror of
https://github.com/Casvt/MIND.git
synced 2026-04-03 03:00:22 -04:00
First version of Noted!
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -127,3 +127,10 @@ dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
|
||||
# VS code
|
||||
*.code-workspace
|
||||
.vscode/
|
||||
100
Noted.py
Normal file
100
Noted.py
Normal file
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
#-*- coding: utf-8 -*-
|
||||
|
||||
from os import urandom
|
||||
from os.path import dirname, join
|
||||
from sys import version_info
|
||||
|
||||
from flask import Flask, render_template, request
|
||||
from waitress.server import create_server
|
||||
|
||||
from backend.db import close_db, setup_db
|
||||
from frontend.api import api, reminder_handler
|
||||
from frontend.ui import ui
|
||||
|
||||
HOST = '0.0.0.0'
|
||||
PORT = '8080'
|
||||
THREADS = 10
|
||||
|
||||
def _folder_path(*folders) -> str:
|
||||
"""Turn filepaths relative to the project folder into absolute paths
|
||||
Returns:
|
||||
str: The absolute filepath
|
||||
"""
|
||||
return join(dirname(__file__), *folders)
|
||||
|
||||
def _create_app() -> Flask:
|
||||
"""Create a Flask app instance
|
||||
Returns:
|
||||
Flask: The created app instance
|
||||
"""
|
||||
app = Flask(
|
||||
__name__,
|
||||
template_folder=_folder_path('frontend','templates'),
|
||||
static_folder=_folder_path('frontend','static'),
|
||||
static_url_path='/static'
|
||||
)
|
||||
app.config['SECRET_KEY'] = urandom(32)
|
||||
app.config['JSONIFY_PRETTYPRINT_REGULAR'] = True
|
||||
app.config['JSON_SORT_KEYS'] = False
|
||||
|
||||
# Add error handlers
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
if request.path.startswith('/api'):
|
||||
return {'error': 'Not Found', 'result': {}}, 404
|
||||
else:
|
||||
return render_template('page_not_found.html')
|
||||
|
||||
@app.errorhandler(400)
|
||||
def bad_request(e):
|
||||
return {'error': 'Bad request', 'result': {}}, 400
|
||||
|
||||
@app.errorhandler(405)
|
||||
def method_not_allowed(e):
|
||||
return {'error': 'Method not allowed', 'result': {}}, 405
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(e):
|
||||
return {'error': 'Internal error', 'result': {}}, 500
|
||||
|
||||
app.register_blueprint(ui)
|
||||
app.register_blueprint(api, url_prefix="/api")
|
||||
|
||||
# Setup closing database
|
||||
app.teardown_appcontext(close_db)
|
||||
|
||||
return app
|
||||
|
||||
def Noted() -> None:
|
||||
"""The main function of Noted
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
# Check python version
|
||||
if (version_info.major < 3) or (version_info.major == 3 and version_info.minor < 7):
|
||||
print('Error: the minimum python version required is python3.7 (currently ' + version_info.major + '.' + version_info.minor + '.' + version_info.micro + ')')
|
||||
|
||||
# Register web server
|
||||
app = _create_app()
|
||||
with app.app_context():
|
||||
setup_db()
|
||||
reminder_handler._find_next_reminder()
|
||||
|
||||
# Start thread
|
||||
reminder_handler.thread.start()
|
||||
|
||||
# Create waitress server and run
|
||||
server = create_server(app, host=HOST, port=PORT, threads=THREADS)
|
||||
print(f'Noted running on http://{HOST}:{PORT}/')
|
||||
server.run()
|
||||
print(f'\nShutting down Noted...')
|
||||
|
||||
# Stopping thread
|
||||
reminder_handler.stop_handling()
|
||||
|
||||
print('Bye')
|
||||
return
|
||||
|
||||
if __name__ == "__main__":
|
||||
Noted()
|
||||
58
backend/custom_exceptions.py
Normal file
58
backend/custom_exceptions.py
Normal file
@@ -0,0 +1,58 @@
|
||||
#-*- coding: utf-8 -*-
|
||||
|
||||
class UsernameTaken(Exception):
|
||||
"""The username is already taken"""
|
||||
api_response = {'error': 'UsernameTaken', 'result': {}, 'code': 400}
|
||||
|
||||
class UsernameInvalid(Exception):
|
||||
"""The username contains invalid characters"""
|
||||
api_response = {'error': 'UsernameInvalid', 'result': {}, 'code': 400}
|
||||
|
||||
class UserNotFound(Exception):
|
||||
"""The user requested by id or username can not be found"""
|
||||
api_response = {'error': 'UserNotFound', 'result': {}, 'code': 404}
|
||||
|
||||
class AccessUnauthorized(Exception):
|
||||
"""The password given is not correct"""
|
||||
api_response = {'error': 'AccessUnauthorized', 'result': {}, 'code': 401}
|
||||
|
||||
class ReminderNotFound(Exception):
|
||||
"""The reminder with the id can not be found"""
|
||||
api_response = {'error': 'ReminderNotFound', 'result': {}, 'code': 404}
|
||||
|
||||
class NotificationServiceNotFound(Exception):
|
||||
"""The notification service was not found"""
|
||||
api_response = {'error': 'NotificationServiceNotFound', 'result': {}, 'code': 404}
|
||||
|
||||
class NotificationServiceInUse(Exception):
|
||||
"""The notification service is wished to be deleted but a reminder is still using it"""
|
||||
api_response = {'error': 'NotificationServiceInUse', 'result': {}, 'code': 400}
|
||||
|
||||
class InvalidTime(Exception):
|
||||
"""The time given is in the past"""
|
||||
api_response = {'error': 'InvalidTime', 'result': {}, 'code': 400}
|
||||
|
||||
class InvalidURL(Exception):
|
||||
"""The apprise url is invalid"""
|
||||
api_response = {'error': 'InvalidURL', 'result': {}, 'code': 400}
|
||||
|
||||
class KeyNotFound(Exception):
|
||||
"""A key was not found in the input that is required to be given"""
|
||||
def __init__(self, key: str=''):
|
||||
self.key = key
|
||||
super().__init__(self.key)
|
||||
|
||||
@property
|
||||
def api_response(self):
|
||||
return {'error': 'KeyNotFound', 'result': {'key': self.key}, 'code': 400}
|
||||
|
||||
class InvalidKeyValue(Exception):
|
||||
"""The value of a key is invalid"""
|
||||
def __init__(self, key: str='', value: str=''):
|
||||
self.key = key
|
||||
self.value = value
|
||||
super().__init__(self.key)
|
||||
|
||||
@property
|
||||
def api_response(self):
|
||||
return {'error': 'KeyNotFound', 'result': {'key': self.key, 'value': self.value}, 'code': 400}
|
||||
93
backend/db.py
Normal file
93
backend/db.py
Normal file
@@ -0,0 +1,93 @@
|
||||
#-*- coding: utf-8 -*-
|
||||
|
||||
from sqlite3 import Connection, Row
|
||||
from threading import current_thread
|
||||
from typing import Union
|
||||
|
||||
from flask import g
|
||||
|
||||
NOTED_DB_FILE = 'Noted.db'
|
||||
|
||||
class Singleton(type):
|
||||
_instances = {}
|
||||
def __call__(cls, *args, **kwargs):
|
||||
i = f'{cls}{current_thread()}'
|
||||
if i not in cls._instances:
|
||||
cls._instances[i] = super(Singleton, cls).__call__(*args, **kwargs)
|
||||
|
||||
return cls._instances[i]
|
||||
|
||||
class DBConnection(Connection, metaclass=Singleton):
|
||||
def __init__(self, file: str, timeout: float):
|
||||
super().__init__(file, timeout=timeout)
|
||||
super().cursor().execute("PRAGMA foreign_keys = ON;")
|
||||
|
||||
def get_db(output_type: Union[dict, tuple]=tuple):
|
||||
"""Get a database cursor instance. Coupled to Flask's g.
|
||||
|
||||
Args:
|
||||
output_type (Union[dict, tuple], optional): The type of output: a tuple or dictionary with the row values. Defaults to tuple.
|
||||
|
||||
Returns:
|
||||
Cursor: The Cursor instance to use
|
||||
"""
|
||||
try:
|
||||
cursor = g.cursor
|
||||
except AttributeError:
|
||||
db = DBConnection(NOTED_DB_FILE, timeout=20.0)
|
||||
cursor = g.cursor = db.cursor()
|
||||
|
||||
if output_type is dict:
|
||||
cursor.row_factory = Row
|
||||
else:
|
||||
cursor.row_factory = None
|
||||
|
||||
return g.cursor
|
||||
|
||||
def close_db(e=None) -> None:
|
||||
"""Savely closes the database connection
|
||||
"""
|
||||
try:
|
||||
cursor = g.cursor
|
||||
db = cursor.connection
|
||||
cursor.close()
|
||||
delattr(g, 'cursor')
|
||||
db.commit()
|
||||
except AttributeError:
|
||||
pass
|
||||
return
|
||||
|
||||
def setup_db() -> None:
|
||||
"""Setup the database
|
||||
"""
|
||||
cursor = get_db()
|
||||
|
||||
cursor.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS users(
|
||||
id INTEGER PRIMARY KEY,
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
salt VARCHAR(40) NOT NULL,
|
||||
hash VARCHAR(100) NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS notification_services(
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
title VARCHAR(255),
|
||||
url TEXT,
|
||||
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS reminders(
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
text TEXT,
|
||||
time INTEGER NOT NULL,
|
||||
notification_service INTEGER NOT NULL,
|
||||
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (notification_service) REFERENCES notification_services(id)
|
||||
);
|
||||
""")
|
||||
|
||||
return
|
||||
147
backend/notification_service.py
Normal file
147
backend/notification_service.py
Normal file
@@ -0,0 +1,147 @@
|
||||
#-*- coding: utf-8 -*-
|
||||
|
||||
from typing import List
|
||||
|
||||
from apprise import Apprise
|
||||
|
||||
from backend.custom_exceptions import (InvalidURL, NotificationServiceInUse,
|
||||
NotificationServiceNotFound)
|
||||
from backend.db import get_db
|
||||
|
||||
class NotificationService:
|
||||
def __init__(self, notification_service_id: int) -> None:
|
||||
self.id = notification_service_id
|
||||
|
||||
if not get_db().execute(
|
||||
"SELECT 1 FROM notification_services WHERE id = ? LIMIT 1",
|
||||
(self.id,)
|
||||
).fetchone():
|
||||
raise NotificationServiceNotFound
|
||||
|
||||
def get(self) -> dict:
|
||||
"""Get the info about the notification service
|
||||
|
||||
Returns:
|
||||
dict: The info about the notification service
|
||||
"""
|
||||
result = dict(get_db(dict).execute(
|
||||
"SELECT id, title, url FROM notification_services WHERE id = ? LIMIT 1",
|
||||
(self.id,)
|
||||
).fetchone())
|
||||
|
||||
return result
|
||||
|
||||
def update(
|
||||
self,
|
||||
title: str = None,
|
||||
url: str = None
|
||||
) -> dict:
|
||||
"""Edit the notification service
|
||||
|
||||
Args:
|
||||
title (str, optional): The new title of the service. Defaults to None.
|
||||
url (str, optional): The new url of the service. Defaults to None.
|
||||
|
||||
Returns:
|
||||
dict: The new info about the service
|
||||
"""
|
||||
if not Apprise().add(url):
|
||||
raise InvalidURL
|
||||
|
||||
# Get current data and update it with new values
|
||||
data = self.get()
|
||||
new_values = {
|
||||
'title': title,
|
||||
'url': url
|
||||
}
|
||||
for k, v in new_values.items():
|
||||
if v is not None:
|
||||
data[k] = v
|
||||
|
||||
# Update database
|
||||
get_db().execute("""
|
||||
UPDATE notification_services
|
||||
SET title=?, url=?
|
||||
WHERE id = ?;
|
||||
""", (
|
||||
data["title"],
|
||||
data["url"],
|
||||
self.id
|
||||
))
|
||||
|
||||
return self.get()
|
||||
|
||||
def delete(self) -> None:
|
||||
"""Delete the service
|
||||
|
||||
Raises:
|
||||
NotificationServiceInUse: The service is still used by a reminder
|
||||
"""
|
||||
# Check if no reminders exist with this service
|
||||
cursor = get_db()
|
||||
cursor.execute(
|
||||
"SELECT id FROM reminders WHERE notification_service = ? LIMIT 1",
|
||||
(self.id,)
|
||||
)
|
||||
if cursor.fetchone():
|
||||
raise NotificationServiceInUse
|
||||
|
||||
cursor.execute(
|
||||
"DELETE FROM notification_services WHERE id = ?",
|
||||
(self.id,)
|
||||
)
|
||||
return
|
||||
|
||||
class NotificationServices:
|
||||
def __init__(self, user_id: int) -> None:
|
||||
self.user_id = user_id
|
||||
|
||||
def fetchall(self) -> List[dict]:
|
||||
"""Get a list of all notification services
|
||||
|
||||
Returns:
|
||||
List[dict]: The list of all notification services
|
||||
"""
|
||||
result = list(map(dict, get_db(dict).execute(
|
||||
"SELECT id, title, url FROM notification_services WHERE user_id = ? ORDER BY title, id",
|
||||
(self.user_id,)
|
||||
).fetchall()))
|
||||
|
||||
return result
|
||||
|
||||
def fetchone(self, notification_service_id: int) -> NotificationService:
|
||||
"""Get one notification service based on it's id
|
||||
|
||||
Args:
|
||||
notification_service_id (int): The id of the desired service
|
||||
|
||||
Returns:
|
||||
NotificationService: Instance of NotificationService
|
||||
"""
|
||||
return NotificationService(notification_service_id)
|
||||
|
||||
def add(self, title: str, url: str) -> NotificationService:
|
||||
"""Add a notification service
|
||||
|
||||
Args:
|
||||
title (str): The title of the service
|
||||
url (str): The apprise url of the service
|
||||
|
||||
Raises:
|
||||
InvalidURL: The apprise url is invalid
|
||||
|
||||
Returns:
|
||||
dict: The info about the new service
|
||||
"""
|
||||
if not Apprise().add(url):
|
||||
raise InvalidURL
|
||||
|
||||
new_id = get_db().execute("""
|
||||
INSERT INTO notification_services(user_id, title, url)
|
||||
VALUES (?,?,?)
|
||||
""",
|
||||
(self.user_id, title, url)
|
||||
).lastrowid
|
||||
|
||||
return self.fetchone(new_id)
|
||||
|
||||
309
backend/reminders.py
Normal file
309
backend/reminders.py
Normal file
@@ -0,0 +1,309 @@
|
||||
#-*- coding: utf-8 -*-
|
||||
|
||||
from sqlite3 import IntegrityError
|
||||
from threading import Thread
|
||||
from time import sleep, time as epoch_time
|
||||
from typing import List, Literal
|
||||
|
||||
from apprise import Apprise
|
||||
from flask import Flask
|
||||
|
||||
from backend.custom_exceptions import (InvalidTime,
|
||||
NotificationServiceNotFound,
|
||||
ReminderNotFound)
|
||||
from backend.db import close_db, get_db
|
||||
|
||||
class ReminderHandler():
|
||||
"""Run in a thread to handle the set reminders
|
||||
"""
|
||||
def __init__(self, context) -> None:
|
||||
self.context = context
|
||||
self.thread = Thread(target=self._handle, name='Reminder Handler')
|
||||
self.stop = False
|
||||
|
||||
return
|
||||
|
||||
def _find_next_reminder(self) -> None:
|
||||
"""Note when next reminder is (could be in the past) or otherwise None
|
||||
"""
|
||||
|
||||
with self.context():
|
||||
next_timestamp = get_db().execute(
|
||||
"SELECT time FROM reminders ORDER BY time LIMIT 1;"
|
||||
).fetchone()
|
||||
|
||||
if next_timestamp is None:
|
||||
self.next_reminder: None = next_timestamp
|
||||
else:
|
||||
self.next_reminder: int = next_timestamp[0]
|
||||
|
||||
return
|
||||
|
||||
def submit_next_reminder(self, timestamp: int=None) -> bool:
|
||||
if timestamp is None:
|
||||
self._find_next_reminder()
|
||||
return False
|
||||
|
||||
if self.next_reminder is None:
|
||||
self.next_reminder = timestamp
|
||||
return True
|
||||
|
||||
if timestamp < self.next_reminder:
|
||||
self.next_reminder = timestamp
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _handle(self) -> None:
|
||||
while not self.stop:
|
||||
if self.next_reminder and self.next_reminder <= epoch_time():
|
||||
with self.context():
|
||||
cursor = get_db()
|
||||
# Get all reminders for the timestamp
|
||||
reminders = cursor.execute(
|
||||
"SELECT notification_service, title, text FROM reminders WHERE time = ?",
|
||||
(self.next_reminder,)
|
||||
).fetchall()
|
||||
|
||||
# Send of each reminder
|
||||
for reminder in reminders:
|
||||
a = Apprise()
|
||||
url = cursor.execute(
|
||||
"SELECT url FROM notification_services WHERE id = ?",
|
||||
(reminder[0],)
|
||||
).fetchone()[0]
|
||||
a.add(url)
|
||||
a.notify(title=reminder[1], body=reminder[2])
|
||||
|
||||
# Delete the reminders from the database
|
||||
cursor.execute("DELETE FROM reminders WHERE time = ?", (self.next_reminder,))
|
||||
|
||||
# Note when next reminder is (could be in the past) or otherwise None
|
||||
self._find_next_reminder()
|
||||
|
||||
sleep(5)
|
||||
return
|
||||
|
||||
def stop_handling(self) -> None:
|
||||
self.stop = True
|
||||
self.thread.join()
|
||||
return
|
||||
|
||||
handler_context = Flask('handler')
|
||||
handler_context.teardown_appcontext(close_db)
|
||||
reminder_handler = ReminderHandler(handler_context.app_context)
|
||||
|
||||
class Reminder:
|
||||
"""Represents a reminder
|
||||
"""
|
||||
def __init__(self, reminder_id: int):
|
||||
self.id = reminder_id
|
||||
|
||||
# Check if reminder exists
|
||||
if not get_db().execute("SELECT 1 FROM reminders WHERE id = ? LIMIT 1", (self.id,)).fetchone():
|
||||
raise ReminderNotFound
|
||||
|
||||
def get(self) -> dict:
|
||||
"""Get info about the reminder
|
||||
|
||||
Returns:
|
||||
dict: The info about the reminder
|
||||
"""
|
||||
reminder: dict = get_db(dict).execute("""
|
||||
SELECT
|
||||
r.id,
|
||||
r.title, r.text,
|
||||
r.time,
|
||||
r.notification_service,
|
||||
ns.title AS notification_service_title
|
||||
FROM
|
||||
reminders r
|
||||
INNER JOIN notification_services ns
|
||||
ON
|
||||
r.notification_service = ns.id
|
||||
AND r.id = ?;
|
||||
""",
|
||||
(self.id,)
|
||||
).fetchone()
|
||||
|
||||
return dict(reminder)
|
||||
|
||||
def update(
|
||||
self,
|
||||
title: str = None,
|
||||
time: int = None,
|
||||
notification_service: int = None,
|
||||
text: str = None
|
||||
) -> dict:
|
||||
"""Edit the reminder
|
||||
|
||||
Args:
|
||||
title (str): The new title of the entry. Defaults to None.
|
||||
time (int): The new epoch timestamp the the reminder should be send. Defaults to None.
|
||||
notification_service (int): The new id of the notification service to use to send the reminder. Defaults to None.
|
||||
text (str, optional): The new body of the reminder. Defaults to None.
|
||||
|
||||
Returns:
|
||||
dict: The new password info
|
||||
"""
|
||||
# Validate data
|
||||
if time < epoch_time():
|
||||
raise InvalidTime
|
||||
time = round(time)
|
||||
|
||||
# Get current data and update it with new values
|
||||
data = self.get()
|
||||
new_values = {
|
||||
'title': title,
|
||||
'time': time,
|
||||
'notification_service': notification_service,
|
||||
'text': text
|
||||
}
|
||||
for k, v in new_values.items():
|
||||
if v is not None:
|
||||
data[k] = v
|
||||
|
||||
# Update database
|
||||
try:
|
||||
get_db().execute("""
|
||||
UPDATE reminders
|
||||
SET title=?, text=?, time=?, notification_service=?
|
||||
WHERE id = ?;
|
||||
""", (
|
||||
data["title"],
|
||||
data["text"],
|
||||
data["time"],
|
||||
data["notification_service"],
|
||||
self.id
|
||||
))
|
||||
except IntegrityError:
|
||||
raise NotificationServiceNotFound
|
||||
reminder_handler.submit_next_reminder(time)
|
||||
|
||||
return self.get()
|
||||
|
||||
def delete(self) -> None:
|
||||
"""Delete the reminder
|
||||
"""
|
||||
get_db().execute("DELETE FROM reminders WHERE id = ?", (self.id,))
|
||||
reminder_handler.submit_next_reminder(None)
|
||||
return
|
||||
|
||||
class Reminders:
|
||||
"""Represents the reminder vault of the user account
|
||||
"""
|
||||
sort_functions = {
|
||||
'title': (lambda r: (r['title'], r['time']), False),
|
||||
'title_reversed': (lambda r: (r['title'], r['time']), True),
|
||||
'time': (lambda r: r['time'], False),
|
||||
'time_reversed': (lambda r: r['time'], True)
|
||||
}
|
||||
|
||||
def __init__(self, user_id: int):
|
||||
self.user_id = user_id
|
||||
|
||||
def fetchall(self, sort_by: Literal["time", "time_reversed", "title", "title_reversed"] = "time") -> List[dict]:
|
||||
"""Get all reminders
|
||||
|
||||
Args:
|
||||
sort_by (Literal["time", "time_reversed", "title", "title_reversed"], optional): How to sort the result. Defaults to "time".
|
||||
|
||||
Returns:
|
||||
List[dict]: The id, title, text, time, notification_service and notification_service_title of each reminder
|
||||
"""
|
||||
sort_function = self.sort_functions.get(
|
||||
sort_by,
|
||||
self.sort_functions['time']
|
||||
)
|
||||
|
||||
# Fetch all reminders
|
||||
reminders: list = list(map(dict, get_db(dict).execute("""
|
||||
SELECT
|
||||
r.id,
|
||||
r.title, r.text,
|
||||
r.time,
|
||||
r.notification_service,
|
||||
ns.title AS notification_service_title
|
||||
FROM
|
||||
reminders r
|
||||
INNER JOIN notification_services ns
|
||||
ON
|
||||
r.notification_service = ns.id
|
||||
AND r.user_id = ?;
|
||||
""",
|
||||
(self.user_id,)
|
||||
).fetchall()))
|
||||
|
||||
# Sort result
|
||||
reminders.sort(key=sort_function[0], reverse=sort_function[1])
|
||||
|
||||
return reminders
|
||||
|
||||
def search(self, query: str) -> List[dict]:
|
||||
"""Search for reminders
|
||||
|
||||
Args:
|
||||
query (str): The term to search for
|
||||
|
||||
Returns:
|
||||
List[dict]: All reminders that match. Similar output to self.fetchall
|
||||
"""
|
||||
query = query.lower()
|
||||
passwords = self.fetchall()
|
||||
passwords = list(filter(
|
||||
lambda p: (
|
||||
query in p["title"].lower()
|
||||
or query in p["text"].lower()
|
||||
or query in p["notification_service_title"].lower()
|
||||
),
|
||||
passwords
|
||||
))
|
||||
return passwords
|
||||
|
||||
def fetchone(self, id: int) -> Reminder:
|
||||
"""Get one reminder
|
||||
|
||||
Args:
|
||||
id (int): The id of the reminder to fetch
|
||||
|
||||
Returns:
|
||||
Reminder: A Reminder instance
|
||||
"""
|
||||
return Reminder(id)
|
||||
|
||||
def add(
|
||||
self,
|
||||
title: str,
|
||||
time: int,
|
||||
notification_service: int,
|
||||
text: str = ''
|
||||
) -> Reminder:
|
||||
"""Add a reminder
|
||||
|
||||
Args:
|
||||
title (str): The title of the entry
|
||||
time (int): The epoch timestamp the the reminder should be send.
|
||||
notification_service (int): The id of the notification service to use to send the reminder.
|
||||
text (str, optional): The body of the reminder. Defaults to ''.
|
||||
|
||||
Returns:
|
||||
dict: The info about the reminder
|
||||
"""
|
||||
# Validate data
|
||||
if time < epoch_time():
|
||||
raise InvalidTime
|
||||
time = round(time)
|
||||
|
||||
# Insert into db
|
||||
try:
|
||||
id = get_db().execute("""
|
||||
INSERT INTO reminders(user_id, title, text, time, notification_service)
|
||||
VALUES (?,?,?,?,?);
|
||||
""", (self.user_id, title, text, time, notification_service,)
|
||||
).lastrowid
|
||||
except IntegrityError:
|
||||
raise NotificationServiceNotFound
|
||||
reminder_handler.submit_next_reminder(time)
|
||||
|
||||
# Return info
|
||||
return self.fetchone(id)
|
||||
36
backend/security.py
Normal file
36
backend/security.py
Normal file
@@ -0,0 +1,36 @@
|
||||
#-*- coding: utf-8 -*-
|
||||
|
||||
from base64 import urlsafe_b64encode
|
||||
from hashlib import pbkdf2_hmac
|
||||
from secrets import token_bytes
|
||||
from typing import Tuple
|
||||
|
||||
def get_hash(salt: bytes, data: str) -> bytes:
|
||||
"""Hash a string using the supplied salt
|
||||
|
||||
Args:
|
||||
salt (bytes): The salt to use wwhen hashing
|
||||
data (str): The data to hash
|
||||
|
||||
Returns:
|
||||
bytes: The b64 encoded hash of the supplied string
|
||||
"""
|
||||
return urlsafe_b64encode(
|
||||
pbkdf2_hmac('sha256', data.encode(), salt, 100_000)
|
||||
)
|
||||
|
||||
def generate_salt_hash(password: str) -> Tuple[bytes, bytes]:
|
||||
"""Generate a salt and get the hash of the password
|
||||
|
||||
Args:
|
||||
password (str): The password to generate for
|
||||
|
||||
Returns:
|
||||
Tuple[bytes, bytes]: The salt (1) and hashed_password (2)
|
||||
"""
|
||||
# Hash the password
|
||||
salt = token_bytes()
|
||||
hashed_password = get_hash(salt, password)
|
||||
del password
|
||||
|
||||
return salt, hashed_password
|
||||
133
backend/users.py
Normal file
133
backend/users.py
Normal file
@@ -0,0 +1,133 @@
|
||||
#-*- coding: utf-8 -*-
|
||||
|
||||
from backend.custom_exceptions import (AccessUnauthorized, UsernameInvalid,
|
||||
UsernameTaken, UserNotFound)
|
||||
from backend.db import get_db
|
||||
from backend.notification_service import NotificationServices
|
||||
from backend.reminders import Reminders
|
||||
from backend.security import generate_salt_hash, get_hash
|
||||
|
||||
ONEPASS_USERNAME_CHARACTERS = 'abcedfghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.!@$'
|
||||
ONEPASS_INVALID_USERNAMES = ['users','api']
|
||||
|
||||
class User:
|
||||
"""Represents an user account
|
||||
"""
|
||||
def __init__(self, username: str, password: str):
|
||||
# Fetch data of user to check if user exists and to check if password is correct
|
||||
result = get_db(dict).execute(
|
||||
"SELECT id, salt, hash FROM users WHERE username = ?",
|
||||
(username,)
|
||||
).fetchone()
|
||||
if not result:
|
||||
raise UserNotFound
|
||||
self.username = username
|
||||
self.salt = result['salt']
|
||||
self.user_id = result['id']
|
||||
|
||||
# check password
|
||||
hash_password = get_hash(result['salt'], password)
|
||||
if not hash_password == result['hash']:
|
||||
raise AccessUnauthorized
|
||||
|
||||
@property
|
||||
def reminders(self) -> Reminders:
|
||||
"""Get access to the reminders of the user account
|
||||
|
||||
Returns:
|
||||
Reminders: Reminders instance that can be used to access the reminders of the user account
|
||||
"""
|
||||
if not hasattr(self, 'reminders_instance'):
|
||||
self.reminders_instance = Reminders(self.user_id)
|
||||
return self.reminders_instance
|
||||
|
||||
@property
|
||||
def notification_services(self) -> NotificationServices:
|
||||
"""Get access to the notification services of the user account
|
||||
|
||||
Returns:
|
||||
NotificationServices: NotificationServices instance that can be used to access the notification services of the user account
|
||||
"""
|
||||
if not hasattr(self, 'notification_services_instance'):
|
||||
self.notification_services_instance = NotificationServices(self.user_id)
|
||||
return self.notification_services_instance
|
||||
|
||||
def edit_password(self, new_password: str) -> None:
|
||||
"""Change the password of the account
|
||||
|
||||
Args:
|
||||
new_password (str): The new password
|
||||
"""
|
||||
# Encrypt raw key with new password
|
||||
hash_password = get_hash(self.salt, new_password)
|
||||
|
||||
# Update database
|
||||
get_db().execute(
|
||||
"UPDATE users SET hash = ? WHERE id = ?",
|
||||
(hash_password, self.user_id)
|
||||
)
|
||||
return
|
||||
|
||||
def delete(self) -> None:
|
||||
"""Delete the user account
|
||||
"""
|
||||
cursor = get_db()
|
||||
cursor.execute("DELETE FROM reminders WHERE user_id = ?", (self.user_id,))
|
||||
cursor.execute("DELETE FROM notification_services WHERE user_id = ?", (self.user_id,))
|
||||
cursor.execute("DELETE FROM users WHERE id = ?", (self.user_id,))
|
||||
return
|
||||
|
||||
def _check_username(username: str) -> None:
|
||||
"""Check if username is valid
|
||||
|
||||
Args:
|
||||
username (str): The username to check
|
||||
|
||||
Raises:
|
||||
UsernameInvalid: The username is not valid
|
||||
"""
|
||||
if username in ONEPASS_INVALID_USERNAMES or username.isdigit():
|
||||
raise UsernameInvalid
|
||||
if list(filter(lambda c: not c in ONEPASS_USERNAME_CHARACTERS, username)):
|
||||
raise UsernameInvalid
|
||||
return
|
||||
|
||||
def register_user(username: str, password: str) -> int:
|
||||
"""Add a user
|
||||
|
||||
Args:
|
||||
username (str): The username of the new user
|
||||
password (str): The password of the new user
|
||||
|
||||
Raises:
|
||||
UsernameInvalid: Username not allowed or contains invalid characters
|
||||
UsernameTaken: Username is already taken; usernames must be unique
|
||||
|
||||
Returns:
|
||||
user_id (int): The id of the new user. User registered successful
|
||||
"""
|
||||
#check if username is valid
|
||||
_check_username(username)
|
||||
|
||||
cursor = get_db()
|
||||
|
||||
# Check if username isn't already taken
|
||||
if cursor.execute(
|
||||
"SELECT 1 FROM users WHERE username = ? LIMIT 1", (username,)
|
||||
).fetchone():
|
||||
raise UsernameTaken
|
||||
|
||||
# Generate salt and key exclusive for user
|
||||
salt, hashed_password = generate_salt_hash(password)
|
||||
del password
|
||||
|
||||
# Add user to userlist
|
||||
user_id = cursor.execute(
|
||||
"""
|
||||
INSERT INTO users(username, salt, hash)
|
||||
VALUES (?,?,?);
|
||||
""",
|
||||
(username, salt, hashed_password)
|
||||
).lastrowid
|
||||
|
||||
return user_id
|
||||
494
frontend/api.py
Normal file
494
frontend/api.py
Normal file
@@ -0,0 +1,494 @@
|
||||
#-*- coding: utf-8 -*-
|
||||
|
||||
from os import urandom
|
||||
from time import time as epoch_time
|
||||
from typing import Any, Tuple
|
||||
|
||||
from flask import Blueprint, g, request
|
||||
|
||||
from backend.custom_exceptions import (AccessUnauthorized, InvalidKeyValue, InvalidTime, InvalidURL,
|
||||
KeyNotFound, NotificationServiceInUse, NotificationServiceNotFound, ReminderNotFound,
|
||||
UsernameInvalid, UsernameTaken,
|
||||
UserNotFound)
|
||||
from backend.notification_service import (NotificationService,
|
||||
NotificationServices)
|
||||
from backend.reminders import reminder_handler, Reminders
|
||||
from backend.users import User, register_user
|
||||
|
||||
api = Blueprint('api', __name__)
|
||||
api_key_map = {}
|
||||
|
||||
"""
|
||||
AUTHENTICATION:
|
||||
After making a POST /auth/login request, you'll receive an api_key in the output.
|
||||
From then on, make all requests with the url parameter api_key, where the value is the string you received.
|
||||
One hour after logging in, the api key expires and you are required to login again to get a new api_key.
|
||||
|
||||
If no api key is supplied or it is invalid, 401 'ApiKeyInvalid' is returned.
|
||||
If the api key supplied has expired, 401 'ApiKeyExpired' is returned.
|
||||
"""
|
||||
|
||||
def return_api(result: Any, error: str=None, code: int=200) -> Tuple[dict, int]:
|
||||
return {'error': error, 'result': result}, code
|
||||
|
||||
def auth(method):
|
||||
"""Used as decorator and, if applied to route, restricts the route to authorized users and supplies user specific info
|
||||
"""
|
||||
def wrapper(*args,**kwargs):
|
||||
hashed_api_key = hash(request.values.get('api_key',''))
|
||||
if not hashed_api_key in api_key_map:
|
||||
return return_api({}, 'ApiKeyInvalid', 401)
|
||||
|
||||
exp = api_key_map[hashed_api_key]['exp']
|
||||
if exp <= epoch_time():
|
||||
return return_api({}, 'ApiKeyExpired', 401)
|
||||
|
||||
# Api key valid
|
||||
g.hashed_api_key = hashed_api_key
|
||||
g.exp = exp
|
||||
g.user_data = api_key_map[hashed_api_key]['user_data']
|
||||
return method(*args, **kwargs)
|
||||
|
||||
wrapper.__name__ = method.__name__
|
||||
return wrapper
|
||||
|
||||
def error_handler(method):
|
||||
"""Catches the errors that can occur in the endpoint and returns the correct api error
|
||||
"""
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return method(*args, **kwargs)
|
||||
except (UsernameTaken, UsernameInvalid, UserNotFound,
|
||||
AccessUnauthorized,
|
||||
ReminderNotFound, NotificationServiceNotFound, NotificationServiceInUse,
|
||||
InvalidTime, InvalidURL,
|
||||
KeyNotFound, InvalidKeyValue) as e:
|
||||
return return_api(**e.api_response)
|
||||
|
||||
wrapper.__name__ = method.__name__
|
||||
return wrapper
|
||||
|
||||
def extract_key(values: dict, key: str, check_existence: bool=True) -> Any:
|
||||
value: str = values.get(key)
|
||||
if check_existence and value is None:
|
||||
raise KeyNotFound(key)
|
||||
|
||||
if value is not None:
|
||||
# Check value and optionally convert
|
||||
if key in ('time', 'notification_service'):
|
||||
try:
|
||||
value = int(value)
|
||||
except (ValueError, TypeError):
|
||||
raise InvalidKeyValue(key, value)
|
||||
|
||||
elif key == 'sort_by':
|
||||
if not value in Reminders.sort_functions:
|
||||
raise InvalidKeyValue(key, value)
|
||||
|
||||
else:
|
||||
if key == 'sort_by':
|
||||
value = 'time'
|
||||
|
||||
elif key == 'text':
|
||||
value = ''
|
||||
|
||||
return value
|
||||
|
||||
#===================
|
||||
# Authentication endpoints
|
||||
#===================
|
||||
|
||||
@api.route('/auth/login', methods=['POST'])
|
||||
@error_handler
|
||||
def api_login():
|
||||
"""
|
||||
Endpoint: /auth/login
|
||||
Description: Login to a user account
|
||||
Requires being logged in: No
|
||||
Methods:
|
||||
POST:
|
||||
Parameters (body (content-type: application/json)):
|
||||
username (required): the username of the user account
|
||||
password (required): the password of the user account
|
||||
Returns:
|
||||
200:
|
||||
The apikey to use for further requests and expiration time (epoch)
|
||||
400:
|
||||
KeyNotFound: One of the required parameters was not given
|
||||
401:
|
||||
PasswordInvalid: The password given is not correct for the user account
|
||||
404:
|
||||
UsernameNotFound: The username was not found
|
||||
"""
|
||||
data = request.get_json()
|
||||
|
||||
# Check if required keys are given
|
||||
username = extract_key(data, 'username')
|
||||
password = extract_key(data, 'password')
|
||||
|
||||
# Check credentials
|
||||
user = User(username, password)
|
||||
|
||||
# Login valid
|
||||
while True:
|
||||
api_key = urandom(16).hex() # <- length api key / 2
|
||||
hashed_api_key = hash(api_key)
|
||||
if not hashed_api_key in api_key_map:
|
||||
break
|
||||
exp = epoch_time() + 3600
|
||||
api_key_map.update({
|
||||
hashed_api_key: {
|
||||
'exp': exp,
|
||||
'user_data': user
|
||||
}
|
||||
})
|
||||
|
||||
result = {'api_key': api_key, 'expires': exp}
|
||||
return return_api(result)
|
||||
|
||||
@api.route('/auth/logout', methods=['POST'])
|
||||
@error_handler
|
||||
@auth
|
||||
def api_logout():
|
||||
"""
|
||||
Endpoint: /auth/logout
|
||||
Description: Logout of a user account
|
||||
Requires being logged in: Yes
|
||||
Methods:
|
||||
POST:
|
||||
Returns:
|
||||
200:
|
||||
Logout successful
|
||||
"""
|
||||
api_key_map.pop(g.hashed_api_key)
|
||||
return return_api({})
|
||||
|
||||
@api.route('/auth/status', methods=['GET'])
|
||||
@error_handler
|
||||
@auth
|
||||
def api_status():
|
||||
"""
|
||||
Endpoint: /auth/status
|
||||
Description: Get current status of login
|
||||
Requires being logged in: Yes
|
||||
Methods:
|
||||
GET:
|
||||
Returns:
|
||||
200:
|
||||
The username of the logged in account and the expiration time of the api key (epoch)
|
||||
"""
|
||||
result = {
|
||||
'expires': api_key_map[g.hashed_api_key]['exp'],
|
||||
'username': api_key_map[g.hashed_api_key]['user_data'].username
|
||||
}
|
||||
return return_api(result)
|
||||
|
||||
#===================
|
||||
# User endpoints
|
||||
#===================
|
||||
|
||||
@api.route('/user/add', methods=['POST'])
|
||||
@error_handler
|
||||
def api_add_user():
|
||||
"""
|
||||
Endpoint: /user/add
|
||||
Description: Create a new user account
|
||||
Requires being logged in: No
|
||||
Methods:
|
||||
POST:
|
||||
Parameters (body (content-type: application/json)):
|
||||
username (required): the username of the new user account
|
||||
password (required): the password of the new user account
|
||||
Returns:
|
||||
201:
|
||||
The user id of the new user account
|
||||
400:
|
||||
KeyNotFound: One of the required parameters was not given
|
||||
UsernameInvalid: The username given is not allowed
|
||||
UsernameTaken: The username given is already in use
|
||||
"""
|
||||
data = request.get_json()
|
||||
|
||||
# Check if required keys are given
|
||||
username = extract_key(data, 'username')
|
||||
password = extract_key(data, 'password')
|
||||
|
||||
# Add user
|
||||
user_id = register_user(username, password)
|
||||
return return_api({'user_id': user_id}, code=201)
|
||||
|
||||
@api.route('/user', methods=['PUT','DELETE'])
|
||||
@error_handler
|
||||
@auth
|
||||
def api_manage_user():
|
||||
"""
|
||||
Endpoint: /user
|
||||
Description: Manage a user account
|
||||
Requires being logged in: Yes
|
||||
Methods:
|
||||
PUT:
|
||||
Description: Change the password of the user account
|
||||
Parameters (body (content-type: application/json)):
|
||||
new_password (required): the new password of the user account
|
||||
Returns:
|
||||
200:
|
||||
Password updated successfully
|
||||
400:
|
||||
KeyNotFound: One of the required parameters was not given
|
||||
DELETE:
|
||||
Description: Delete the user account
|
||||
Returns:
|
||||
200:
|
||||
Account deleted successfully
|
||||
"""
|
||||
if request.method == 'PUT':
|
||||
data = request.get_json()
|
||||
|
||||
# Check if required key is given
|
||||
new_password = extract_key(data, 'new_password')
|
||||
|
||||
# Edit user
|
||||
g.user_data.edit_password(new_password)
|
||||
return return_api({})
|
||||
|
||||
elif request.method == 'DELETE':
|
||||
# Delete user
|
||||
g.user_data.delete()
|
||||
api_key_map.pop(g.hashed_api_key)
|
||||
return return_api({})
|
||||
|
||||
#===================
|
||||
# Notification service endpoints
|
||||
#===================
|
||||
|
||||
@api.route('/notificationservices', methods=['GET','POST'])
|
||||
@error_handler
|
||||
@auth
|
||||
def api_notification_services_list():
|
||||
"""
|
||||
Endpoint: /notificationservices
|
||||
Description: Manage the notification services
|
||||
Requires being logged in: Yes
|
||||
Methods:
|
||||
GET:
|
||||
Description: Get a list of all notification services
|
||||
Returns:
|
||||
200:
|
||||
The id, title and url of every notification service
|
||||
POST:
|
||||
Description: Add a notification service
|
||||
Parameters (body (content-type: application/json)):
|
||||
title (required): the title of the notification service
|
||||
url (required): the apprise url of the notification service
|
||||
Returns:
|
||||
200:
|
||||
The id of the new notification service
|
||||
400:
|
||||
KeyNotFound: One of the required parameters was not given
|
||||
"""
|
||||
services: NotificationServices = g.user_data.notification_services
|
||||
|
||||
if request.method == 'GET':
|
||||
result = services.fetchall()
|
||||
return return_api(result)
|
||||
|
||||
elif request.method == 'POST':
|
||||
data = request.get_json()
|
||||
title = extract_key(data, 'title')
|
||||
url = extract_key(data, 'url')
|
||||
result = services.add(title=title,
|
||||
url=url).get()
|
||||
return return_api(result, code=201)
|
||||
|
||||
@api.route('/notificationservices/<int:n_id>', methods=['GET', 'PUT', 'DELETE'])
|
||||
@error_handler
|
||||
@auth
|
||||
def api_notification_service(n_id: int):
|
||||
"""
|
||||
Endpoint: /notificationservices/<n_id>
|
||||
Description: Manage a specific notification service
|
||||
Requires being logged in: Yes
|
||||
URL Parameters:
|
||||
<n_id>:
|
||||
The id of the notification service
|
||||
Methods:
|
||||
GET:
|
||||
Returns:
|
||||
200:
|
||||
All info about the notification service
|
||||
404:
|
||||
No notification service found with the given id
|
||||
PUT:
|
||||
Description: Edit the notification service
|
||||
Parameters (body (content-type: application/json)):
|
||||
title: The new title of the entry.
|
||||
url: The new apprise url of the entry.
|
||||
Returns:
|
||||
200:
|
||||
Notification service updated successfully
|
||||
400:
|
||||
The apprise url is invalid
|
||||
404:
|
||||
No notification service found with the given id
|
||||
DELETE:
|
||||
Description: Delete the notification service
|
||||
Returns:
|
||||
200:
|
||||
Notification service deleted successfully
|
||||
404:
|
||||
No notification service found with the given id
|
||||
"""
|
||||
service: NotificationService = g.user_data.notification_services.fetchone(n_id)
|
||||
|
||||
if request.method == 'GET':
|
||||
result = service.get()
|
||||
return return_api(result)
|
||||
|
||||
elif request.method == 'PUT':
|
||||
data = request.get_json()
|
||||
title = extract_key(data, 'title', check_existence=False)
|
||||
url = extract_key(data, 'url', check_existence=False)
|
||||
|
||||
result = service.update(title=title,
|
||||
url=url)
|
||||
return return_api(result)
|
||||
|
||||
elif request.method == 'DELETE':
|
||||
service.delete()
|
||||
return return_api({})
|
||||
|
||||
#===================
|
||||
# Vault endpoints
|
||||
#===================
|
||||
|
||||
@api.route('/reminders', methods=['GET','POST'])
|
||||
@error_handler
|
||||
@auth
|
||||
def api_reminders_list():
|
||||
"""
|
||||
Endpoint: /reminders
|
||||
Description: Manage the reminders
|
||||
Requires being logged in: Yes
|
||||
Methods:
|
||||
GET:
|
||||
Description: Get a list of all reminders
|
||||
Parameters (url):
|
||||
sort_by: how to sort the result. Allowed values are 'title', 'title_reversed', 'date_added' and 'date_added_reversed'
|
||||
Returns:
|
||||
200:
|
||||
The id, title, url and username of every reminder
|
||||
POST:
|
||||
Description: Add a reminder
|
||||
Parameters (body (content-type: application/json)):
|
||||
title (required): the title of the reminder
|
||||
time (required): the epoch timestamp that the reminder should be sent at
|
||||
notification_service (required): the id of the notification service to use to send the notification
|
||||
text: the body of the reminder
|
||||
Returns:
|
||||
200:
|
||||
The id of the new reminder entry
|
||||
400:
|
||||
KeyNotFound: One of the required parameters was not given
|
||||
"""
|
||||
reminders: Reminders = g.user_data.reminders
|
||||
|
||||
if request.method == 'GET':
|
||||
sort_by = extract_key(request.values, 'sort_by', check_existence=False)
|
||||
result = reminders.fetchall(sort_by=sort_by)
|
||||
return return_api(result)
|
||||
|
||||
elif request.method == 'POST':
|
||||
data = request.get_json()
|
||||
title = extract_key(data, 'title')
|
||||
time = extract_key(data, 'time')
|
||||
notification_service = extract_key(data, 'notification_service')
|
||||
text = extract_key(data, 'text', check_existence=False)
|
||||
|
||||
result = reminders.add(title=title,
|
||||
time=time,
|
||||
notification_service=notification_service,
|
||||
text=text)
|
||||
return return_api(result.get(), code=201)
|
||||
|
||||
@api.route('/reminders/search', methods=['GET'])
|
||||
@error_handler
|
||||
@auth
|
||||
def api_reminders_query():
|
||||
"""
|
||||
Endpoint: /reminders/search
|
||||
Description: Search through the list of reminders
|
||||
Requires being logged in: Yes
|
||||
Methods:
|
||||
GET:
|
||||
Parameters (url):
|
||||
query (required): The search term
|
||||
Returns:
|
||||
200:
|
||||
The search results, listed like GET /reminders
|
||||
400:
|
||||
KeyNotFound: One of the required parameters was not given
|
||||
"""
|
||||
query = extract_key(request.values, 'query')
|
||||
|
||||
result = g.user_data.reminders.search(query)
|
||||
return return_api(result)
|
||||
|
||||
@api.route('/reminders/<r_id>', methods=['GET','PUT','DELETE'])
|
||||
@error_handler
|
||||
@auth
|
||||
def api_get_reminder(r_id: int):
|
||||
"""
|
||||
Endpoint: /reminders/<r_id>
|
||||
Description: Manage a specific reminder
|
||||
Requires being logged in: Yes
|
||||
URL Parameters:
|
||||
<r_id>:
|
||||
The id of the reminder
|
||||
Methods:
|
||||
GET:
|
||||
Returns:
|
||||
200:
|
||||
All info about the reminder
|
||||
404:
|
||||
No reminder found with the given id
|
||||
PUT:
|
||||
Description: Edit the reminder
|
||||
Parameters (body (content-type: application/json)):
|
||||
title: The new title of the entry.
|
||||
time: The new epoch timestamp the the reminder should be send.
|
||||
notification_service: The new id of the notification service to use to send the reminder.
|
||||
text: The new body of the reminder.
|
||||
Returns:
|
||||
200:
|
||||
Reminder updated successfully
|
||||
404:
|
||||
No reminder found with the given id
|
||||
DELETE:
|
||||
Description: Delete the reminder
|
||||
Returns:
|
||||
200:
|
||||
Reminder deleted successfully
|
||||
404:
|
||||
No reminder found with the given id
|
||||
"""
|
||||
reminders: Reminders = g.user_data.reminders
|
||||
if request.method == 'GET':
|
||||
result = reminders.fetchone(r_id).get()
|
||||
return return_api(result)
|
||||
|
||||
elif request.method == 'PUT':
|
||||
data = request.get_json()
|
||||
title = extract_key(data, 'title', check_existence=False)
|
||||
time = extract_key(data, 'time', check_existence=False)
|
||||
notification_service = extract_key(data, 'notification_service', check_existence=False)
|
||||
text = extract_key(data, 'text', check_existence=False)
|
||||
|
||||
result = reminders.fetchone(r_id).update(title=title,
|
||||
time=time,
|
||||
notification_service=notification_service,
|
||||
text=text)
|
||||
return return_api(result)
|
||||
|
||||
elif request.method == 'DELETE':
|
||||
reminders.fetchone(r_id).delete()
|
||||
return return_api({})
|
||||
54
frontend/static/css/add_edit.css
Normal file
54
frontend/static/css/add_edit.css
Normal file
@@ -0,0 +1,54 @@
|
||||
.form-container {
|
||||
max-width: 30rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.form-container > form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-container > form input,
|
||||
.form-container > form select,
|
||||
.form-container > form textarea {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.sub-inputs,
|
||||
.options {
|
||||
--gap: 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--gap);
|
||||
}
|
||||
|
||||
.sub-inputs > input,
|
||||
.sub-inputs > select {
|
||||
width: calc(50% - (var(--gap) / 2));
|
||||
}
|
||||
|
||||
.options > button {
|
||||
width: 6rem;
|
||||
|
||||
border: 2px solid var(--color-gray);
|
||||
padding: .5rem 1rem;
|
||||
|
||||
font-size: 1.1rem;
|
||||
|
||||
transition: background-color .1s ease-in-out;
|
||||
}
|
||||
|
||||
.options > button:hover {
|
||||
background-color: var(--color-gray);
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.sub-inputs > input,
|
||||
.sub-inputs > select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
260
frontend/static/css/general.css
Normal file
260
frontend/static/css/general.css
Normal file
@@ -0,0 +1,260 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-light: #ffffff;
|
||||
--color-gray: #3c3c3c;
|
||||
--color-dark: #1b1b1b;
|
||||
|
||||
--color-error: rgb(219, 84, 97);
|
||||
--color-success: rgb(84, 219, 104);
|
||||
|
||||
--header-height: 4.5rem;
|
||||
--nav-width: 4rem;
|
||||
--rem-clamp: clamp(.5rem, 2vw, 1rem);
|
||||
--default-shadow: 0 1px 2px 0 rgb(0 0 0 / 60%), 0 2px 6px 2px rgb(0 0 0 / 30%);
|
||||
}
|
||||
|
||||
/* Default properties */
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-dark);
|
||||
color: var(--color-light);
|
||||
}
|
||||
|
||||
button:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
|
||||
border: 2px solid var(--color-gray);
|
||||
border-radius: 4px;
|
||||
padding: .75rem;
|
||||
outline: 0;
|
||||
box-shadow: var(--default-shadow);
|
||||
background-color: var(--color-dark);
|
||||
color: var(--color-light);
|
||||
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--color-gray);
|
||||
}
|
||||
|
||||
input[type="datetime-local"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
svg path,
|
||||
svg rect {
|
||||
fill: var(--color-light);
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color-error) !important;
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
|
||||
.error-icon path,
|
||||
.error-icon rect {
|
||||
fill: var(--color-error) !important;
|
||||
}
|
||||
|
||||
.error-input {
|
||||
border: 2px solid var(--color-error) !important;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--color-success) !important;
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
|
||||
/* General styling */
|
||||
body {
|
||||
height: 100vh;
|
||||
background-color: var(--color-dark);
|
||||
color: var(--color-light);
|
||||
}
|
||||
|
||||
header {
|
||||
width: 100%;
|
||||
height: var(--header-height);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
padding: 1rem;
|
||||
box-shadow: var(--default-shadow);
|
||||
background-color: var(--color-gray);
|
||||
}
|
||||
|
||||
header > div {
|
||||
height: 100%;
|
||||
transform: translateX(-2.6rem);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
transition: transform .3s ease-in-out;
|
||||
}
|
||||
|
||||
#toggle-nav {
|
||||
--height: 1.5rem;
|
||||
height: var(--height);
|
||||
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
#toggle-nav svg {
|
||||
height: var(--height);
|
||||
width: var(--height);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.3rem, 7vw, 2rem);
|
||||
}
|
||||
|
||||
.nav-divider {
|
||||
position: relative;
|
||||
height: calc(100% - var(--header-height));
|
||||
|
||||
display: flex;
|
||||
|
||||
padding-block: var(--rem-clamp);
|
||||
}
|
||||
|
||||
.nav-divider.show-nav > nav {
|
||||
left: var(--rem-clamp);
|
||||
}
|
||||
|
||||
.nav-divider.show-nav > .window-container {
|
||||
margin-left: calc(var(--nav-width) + var(--rem-clamp));
|
||||
}
|
||||
|
||||
nav {
|
||||
--padding: .5rem;
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
left: var(--rem-clamp);
|
||||
height: calc(100% - (2 * var(--rem-clamp)));
|
||||
width: var(--nav-width);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--padding);
|
||||
overflow-y: auto;
|
||||
|
||||
padding: var(--padding);
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-gray);
|
||||
|
||||
transition: left .3s ease-in-out;
|
||||
}
|
||||
|
||||
nav > div {
|
||||
width: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--padding);
|
||||
}
|
||||
|
||||
nav > div > button {
|
||||
width: 100%;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
padding: .5rem;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-dark);
|
||||
color: var(--color-light);
|
||||
|
||||
transition: background-color .1s ease-in-out;
|
||||
}
|
||||
|
||||
nav > div > button:hover {
|
||||
background-color: var(--color-gray);
|
||||
}
|
||||
|
||||
nav > div > button svg {
|
||||
height: 1.8rem;
|
||||
width: 2rem;
|
||||
}
|
||||
|
||||
.window-container {
|
||||
margin-left: calc(4rem + var(--rem-clamp));
|
||||
width: 100%;
|
||||
|
||||
display: flex;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
transition: margin-left .3s ease-in-out;
|
||||
}
|
||||
|
||||
.window-container > div {
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
|
||||
transform: translateX(0);
|
||||
|
||||
transition: transform .5s ease-in-out;
|
||||
}
|
||||
|
||||
.window-container:has(.show-window) #home,
|
||||
.window-container:has(.show-window) .show-window {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.window-container > div:not(#home) {
|
||||
padding: var(--rem-clamp);
|
||||
}
|
||||
|
||||
.window-container > div:not(#home) > h2 {
|
||||
text-align: center;
|
||||
font-size: clamp(1.3rem, 5vw, 2rem);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.window-container > div:not(#home) > p {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.window-container > div:not(#home) > p > a {
|
||||
color: var(--color-light);
|
||||
}
|
||||
|
||||
@media (max-width: 543px) {
|
||||
.window-container {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
nav {
|
||||
left: -100%;
|
||||
}
|
||||
}
|
||||
69
frontend/static/css/login.css
Normal file
69
frontend/static/css/login.css
Normal file
@@ -0,0 +1,69 @@
|
||||
main {
|
||||
height: calc(100vh - var(--header-height));
|
||||
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
main.show-create > .form-container {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
.form-container {
|
||||
height: inherit;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
padding: 1rem;
|
||||
|
||||
transition: transform .25s ease-in-out;
|
||||
}
|
||||
|
||||
form {
|
||||
width: 30rem;
|
||||
margin-inline: auto;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
border-radius: 4px;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-gray);
|
||||
color: var(--color-light);
|
||||
}
|
||||
|
||||
form h2 {
|
||||
font-size: clamp(1.2rem, 7vw, 2rem);
|
||||
}
|
||||
|
||||
form input {
|
||||
max-width: 20rem;
|
||||
}
|
||||
|
||||
.switch-button {
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
color: var(--color-light);
|
||||
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
form button[type="submit"] {
|
||||
padding: .5rem 1rem;
|
||||
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
p.error {
|
||||
width: 100%;
|
||||
max-width: 20rem;
|
||||
margin-top: -.5rem;
|
||||
|
||||
padding-inline: .5rem;
|
||||
|
||||
text-align: left;
|
||||
}
|
||||
97
frontend/static/css/notification.css
Normal file
97
frontend/static/css/notification.css
Normal file
@@ -0,0 +1,97 @@
|
||||
.table-container {
|
||||
margin-top: 2rem;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
#add-service-button {
|
||||
padding: .5rem 8rem;
|
||||
background-color: var(--color-gray);
|
||||
}
|
||||
|
||||
#add-service-button > svg {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.table-container > table {
|
||||
margin-inline: auto;
|
||||
|
||||
border-spacing: 0px;
|
||||
}
|
||||
|
||||
.table-container > table th,
|
||||
.table-container > table td {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.table-container > table th {
|
||||
padding-block: .5rem;
|
||||
}
|
||||
|
||||
.table-container > table td {
|
||||
border-top: 1px solid var(--color-gray);
|
||||
}
|
||||
|
||||
.table-container > table tr:has(input:not(:read-only)) td {
|
||||
padding-block: .5rem;
|
||||
}
|
||||
|
||||
.title-column {
|
||||
width: 8rem;
|
||||
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.url-column {
|
||||
width: 21rem;
|
||||
}
|
||||
|
||||
#services-list input {
|
||||
min-width: 6rem;
|
||||
|
||||
border-radius: 2px;
|
||||
padding: .5rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.url-column > input {
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
#services-list input:read-only {
|
||||
border-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#services-list tr:has(input:not(:read-only)) td.action-column {
|
||||
padding-block: 1rem;
|
||||
}
|
||||
|
||||
.action-column {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
|
||||
padding: .5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.action-column svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
#services-list tr:has(input:read-only) > td.action-column > button[data-type="edit"],
|
||||
#services-list tr:not(:has(input:read-only)) > td.action-column > button[data-type="save"] {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#services-list tr:not(:has(input:read-only)) > td.action-column > button[data-type="edit"],
|
||||
#services-list tr:has(input:read-only) > td.action-column > button[data-type="save"] {
|
||||
display: none;
|
||||
}
|
||||
21
frontend/static/css/page_not_found.css
Normal file
21
frontend/static/css/page_not_found.css
Normal file
@@ -0,0 +1,21 @@
|
||||
main {
|
||||
height: calc(100% - var(--header-height));
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: clamp(3rem, 12vw, 5rem);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: clamp(1rem, 4.2vw, 1.75rem);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-light);
|
||||
}
|
||||
158
frontend/static/css/reminders.css
Normal file
158
frontend/static/css/reminders.css
Normal file
@@ -0,0 +1,158 @@
|
||||
/* SEARCH BAR */
|
||||
.search-container {
|
||||
max-width: 40rem;
|
||||
margin: auto;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
padding-block: clamp(1rem, 4vw, 2rem);
|
||||
}
|
||||
|
||||
#search-form {
|
||||
margin-inline: 1rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
|
||||
border: 2px solid var(--color-gray);
|
||||
border-radius: 4px;
|
||||
|
||||
box-shadow: var(--default-shadow);
|
||||
}
|
||||
|
||||
.search-bar button {
|
||||
width: 3.5rem;
|
||||
padding: .8rem;
|
||||
}
|
||||
|
||||
.search-bar button svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
border: 0;
|
||||
padding-block: 1rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#clear-button {
|
||||
opacity: 0;
|
||||
|
||||
transition: opacity .1s linear;
|
||||
}
|
||||
|
||||
.search-bar:focus-within #clear-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* REMINDER LIST */
|
||||
#reminder-list {
|
||||
max-width: 43rem;
|
||||
margin-inline: auto;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
#add-entry {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
#add-entry svg {
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
}
|
||||
|
||||
#add-entry:not(:has(p#no-service-error.hidden)) path {
|
||||
fill: var(--color-error);
|
||||
}
|
||||
|
||||
#no-service-error {
|
||||
font-size: .8rem !important;
|
||||
}
|
||||
|
||||
.entry {
|
||||
width: 13rem;
|
||||
height: 6rem;
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
padding: 1rem;
|
||||
background-color: var(--color-gray);
|
||||
}
|
||||
|
||||
.entry h2 {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.entry-overlay {
|
||||
--height: 1.7rem;
|
||||
|
||||
opacity: 0;
|
||||
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: var(--height);
|
||||
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
|
||||
border-bottom-left-radius: inherit;
|
||||
border-bottom-right-radius: inherit;
|
||||
padding: .5rem;
|
||||
background-color: rgb(0 0 0 / 25%);
|
||||
|
||||
transition: opacity .2s ease-in;
|
||||
}
|
||||
|
||||
.entry-overlay::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 25px;
|
||||
width: 100%;
|
||||
background: linear-gradient(transparent, rgb(0 0 0 / 25%));
|
||||
left: 0;
|
||||
bottom: var(--height);
|
||||
}
|
||||
|
||||
.entry-overlay > button {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.entry-overlay > button > svg {
|
||||
width: .9rem;
|
||||
height: .9rem;
|
||||
}
|
||||
|
||||
.entry:hover > .entry-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 543px) {
|
||||
header > div {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.entry {
|
||||
flex-grow: 1;
|
||||
width: 9rem;
|
||||
}
|
||||
}
|
||||
42
frontend/static/css/settings.css
Normal file
42
frontend/static/css/settings.css
Normal file
@@ -0,0 +1,42 @@
|
||||
.settings-container {
|
||||
max-width: 50rem;
|
||||
margin-inline: auto;
|
||||
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-container > h3 {
|
||||
margin-bottom: 1.25rem;
|
||||
|
||||
padding-bottom: .5rem;
|
||||
border-bottom: 2px solid var(--color-gray);
|
||||
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.settings-container > h3:not(:first-of-type) {
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
|
||||
.settings-container button {
|
||||
padding: .5rem 2rem;
|
||||
background-color: var(--color-gray);
|
||||
|
||||
box-shadow: var(--default-shadow);
|
||||
}
|
||||
|
||||
#change-password-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#change-password-form > input {
|
||||
max-width: 20rem;
|
||||
}
|
||||
|
||||
#delete-account-button {
|
||||
background-color: var(--color-error);
|
||||
}
|
||||
58
frontend/static/js/add.js
Normal file
58
frontend/static/js/add.js
Normal file
@@ -0,0 +1,58 @@
|
||||
const inputs = {
|
||||
'title': document.getElementById('title-input'),
|
||||
'time': document.getElementById('time-input'),
|
||||
'notification_service': document.getElementById('notification-service-input'),
|
||||
'text': document.getElementById('text-input')
|
||||
};
|
||||
|
||||
function addReminder() {
|
||||
const data = {
|
||||
'title': inputs.title.value,
|
||||
'time': new Date(inputs.time.value).getTime() / 1000,
|
||||
'notification_service': inputs.notification_service.value,
|
||||
'text': inputs.text.value
|
||||
};
|
||||
fetch(`/api/reminders?api_key=${api_key}`, {
|
||||
'method': 'POST',
|
||||
'headers': {'Content-Type': 'application/json'},
|
||||
'body': JSON.stringify(data)
|
||||
})
|
||||
.then(response => {
|
||||
// catch errors
|
||||
if (!response.ok) {
|
||||
return Promise.reject(response.status);
|
||||
};
|
||||
|
||||
fillList();
|
||||
closeAdd();
|
||||
return
|
||||
})
|
||||
.catch(e => {
|
||||
if (e === 401) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
console.log(e);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
function showAdd() {
|
||||
if (document.getElementById('no-service-error').classList.contains('hidden')) {
|
||||
showWindow('add');
|
||||
};
|
||||
};
|
||||
|
||||
function closeAdd() {
|
||||
hideWindow();
|
||||
setTimeout(() => {
|
||||
inputs.title.value = '';
|
||||
inputs.time.value = '';
|
||||
inputs.notification_service.value = document.querySelector('#notification-service-input option[selected]').value;
|
||||
inputs.text.value = '';
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// code run on load
|
||||
|
||||
document.getElementById('add-form').setAttribute('action', 'javascript:addReminder();');
|
||||
document.getElementById('close-add').addEventListener('click', e => closeAdd());
|
||||
74
frontend/static/js/edit.js
Normal file
74
frontend/static/js/edit.js
Normal file
@@ -0,0 +1,74 @@
|
||||
const edit_inputs = {
|
||||
'title': document.getElementById('title-edit-input'),
|
||||
'time': document.getElementById('time-edit-input'),
|
||||
'notification_service': document.getElementById('notification-service-edit-input'),
|
||||
'text': document.getElementById('text-edit-input')
|
||||
};
|
||||
|
||||
function editReminder() {
|
||||
const id = document.getElementById('edit-form').dataset.id;
|
||||
const data = {
|
||||
'title': edit_inputs.title.value,
|
||||
'time': new Date(edit_inputs.time.value).getTime() / 1000,
|
||||
'notification_service': edit_inputs.notification_service.value,
|
||||
'text': edit_inputs.text.value
|
||||
};
|
||||
fetch(`/api/reminders/${id}?api_key=${api_key}`, {
|
||||
'method': 'PUT',
|
||||
'headers': {'Content-Type': 'application/json'},
|
||||
'body': JSON.stringify(data)
|
||||
})
|
||||
.then(response => {
|
||||
// catch errors
|
||||
if (!response.ok) {
|
||||
return Promise.reject(response.status);
|
||||
};
|
||||
|
||||
fillList();
|
||||
hideWindow();
|
||||
return
|
||||
})
|
||||
.catch(e => {
|
||||
if (e === 401) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
console.log(e);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
function showEdit(id) {
|
||||
document.getElementById('edit-form').dataset.id = id;
|
||||
fetch(`/api/reminders/${id}?api_key=${api_key}`)
|
||||
.then(response => {
|
||||
// catch errors
|
||||
if (!response.ok) {
|
||||
return Promise.reject(response.status);
|
||||
};
|
||||
|
||||
return response.json();
|
||||
})
|
||||
.then(json => {
|
||||
edit_inputs.title.value = json.result.title;
|
||||
edit_inputs.time.value = new Date(
|
||||
(json.result.time + new Date(json.result.time * 1000).getTimezoneOffset() * -60) * 1000
|
||||
).toISOString().slice(0, 16);
|
||||
edit_inputs.notification_service.value = json.result.notification_service;
|
||||
edit_inputs.text.value = json.result.text !== null ? json.result.text : '';
|
||||
showWindow('edit');
|
||||
})
|
||||
.catch(e => {
|
||||
if (e === 401) {
|
||||
window.location.href = '/';
|
||||
} else if (e === 404) {
|
||||
fillList();
|
||||
} else {
|
||||
console.log(e);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// code run on load
|
||||
|
||||
document.getElementById('edit-form').setAttribute('action', 'javascript:editReminder();');
|
||||
document.getElementById('close-edit').addEventListener('click', e => hideWindow());
|
||||
47
frontend/static/js/general.js
Normal file
47
frontend/static/js/general.js
Normal file
@@ -0,0 +1,47 @@
|
||||
function logout() {
|
||||
fetch(`/api/auth/logout?api_key=${api_key}`, {
|
||||
'method': 'POST'
|
||||
})
|
||||
.then(response => {
|
||||
sessionStorage.removeItem('api_key');
|
||||
window.location.href = '/';
|
||||
});
|
||||
};
|
||||
|
||||
function toggleNav() {
|
||||
document.querySelector('.nav-divider').classList.toggle('show-nav');
|
||||
};
|
||||
|
||||
function showWindow(id) {
|
||||
document.querySelectorAll(`.window-container > div:not(#home)`).forEach(e => {
|
||||
if (e.id === id) {
|
||||
e.classList.remove('hidden');
|
||||
setTimeout(() => e.classList.add('show-window'), 0);
|
||||
} else {
|
||||
e.classList.add('hidden');
|
||||
e.classList.remove('show-window');
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
function hideWindow() {
|
||||
document.querySelector('.show-window').classList.remove('show-window');
|
||||
};
|
||||
|
||||
// code run on load
|
||||
|
||||
const api_key = sessionStorage.getItem('api_key');
|
||||
if (api_key === null) {
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
document.getElementById('toggle-nav').addEventListener('click', e => toggleNav());
|
||||
|
||||
document.getElementById('home-button').addEventListener('click', e => hideWindow());
|
||||
document.getElementById('notification-services-button').addEventListener('click', e => showWindow('notification'));
|
||||
document.getElementById('settings-button').addEventListener('click', e => showWindow('settings'));
|
||||
document.getElementById('logout-button').addEventListener('click', e => logout());
|
||||
|
||||
const edit_icon = '<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 24 24" style="enable-background:new 0 0 512 512" xml:space="preserve"><g><g id="_01_align_center" data-name="01 align center"><path d="M22.94,1.06a3.626,3.626,0,0,0-5.124,0L0,18.876V24H5.124L22.94,6.184A3.627,3.627,0,0,0,22.94,1.06ZM4.3,22H2V19.7L15.31,6.4l2.3,2.3ZM21.526,4.77,19.019,7.277l-2.295-2.3L19.23,2.474a1.624,1.624,0,0,1,2.3,2.3Z"></path></g></g></svg>';
|
||||
const delete_icon = '<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 24 24" style="enable-background:new 0 0 512 512" xml:space="preserve"><g><g id="_01_align_center" data-name="01 align center"><path d="M22,4H17V2a2,2,0,0,0-2-2H9A2,2,0,0,0,7,2V4H2V6H4V21a3,3,0,0,0,3,3H17a3,3,0,0,0,3-3V6h2ZM9,2h6V4H9Zm9,19a1,1,0,0,1-1,1H7a1,1,0,0,1-1-1V6H18Z"></path><rect x="9" y="10" width="2" height="8"></rect><rect x="13" y="10" width="2" height="8"></rect></g></g></svg>';
|
||||
const save_icon = '<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 24 24" style="enable-background:new 0 0 512 512" xml:space="preserve"><g><path d="M12,10a4,4,0,1,0,4,4A4,4,0,0,0,12,10Zm0,6a2,2,0,1,1,2-2A2,2,0,0,1,12,16Z"></path><path d="M22.536,4.122,19.878,1.464A4.966,4.966,0,0,0,16.343,0H5A5.006,5.006,0,0,0,0,5V19a5.006,5.006,0,0,0,5,5H19a5.006,5.006,0,0,0,5-5V7.657A4.966,4.966,0,0,0,22.536,4.122ZM17,2.08V3a3,3,0,0,1-3,3H10A3,3,0,0,1,7,3V2h9.343A2.953,2.953,0,0,1,17,2.08ZM22,19a3,3,0,0,1-3,3H5a3,3,0,0,1-3-3V5A3,3,0,0,1,5,2V3a5.006,5.006,0,0,0,5,5h4a4.991,4.991,0,0,0,4.962-4.624l2.16,2.16A3.02,3.02,0,0,1,22,7.657Z"></path></g></svg>';
|
||||
86
frontend/static/js/login.js
Normal file
86
frontend/static/js/login.js
Normal file
@@ -0,0 +1,86 @@
|
||||
function login(data=null) {
|
||||
document.getElementById('username-error').classList.add('hidden');
|
||||
document.getElementById('username-input').classList.remove('error-input');
|
||||
document.getElementById('password-error').classList.add('hidden');
|
||||
document.getElementById('password-input').classList.remove('error-input');
|
||||
|
||||
if (data === null) {
|
||||
data = {
|
||||
'username': document.getElementById('username-input').value,
|
||||
'password': document.getElementById('password-input').value
|
||||
};
|
||||
};
|
||||
fetch(`/api/auth/login`, {
|
||||
'method': 'POST',
|
||||
'headers': {'Content-Type': 'application/json'},
|
||||
'body': JSON.stringify(data)
|
||||
})
|
||||
.then(response => {
|
||||
// catch errors
|
||||
if (!response.ok) {
|
||||
return Promise.reject(response.status);
|
||||
};
|
||||
|
||||
return response.json();
|
||||
})
|
||||
.then(json => {
|
||||
sessionStorage.setItem('api_key', json.result.api_key);
|
||||
window.location.href = '/reminders';
|
||||
})
|
||||
.catch(e => {
|
||||
if (e === 401) {
|
||||
document.getElementById('password-error').classList.remove('hidden');
|
||||
document.getElementById('password-input').classList.add('error-input');
|
||||
} else if (e === 404) {
|
||||
document.getElementById('username-error').classList.remove('hidden');
|
||||
document.getElementById('username-input').classList.add('error-input');
|
||||
} else {
|
||||
console.log(e);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
function create() {
|
||||
document.getElementById('new-username-error').classList.add('hidden');
|
||||
document.getElementById('new-username-input').classList.remove('error-input');
|
||||
document.getElementById('taken-username-error').classList.add('hidden');
|
||||
|
||||
const data = {
|
||||
'username': document.getElementById('new-username-input').value,
|
||||
'password': document.getElementById('new-password-input').value
|
||||
};
|
||||
fetch(`/api/user/add`, {
|
||||
'method': 'POST',
|
||||
'headers': {'Content-Type': 'application/json'},
|
||||
'body': JSON.stringify(data)
|
||||
})
|
||||
.then(response => response.json() )
|
||||
.then(json => {
|
||||
// catch errors
|
||||
if (json.error !== null) {
|
||||
return Promise.reject(json.error);
|
||||
};
|
||||
login(data);
|
||||
})
|
||||
.catch(e => {
|
||||
if (e === 'UsernameInvalid') {
|
||||
document.getElementById('new-username-error').classList.remove('hidden');
|
||||
document.getElementById('new-username-input').classList.add('error-input');
|
||||
} else if (e === 'UsernameTaken') {
|
||||
document.getElementById('taken-username-error').classList.remove('hidden');
|
||||
document.getElementById('new-username-input').classList.add('error-input');
|
||||
} else {
|
||||
console.log(e);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function toggleWindow() {
|
||||
document.querySelector('main').classList.toggle('show-create');
|
||||
};
|
||||
|
||||
// code run on load
|
||||
|
||||
document.getElementById('login-form').setAttribute('action', 'javascript:login();');
|
||||
document.getElementById('create-form').setAttribute('action', 'javascript:create();');
|
||||
document.querySelectorAll('.switch-button').forEach(e => e.addEventListener('click', e => toggleWindow()));
|
||||
209
frontend/static/js/notification.js
Normal file
209
frontend/static/js/notification.js
Normal file
@@ -0,0 +1,209 @@
|
||||
function fillNotificationSelection() {
|
||||
fetch(`/api/notificationservices?api_key=${api_key}`)
|
||||
.then(response => {
|
||||
// catch errors
|
||||
if (!response.ok) {
|
||||
return Promise.reject(response.status);
|
||||
};
|
||||
|
||||
return response.json();
|
||||
})
|
||||
.then(json => {
|
||||
if (json.result.length) {
|
||||
document.getElementById('no-service-error').classList.add('hidden');
|
||||
[document.getElementById('notification-service-input'),
|
||||
document.getElementById('notification-service-edit-input')].forEach(options => {
|
||||
options.innerHTML = '';
|
||||
json.result.forEach(service => {
|
||||
const entry = document.createElement('option');
|
||||
entry.value = service.id;
|
||||
entry.innerText = service.title;
|
||||
options.appendChild(entry);
|
||||
});
|
||||
options.querySelector(':nth-child(1)').setAttribute('selected', '');
|
||||
});
|
||||
|
||||
const table = document.getElementById('services-list');
|
||||
table.querySelectorAll('tr:not(#add-row)').forEach(e => e.remove());
|
||||
json.result.forEach(service => {
|
||||
const entry = document.createElement('tr');
|
||||
entry.dataset.id = service.id;
|
||||
|
||||
const title_container = document.createElement('td');
|
||||
title_container.classList.add('title-column');
|
||||
const title = document.createElement('input');
|
||||
title.setAttribute('readonly', '');
|
||||
title.setAttribute('type', 'text');
|
||||
title.value = service.title;
|
||||
title_container.appendChild(title);
|
||||
entry.appendChild(title_container);
|
||||
|
||||
const url_container = document.createElement('td');
|
||||
url_container.classList.add('url-column');
|
||||
const url = document.createElement('input');
|
||||
url.setAttribute('readonly', '');
|
||||
url.setAttribute('type', 'text');
|
||||
url.value = service.url;
|
||||
url_container.appendChild(url);
|
||||
entry.appendChild(url_container);
|
||||
|
||||
const actions = document.createElement('td');
|
||||
actions.classList.add('action-column');
|
||||
entry.appendChild(actions);
|
||||
|
||||
const edit_button = document.createElement('button');
|
||||
edit_button.dataset.type = 'edit';
|
||||
edit_button.addEventListener('click', e => editService(service.id));
|
||||
edit_button.title = 'Edit';
|
||||
edit_button.setAttribute('aria-label', 'Edit');
|
||||
edit_button.innerHTML = edit_icon;
|
||||
actions.appendChild(edit_button);
|
||||
|
||||
const save_button = document.createElement('button');
|
||||
save_button.dataset.type = 'save';
|
||||
save_button.addEventListener('click', e => saveService(service.id));
|
||||
save_button.title = 'Save Edits';
|
||||
save_button.setAttribute('aria-label', 'Save Edits');
|
||||
save_button.innerHTML = save_icon;
|
||||
actions.appendChild(save_button);
|
||||
|
||||
const delete_button = document.createElement('button');
|
||||
delete_button.dataset.type = 'delete';
|
||||
delete_button.addEventListener('click', e => deleteService(service.id));
|
||||
delete_button.title = 'Delete';
|
||||
delete_button.setAttribute('aria-label', 'Delete');
|
||||
delete_button.innerHTML = delete_icon;
|
||||
actions.appendChild(delete_button);
|
||||
|
||||
table.appendChild(entry);
|
||||
});
|
||||
} else {
|
||||
document.getElementById('no-service-error').classList.remove('hidden');
|
||||
};
|
||||
})
|
||||
.catch(e => {
|
||||
if (e === 401) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
console.log(e);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
function deleteService(id) {
|
||||
const row = document.querySelector(`tr[data-id="${id}"]`);
|
||||
fetch(`/api/notificationservices/${id}?api_key=${api_key}`, {
|
||||
'method': 'DELETE'
|
||||
})
|
||||
.then(response => {
|
||||
// catch errors
|
||||
if (!response.ok) {
|
||||
return Promise.reject(response.status);
|
||||
};
|
||||
|
||||
row.remove();
|
||||
})
|
||||
.catch(e => {
|
||||
if (e === 401) {
|
||||
window.location.href = '/';
|
||||
} else if (e === 400) {
|
||||
const delete_button = row.querySelector('button[title="Delete"]');
|
||||
delete_button.classList.add('error-icon');
|
||||
delete_button.title = 'The notification service is still in use by a reminder';
|
||||
} else {
|
||||
console.log(e);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
function editService(id) {
|
||||
document.querySelectorAll(`tr[data-id="${id}"] input`).forEach(e => e.removeAttribute('readonly'));
|
||||
};
|
||||
|
||||
function saveService(id) {
|
||||
const row = document.querySelector(`tr[data-id="${id}"]`);
|
||||
const save_button = row.querySelector('button[data-type="save"]');
|
||||
const data = {
|
||||
'title': row.querySelector(`td.title-column > input`).value,
|
||||
'url': row.querySelector(`td.url-column > input`).value
|
||||
};
|
||||
fetch(`/api/notificationservices/${id}?api_key=${api_key}`, {
|
||||
'method': 'PUT',
|
||||
'headers': {'Content-Type': 'application/json'},
|
||||
'body': JSON.stringify(data)
|
||||
})
|
||||
.then(response => {
|
||||
// catch errors
|
||||
if (!response.ok) {
|
||||
return Promise.reject(response.status);
|
||||
};
|
||||
|
||||
fillNotificationSelection();
|
||||
})
|
||||
.catch(e => {
|
||||
if (e === 401) {
|
||||
window.location.href = '/';
|
||||
} else if (e === 400) {
|
||||
save_button.classList.add('error-icon');
|
||||
save_button.title = 'Invalid Apprise URL';
|
||||
save_button.setAttribute('aria-label', 'Invalid Apprise URL');
|
||||
} else {
|
||||
console.log(e);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
function toggleAddService() {
|
||||
document.getElementById('add-row').classList.toggle('hidden');
|
||||
};
|
||||
|
||||
function addService() {
|
||||
const inputs_buttons = {
|
||||
'save_button': document.querySelector('#add-row button[data-type="save"]'),
|
||||
'title': document.querySelector('#add-row td.title-column input'),
|
||||
'url': document.querySelector('#add-row td.url-column input')
|
||||
};
|
||||
const data = {
|
||||
'title': inputs_buttons.title.value,
|
||||
'url': inputs_buttons.url.value
|
||||
};
|
||||
fetch(`/api/notificationservices?api_key=${api_key}`, {
|
||||
'method': 'POST',
|
||||
'headers': {'Content-Type': 'application/json'},
|
||||
'body': JSON.stringify(data)
|
||||
})
|
||||
.then(response => {
|
||||
// catch errors
|
||||
if (!response.ok) {
|
||||
return Promise.reject(response.status);
|
||||
};
|
||||
|
||||
inputs_buttons.title.value = '';
|
||||
inputs_buttons.url.value = '';
|
||||
|
||||
inputs_buttons.save_button.classList.remove('error-icon');
|
||||
inputs_buttons.save_button.title = 'Add';
|
||||
inputs_buttons.save_button.setAttribute('aria-label', 'Add');
|
||||
|
||||
toggleAddService();
|
||||
fillNotificationSelection();
|
||||
})
|
||||
.catch(e => {
|
||||
if (e === 401) {
|
||||
window.location.href = '/';
|
||||
} else if (e === 400) {
|
||||
inputs_buttons.save_button.classList.add('error-icon');
|
||||
inputs_buttons.save_button.title = 'Invalid Apprise URL';
|
||||
inputs_buttons.save_button.setAttribute('aria-label', 'Invalid Apprise URL');
|
||||
} else {
|
||||
console.log(e);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// code run on load
|
||||
|
||||
fillNotificationSelection();
|
||||
|
||||
document.getElementById('add-service-button').addEventListener('click', e => toggleAddService());
|
||||
document.querySelector('#add-row button[data-type="save"]').addEventListener('click', e => addService());
|
||||
121
frontend/static/js/reminders.js
Normal file
121
frontend/static/js/reminders.js
Normal file
@@ -0,0 +1,121 @@
|
||||
function fillTable(result) {
|
||||
const table = document.getElementById('reminder-list');
|
||||
table.querySelectorAll('button:not(#add-entry)').forEach(e => e.remove());
|
||||
|
||||
result.forEach(reminder => {
|
||||
const entry = document.createElement('button');
|
||||
entry.classList.add('entry');
|
||||
entry.dataset.id = reminder.id;
|
||||
|
||||
const title = document.createElement('h2');
|
||||
title.innerText = reminder.title;
|
||||
entry.appendChild(title);
|
||||
|
||||
const time = document.createElement('p');
|
||||
var d = new Date(reminder.time * 1000);
|
||||
var formatted_date = d.toLocaleString('en-CA').slice(0,10) + ' ' + d.toTimeString().slice(0,5);
|
||||
time.innerText = formatted_date;
|
||||
entry.appendChild(time);
|
||||
|
||||
const options = document.createElement('div');
|
||||
options.classList.add('entry-overlay');
|
||||
entry.appendChild(options);
|
||||
|
||||
const edit_entry = document.createElement('button');
|
||||
edit_entry.addEventListener('click', e => showEdit(reminder.id));
|
||||
edit_entry.innerHTML = edit_icon;
|
||||
edit_entry.title = 'Edit reminder';
|
||||
edit_entry.setAttribute('aria-label', 'Edit reminder');
|
||||
options.appendChild(edit_entry);
|
||||
|
||||
const delete_entry = document.createElement('button');
|
||||
delete_entry.addEventListener('click', e => deleteReminder(reminder.id));
|
||||
delete_entry.innerHTML = delete_icon;
|
||||
delete_entry.title = 'Delete reminder';
|
||||
delete_entry.setAttribute('aria-label', 'Delete reminder');
|
||||
options.appendChild(delete_entry);
|
||||
|
||||
table.appendChild(entry);
|
||||
});
|
||||
};
|
||||
|
||||
function fillList() {
|
||||
fetch(`/api/reminders?api_key=${api_key}`)
|
||||
.then(response => {
|
||||
// catch errors
|
||||
if (!response.ok) {
|
||||
return Promise.reject(response.status);
|
||||
};
|
||||
return response.json();
|
||||
})
|
||||
.then(json => {
|
||||
fillTable(json.result);
|
||||
})
|
||||
.catch(e => {
|
||||
if (e === 401) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
console.log(e);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
function search() {
|
||||
const query = document.getElementById('search-input').value;
|
||||
fetch(`/api/reminders/search?api_key=${api_key}&query=${query}`)
|
||||
.then(response => {
|
||||
// catch errors
|
||||
if (!response.ok) {
|
||||
return Promise.reject(response.status);
|
||||
};
|
||||
return response.json();
|
||||
})
|
||||
.then(json => {
|
||||
fillTable(json.result);
|
||||
})
|
||||
.catch(e => {
|
||||
if (e === 401) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
console.log(e);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
function clearSearch() {
|
||||
document.getElementById('search-input').value = '';
|
||||
fillList();
|
||||
}
|
||||
|
||||
function deleteReminder(id) {
|
||||
const entry = document.querySelector(`button.entry[data-id="${id}"]`);
|
||||
entry.remove();
|
||||
|
||||
fetch(`/api/reminders/${id}?api_key=${api_key}`, {
|
||||
'method': 'DELETE'
|
||||
})
|
||||
.then(response => {
|
||||
// catch errors
|
||||
if (!response.ok) {
|
||||
return Promise.reject(response.status);
|
||||
};
|
||||
})
|
||||
.catch(e => {
|
||||
if (e === 401) {
|
||||
window.location.href = '/';
|
||||
} else if (e === 404) {
|
||||
fillList();
|
||||
} else {
|
||||
console.log(e);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// code run on load
|
||||
|
||||
fillList();
|
||||
setInterval(fillList, 60000);
|
||||
|
||||
document.getElementById('search-form').setAttribute('action', 'javascript:search();');
|
||||
document.getElementById('clear-button').addEventListener('click', e => clearSearch());
|
||||
document.getElementById('add-entry').addEventListener('click', e => showAdd());
|
||||
38
frontend/static/js/settings.js
Normal file
38
frontend/static/js/settings.js
Normal file
@@ -0,0 +1,38 @@
|
||||
function changePassword() {
|
||||
const data = {
|
||||
'new_password': document.getElementById('password-input').value
|
||||
};
|
||||
fetch(`/api/user?api_key=${api_key}`, {
|
||||
'method': 'PUT',
|
||||
'headers': {'Content-Type': 'application/json'},
|
||||
'body': JSON.stringify(data)
|
||||
})
|
||||
.then(response => {
|
||||
// catch errors
|
||||
if (!response.ok) {
|
||||
return Promise.reject(response.status);
|
||||
};
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(e => {
|
||||
if (e === 401) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
console.log(e);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
function deleteAccount() {
|
||||
fetch(`/api/user?api_key=${api_key}`, {
|
||||
'method': 'DELETE'
|
||||
})
|
||||
.then(response => {
|
||||
window.location.href = '/';
|
||||
});
|
||||
};
|
||||
|
||||
// code run on load
|
||||
|
||||
document.getElementById('change-password-form').setAttribute('action', 'javascript:changePassword()');
|
||||
document.getElementById('delete-account-button').addEventListener('click', e => deleteAccount());
|
||||
43
frontend/templates/login.html
Normal file
43
frontend/templates/login.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<link rel="stylesheet" href="/static/css/general.css">
|
||||
<link rel="stylesheet" href="/static/css/login.css">
|
||||
<script src="/static/js/login.js" defer></script>
|
||||
|
||||
<title>Login - Noted</title>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Noted Reminders</h1>
|
||||
</header>
|
||||
<main>
|
||||
<div class="form-container">
|
||||
<form id="login-form">
|
||||
<h2>Login</h2>
|
||||
<input type="text" id="username-input" autocomplete="username" placeholder="Username" required autofocus>
|
||||
<p class="error hidden" id="username-error">*Username not found</p>
|
||||
<input type="password" id="password-input" autocomplete="current-password" placeholder="Password" required>
|
||||
<p class="error hidden" id="password-error">*Password incorrect</p>
|
||||
<button type="button" class="switch-button" id="create-account">Or create an account</button>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="form-container">
|
||||
<form id="create-form">
|
||||
<h2>Create</h2>
|
||||
<input type="text" id="new-username-input" autocomplete="username" placeholder="Username" required>
|
||||
<p class="error hidden" id="new-username-error">*Username invalid</p>
|
||||
<p class="error hidden" id="taken-username-error">*Username already taken</p>
|
||||
<input type="password" id="new-password-input" autocomplete="new-password" placeholder="Password" required>
|
||||
<button type="button" class="switch-button" id="login-account">Or log into an account</button>
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
23
frontend/templates/page_not_found.html
Normal file
23
frontend/templates/page_not_found.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<link rel="stylesheet" href="/static/css/general.css">
|
||||
<link rel="stylesheet" href="/static/css/page_not_found.css">
|
||||
|
||||
<title>Page Not Found - Noted</title>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Noted Reminders</h1>
|
||||
</header>
|
||||
<main>
|
||||
<h2>Noted</h1>
|
||||
<p>404 - Page not found :(</p>
|
||||
<a href="/">Go to home page</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
213
frontend/templates/reminders.html
Normal file
213
frontend/templates/reminders.html
Normal file
@@ -0,0 +1,213 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<link rel="stylesheet" href="/static/css/general.css">
|
||||
<link rel="stylesheet" href="/static/css/reminders.css">
|
||||
<link rel="stylesheet" href="/static/css/add_edit.css">
|
||||
<link rel="stylesheet" href="/static/css/notification.css">
|
||||
<link rel="stylesheet" href="/static/css/settings.css">
|
||||
<script src="/static/js/general.js" defer></script>
|
||||
<script src="/static/js/reminders.js" defer></script>
|
||||
<script src="/static/js/add.js" defer></script>
|
||||
<script src="/static/js/edit.js" defer></script>
|
||||
<script src="/static/js/notification.js" defer></script>
|
||||
<script src="/static/js/settings.js" defer></script>
|
||||
|
||||
<title>Reminders - Noted</title>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div>
|
||||
<button id="toggle-nav">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 24 24" style="enable-background:new 0 0 512 512" xml:space="preserve">
|
||||
<g>
|
||||
<rect y="11" width="24" height="2" rx="1"></rect>
|
||||
<rect y="4" width="24" height="2" rx="1"></rect>
|
||||
<rect y="18" width="24" height="2" rx="1"></rect>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
<h1>Noted Reminders</h1>
|
||||
</div>
|
||||
</header>
|
||||
<div class="nav-divider">
|
||||
<nav>
|
||||
<div>
|
||||
<button id="home-button" aria-label="Home" title="Home">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 24 24" style="enable-background:new 0 0 512 512" xml:space="preserve">
|
||||
<g>
|
||||
<path d="M23.121,9.069,15.536,1.483a5.008,5.008,0,0,0-7.072,0L.879,9.069A2.978,2.978,0,0,0,0,11.19v9.817a3,3,0,0,0,3,3H21a3,3,0,0,0,3-3V11.19A2.978,2.978,0,0,0,23.121,9.069ZM15,22.007H9V18.073a3,3,0,0,1,6,0Zm7-1a1,1,0,0,1-1,1H17V18.073a5,5,0,0,0-10,0v3.934H3a1,1,0,0,1-1-1V11.19a1.008,1.008,0,0,1,.293-.707L9.878,2.9a3.008,3.008,0,0,1,4.244,0l7.585,7.586A1.008,1.008,0,0,1,22,11.19Z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="notification-services-button" aria-label="Notification Services" title="Notification Services">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 24 24" style="enable-background:new 0 0 512 512" xml:space="preserve">
|
||||
<g>
|
||||
<path d="M23.83,14.12l-1.56-5.84c-.88-3.15-3.47-5.48-6.58-6.11-.01-.01-.02-.03-.03-.04-1.57-1.38-3.58-2.14-5.66-2.14C5.94,0,2.5,2.76,1.63,6.68L.11,12.35c-.31,1.38,.03,2.81,.91,3.91,.89,1.1,2.21,1.74,3.62,1.74h.19c.06,.09,.12,.18,.19,.26,.89,1.1,2.21,1.74,3.62,1.74h.96c.23,2.24,2.1,4,4.4,4s4.17-1.76,4.4-4h.95c1.46,0,2.81-.67,3.7-1.84,.88-1.17,1.17-2.64,.78-4.04Zm-21.25,.89c-.5-.63-.69-1.44-.53-2.19l1.52-5.67c.67-3.04,3.32-5.16,6.43-5.16,.62,0,1.22,.11,1.8,.28-3.05,.8-5.46,3.22-6.17,6.4l-1.52,5.67c-.12,.53-.14,1.06-.08,1.58-.57-.13-1.08-.45-1.45-.92Zm11.42,6.99c-1.2,0-2.19-.86-2.4-2h4.8c-.21,1.14-1.2,2-2.4,2Zm7.46-5.04c-.5,.66-1.27,1.04-2.11,1.04H8.64c-.81,0-1.56-.36-2.06-.99-.5-.63-.69-1.44-.53-2.19l1.52-5.67c.67-3.04,3.32-5.16,6.43-5.16,2.95,0,5.55,1.98,6.34,4.81l1.56,5.84c.22,.8,.06,1.64-.44,2.31Z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="settings-button" aria-label="Settings" title="Settings">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 24 24" style="enable-background:new 0 0 512 512" xml:space="preserve">
|
||||
<g>
|
||||
<path d="M1,4.75H3.736a3.728,3.728,0,0,0,7.195,0H23a1,1,0,0,0,0-2H10.931a3.728,3.728,0,0,0-7.195,0H1a1,1,0,0,0,0,2ZM7.333,2a1.75,1.75,0,1,1-1.75,1.75A1.752,1.752,0,0,1,7.333,2Z"></path>
|
||||
<path d="M23,11H20.264a3.727,3.727,0,0,0-7.194,0H1a1,1,0,0,0,0,2H13.07a3.727,3.727,0,0,0,7.194,0H23a1,1,0,0,0,0-2Zm-6.333,2.75A1.75,1.75,0,1,1,18.417,12,1.752,1.752,0,0,1,16.667,13.75Z"></path>
|
||||
<path d="M23,19.25H10.931a3.728,3.728,0,0,0-7.195,0H1a1,1,0,0,0,0,2H3.736a3.728,3.728,0,0,0,7.195,0H23a1,1,0,0,0,0-2ZM7.333,22a1.75,1.75,0,1,1,1.75-1.75A1.753,1.753,0,0,1,7.333,22Z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button id="logout-button" aria-label="Log out of Noted" tilte="Logout">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 24 24" style="enable-background:new 0 0 512 512" xml:space="preserve">
|
||||
<g>
|
||||
<path d="M11.476,15a1,1,0,0,0-1,1v3a3,3,0,0,1-3,3H5a3,3,0,0,1-3-3V5A3,3,0,0,1,5,2H7.476a3,3,0,0,1,3,3V8a1,1,0,0,0,2,0V5a5.006,5.006,0,0,0-5-5H5A5.006,5.006,0,0,0,0,5V19a5.006,5.006,0,0,0,5,5H7.476a5.006,5.006,0,0,0,5-5V16A1,1,0,0,0,11.476,15Z"></path>
|
||||
<path d="M22.867,9.879,18.281,5.293a1,1,0,1,0-1.414,1.414l4.262,4.263L6,11a1,1,0,0,0,0,2H6l15.188-.031-4.323,4.324a1,1,0,1,0,1.414,1.414l4.586-4.586A3,3,0,0,0,22.867,9.879Z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="window-container">
|
||||
<div id="home">
|
||||
<div class="search-container">
|
||||
<form id="search-form">
|
||||
<div class="search-bar">
|
||||
<button type="submit">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 513.749 513.749" style="enable-background:new 0 0 512 512" xml:space="preserve">
|
||||
<g>
|
||||
<g>
|
||||
<path d="M504.352,459.061l-99.435-99.477c74.402-99.427,54.115-240.344-45.312-314.746S119.261-9.277,44.859,90.15 S-9.256,330.494,90.171,404.896c79.868,59.766,189.565,59.766,269.434,0l99.477,99.477c12.501,12.501,32.769,12.501,45.269,0 c12.501-12.501,12.501-32.769,0-45.269L504.352,459.061z M225.717,385.696c-88.366,0-160-71.634-160-160s71.634-160,160-160 s160,71.634,160,160C385.623,314.022,314.044,385.602,225.717,385.696z"></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
<input type="text" id="search-input" required placeholder="Search..." aria-placeholder="Search for reminders">
|
||||
<button type="button" id="clear-button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 512.021 512.021" style="enable-background:new 0 0 512 512" xml:space="preserve">
|
||||
<g>
|
||||
<g>
|
||||
<path d="M301.258,256.01L502.645,54.645c12.501-12.501,12.501-32.769,0-45.269c-12.501-12.501-32.769-12.501-45.269,0l0,0 L256.01,210.762L54.645,9.376c-12.501-12.501-32.769-12.501-45.269,0s-12.501,32.769,0,45.269L210.762,256.01L9.376,457.376 c-12.501,12.501-12.501,32.769,0,45.269s32.769,12.501,45.269,0L256.01,301.258l201.365,201.387 c12.501,12.501,32.769,12.501,45.269,0c12.501-12.501,12.501-32.769,0-45.269L301.258,256.01z"></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div id="reminder-list">
|
||||
<button class="entry" id="add-entry" aria-label="Add reminder" title="Add reminder">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve">
|
||||
<g>
|
||||
<g>
|
||||
<path d="M480,224H288V32c0-17.673-14.327-32-32-32s-32,14.327-32,32v192H32c-17.673,0-32,14.327-32,32s14.327,32,32,32h192v192 c0,17.673,14.327,32,32,32s32-14.327,32-32V288h192c17.673,0,32-14.327,32-32S497.673,224,480,224z"></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<p class="error hidden" id="no-service-error">Add a notification service first!</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="add">
|
||||
<h2>Add a reminder</h2>
|
||||
<div class="form-container">
|
||||
<form id="add-form">
|
||||
<input type="text" id="title-input" placeholder="Title" required>
|
||||
<div class="sub-inputs">
|
||||
<input type="datetime-local" id="time-input" required>
|
||||
<select id="notification-service-input" required></select>
|
||||
</div>
|
||||
<textarea id="text-input" cols="30" rows="10" placeholder="Text (optional)"></textarea>
|
||||
<div class="options">
|
||||
<button type="button" id="close-add">Cancel</button>
|
||||
<button type="submit">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div id="edit">
|
||||
<h2>Edit a reminder</h2>
|
||||
<div class="form-container">
|
||||
<form id="edit-form">
|
||||
<input type="text" id="title-edit-input" placeholder="Title" required>
|
||||
<div class="sub-inputs">
|
||||
<input type="datetime-local" id="time-edit-input" required>
|
||||
<select id="notification-service-edit-input" required></select>
|
||||
</div>
|
||||
<textarea id="text-edit-input" cols="30" rows="10" placeholder="Text (optional)"></textarea>
|
||||
<div class="options">
|
||||
<button type="button" id="close-edit">Cancel</button>
|
||||
<button type="submit">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div id="notification">
|
||||
<h2>Notification Services</h2>
|
||||
<p>Setup your notification providers here. See the <a target="_blank" href="https://github.com/caronc/apprise#supported-notifications">Apprise URL documentation</a> to learn how to make a valid Apprise URL.</p>
|
||||
<div class="table-container">
|
||||
<button id="add-service-button" title="Add notification service" aria-label="Add notification service">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve">
|
||||
<g>
|
||||
<g>
|
||||
<path d="M480,224H288V32c0-17.673-14.327-32-32-32s-32,14.327-32,32v192H32c-17.673,0-32,14.327-32,32s14.327,32,32,32h192v192 c0,17.673,14.327,32,32,32s32-14.327,32-32V288h192c17.673,0,32-14.327,32-32S497.673,224,480,224z"></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="title-column">Title</th>
|
||||
<th class="url-column">Apprise URL</th>
|
||||
<th title="Actions" aria-label="Actions" class="action-column">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 24 24" style="enable-background:new 0 0 512 512" xml:space="preserve">
|
||||
<g>
|
||||
<path d="M11.24,24a2.262,2.262,0,0,1-.948-.212,2.18,2.18,0,0,1-1.2-2.622L10.653,16H6.975A3,3,0,0,1,4.1,12.131l3.024-10A2.983,2.983,0,0,1,10,0h3.693a2.6,2.6,0,0,1,2.433,3.511L14.443,8H17a3,3,0,0,1,2.483,4.684l-6.4,10.3A2.2,2.2,0,0,1,11.24,24ZM10,2a1,1,0,0,0-.958.71l-3.024,10A1,1,0,0,0,6.975,14H12a1,1,0,0,1,.957,1.29L11.01,21.732a.183.183,0,0,0,.121.241A.188.188,0,0,0,11.4,21.9l6.4-10.3a1,1,0,0,0,.078-1.063A.979.979,0,0,0,17,10H13a1,1,0,0,1-.937-1.351l2.19-5.84A.6.6,0,0,0,13.693,2Z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="services-list">
|
||||
<tr id="add-row" class="hidden">
|
||||
<td class="title-column">
|
||||
<input type="text">
|
||||
</td>
|
||||
<td class="url-column">
|
||||
<input type="text">
|
||||
</td>
|
||||
<td class="action-column">
|
||||
<button data-type="save" title="Add" aria-label="Add">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 24 24" style="enable-background:new 0 0 512 512" xml:space="preserve">
|
||||
<g>
|
||||
<path d="M12,10a4,4,0,1,0,4,4A4,4,0,0,0,12,10Zm0,6a2,2,0,1,1,2-2A2,2,0,0,1,12,16Z"></path><path d="M22.536,4.122,19.878,1.464A4.966,4.966,0,0,0,16.343,0H5A5.006,5.006,0,0,0,0,5V19a5.006,5.006,0,0,0,5,5H19a5.006,5.006,0,0,0,5-5V7.657A4.966,4.966,0,0,0,22.536,4.122ZM17,2.08V3a3,3,0,0,1-3,3H10A3,3,0,0,1,7,3V2h9.343A2.953,2.953,0,0,1,17,2.08ZM22,19a3,3,0,0,1-3,3H5a3,3,0,0,1-3-3V5A3,3,0,0,1,5,2V3a5.006,5.006,0,0,0,5,5h4a4.991,4.991,0,0,0,4.962-4.624l2.16,2.16A3.02,3.02,0,0,1,22,7.657Z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settings">
|
||||
<h2>Settings</h2>
|
||||
<div class="settings-container">
|
||||
<h3>Change Password</h3>
|
||||
<form id="change-password-form">
|
||||
<input type="password" id="password-input" autocomplete="new-password" required>
|
||||
<button type="submit">Change</button>
|
||||
</form>
|
||||
<h3>Delete Account</h3>
|
||||
<button id="delete-account-button">Delete Account</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
15
frontend/ui.py
Normal file
15
frontend/ui.py
Normal file
@@ -0,0 +1,15 @@
|
||||
#-*- coding: utf-8 -*-
|
||||
|
||||
from flask import Blueprint, render_template
|
||||
|
||||
ui = Blueprint('ui', __name__)
|
||||
|
||||
methods = ['GET']
|
||||
|
||||
@ui.route('/', methods=methods)
|
||||
def ui_login():
|
||||
return render_template('login.html')
|
||||
|
||||
@ui.route('/reminders', methods=methods)
|
||||
def ui_reminders():
|
||||
return render_template('reminders.html')
|
||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Flask>=2.1.2
|
||||
waitress>=2.1.2
|
||||
Reference in New Issue
Block a user