Merge pull request #54 from Casvt/Development

V1.3.2
This commit is contained in:
Casvt
2023-07-04 15:34:19 +02:00
committed by GitHub
44 changed files with 2848 additions and 961 deletions

View File

@@ -1,5 +1,5 @@
# Byte-compiled / optimized / DLL files
__pycache__/
**/__pycache__/
*.py[cod]
*$py.class
@@ -129,14 +129,15 @@ dmypy.json
.pyre/
# Database
*.db
**/*.db
**/*.db-shm
**/*.db-wal
# VS code
*.code-workspace
.vscode/
# Docker
Dockerfile
.dockerignore
docker-compose.yml
@@ -146,8 +147,15 @@ docker-compose.yml
.github/
# Various files
*.md
**/*.md
LICENSE
# Tests
tests/
# Project management files
release.sh
docs/
docs-requirements.txt
mkdocs.yml
generate_api_docs.py

25
.github/workflows/build_docs.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: Update and build docs
on:
push:
branches:
- Development
jobs:
update_build:
name: Update API docs and build docs
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v3
name: Setup checkout
- uses: actions/setup-python@v4
name: Setup python 3.8
with:
python-version: 3.8
cache: 'pip'
- run: pip install -r requirements.txt -r project_management/docs-requirements.txt
name: Install dependencies
- run: python3 project_management/generate_api_docs.py
name: Generate API docs
- run: mkdocs gh-deploy --force -f project_management/mkdocs.yml
name: Build docs

32
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: Tests
on:
push:
branches:
- Development
jobs:
test:
runs-on: ubuntu-20.04
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11"]
steps:
- uses: actions/checkout@v3
name: Set up checkout
with:
fetch-depth: 0
- uses: actions/setup-python@v4
name: Set up Python ${{ matrix.python-version }}
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- run: pip install -r requirements.txt
name: Install dependencies
- run: |
mkdir db
python -m unittest discover -s ./tests -p '*.py'
name: Run Tests

5
.gitignore vendored
View File

@@ -130,6 +130,11 @@ dmypy.json
# Database
*.db
*.db-shm
*.db-wal
# VS code
*.code-workspace
# Project management files
release.sh

View File

@@ -1,6 +1,6 @@
# Contributing to MIND
## General steps
Contributing to MIND consists of 5 steps, listed hereunder.
Contributing to MIND consists of 5 steps:
1. Make a [contributing request](https://github.com/Casvt/MIND/issues/new?template=contribute-request.md), where you describe what you plan on doing. This request needs to get approved before you can start, or your pull request won't be accepted. This is to avoid multiple people from doing the same thing and to avoid you wasting your time if we do not wish the changes. This is also where discussions can be held about how something will be implemented.
2. When the request is accepted, start your local development (more info about this below).
@@ -11,24 +11,25 @@ Contributing to MIND consists of 5 steps, listed hereunder.
## Local development steps
Once your request is accepted, you can start your local development.
1. Clone the repository onto your computer and open it using your prefered IDE (Visual Studio Code is used by us).
1. Fork the repository and clone the fork onto your computer and open it using your preferred IDE (Visual Studio Code is used by us).
2. Make the changes needed and write accompanying tests.
3. Check if the code written follows the styling guide below.
4. Run the finished version, using python 3.7, to check if you've made any errors.
5. Run the tests (unittest is used). This can be done with a button click within VS Code, or with the following command where you need to be inside the root folder of the project:
4. If you want to run the tests manually before committing, use the command below in the root folder of the project:
```bash
python3 -m unittest discover -v -s './tests' -p '*_test.py'
```
6. Test your version thoroughly to catch as many bugs as possible (if any).
5. Update the docs if needed.
6. Commit and push to your fork. When you push, GitHub Actions will do a lot of work for you: the tests are run again on python versions 3.8 - 3.11, the API documentation is updated if any changes have been made to the API and the docs are updated if any changes have been made to the docs. All GitHub Actions need to succeed before you're allowed to make a PR (you'll see a green checkmark next to the commit in GitHub).
## Styling guide
The code of MIND is written in such way that it follows the following rules. Your code should too.
1. Compatible with python 3.7 .
1. Compatible with python 3.8 .
2. Tabs (4 space size) are used for indentation.
3. Use type hints as much as possible, though don't if it requires importing extra functions or classes (except for the `typing` library).
4. Each function in the backend needs a doc string describing the function, what the inputs are, what errors could be raised from within the function and what the output is.
5. Each function in the `api.py` file should have a doc string describing the api endpoint. For the formatting, look at the doc strings of the other functions in the file.
6. The imports need to be sorted (the extension `isort` is used in VS Code).
7. The code needs to be compatible with Linux, MacOS and Windows.
8. The code should, though not strictly enforced, reasonably comply with the rule of 80 characters per line.
5. The imports need to be sorted (the extension `isort` is used in VS Code).
6. The code needs to be compatible with Linux, MacOS, Windows and Docker.
7. The code should, though not strictly enforced, reasonably comply with the rule of 80 characters per line.
If you just code in the same style as the current code, you'll follow most of these rules automatically.

View File

@@ -1,6 +1,7 @@
# syntax=docker/dockerfile:1
FROM python:3.8-slim-buster
STOPSIGNAL SIGINT
WORKDIR /app

29
MIND.py
View File

@@ -11,16 +11,23 @@ from flask import Flask, render_template, request
from waitress.server import create_server
from werkzeug.middleware.dispatcher import DispatcherMiddleware
from backend.db import DBConnection, close_db, setup_db
from frontend.api import api, reminder_handler
from backend.db import DBConnection, ThreadedTaskDispatcher, close_db, setup_db
from frontend.api import api, api_prefix, reminder_handler
from frontend.ui import ui
HOST = '0.0.0.0'
PORT = '8080'
URL_PREFIX = '' # Must either be empty or start with '/' e.g. '/mind'
LOGGING_LEVEL = logging.INFO
THREADS = 10
DB_FILENAME = 'db', 'MIND.db'
logging.basicConfig(
level=LOGGING_LEVEL,
format='[%(asctime)s][%(threadName)s][%(levelname)s] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
def _folder_path(*folders) -> str:
"""Turn filepaths relative to the project folder into absolute paths
Returns:
@@ -32,7 +39,7 @@ def _create_app() -> Flask:
"""Create a Flask app instance
Returns:
Flask: The created app instance
"""
"""
app = Flask(
__name__,
template_folder=_folder_path('frontend','templates'),
@@ -65,7 +72,7 @@ def _create_app() -> Flask:
return render_template('page_not_found.html', url_prefix=logging.URL_PREFIX)
app.register_blueprint(ui)
app.register_blueprint(api, url_prefix="/api")
app.register_blueprint(api, url_prefix=api_prefix)
# Setup closing database
app.teardown_appcontext(close_db)
@@ -79,7 +86,7 @@ def MIND() -> 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 + ')')
logging.error('Error: the minimum python version required is python3.7 (currently ' + version_info.major + '.' + version_info.minor + '.' + version_info.micro + ')')
exit(1)
# Register web server
@@ -90,18 +97,22 @@ def MIND() -> None:
app = _create_app()
with app.app_context():
if isfile(_folder_path('db', 'Noted.db')):
move(_folder_path('db', 'Noted.db'), _folder_path('db', 'MIND.db'))
move(_folder_path('db', 'Noted.db'), _folder_path(*DB_FILENAME))
db_location = _folder_path(*DB_FILENAME)
logging.debug(f'Database location: {db_location}')
makedirs(dirname(db_location), exist_ok=True)
DBConnection.file = db_location
setup_db()
reminder_handler.find_next_reminder()
# Create waitress server and run
server = create_server(app, host=HOST, port=PORT, threads=THREADS)
print(f'MIND running on http://{HOST}:{PORT}{URL_PREFIX}')
dispatcher = ThreadedTaskDispatcher()
dispatcher.set_thread_count(THREADS)
server = create_server(app, _dispatcher=dispatcher, host=HOST, port=PORT, threads=THREADS)
logging.info(f'MIND running on http://{HOST}:{PORT}{URL_PREFIX}')
server.run()
print(f'\nShutting down MIND...')
# Stopping thread
reminder_handler.stop_handling()

View File

@@ -1,30 +1,28 @@
# MIND Reminders
A simple self hosted reminder platform that uses push to send notifications to your device. Set the reminder and forget about it! 📢
# MIND
[![Docker Pulls](https://img.shields.io/docker/pulls/mrcas/mind.svg)](https://hub.docker.com/r/mrcas/mind)
[![GitHub Downloads](https://img.shields.io/github/downloads/Casvt/MIND/total.svg)](https://github.com/Casvt/MIND/releases)
__A simple self hosted reminder application that can send push notifications to your device. Set the reminder and forget about it!__
Mind is a simple self hosted application for creating reminders that get pushed to your device using the [Apprise](https://github.com/caronc/apprise) API. You can send messages to just about every platform, including scheduled emails!
## Screenshots
![mind-reminders-home](https://user-images.githubusercontent.com/57927413/213593220-495aeb86-2bf8-4c43-895d-c7cba38c3cee.png)
## Workings
MIND can be used for sending notifications at the desired time. This can be a set time, like a yearly reminder for a birthday, or at a button click, to easily send a predefined notification when you want to.
![mind-reminders-add-notification-services](https://user-images.githubusercontent.com/57927413/212755314-1104531e-7feb-4e59-af1d-927576e47152.png)
## Features
- Works cross-timezone
- Notifications are sent with second-precision
- Uses the [apprise library](https://github.com/caronc/apprise), giving you 80+ platforms to send notifications to
- Easily manage the reminders with sorting options, search ability and color coding
- Docker image available
- Mobile friendly web-interface
- API available
![mind-reminders-edit](https://user-images.githubusercontent.com/57927413/213594471-ecc99a72-cf0f-4570-8e78-92ffbf37e59d.png)
### Planned Features
You can see the planned features in the [Project board](https://github.com/users/Casvt/projects/3).
![mind-reminders-settings](https://user-images.githubusercontent.com/57927413/212755327-b45da53c-72f7-480c-9a77-eaad28803fbb.png)
## Core Features
* Basic auth
* Utilizes Apprise
* Create, edit and delete reminders
* Schedule reminders
* Recurring reminders
* Docker image
* Mobile friendly
## Planned Features
You can see our planned features in our [Project board](https://github.com/users/Casvt/projects/3).
## Installation
## Getting started
Replace the timezone value (`TZ=`) to the [TZ database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) of your timezone!
```bash
docker run -d \
@@ -34,13 +32,9 @@ docker run -d \
-p 8080:8080 \
mrcas/mind:latest
```
## Getting Started
- Create a new account
- Click the bell icon on the left side to add an Apprise push option and save it (Here is an example using Pushover)
![mind-reminders-notification-service](https://user-images.githubusercontent.com/57927413/213593832-6c62307c-cf7c-4d11-b6ce-dea33676d477.png)
More information about installing can be found [in the wiki](https://casvt.github.io/MIND/).
- Click the home icon and create a reminder!
You can see the [wiki](https://github.com/Casvt/MIND/wiki) for instructions on how to install MIND on other OS'es.
## Contact
- For support, a [discord server](https://discord.gg/nMNdgG7vsE) is available
- Alternatively, [make an issue](https://github.com/Casvt/MIND/issues)

View File

@@ -1,33 +1,39 @@
#-*- coding: utf-8 -*-
import logging
from typing import Any, Dict
class CustomException(Exception):
def __init__(self, e=None) -> None:
logging.warning(self.__doc__)
super().__init__(e)
return
class UsernameTaken(Exception):
class UsernameTaken(CustomException):
"""The username is already taken"""
api_response = {'error': 'UsernameTaken', 'result': {}, 'code': 400}
class UsernameInvalid(Exception):
class UsernameInvalid(CustomException):
"""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"""
class UserNotFound(CustomException):
"""The user requested can not be found"""
api_response = {'error': 'UserNotFound', 'result': {}, 'code': 404}
class AccessUnauthorized(Exception):
class AccessUnauthorized(CustomException):
"""The password given is not correct"""
api_response = {'error': 'AccessUnauthorized', 'result': {}, 'code': 401}
class ReminderNotFound(Exception):
class ReminderNotFound(CustomException):
"""The reminder with the id can not be found"""
api_response = {'error': 'ReminderNotFound', 'result': {}, 'code': 404}
class NotificationServiceNotFound(Exception):
class NotificationServiceNotFound(CustomException):
"""The notification service was not found"""
api_response = {'error': 'NotificationServiceNotFound', 'result': {}, 'code': 404}
class NotificationServiceInUse(Exception):
class NotificationServiceInUse(CustomException):
"""The notification service is wished to be deleted but a reminder is still using it"""
def __init__(self, type: str=''):
self.type = type
@@ -37,15 +43,11 @@ class NotificationServiceInUse(Exception):
def api_response(self) -> Dict[str, Any]:
return {'error': 'NotificationServiceInUse', 'result': {'type': self.type}, 'code': 400}
class InvalidTime(Exception):
class InvalidTime(CustomException):
"""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):
class KeyNotFound(CustomException):
"""A key was not found in the input that is required to be given"""
def __init__(self, key: str=''):
self.key = key
@@ -55,7 +57,7 @@ class KeyNotFound(Exception):
def api_response(self) -> Dict[str, Any]:
return {'error': 'KeyNotFound', 'result': {'key': self.key}, 'code': 400}
class InvalidKeyValue(Exception):
class InvalidKeyValue(CustomException):
"""The value of a key is invalid"""
def __init__(self, key: str='', value: str=''):
self.key = key
@@ -66,6 +68,20 @@ class InvalidKeyValue(Exception):
def api_response(self) -> Dict[str, Any]:
return {'error': 'InvalidKeyValue', 'result': {'key': self.key, 'value': self.value}, 'code': 400}
class TemplateNotFound(Exception):
class TemplateNotFound(CustomException):
"""The template was not found"""
api_response = {'error': 'TemplateNotFound', 'result': {}, 'code': 404}
class APIKeyInvalid(CustomException):
"""The API key is not correct"""
api_response = {'error': 'APIKeyInvalid', 'result': {}, 'code': 401}
def __init__(self, e=None) -> None:
return
class APIKeyExpired(CustomException):
"""The API key has expired"""
api_response = {'error': 'APIKeyExpired', 'result': {}, 'code': 401}
def __init__(self, e=None) -> None:
return

View File

@@ -1,30 +1,58 @@
#-*- coding: utf-8 -*-
from datetime import datetime
from sqlite3 import Connection, Row
from threading import current_thread
import logging
from sqlite3 import Connection, ProgrammingError, Row
from threading import current_thread, main_thread
from time import time
from typing import Union
from flask import g
from waitress.task import ThreadedTaskDispatcher as OldThreadedTaskDispatcher
__DATABASE_VERSION__ = 5
from backend.custom_exceptions import AccessUnauthorized, UserNotFound
__DATABASE_VERSION__ = 6
class Singleton(type):
_instances = {}
def __call__(cls, *args, **kwargs):
i = f'{cls}{current_thread()}'
if i not in cls._instances:
if (i not in cls._instances
or cls._instances[i].closed):
logging.debug(f'Creating singleton instance: {i}')
cls._instances[i] = super(Singleton, cls).__call__(*args, **kwargs)
return cls._instances[i]
class ThreadedTaskDispatcher(OldThreadedTaskDispatcher):
def handler_thread(self, thread_no: int) -> None:
super().handler_thread(thread_no)
i = f'{DBConnection}{current_thread()}'
if i in Singleton._instances and not Singleton._instances[i].closed:
logging.debug(f'Closing singleton instance: {i}')
Singleton._instances[i].close()
def shutdown(self, cancel_pending: bool = True, timeout: int = 5) -> bool:
print()
logging.info('Shutting down MIND...')
super().shutdown(cancel_pending, timeout)
DBConnection(20.0).close()
class DBConnection(Connection, metaclass=Singleton):
file = ''
def __init__(self, timeout: float) -> None:
logging.debug(f'Opening database connection for {current_thread()}')
super().__init__(self.file, timeout=timeout)
super().cursor().execute("PRAGMA foreign_keys = ON;")
self.closed = False
return
def close(self) -> None:
logging.debug(f'Closing database connection for {current_thread()}')
self.closed = True
super().close()
return
def get_db(output_type: Union[dict, tuple]=tuple):
@@ -54,11 +82,13 @@ def close_db(e=None) -> None:
"""
try:
cursor = g.cursor
db = cursor.connection
db: DBConnection = cursor.connection
cursor.close()
delattr(g, 'cursor')
db.commit()
except AttributeError:
if current_thread() is main_thread():
db.close()
except (AttributeError, ProgrammingError):
pass
return
@@ -67,7 +97,7 @@ def migrate_db(current_db_version: int) -> None:
Migrate a MIND database from it's current version
to the newest version supported by the MIND version installed.
"""
print('Migrating database to newer version...')
logging.info('Migrating database to newer version...')
cursor = get_db()
if current_db_version == 1:
# V1 -> V2
@@ -168,13 +198,22 @@ def migrate_db(current_db_version: int) -> None:
COMMIT;
""")
current_db_version = 5
if current_db_version == 5:
# V5 -> V6
from backend.users import User
try:
User('User1', 'Password1').delete()
except (UserNotFound, AccessUnauthorized):
pass
return
def setup_db() -> None:
"""Setup the database
"""
cursor = get_db()
cursor.execute("PRAGMA journal_mode = wal;")
cursor.executescript("""
CREATE TABLE IF NOT EXISTS users(
@@ -256,6 +295,7 @@ def setup_db() -> None:
"SELECT value FROM config WHERE key = 'database_version' LIMIT 1;"
).fetchone()[0])
logging.debug(f'Current database version {current_db_version} and desired database version {__DATABASE_VERSION__}')
if current_db_version < __DATABASE_VERSION__:
migrate_db(current_db_version)
cursor.execute(

View File

@@ -1,21 +1,20 @@
#-*- coding: utf-8 -*-
import logging
from typing import List
from apprise import Apprise
from backend.custom_exceptions import (InvalidURL, NotificationServiceInUse,
from backend.custom_exceptions import (NotificationServiceInUse,
NotificationServiceNotFound)
from backend.db import get_db
class NotificationService:
def __init__(self, notification_service_id: int) -> None:
def __init__(self, user_id: int, 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,)
"SELECT 1 FROM notification_services WHERE id = ? AND user_id = ? LIMIT 1;",
(self.id, user_id)
).fetchone():
raise NotificationServiceNotFound
@@ -46,9 +45,8 @@ class NotificationService:
Returns:
dict: The new info about the service
"""
if not Apprise().add(url):
raise InvalidURL
logging.info(f'Updating notification service {self.id}: {title=}, {url=}')
# Get current data and update it with new values
data = self.get()
new_values = {
@@ -79,7 +77,9 @@ class NotificationService:
Raises:
NotificationServiceInUse: The service is still used by a reminder
"""
"""
logging.info(f'Deleting notification service {self.id}')
# Check if no reminders exist with this service
cursor = get_db()
cursor.execute("""
@@ -157,7 +157,7 @@ class NotificationServices:
Returns:
NotificationService: Instance of NotificationService
"""
return NotificationService(notification_service_id)
return NotificationService(self.user_id, notification_service_id)
def add(self, title: str, url: str) -> NotificationService:
"""Add a notification service
@@ -166,15 +166,11 @@ class NotificationServices:
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
"""
logging.info(f'Adding notification service with {title=}, {url=}')
new_id = get_db().execute("""
INSERT INTO notification_services(user_id, title, url)
VALUES (?,?,?)

View File

@@ -1,6 +1,7 @@
#-*- coding: utf-8 -*-
from datetime import datetime
import logging
from sqlite3 import IntegrityError
from threading import Timer
from typing import List, Literal
@@ -17,7 +18,6 @@ from backend.db import close_db, get_db
filter_function = lambda query, p: (
query in p["title"].lower()
or query in p["text"].lower()
or query in p["notification_service_title"].lower()
)
def _find_next_time(
@@ -30,7 +30,11 @@ def _find_next_time(
current_time = datetime.fromtimestamp(datetime.utcnow().timestamp())
while new_time <= current_time:
new_time += td
return int(new_time.timestamp())
result = int(new_time.timestamp())
logging.debug(
f'{original_time=}, {current_time=} and interval of {repeat_interval} {repeat_quantity} leads to {result}'
)
return result
class ReminderHandler:
"""Handle set reminders
@@ -83,6 +87,7 @@ class ReminderHandler:
"DELETE FROM reminders WHERE id = ?;",
(reminder['id'],)
)
logging.info(f'Deleted reminder {reminder["id"]}')
else:
# Set next time
new_time = _find_next_time(
@@ -124,8 +129,14 @@ class ReminderHandler:
or time < self.next_trigger['time']):
if self.next_trigger['thread'] is not None:
self.next_trigger['thread'].cancel()
t = time - datetime.utcnow().timestamp()
self.next_trigger['thread'] = Timer(t, self.__trigger_reminders, (time,))
self.next_trigger['thread'] = Timer(
t,
self.__trigger_reminders,
(time,)
)
self.next_trigger['thread'].name = "ReminderHandler"
self.next_trigger['thread'].start()
self.next_trigger['time'] = time
@@ -143,13 +154,13 @@ reminder_handler = ReminderHandler(handler_context.app_context)
class Reminder:
"""Represents a reminder
"""
def __init__(self, reminder_id: int):
def __init__(self, user_id: int, 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,)
"SELECT 1 FROM reminders WHERE id = ? AND user_id = ? LIMIT 1",
(self.id, user_id)
).fetchone():
raise ReminderNotFound
@@ -210,6 +221,10 @@ class Reminder:
Returns:
dict: The new reminder info
"""
logging.info(
f'Updating notification service {self.id}: '
+ f'{title=}, {time=}, {notification_services=}, {text=}, {repeat_quantity=}, {repeat_interval=}, {color=}'
)
cursor = get_db()
# Validate data
@@ -302,7 +317,8 @@ class Reminder:
def delete(self) -> None:
"""Delete the reminder
"""
"""
logging.info(f'Deleting reminder {self.id}')
get_db().execute("DELETE FROM reminders WHERE id = ?", (self.id,))
reminder_handler.find_next_reminder()
return
@@ -311,24 +327,26 @@ class Reminders:
"""Represents the reminder library 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)
'time': (lambda r: (r['time'], r['title'], r['text'], r['color']), False),
'time_reversed': (lambda r: (r['time'], r['title'], r['text'], r['color']), True),
'title': (lambda r: (r['title'], r['time'], r['text'], r['color']), False),
'title_reversed': (lambda r: (r['title'], r['time'], r['text'], r['color']), True),
'date_added': (lambda r: r['id'], False),
'date_added_reversed': (lambda r: r['id'], 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]:
def fetchall(self, sort_by: Literal["time", "time_reversed", "title", "title_reversed", "date_added", "date_added_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".
sort_by (Literal["time", "time_reversed", "title", "title_reversed", "date_added", "date_added_reversed"], optional): How to sort the result. Defaults to "time".
Returns:
List[dict]: The id, title, text, time and color of each reminder
"""
"""
sort_function = self.sort_functions.get(
sort_by,
self.sort_functions['time']
@@ -354,11 +372,12 @@ class Reminders:
return reminders
def search(self, query: str) -> List[dict]:
def search(self, query: str, sort_by: Literal["time", "time_reversed", "title", "title_reversed", "date_added", "date_added_reversed"] = "time") -> List[dict]:
"""Search for reminders
Args:
query (str): The term to search for
sort_by (Literal["time", "time_reversed", "title", "title_reversed", "date_added", "date_added_reversed"], optional): How to sort the result. Defaults to "time".
Returns:
List[dict]: All reminders that match. Similar output to self.fetchall
@@ -366,7 +385,7 @@ class Reminders:
query = query.lower()
reminders = list(filter(
lambda p: filter_function(query, p),
self.fetchall()
self.fetchall(sort_by)
))
return reminders
@@ -379,7 +398,7 @@ class Reminders:
Returns:
Reminder: A Reminder instance
"""
return Reminder(id)
return Reminder(self.user_id, id)
def add(
self,
@@ -408,6 +427,11 @@ class Reminders:
Returns:
dict: The info about the reminder
"""
logging.info(
f'Adding reminder with {title=}, {time=}, {notification_services=}, '
+ f'{text=}, {repeat_quantity=}, {repeat_interval=}, {color=}'
)
if time < datetime.utcnow().timestamp():
raise InvalidTime
time = round(time)
@@ -418,6 +442,13 @@ class Reminders:
raise InvalidKeyValue('repeat_interval', repeat_interval)
cursor = get_db()
for service in notification_services:
if not cursor.execute(
"SELECT 1 FROM notification_services WHERE id = ? AND user_id = ? LIMIT 1;",
(service, self.user_id)
).fetchone():
raise NotificationServiceNotFound
if repeat_quantity is None and repeat_interval is None:
id = cursor.execute("""
INSERT INTO reminders(user_id, title, text, time, color)
@@ -444,27 +475,29 @@ class Reminders:
# Return info
return self.fetchone(id)
def test_reminder(
title: str,
notification_services: List[int],
text: str = ''
) -> None:
"""Test send a reminder draft
def test_reminder(
self,
title: str,
notification_services: List[int],
text: str = ''
) -> None:
"""Test send a reminder draft
Args:
title (str): Title title of the entry
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 ''.
"""
a = Apprise()
cursor = get_db(dict)
for service in notification_services:
url = cursor.execute(
"SELECT url FROM notification_services WHERE id = ? LIMIT 1;",
(service,)
).fetchone()
if not url:
raise NotificationServiceNotFound
a.add(url[0])
a.notify(title=title, body=text)
return
Args:
title (str): Title title of the entry
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 ''.
"""
logging.info(f'Testing reminder with {title=}, {notification_services=}, {text=}')
a = Apprise()
cursor = get_db(dict)
for service in notification_services:
url = cursor.execute(
"SELECT url FROM notification_services WHERE id = ? AND user_id = ? LIMIT 1;",
(service, self.user_id)
).fetchone()
if not url:
raise NotificationServiceNotFound
a.add(url[0])
a.notify(title=title, body=text)
return

View File

@@ -1,7 +1,8 @@
#-*- coding: utf-8 -*-
import logging
from sqlite3 import IntegrityError
from typing import List
from typing import List, Literal
from apprise import Apprise
@@ -9,17 +10,21 @@ from backend.custom_exceptions import (NotificationServiceNotFound,
ReminderNotFound)
from backend.db import get_db
filter_function = lambda query, p: (
query in p["title"].lower()
or query in p["text"].lower()
)
class StaticReminder:
"""Represents a static reminder
"""
def __init__(self, reminder_id: int) -> None:
def __init__(self, user_id: int, reminder_id: int) -> None:
self.id = reminder_id
# Check if reminder exists
if not get_db().execute(
"SELECT 1 FROM static_reminders WHERE id = ? LIMIT 1;",
(self.id,)
"SELECT 1 FROM static_reminders WHERE id = ? AND user_id = ? LIMIT 1;",
(self.id, user_id)
).fetchone():
raise ReminderNotFound
@@ -70,7 +75,12 @@ class StaticReminder:
Returns:
dict: The new static reminder info
"""
"""
logging.info(
f'Updating static reminder {self.id}: '
+ f'{title=}, {notification_services=}, {text=}, {color=}'
)
# Get current data and update it with new values
data = self.get()
new_values = {
@@ -115,22 +125,37 @@ class StaticReminder:
def delete(self) -> None:
"""Delete the static reminder
"""
logging.info(f'Deleting static reminder {self.id}')
get_db().execute("DELETE FROM static_reminders WHERE id = ?", (self.id,))
return
class StaticReminders:
"""Represents the static reminder library of the user account
"""
sort_functions = {
'title': (lambda r: (r['title'], r['text'], r['color']), False),
'title_reversed': (lambda r: (r['title'], r['text'], r['color']), True),
'date_added': (lambda r: r['id'], False),
'date_added_reversed': (lambda r: r['id'], True)
}
def __init__(self, user_id: int) -> None:
self.user_id = user_id
def fetchall(self) -> List[dict]:
def fetchall(self, sort_by: Literal["title", "title_reversed", "date_added", "date_added_reversed"] = "title") -> List[dict]:
"""Get all static reminders
Args:
sort_by (Literal["title", "title_reversed", "date_added", "date_added_reversed"], optional): How to sort the result. Defaults to "title".
Returns:
List[dict]: The id, title, text and color of each static reminder
"""
"""
sort_function = self.sort_functions.get(
sort_by,
self.sort_functions['title']
)
reminders: list = list(map(
dict,
get_db(dict).execute("""
@@ -146,8 +171,28 @@ class StaticReminders:
)
))
# Sort result
reminders.sort(key=sort_function[0], reverse=sort_function[1])
return reminders
def search(self, query: str, sort_by: Literal["title", "title_reversed", "date_added", "date_added_reversed"] = "title") -> List[dict]:
"""Search for static reminders
Args:
query (str): The term to search for
sort_by (Literal["title", "title_reversed", "date_added", "date_added_reversed"], optional): How to sort the result. Defaults to "title".
Returns:
List[dict]: All static reminders that match. Similar output to self.fetchall
"""
query = query.lower()
reminders = list(filter(
lambda p: filter_function(query, p),
self.fetchall(sort_by)
))
return reminders
def fetchone(self, id: int) -> StaticReminder:
"""Get one static reminder
@@ -157,7 +202,7 @@ class StaticReminders:
Returns:
StaticReminder: A StaticReminder instance
"""
return StaticReminder(id)
return StaticReminder(self.user_id, id)
def add(
self,
@@ -180,6 +225,10 @@ class StaticReminders:
Returns:
StaticReminder: A StaticReminder instance representing the newly created static reminder
"""
logging.info(
f'Adding static reminder with {title=}, {notification_services=}, {text=}, {color=}'
)
cursor = get_db()
id = cursor.execute("""
INSERT INTO static_reminders(user_id, title, text, color)
@@ -207,13 +256,16 @@ class StaticReminders:
Raises:
ReminderNotFound: The static reminder with the given id was not found
"""
logging.info(f'Triggering static reminder {self.id}')
cursor = get_db(dict)
reminder = cursor.execute("""
SELECT title, text
FROM static_reminders
WHERE id = ?
WHERE
id = ?
AND user_id = ?
LIMIT 1;
""", (id,)).fetchone()
""", (id, self.user_id)).fetchone()
if not reminder:
raise ReminderNotFound
reminder = dict(reminder)

View File

@@ -1,22 +1,27 @@
#-*- coding: utf-8 -*-
import logging
from sqlite3 import IntegrityError
from typing import List
from typing import List, Literal
from backend.custom_exceptions import (NotificationServiceNotFound,
TemplateNotFound)
from backend.db import get_db
filter_function = lambda query, p: (
query in p["title"].lower()
or query in p["text"].lower()
)
class Template:
"""Represents a template
"""
def __init__(self, template_id: int):
def __init__(self, user_id: int, template_id: int):
self.id = template_id
exists = get_db().execute(
"SELECT 1 FROM templates WHERE id = ? LIMIT 1;",
(self.id,)
"SELECT 1 FROM templates WHERE id = ? AND user_id = ? LIMIT 1;",
(self.id, user_id)
).fetchone()
if not exists:
raise TemplateNotFound
@@ -65,6 +70,11 @@ class Template:
Returns:
dict: The new template info
"""
logging.info(
f'Updating template {self.id}: '
+ f'{title=}, {notification_services=}, {text=}, {color=}'
)
cursor = get_db()
data = self.get()
@@ -107,21 +117,37 @@ class Template:
def delete(self) -> None:
"""Delete the template
"""
logging.info(f'Deleting template {self.id}')
get_db().execute("DELETE FROM templates WHERE id = ?;", (self.id,))
return
class Templates:
"""Represents the template library of the user account
"""
sort_functions = {
'title': (lambda r: (r['title'], r['text'], r['color']), False),
'title_reversed': (lambda r: (r['title'], r['text'], r['color']), True),
'date_added': (lambda r: r['id'], False),
'date_added_reversed': (lambda r: r['id'], True)
}
def __init__(self, user_id: int):
self.user_id = user_id
def fetchall(self) -> List[dict]:
def fetchall(self, sort_by: Literal["title", "title_reversed", "date_added", "date_added_reversed"] = "title") -> List[dict]:
"""Get all templates
Args:
sort_by (Literal["title", "title_reversed", "date_added", "date_added_reversed"], optional): How to sort the result. Defaults to "title".
Returns:
List[dict]: The id, title, text and color
"""
sort_function = self.sort_functions.get(
sort_by,
self.sort_functions['title']
)
templates: list = list(map(dict, get_db(dict).execute("""
SELECT
id,
@@ -134,8 +160,28 @@ class Templates:
(self.user_id,)
)))
# Sort result
templates.sort(key=sort_function[0], reverse=sort_function[1])
return templates
def search(self, query: str, sort_by: Literal["title", "title_reversed", "date_added", "date_added_reversed"] = "title") -> List[dict]:
"""Search for templates
Args:
query (str): The term to search for
sort_by (Literal["title", "title_reversed", "date_added", "date_added_reversed"], optional): How to sort the result. Defaults to "title".
Returns:
List[dict]: All templates that match. Similar output to self.fetchall
"""
query = query.lower()
reminders = list(filter(
lambda p: filter_function(query, p),
self.fetchall(sort_by)
))
return reminders
def fetchone(self, id: int) -> Template:
"""Get one template
@@ -145,7 +191,7 @@ class Templates:
Returns:
Template: A Template instance
"""
return Template(id)
return Template(self.user_id, id)
def add(
self,
@@ -164,7 +210,11 @@ class Templates:
Returns:
Template: The info about the template
"""
"""
logging.info(
f'Adding template with {title=}, {notification_services=}, {text=}, {color=}'
)
cursor = get_db()
id = cursor.execute("""
INSERT INTO templates(user_id, title, text, color)

View File

@@ -1,5 +1,6 @@
#-*- coding: utf-8 -*-
import logging
from backend.custom_exceptions import (AccessUnauthorized, UsernameInvalid,
UsernameTaken, UserNotFound)
from backend.db import get_db
@@ -27,7 +28,7 @@ class User:
self.salt = result['salt']
self.user_id = result['id']
# check password
# Check password
hash_password = get_hash(result['salt'], password)
if not hash_password == result['hash']:
raise AccessUnauthorized
@@ -90,11 +91,14 @@ class User:
"UPDATE users SET hash = ? WHERE id = ?",
(hash_password, self.user_id)
)
logging.info(f'The user {self.username} ({self.user_id}) changed their password')
return
def delete(self) -> None:
"""Delete the user account
"""
"""
logging.info(f'Deleting the user {self.username} ({self.user_id})')
cursor = get_db()
cursor.execute("DELETE FROM reminders WHERE user_id = ?", (self.user_id,))
cursor.execute("DELETE FROM templates WHERE user_id = ?", (self.user_id,))
@@ -111,7 +115,8 @@ def _check_username(username: str) -> None:
Raises:
UsernameInvalid: The username is not valid
"""
"""
logging.debug(f'Checking the username {username}')
if username in ONEPASS_INVALID_USERNAMES or username.isdigit():
raise UsernameInvalid
if list(filter(lambda c: not c in ONEPASS_USERNAME_CHARACTERS, username)):
@@ -132,6 +137,8 @@ def register_user(username: str, password: str) -> int:
Returns:
user_id (int): The id of the new user. User registered successful
"""
logging.info(f'Registering user with username {username}')
# Check if username is valid
_check_username(username)
@@ -156,4 +163,5 @@ def register_user(username: str, password: str) -> int:
(username, salt, hashed_password)
).lastrowid
logging.debug(f'Newly registered user has id {user_id}')
return user_id

625
docs/api.md Normal file
View File

@@ -0,0 +1,625 @@
# API
Below is the API documentation. Report an issue on [GitHub](https://github.com/Casvt/MIND/issues).
All endpoints have the `/api` prefix. That means, for example, that `/auth/login` can be reached at `/api/auth/login`.
## Authentication
Authentication is done using an API key.
To log in, make a POST request to the [`/api/auth/login`](#authlogin) endpoint.
You'll receive an API key, which you can then use in your requests to authenticate.
Supply it via the url parameter `api_key`.
This API key is valid for one hour after which the key expires, any further requests return 401 'APIKeyExpired' and you are required to log in again.
If no `api_key` is supplied or it is invalid, 401 `APIKeyInvalid` is returned.
For example:
```bash
curl -sSL 'http://192.168.2.15:8080/api/reminders?api_key=ABCDEFG'
```
## Supplying data
Often, data needs to be supplied with a request.
If the parameters need to be supplied via `url`, add them to the url as url parameters.
If the parameters need to be supplied via `body`, add them to the body as a json object and supply the `Content-Type: application/json` header.
For example:
```bash
# URL parameter
curl -sSL 'http://192.168.2.15:8080/api/reminders/search?api_key=ABCDEFG&query=Fountain&sort_by=time_reversed'
# Body parameter
curl -sSLX POST \
-H 'Content-Type: application/json' \
-d '{"title": "Test service", "url": "test://fake/url"}' \
'http://192.168.2.15:8080/api/notificationservices?api_key=ABCDEFG'
```
## Endpoints
The following is automatically generated. Please report any issues on [GitHub](https://github.com/Casvt/MIND/issues).
### `/auth/login`
| Requires being logged in | Description |
| ------------------------ | ----------- |
| No | Login to a user account |
??? POST
**Parameters (body)**
| Name | Required | Description | Allowed values |
| ---- | -------- | ----------- | -------------- |
| username | Yes | The username of the user account | N/A |
| password | Yes | The password of the user account | N/A |
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 201| N/A | Success |
| 400 | KeyNotFound | A key was not found in the input that is required to be given |
| 401 | AccessUnauthorized | The password given is not correct |
| 404 | UserNotFound | The user requested can not be found |
### `/auth/logout`
| Requires being logged in | Description |
| ------------------------ | ----------- |
| Yes | Logout of a user account |
??? POST
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 201| N/A | Success |
### `/auth/status`
| Requires being logged in | Description |
| ------------------------ | ----------- |
| Yes | Get current status of login |
??? GET
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200| N/A | Success |
### `/user/add`
| Requires being logged in | Description |
| ------------------------ | ----------- |
| No | Create a new user account |
??? POST
**Parameters (body)**
| Name | Required | Description | Allowed values |
| ---- | -------- | ----------- | -------------- |
| username | Yes | The username of the user account | N/A |
| password | Yes | The password of the user account | N/A |
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 201| N/A | Success |
| 400 | KeyNotFound | A key was not found in the input that is required to be given |
| 400 | UsernameInvalid | The username contains invalid characters |
| 400 | UsernameTaken | The username is already taken |
### `/user`
| Requires being logged in | Description |
| ------------------------ | ----------- |
| Yes | Manage a user account |
??? PUT
Change the password of the user account
**Parameters (body)**
| Name | Required | Description | Allowed values |
| ---- | -------- | ----------- | -------------- |
| new_password | Yes | The new password of the user account | N/A |
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200| N/A | Success |
| 400 | KeyNotFound | A key was not found in the input that is required to be given |
??? DELETE
Delete the user account
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200| N/A | Success |
### `/notificationservices`
| Requires being logged in | Description |
| ------------------------ | ----------- |
| Yes | Manage the notification services |
??? GET
Get a list of all notification services
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200| N/A | Success |
??? POST
Add a notification service
**Parameters (body)**
| Name | Required | Description | Allowed values |
| ---- | -------- | ----------- | -------------- |
| title | Yes | The title of the entry | N/A |
| url | Yes | The Apprise URL of the notification service | N/A |
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 201| N/A | Success |
| 400 | InvalidKeyValue | The value of a key is invalid |
| 400 | KeyNotFound | A key was not found in the input that is required to be given |
### `/notificationservices/<int:n_id>`
| Requires being logged in | Description |
| ------------------------ | ----------- |
| Yes | Manage a specific notification service |
Replace `<int:n_id>` with the ID of the entry. For example: `/notificationservices/2`.
??? GET
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200| N/A | Success |
| 404 | NotificationServiceNotFound | The notification service was not found |
??? PUT
Edit the notification service
**Parameters (body)**
| Name | Required | Description | Allowed values |
| ---- | -------- | ----------- | -------------- |
| title | No | The title of the entry | N/A |
| url | No | The Apprise URL of the notification service | N/A |
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200| N/A | Success |
| 400 | InvalidKeyValue | The value of a key is invalid |
| 404 | NotificationServiceNotFound | The notification service was not found |
??? DELETE
Delete the notification service
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200| N/A | Success |
| 404 | NotificationServiceNotFound | The notification service was not found |
### `/reminders`
| Requires being logged in | Description |
| ------------------------ | ----------- |
| Yes | Manage the reminders |
??? GET
Get a list of all reminders
**Parameters (url)**
| Name | Required | Description | Allowed values |
| ---- | -------- | ----------- | -------------- |
| sort_by | No | How to sort the result | `time`, `time_reversed`, `title`, `title_reversed`, `date_added`, `date_added_reversed` |
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200| N/A | Success |
| 400 | InvalidKeyValue | The value of a key is invalid |
??? POST
Add a reminder
**Parameters (body)**
| Name | Required | Description | Allowed values |
| ---- | -------- | ----------- | -------------- |
| title | Yes | The title of the entry | N/A |
| time | Yes | The UTC epoch timestamp that the reminder should be sent at | N/A |
| notification_services | Yes | Array of the id's of the notification services to use to send the notification | N/A |
| text | No | The body of the entry | N/A |
| repeat_quantity | No | The quantity of the repeat_interval | `years`, `months`, `weeks`, `days`, `hours`, `minutes` |
| repeat_interval | No | The number of the interval | N/A |
| color | No | The hex code of the color of the entry, which is shown in the web-ui | N/A |
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 201| N/A | Success |
| 400 | InvalidKeyValue | The value of a key is invalid |
| 400 | InvalidTime | The time given is in the past |
| 400 | KeyNotFound | A key was not found in the input that is required to be given |
| 404 | NotificationServiceNotFound | The notification service was not found |
### `/reminders/search`
| Requires being logged in | Description |
| ------------------------ | ----------- |
| Yes | Search through the list of reminders |
??? GET
**Parameters (url)**
| Name | Required | Description | Allowed values |
| ---- | -------- | ----------- | -------------- |
| sort_by | No | How to sort the result | `time`, `time_reversed`, `title`, `title_reversed`, `date_added`, `date_added_reversed` |
| query | Yes | The search term | N/A |
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200| N/A | Success |
| 400 | InvalidKeyValue | The value of a key is invalid |
### `/reminders/test`
| Requires being logged in | Description |
| ------------------------ | ----------- |
| Yes | Test send a reminder draft |
??? POST
**Parameters (body)**
| Name | Required | Description | Allowed values |
| ---- | -------- | ----------- | -------------- |
| title | Yes | The title of the entry | N/A |
| notification_services | Yes | Array of the id's of the notification services to use to send the notification | N/A |
| text | No | The body of the entry | N/A |
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 201| N/A | Success |
| 400 | InvalidKeyValue | The value of a key is invalid |
| 400 | KeyNotFound | A key was not found in the input that is required to be given |
| 404 | NotificationServiceNotFound | The notification service was not found |
### `/reminders/<int:r_id>`
| Requires being logged in | Description |
| ------------------------ | ----------- |
| Yes | Manage a specific reminder |
Replace `<int:r_id>` with the ID of the entry. For example: `/reminders/2`.
??? GET
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200| N/A | Success |
| 404 | ReminderNotFound | The reminder with the id can not be found |
??? PUT
Edit the reminder
**Parameters (body)**
| Name | Required | Description | Allowed values |
| ---- | -------- | ----------- | -------------- |
| title | No | The title of the entry | N/A |
| time | No | The UTC epoch timestamp that the reminder should be sent at | N/A |
| notification_services | No | Array of the id's of the notification services to use to send the notification | N/A |
| text | No | The body of the entry | N/A |
| repeat_quantity | No | The quantity of the repeat_interval | `years`, `months`, `weeks`, `days`, `hours`, `minutes` |
| repeat_interval | No | The number of the interval | N/A |
| color | No | The hex code of the color of the entry, which is shown in the web-ui | N/A |
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200| N/A | Success |
| 400 | InvalidKeyValue | The value of a key is invalid |
| 400 | InvalidTime | The time given is in the past |
| 404 | NotificationServiceNotFound | The notification service was not found |
| 404 | ReminderNotFound | The reminder with the id can not be found |
??? DELETE
Delete the reminder
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200| N/A | Success |
| 404 | ReminderNotFound | The reminder with the id can not be found |
### `/templates`
| Requires being logged in | Description |
| ------------------------ | ----------- |
| Yes | Manage the templates |
??? GET
Get a list of all templates
**Parameters (url)**
| Name | Required | Description | Allowed values |
| ---- | -------- | ----------- | -------------- |
| sort_by | No | How to sort the result | `title`, `title_reversed`, `date_added`, `date_added_reversed` |
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200| N/A | Success |
| 400 | InvalidKeyValue | The value of a key is invalid |
??? POST
Add a template
**Parameters (body)**
| Name | Required | Description | Allowed values |
| ---- | -------- | ----------- | -------------- |
| title | Yes | The title of the entry | N/A |
| notification_services | Yes | Array of the id's of the notification services to use to send the notification | N/A |
| text | No | The body of the entry | N/A |
| color | No | The hex code of the color of the entry, which is shown in the web-ui | N/A |
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 201| N/A | Success |
| 400 | InvalidKeyValue | The value of a key is invalid |
| 400 | KeyNotFound | A key was not found in the input that is required to be given |
| 404 | NotificationServiceNotFound | The notification service was not found |
### `/templates/search`
| Requires being logged in | Description |
| ------------------------ | ----------- |
| Yes | Search through the list of templates |
??? GET
**Parameters (url)**
| Name | Required | Description | Allowed values |
| ---- | -------- | ----------- | -------------- |
| sort_by | No | How to sort the result | `title`, `title_reversed`, `date_added`, `date_added_reversed` |
| query | Yes | The search term | N/A |
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200| N/A | Success |
| 400 | InvalidKeyValue | The value of a key is invalid |
### `/templates/<int:t_id>`
| Requires being logged in | Description |
| ------------------------ | ----------- |
| Yes | Manage a specific template |
Replace `<int:t_id>` with the ID of the entry. For example: `/templates/2`.
??? GET
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200| N/A | Success |
| 404 | TemplateNotFound | The template was not found |
??? PUT
Edit the template
**Parameters (body)**
| Name | Required | Description | Allowed values |
| ---- | -------- | ----------- | -------------- |
| title | No | The title of the entry | N/A |
| notification_services | No | Array of the id's of the notification services to use to send the notification | N/A |
| text | No | The body of the entry | N/A |
| color | No | The hex code of the color of the entry, which is shown in the web-ui | N/A |
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200| N/A | Success |
| 400 | InvalidKeyValue | The value of a key is invalid |
| 404 | NotificationServiceNotFound | The notification service was not found |
| 404 | TemplateNotFound | The template was not found |
??? DELETE
Delete the template
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200| N/A | Success |
| 404 | TemplateNotFound | The template was not found |
### `/staticreminders`
| Requires being logged in | Description |
| ------------------------ | ----------- |
| Yes | Manage the static reminders |
??? GET
Get a list of all static reminders
**Parameters (url)**
| Name | Required | Description | Allowed values |
| ---- | -------- | ----------- | -------------- |
| sort_by | No | How to sort the result | `title`, `title_reversed`, `date_added`, `date_added_reversed` |
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200| N/A | Success |
| 400 | InvalidKeyValue | The value of a key is invalid |
??? POST
Add a static reminder
**Parameters (body)**
| Name | Required | Description | Allowed values |
| ---- | -------- | ----------- | -------------- |
| title | Yes | The title of the entry | N/A |
| notification_services | Yes | Array of the id's of the notification services to use to send the notification | N/A |
| text | No | The body of the entry | N/A |
| color | No | The hex code of the color of the entry, which is shown in the web-ui | N/A |
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 201| N/A | Success |
| 400 | InvalidKeyValue | The value of a key is invalid |
| 400 | KeyNotFound | A key was not found in the input that is required to be given |
| 404 | NotificationServiceNotFound | The notification service was not found |
### `/staticreminders/search`
| Requires being logged in | Description |
| ------------------------ | ----------- |
| Yes | Search through the list of staticreminders |
??? GET
**Parameters (url)**
| Name | Required | Description | Allowed values |
| ---- | -------- | ----------- | -------------- |
| sort_by | No | How to sort the result | `title`, `title_reversed`, `date_added`, `date_added_reversed` |
| query | Yes | The search term | N/A |
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200| N/A | Success |
| 400 | InvalidKeyValue | The value of a key is invalid |
### `/staticreminders/<int:s_id>`
| Requires being logged in | Description |
| ------------------------ | ----------- |
| Yes | Manage a specific static reminder |
Replace `<int:s_id>` with the ID of the entry. For example: `/staticreminders/2`.
??? GET
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200| N/A | Success |
| 404 | ReminderNotFound | The reminder with the id can not be found |
??? POST
Trigger the static reminder
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 201| N/A | Success |
| 404 | ReminderNotFound | The reminder with the id can not be found |
??? PUT
Edit the static reminder
**Parameters (body)**
| Name | Required | Description | Allowed values |
| ---- | -------- | ----------- | -------------- |
| title | No | The title of the entry | N/A |
| notification_services | No | Array of the id's of the notification services to use to send the notification | N/A |
| text | No | The body of the entry | N/A |
| color | No | The hex code of the color of the entry, which is shown in the web-ui | N/A |
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200| N/A | Success |
| 400 | InvalidKeyValue | The value of a key is invalid |
| 404 | NotificationServiceNotFound | The notification service was not found |
| 404 | ReminderNotFound | The reminder with the id can not be found |
??? DELETE
Delete the static reminder
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200| N/A | Success |
| 404 | ReminderNotFound | The reminder with the id can not be found |

234
docs/assets/css/extra.css Normal file
View File

@@ -0,0 +1,234 @@
:root {
--color-light: #ffffff;
--color-dim-light: #b5b5b5;
--color-gray: #3c3c3c;
--color-dark: #1b1b1b;
}
.md-typeset code {
border-radius: 6px;
}
.md-clipboard {
color: var(--color-gray);
}
:hover > .md-clipboard {
color: var(--color-dark);
}
.md-clipboard:hover {
color: var(--color-light);
}
.md-typeset table:not([class]) {
border-radius: 6px;
}
.md-typeset a:not(.headerlink) {
text-decoration: underline;
}
/* */
/* Light mode */
/* */
[data-md-color-scheme="MIND"] {
/* Background color of header */
--md-primary-fg-color: var(--color-gray);
/* Header and code background color and clipboard color */
--md-default-fg-color--light: var(--color-dim-light);
--md-primary-fg-color--lighter: #00000052;
--md-default-fg-color--lightest: var(--color-dim-light);
/* Color in header */
--md-primary-bg-color: var(--color-light);
/* Color in placeholder of search and non-hover for nav */
--md-primary-bg-color--light: var(--color-dim-light);
--md-primary-bg-color--lighter: #ffffff4d;
--md-primary-bg-color--lightest: #ffffff1f;
/* Background color */
--md-default-bg-color: var(--color-light);
/* Color in search */
--md-default-fg-color: var(--color-dark);
/* Hover color for mark and ToC */
--md-accent-fg-color: var(--color-gray);
/* Code color */
--md-code-fg-color: var(--color-light);
/* Code background color */
--md-code-bg-color: var(--color-gray);
--md-code-hl-color: #ffff0080;
--md-code-hl-number-color: #d52a2a;
--md-code-hl-special-color: #db1457;
--md-code-hl-function-color: #a846b9;
--md-code-hl-constant-color: #6e59d9;
--md-code-hl-keyword-color: #3f6ec6;
--md-code-hl-string-color: #1c7d4d;
/* Slashes in code */
--md-code-hl-name-color: var(--md-code-fg-color);
--md-code-hl-operator-color: var(--color-dim-light);
--md-code-hl-punctuation-color: var(--color-dim-light);
--md-code-hl-comment-color: var(--color-dim-light);
--md-code-hl-generic-color: var(--color-dim-light);
--md-code-hl-variable-color: var(--color-dim-light);
/* Text color */
--md-typeset-color: var(--color-dark);
/* Link color */
--md-typeset-a-color: var(--color-dim-light);
--md-typeset-mark-color: #ffff0080;
--md-typeset-del-color: #f5503d26;
--md-typeset-ins-color: #0bd57026;
--md-typeset-kbd-color: #fafafa;
--md-typeset-kbd-accent-color: #fff;
--md-typeset-kbd-border-color: #b8b8b8;
--md-typeset-table-color: var(--color-gray);
--md-typeset-table-color--light: rgba(0,0,0,.035);
--md-admonition-fg-color: var(--color-dark);
--md-admonition-bg-color: var(--color-light);
--md-warning-fg-color: #000000de;
--md-warning-bg-color: #ff9;
/* Footer color */
--md-footer-fg-color: var(--color-light);
/* Footer background color */
--md-footer-bg-color: var(--color-gray);
--md-shadow-z1: 0 0.2rem 0.5rem #0000000d,0 0 0.05rem #0000001a;
--md-shadow-z2: 0 0.2rem 0.5rem #0000001a,0 0 0.05rem #00000040;
--md-shadow-z3: 0 0.2rem 0.5rem #0003,0 0 0.05rem #00000059;
}
[data-md-color-scheme="MIND"] button[data-md-component="top"],
[data-md-color-scheme="MIND"] h1,
[data-md-color-scheme="MIND"] [data-md-component="search"] {
--md-default-fg-color--light: var(--color-dark);
}
[data-md-color-scheme="MIND"] [data-md-component="toc"] {
color: var(--color-dark);
}
[data-md-color-scheme="MIND"] [data-md-component="toc"] nav {
color: var(--color-gray);
}
[data-md-color-scheme="MIND"] .tabbed-block {
--md-default-fg-color--light: var(--color-dark);
}
[data-md-color-scheme="MIND"] details {
border-color: var(--color-gray);
}
[data-md-color-scheme="MIND"] details > summary {
background-color: transparent;
}
[data-md-color-scheme="MIND"] details > summary::before {
background-color: var(--color-dark);
}
/* */
/* Dark mode */
/* */
[data-md-color-scheme="MIND-dark"] {
/* Background color of header */
--md-primary-fg-color: var(--color-gray);
/* Header and code background color and clipboard color */
--md-default-fg-color--light: var(--color-gray);
--md-primary-fg-color--lighter: #00000052;
--md-default-fg-color--lightest: var(--color-gray);
/* Color in header */
--md-primary-bg-color: var(--color-light);
/* Color in placeholder of search and non-hover for nav */
--md-primary-bg-color--light: var(--color-dim-light);
--md-primary-bg-color--lighter: #ffffff4d;
--md-primary-bg-color--lightest: #ffffff1f;
/* Background color */
--md-default-bg-color: var(--color-dark);
/* Color in search */
--md-default-fg-color: var(--color-light);
/* Hover color for mark and ToC */
--md-accent-fg-color: var(--color-dim-light);
/* Code color */
--md-code-fg-color: var(--color-light);
/* Code background color */
--md-code-bg-color: var(--color-gray);
--md-code-hl-color: #ffff0080;
--md-code-hl-number-color: #d52a2a;
--md-code-hl-special-color: #db1457;
--md-code-hl-function-color: #a846b9;
--md-code-hl-constant-color: #6e59d9;
--md-code-hl-keyword-color: #3f6ec6;
--md-code-hl-string-color: #1c7d4d;
/* Slashes in code */
--md-code-hl-name-color: var(--md-code-fg-color);
--md-code-hl-operator-color: var(--color-dim-light);
--md-code-hl-punctuation-color: var(--color-dim-light);
--md-code-hl-comment-color: var(--color-dim-light);
--md-code-hl-generic-color: var(--color-dim-light);
--md-code-hl-variable-color: var(--color-dim-light);
/* Text color */
--md-typeset-color: var(--color-light);
/* Link color */
--md-typeset-a-color: var(--color-dim-light);
--md-typeset-mark-color: #ffff0080;
--md-typeset-del-color: #f5503d26;
--md-typeset-ins-color: #0bd57026;
--md-typeset-kbd-color: #fafafa;
--md-typeset-kbd-accent-color: #fff;
--md-typeset-kbd-border-color: #b8b8b8;
--md-typeset-table-color: var(--color-gray);
--md-typeset-table-color--light: rgba(0,0,0,.035);
--md-admonition-fg-color: var(--color-light);
--md-admonition-bg-color: var(--color-dark);
--md-warning-fg-color: #000000de;
--md-warning-bg-color: #ff9;
/* Footer color */
--md-footer-fg-color: var(--color-light);
/* Footer background color */
--md-footer-bg-color: var(--color-gray);
--md-shadow-z1: 0 0.2rem 0.5rem #0000000d,0 0 0.05rem #0000001a;
--md-shadow-z2: 0 0.2rem 0.5rem #0000001a,0 0 0.05rem #00000040;
--md-shadow-z3: 0 0.2rem 0.5rem #0003,0 0 0.05rem #00000059;
}
[data-md-color-scheme="MIND-dark"] button[data-md-component="top"],
[data-md-color-scheme="MIND-dark"] h1,
[data-md-color-scheme="MIND-dark"] [data-md-component="search"] {
--md-default-fg-color--light: var(--color-light);
}
[data-md-color-scheme="MIND-dark"] [data-md-component="toc"] nav {
color: var(--color-dim-light);
}
[data-md-color-scheme="MIND-dark"] .tabbed-block {
--md-default-fg-color--light: var(--color-dark);
}
[data-md-color-scheme="MIND-dark"] details {
border-color: var(--color-gray);
}
[data-md-color-scheme="MIND-dark"] details > summary {
background-color: transparent;
}
[data-md-color-scheme="MIND-dark"] details > summary::before {
background-color: var(--color-dim-light);
}

View File

@@ -0,0 +1,30 @@
<svg viewBox="239.752 39.2 500 500" xmlns="http://www.w3.org/2000/svg" xmlns:bx="https://boxy-svg.com">
<defs>
<linearGradient id="color-0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.000006, 0, 0, 0.999994, -30.815779, -277.541534)" bx:pinned="true">
<title>White</title>
<stop style="stop-color: rgb(255, 255, 255);"></stop>
</linearGradient>
<linearGradient id="color-1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.000006, 0, 0, 0.999994, 19.424101, -198.084808)" bx:pinned="true">
<title>Off-white</title>
<stop style="stop-color: rgb(210, 210, 210)"></stop>
</linearGradient>
<linearGradient id="color-2" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.000006, 0, 0, 0.999994, 5.575318, -62.311562)" bx:pinned="true">
<title>Gray</title>
<stop style="stop-color: rgb(155, 155, 155)"></stop>
</linearGradient>
<linearGradient id="color-3" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.000006, 0, 0, 0.999994, -74.018517, 113.766891)" bx:pinned="true">
<title>Dark gray</title>
<stop style="stop-color: rgb(91, 91, 91)"></stop>
</linearGradient>
<bx:grid x="39.752" y="39.2" width="100" height="100"></bx:grid>
</defs>
<g transform="matrix(2.413543, 0, 0, 2.192117, -660.809998, -485.15448)" style="">
<ellipse style="fill: rgb(91, 91, 91); stroke: url(#color-3);" cx="519.4" cy="257.1" rx="64" ry="64" transform="matrix(0, 1, -1, 0, 773.392883, -159.637634)"></ellipse>
<ellipse style="fill: url(#color-2); stroke: url(#color-2);" cx="455.445" cy="193.1" rx="64.045" ry="64" transform="matrix(0, 1, -1, 0, 668.85199, -52.200024)"></ellipse>
<ellipse style="stroke: url(#color-1); fill: url(#color-1);" cx="391.445" cy="129.1" rx="64" ry="64" transform="matrix(0, 1, -1, 0, 604.85199, -88.244972)"></ellipse>
<g transform="matrix(1, 0, 0, 1, -43.907719, -71.28595)">
<ellipse style="stroke: url(#color-0); fill: url(#color-0);" cx="327.402" cy="65.104" rx="64.045" ry="64" transform="matrix(0, 1, -1, 0, 546.14032, 103.69133)"></ellipse>
<text style="fill: rgb(51, 51, 51); font-family: 'Russo One'; font-size: 148.922px; font-weight: 700; line-height: 238.276px; white-space: pre;" transform="matrix(0.4969, 0, 0, 0.496903, 445.627899, 461.735657)"><tspan>M</tspan><tspan x="0" dy="1em"></tspan></text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

23
docs/backup_your_data.md Normal file
View File

@@ -0,0 +1,23 @@
# Backup your data
It's possible, and very easy, to backup all MIND data. This way, you can recover data if something goes wrong, port your instance over to an other computer or run redundant instances.
## Backing up the data
It's as simple as making a copy of the database file and storing it somewhere safe.
1. Stop MIND, if it's still running.
2. Go inside the docker volume of the container (most likely `mind-db`) or the mapped folder.
3. Inside the volume/folder, you'll find the `MIND.db` file. Make a copy of this. That's all you need.
4. You can now start the instance back up.
The database file contains all data and is the only thing needed to keep a complete backup of your MIND instance.
## Recovering the data
It's as simple as putting the database file in the database folder and restarting the instance.
1. Stop MIND, if it's still running.
2. Go inside the docker volume of the container (most likely `mind-db`) or the mapped folder.
3. Inside the volume/folder, place the database file that you backed up.
4. You can now start the instance back up. Everything should be recovered.

38
docs/index.md Normal file
View File

@@ -0,0 +1,38 @@
# MIND Docs
[![Docker Pulls](https://img.shields.io/docker/pulls/mrcas/mind.svg)](https://hub.docker.com/r/mrcas/mind)
[![GitHub Downloads](https://img.shields.io/github/downloads/Casvt/MIND/total.svg)](https://github.com/Casvt/MIND/releases)
__A simple self hosted reminder application that can send push notifications to your device. Set the reminder and forget about it!__
Getting started:
- [Installation](./installation.md)
- [Setup After Installation](./setup_after_installation.md)
General Information:
- [API Docs](./api.md)
## Workings
MIND can be used for sending notifications at the desired time. This can be a set time, like a yearly reminder for a birthday, or at a button click, to easily send a predefined notification when you want to.
## Features
- Works cross-timezone
- Notifications are sent with second-precision
- Uses the [apprise library](https://github.com/caronc/apprise), giving you 80+ platforms to send notifications to
- Easily manage the reminders with sorting options, search ability and color coding
- Docker image available
- Mobile friendly web-interface
- API available
### Planned Features
You can see the planned features in the [Project board](https://github.com/users/Casvt/projects/3).
## Contact
- For support, a [discord server](https://discord.gg/nMNdgG7vsE) is available
- Alternatively, [make an issue](https://github.com/Casvt/MIND/issues)

97
docs/installation.md Normal file
View File

@@ -0,0 +1,97 @@
# Installation
The recommended way to install MIND is using Docker. After installing MIND, it is advised to read the [Setup After Installation page](setup_after_installation.md).
NOTE: Make sure to set all time related settings (time, date, timezone, etc.) correct on your computer, as MIND depends on it to work correctly.
## Docker
### Database location
We first need to create a named volume, or a folder, to store the database file of MIND in.
=== "Docker CLI"
```bash
docker volume create mind-db
```
=== "Portainer"
- Open `Volumes`
- Click `Add Volume`
- Enter name matching the one you'll use in compose (`mind-db`, in the above provided command)
- Click `Create the volume`
- Open `Stacks`
- Create the stack with the named volume in it.
=== "Folder"
Linux standards suggest to put the database in `/opt/application_name`, as the `/opt` directory is where program options should be stored. In this case, you'd create the desired folder using the following command:
```bash
mkdir /opt/MIND/db
```
### Run the container
Now that we can store the database somewhere, we can get the container running.
=== "Docker CLI"
The command to get the docker container running can be found below. Replace the timezone value (`TZ=`) with the [TZ database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List) of your timezone (value of `TZ identifier` on webpage).
```bash
docker run -d \
--name mind \
-v mind-db:/app/db \
-e TZ=Europe/Amsterdam \
-p 8080:8080 \
mrcas/mind:latest
```
=== "Docker Compose"
The contents of the `docker-compose.yml` file would look like below. Replace the timezone value (`TZ=`) with the [TZ database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List) of your timezone (value of `TZ identifier` on webpage).
```yml
version: '3.3'
services:
mind:
container_name: mind
volumes:
- 'mind-db:/app/db'
environment:
- TZ=Europe/Amsterdam
ports:
- '8080:8080'
image: 'mrcas/mind:latest'
```
Now run the compose by running the following command in the root folder:
```bash
docker compose up -d
```
If you didn't name your docker volume `mind-db` (see [Database location](#database-location)), replace `mind-db` in the command with the name of your volume. If you created a folder, replace `mind-db` with `/opt/MIND/db` or the folder you want.
Information on how to change the port can be found on the [Setup After Installation page](setup_after_installation.md#port).
## Manual Install
See below for installation instructions for your OS if you want to install it manually.
=== "Linux / MacOS"
```bash
sudo apt-get install git python3-pip
sudo git clone https://github.com/Casvt/MIND.git /opt/MIND
cd /opt/MIND
python3 -m pip install -r requirements.txt
python3 MIND.py
```
=== "Windows"
1. Install python [in the Microsoft Store](https://www.microsoft.com/store/productId/9PJPW5LDXLZ5)
2. Install pip [using these instructions](https://www.liquidweb.com/kb/install-pip-windows/)
3. Download [the latest release](https://github.com/Casvt/MIND/zipball/master)
4. Extract the ZIP file
5. With the folder open, right click and select `Open in Terminal`
6. Type the following command:
```bash
python -m pip install -r requirements.txt
```
7. Type the following command:
```bash
python MIND.py
```

View File

@@ -0,0 +1,34 @@
# Setup After Installation
After installing MIND, you should have access to the web-ui. MIND needs some configuration in order for it to work properly.
## Port
The first thing to do is decide if you want to leave MIND running on the default port of `8080`. If you _do_, you can go to the next step. If you want to _change_ the port, continue reading.
=== "Docker CLI"
Alter the command to run the container and replace `-p 8080:8080` with `-p {PORT}:8080`, where `{PORT}` is the desired port (e.g. `-p 8009:8080`). Then run the container with the new version of the command.
=== "Docker Compose"
Alter the file to run the container and replace `- 8080:8080` with `- {PORT}:8080`, where `{PORT}` is the desired port (e.g. `- 8009:8080`). Then run the container with the new version of the file.
=== "Manual Install"
Inside the `MIND.py` file at the top, you can set the port via the `PORT` variable. Change it from `PORT = '8080'` to `PORT = '{PORT}'`, where `{PORT}` is the desired port (e.g. `PORT = '8009'`). Then restart the application.
## Base URL
If you want to set a base url (e.g. for a reverse proxy), go inside the `MIND.py` file and at the top, you can set the base URL via the `URL_PREFIX` variable. Change it from `URL_PREFIX = ''` to `URL_PREFIX = '/{PREFIX}'`, where `{PREFIX}` is the desired URL prefix (e.g. `URL_PREFIX = '/mind'`). Then restart the application.
## Creating an account
When accessing the web-ui, you'll be prompted to log in. Click on `Or create an account`, enter the desired username and password for the account and click `Create`. The account is created and can now be logged in with. The complete authentication process is local and no data is shared with any other service.
## Set your locale
In the settings, you can change your locale, so that the dates and times are displayed in the format used by your country.
## Add a notification service
A notification service is a way of sending a notification. For example an e-mail to a group of people or a PushBullet notification to a specific device. What the actual content of the notification is, is decided by the title and text of the reminder. The notification service only specifies in which way the title and text is sent. You set it up once, and then you can select it when creating a reminder.
Go to the "Notification Services" tab in the web-ui and click the `+` button. A notification service consists of a title (name) and an Apprise URL. See the [Apprise URL documentation](https://github.com/caronc/apprise#supported-notifications) to learn how to make a valid Apprise URL.

File diff suppressed because it is too large Load Diff

View File

@@ -155,8 +155,9 @@ header > div {
width: var(--height);
}
h1 {
font-size: clamp(1.3rem, 7vw, 2rem);
header img {
height: 3rem;
width: fit-content;
}
/* */
@@ -271,12 +272,12 @@ nav > div > button svg {
margin-bottom: 2rem;
}
.window-container > div:not(#home) > p {
text-align: center;
.window-container > div:not(#home) > h2:not(:first-of-type) {
margin-top: 1.5rem;
}
.window-container > div:not(#home) > p > a {
color: var(--color-light);
.window-container > div:not(#home) > p {
text-align: center;
}
.tab-selector {
@@ -286,7 +287,8 @@ nav > div > button svg {
align-items: center;
flex-wrap: wrap;
gap: 1rem;
padding-inline: .5rem;
padding-top: 1rem;
}

View File

@@ -107,6 +107,7 @@
.notification-service-list > div > input {
width: fit-content;
box-shadow: none;
}
.repeat-bar,

View File

@@ -45,10 +45,16 @@
opacity: 1;
}
#sort-input {
width: min-content;
border: 0;
box-shadow: none;
}
/* REMINDER LIST */
#reminder-list,
#static-reminder-list,
#template-list {
#reminder-tab,
#static-reminder-tab,
#template-tab {
--gap: 1rem;
--entry-width: 13rem;
max-width: 43rem;

View File

@@ -25,6 +25,12 @@
box-shadow: var(--default-shadow);
}
.settings-container input,
.settings-container textarea,
.settings-container select {
max-width: 20rem;
}
#change-password-form {
display: flex;
flex-direction: column;
@@ -33,10 +39,32 @@
gap: 1rem;
}
#change-password-form > input {
max-width: 20rem;
}
#delete-account-button {
background-color: var(--color-error);
}
.contact-list {
width: min(100%, 31rem);
margin: auto;
display: flex;
justify-content: center;
gap: 1rem;
flex-wrap: wrap;
}
.contact-list > a {
height: 5rem;
width: 15rem;
display: flex;
justify-content: center;
align-items: center;
border-radius: 6px;
background-color: var(--color-gray);
color: var(--color-light);
text-decoration: none;
font-size: 1.2rem;
}

View File

@@ -0,0 +1,30 @@
<svg viewBox="239.752 39.2 500 500" xmlns="http://www.w3.org/2000/svg" xmlns:bx="https://boxy-svg.com">
<defs>
<linearGradient id="color-0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.000006, 0, 0, 0.999994, -30.815779, -277.541534)" bx:pinned="true">
<title>White</title>
<stop style="stop-color: rgb(255, 255, 255);"></stop>
</linearGradient>
<linearGradient id="color-1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.000006, 0, 0, 0.999994, 19.424101, -198.084808)" bx:pinned="true">
<title>Off-white</title>
<stop style="stop-color: rgb(210, 210, 210)"></stop>
</linearGradient>
<linearGradient id="color-2" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.000006, 0, 0, 0.999994, 5.575318, -62.311562)" bx:pinned="true">
<title>Gray</title>
<stop style="stop-color: rgb(155, 155, 155)"></stop>
</linearGradient>
<linearGradient id="color-3" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.000006, 0, 0, 0.999994, -74.018517, 113.766891)" bx:pinned="true">
<title>Dark gray</title>
<stop style="stop-color: rgb(91, 91, 91)"></stop>
</linearGradient>
<bx:grid x="39.752" y="39.2" width="100" height="100"></bx:grid>
</defs>
<g transform="matrix(2.413543, 0, 0, 2.192117, -660.809998, -485.15448)" style="">
<ellipse style="fill: rgb(91, 91, 91); stroke: url(#color-3);" cx="519.4" cy="257.1" rx="64" ry="64" transform="matrix(0, 1, -1, 0, 773.392883, -159.637634)"></ellipse>
<ellipse style="fill: url(#color-2); stroke: url(#color-2);" cx="455.445" cy="193.1" rx="64.045" ry="64" transform="matrix(0, 1, -1, 0, 668.85199, -52.200024)"></ellipse>
<ellipse style="stroke: url(#color-1); fill: url(#color-1);" cx="391.445" cy="129.1" rx="64" ry="64" transform="matrix(0, 1, -1, 0, 604.85199, -88.244972)"></ellipse>
<g transform="matrix(1, 0, 0, 1, -43.907719, -71.28595)">
<ellipse style="stroke: url(#color-0); fill: url(#color-0);" cx="327.402" cy="65.104" rx="64.045" ry="64" transform="matrix(0, 1, -1, 0, 546.14032, 103.69133)"></ellipse>
<text style="fill: rgb(51, 51, 51); font-family: 'Russo One'; font-size: 148.922px; font-weight: 700; line-height: 238.276px; white-space: pre;" transform="matrix(0.4969, 0, 0, 0.496903, 445.627899, 461.735657)"><tspan>M</tspan><tspan x="0" dy="1em"></tspan></text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,7 +1,7 @@
const types = {
'reminder': document.getElementById('reminder-list'),
'static_reminder': document.getElementById('static-reminder-list'),
'template': document.getElementById('template-list')
'reminder': document.getElementById('reminder-tab'),
'static_reminder': document.getElementById('static-reminder-tab'),
'template': document.getElementById('template-tab')
};
const icons = {
@@ -47,15 +47,20 @@ function logout() {
'method': 'POST'
})
.then(response => {
localStorage.removeItem('MIND_api_key');
const new_stor = JSON.parse(localStorage.getItem('MIND'))
new_stor.api_key = null
localStorage.setItem('MIND', JSON.stringify(new_stor));
window.location.href = `${url_prefix}/`;
});
};
// code run on load
if (localStorage.getItem('MIND') === null)
localStorage.setItem('MIND', JSON.stringify({'api_key': null, 'locale': 'en-GB'}))
const url_prefix = document.getElementById('url_prefix').dataset.value;
const api_key = localStorage.getItem('MIND_api_key');
const api_key = JSON.parse(localStorage.getItem('MIND')).api_key;
if (api_key === null) {
window.location.href = `${url_prefix}/`;
};

View File

@@ -1,3 +1,25 @@
const sorting_options = {};
sorting_options[types.reminder.id] = [
['time', 'Time'],
['time_reversed', 'Time Reversed'],
['title', 'Title'],
['title_reversed', 'Title Reversed'],
['date_added', 'Date Added'],
['date_added_reversed', 'Date Added Reversed']
];
sorting_options[types.static_reminder.id] = [
['title', 'Title'],
['title_reversed', 'Title Reversed'],
['date_added', 'Date Added'],
['date_added_reversed', 'Date Added Reversed']
];
sorting_options[types.template.id] = [
['title', 'Title'],
['title_reversed', 'Title Reversed'],
['date_added', 'Date Added'],
['date_added_reversed', 'Date Added Reversed']
];
function showTab(button) {
// Apply styling to selected button
document.querySelectorAll('.tab-selector > button').forEach(
@@ -5,10 +27,13 @@ function showTab(button) {
);
// Show desired tab and hide all others
document.querySelectorAll('#home > div:not(.tab-selector)').forEach(
document.querySelectorAll('#home > div:not(.tab-selector):not(.search-container)').forEach(
e => e.classList.add('hidden')
);
document.getElementById(button.dataset.target).classList.remove('hidden');
fillSortOptions();
document.querySelector('#search-input').value = '';
};
//
@@ -33,7 +58,7 @@ function fillTable(table, results) {
const time = document.createElement('p');
var offset = new Date(r.time * 1000).getTimezoneOffset() * -60;
var d = new Date((r.time + offset) * 1000);
var formatted_date = d.toLocaleString('en-CA').slice(0,10) + ' ' + d.toTimeString().slice(0,5);
var formatted_date = d.toLocaleString(JSON.parse(localStorage.getItem('MIND')).locale);
if (r.repeat_interval !== null) {
if (r.repeat_interval === 1) {
var quantity = r.repeat_quantity.endsWith('s') ? r.repeat_quantity.slice(0, -1) : r.repeat_quantity;
@@ -72,28 +97,110 @@ function fillLibrary(url, type) {
};
function fillReminders() {
fillLibrary(`/api/reminders?api_key=${api_key}`, types.reminder);
const sorting = document.querySelector('#sort-input').value;
fillLibrary(`/api/reminders?api_key=${api_key}&sort_by=${sorting}`, types.reminder);
};
function fillStaticReminders() {
fillLibrary(`/api/staticreminders?api_key=${api_key}`, types.static_reminder);
function fillStaticReminders(assume_sorting=false) {
let sorting;
if (assume_sorting)
sorting = sorting_options[types.static_reminder.id][0][0];
else
sorting = document.querySelector('#sort-input').value;
fillLibrary(`/api/staticreminders?api_key=${api_key}&sort_by=${sorting}`, types.static_reminder);
}
function fillTemplates() {
fillLibrary(`/api/templates?api_key=${api_key}`, types.template);
function fillTemplates(assume_sorting=false) {
let sorting;
if (assume_sorting)
sorting = sorting_options[types.template.id][0][0];
else
sorting = document.querySelector('#sort-input').value;
fillLibrary(`/api/templates?api_key=${api_key}&sort_by=${sorting}`, types.template);
};
//
// Library search
//
function searchLibrary() {
const query = document.querySelector('#search-input').value,
tab = document.getElementById(
document.querySelector('.tab-selector > button[data-selected="true"]').dataset.target
)
const sorting = document.querySelector('#sort-input').value;
let url;
if (tab === types.reminder)
url = `${url_prefix}/api/reminders/search?api_key=${api_key}&query=${query}&sort_by=${sorting}`;
else if (tab === types.static_reminder)
url = `${url_prefix}/api/staticreminders/search?api_key=${api_key}&query=${query}&sort_by=${sorting}`;
else if (tab === types.template)
url = `${url_prefix}/api/templates/search?api_key=${api_key}&query=${query}&sort_by=${sorting}`;
else return;
fillLibrary(url, tab);
};
function clearSearchLibrary() {
document.querySelector('#search-input').value = '';
const tab = document.getElementById(
document.querySelector('.tab-selector > button[data-selected="true"]').dataset.target
)
if (tab === types.reminder)
fillReminders();
else if (tab === types.static_reminder)
fillStaticReminders();
else if (tab === types.template)
fillTemplates();
else return;
};
//
// Library sort
//
function fillSortOptions() {
const tab = document.getElementById(
document.querySelector('.tab-selector > button[data-selected="true"]').dataset.target
)
const sort_options = sorting_options[tab.id];
const select = document.getElementById('sort-input');
select.innerHTML = '';
sort_options.forEach(o => {
const entry = document.createElement('option');
entry.value = o[0]
entry.innerText = o[1]
select.appendChild(entry);
});
select.querySelector(':first-child').setAttribute('selected', '');
};
function applySorting() {
const query = document.querySelector('#search-input').value;
fetch(`${url_prefix}/api/reminders/search?api_key=${api_key}&query=${query}`)
if (query !== '') {
searchLibrary();
return;
};
const sorting = document.getElementById('sort-input').value,
tab = document.getElementById(
document.querySelector('.tab-selector > button[data-selected="true"]').dataset.target
)
let url;
if (tab === types.reminder)
url = `${url_prefix}/api/reminders?api_key=${api_key}&sort_by=${sorting}`;
else if (tab === types.static_reminder)
url = `${url_prefix}/api/staticreminders?api_key=${api_key}&sort_by=${sorting}`;
else if (tab === types.template)
url = `${url_prefix}/api/templates?api_key=${api_key}&sort_by=${sorting}`;
else return;
fetch(url)
.then(response => {
if (!response.ok) return Promise.reject(response.status);
return response.json();
})
.then(json => fillTable(types.reminder, json.result))
.then(json => fillTable(tab, json.result))
.catch(e => {
if (e === 401)
window.location.href = `${url_prefix}/`;
@@ -102,21 +209,18 @@ function searchLibrary() {
});
};
function clearSearchLibrary() {
document.querySelector('#search-input').value = '';
fillReminders();
};
// code run on load
document.querySelectorAll('.tab-selector > button').forEach(b => {
b.addEventListener('click', e => showTab(b));
});
fillSortOptions();
fillReminders();
fillStaticReminders();
fillTemplates();
fillStaticReminders(assume_sorting=true);
fillTemplates(assume_sorting=true);
setInterval(fillReminders, 60000);
document.querySelector('#search-form').setAttribute('action', 'javascript:searchLibrary();');
document.querySelector('#clear-button').addEventListener('click', e => clearSearchLibrary());
document.querySelector('#sort-input').addEventListener('change', e => applySorting());

View File

@@ -44,7 +44,9 @@ function login(data=null) {
return response.json();
})
.then(json => {
localStorage.setItem('MIND_api_key', json.result.api_key);
const new_stor = JSON.parse(localStorage.getItem('MIND'));
new_stor.api_key = json.result.api_key;
localStorage.setItem('MIND', JSON.stringify(new_stor));
window.location.href = `${url_prefix}/reminders`;
})
.catch(e => {
@@ -93,7 +95,7 @@ function create() {
};
function checkLogin() {
fetch(`${url_prefix}/api/auth/status?api_key=${localStorage.getItem('MIND_api_key')}`)
fetch(`${url_prefix}/api/auth/status?api_key=${JSON.parse(localStorage.getItem('MIND')).api_key}`)
.then(response => {
if (!response.ok) return Promise.reject(response.status);
window.location.href = '/reminders';
@@ -108,6 +110,9 @@ function checkLogin() {
// code run on load
if (localStorage.getItem('MIND') === null)
localStorage.setItem('MIND', JSON.stringify({'api_key': null, 'locale': 'en-GB'}))
const url_prefix = document.getElementById('url_prefix').dataset.value;
checkLogin();

View File

@@ -1,9 +1,3 @@
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')
};
function fillNotificationSelection() {
fetch(`${url_prefix}/api/notificationservices?api_key=${api_key}`)
.then(response => {
@@ -33,6 +27,7 @@ function fillNotificationSelection() {
const table = document.getElementById('services-list');
table.querySelectorAll('tr:not(#add-row)').forEach(e => e.remove());
// table.innerHTML = '';
json.result.forEach(service => {
const entry = document.createElement('tr');
entry.dataset.id = service.id;
@@ -158,13 +153,165 @@ function deleteService(id) {
function toggleAddService() {
document.getElementById('add-row').classList.toggle('hidden');
return;
const cont = document.querySelector('.overflow-container');
if (cont.classList.contains('show-add')) {
// Hide add
cont.classList.remove('show-add');
hideAddServiceWindow();
} else {
// Show add
if (notification_services === null) {
fetch(`${url_prefix}/api/notificationservices/available?api_key=${api_key}`)
.then(response => response.json())
.then(json => {
notification_services = json.result;
const table = document.querySelector('#service-list');
json.result.forEach((result, index) => {
const entry = document.createElement('button');
entry.innerText = result.name;
entry.addEventListener('click', e => showAddServiceWindow(index));
table.appendChild(entry);
});
});
};
cont.classList.add('show-add');
};
};
function showAddServiceWindow(index) {
const window = document.getElementById('add-service-window');
window.innerHTML = '';
if (index === -1) {
// Custom url
const title = document.createElement('h3');
title.innerText = 'Custom URL';
window.appendChild(title);
const desc = document.createElement('p');
desc.innerHTML = 'Enter a custom Apprise URL. See the <a target="_blank" href="https://github.com/caronc/apprise#supported-notifications">Apprise URL documentation</a>.';
window.appendChild(desc);
const service_title = document.createElement('input');
service_title.id = 'service-title';
service_title.type = 'text';
service_title.placeholder = 'Service Title';
service_title.required = true;
window.appendChild(service_title);
const url_input = document.createElement('input');
url_input.type = 'text';
url_input.placeholder = 'Apprise URL';
window.appendChild(url_input);
} else {
const data = notification_services[index];
console.log(data);
const title = document.createElement('h3');
title.innerText = data.name;
window.appendChild(title);
const docs = document.createElement('a');
docs.href = data.doc_url;
docs.target = '_blank';
docs.innerText = 'Documentation';
window.appendChild(docs);
const service_title = document.createElement('input');
service_title.id = 'service-title';
service_title.type = 'text';
service_title.placeholder = 'Service Title';
service_title.required = true;
window.appendChild(service_title);
data.details.tokens.forEach(token => {
if (token.type === 'choice') {
const choice = document.createElement('select');
choice.dataset.map = token.map_to;
choice.dataset.prefix = '';
choice.placeholder = token.name;
choice.required = token.required;
token.options.forEach(option => {
const entry = document.createElement('option');
entry.value = option;
entry.innerText = option;
choice.appendChild(entry);
});
window.appendChild(choice);
} else if (token.type === 'list') {
if (token.content.length === 0) {
} else {
token.content.forEach(content => {
});
};
} else if (token.type === 'string') {
const str_input = document.createElement('input');
str_input.dataset.map = token.map_to;
str_input.dataset.prefix = token.prefix;
str_input.dataset.regex = token.regex;
str_input.type = 'text';
str_input.placeholder = `${token.name}${!token.required ? ' (Optional)' : ''}`;
str_input.required = token.required;
window.appendChild(str_input);
} else if (token.type === 'int') {
const int_input = document.createElement('input');
int_input.dataset.map = token.map_to;
int_input.dataset.prefix = token.prefix;
int_input.type = 'number';
int_input.placeholder = `${token.name}${!token.required ? ' (Optional)' : ''}`;
int_input.required = token.required;
if (token.min !== null)
int_input.min = token.min;
if (token.max !== null)
int_input.max = token.max;
window.appendChild(int_input);
};
});
};
// Bottom options
const options = document.createElement('div');
options.classList.add('options');
const cancel = document.createElement('button');
cancel.type = 'button';
cancel.innerText = 'Cancel';
cancel.addEventListener('click', e => toggleAddService());
options.appendChild(cancel);
const add = document.createElement('button');
add.type = 'submit';
add.innerText = 'Add';
options.appendChild(add);
window.appendChild(options);
document.getElementById('add-service-container').classList.add('show-add-window');
};
function hideAddServiceWindow() {
document.getElementById('add-service-container').classList.remove('show-add-window');
};
function buildAppriseURL() {
return null;
};
function addService() {
const add_button = document.querySelector('#add-row > .action-column > button');
const data = {
'title': inputs_buttons.title.value,
'url': inputs_buttons.url.value
'title': document.querySelector('#add-row > .title-column > input').value,
'url': document.querySelector('#add-row > .url-column > input').value
};
// const add_button = document.querySelector('#add-service-window > .options > button[type="submit"]');
// const data = {
// 'title': document.querySelector('#service-title').value,
// 'url': buildAppriseURL()
// };
fetch(`${url_prefix}/api/notificationservices?api_key=${api_key}`, {
'method': 'POST',
'headers': {'Content-Type': 'application/json'},
@@ -173,13 +320,9 @@ function addService() {
.then(response => {
if (!response.ok) return Promise.reject(response.status);
inputs_buttons.title.value = '';
inputs_buttons.url.value = '';
add_button.classList.remove('error-input');
add_button.title = '';
inputs_buttons.save_button.classList.remove('error-icon');
inputs_buttons.save_button.title = 'Add';
inputs_buttons.save_button.setAttribute('aria-label', 'Add');
toggleAddService();
fillNotificationSelection();
})
@@ -187,9 +330,9 @@ function addService() {
if (e === 401)
window.location.href = `${url_prefix}/`;
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');
// add_button.classList.add('error-input');
add_button.classList.add('error-icon');
add_button.title = 'Invalid Apprise URL';
} else
console.log(e);
});
@@ -199,5 +342,9 @@ function addService() {
fillNotificationSelection();
let notification_services = null;
document.getElementById('add-service-button').addEventListener('click', e => toggleAddService());
document.querySelector('#add-row button[data-type="save"]').addEventListener('click', e => addService());
// document.querySelector('#service-list button').addEventListener('click', e => showAddServiceWindow(-1));
// document.getElementById('add-service-window').setAttribute('action', 'javascript:addService();');
document.querySelector('#add-row > .action-column > button').addEventListener('click', e => addService());

View File

@@ -1,3 +1,7 @@
function loadSettings() {
document.getElementById('locale-input').value = JSON.parse(localStorage.getItem('MIND')).locale;
};
function changePassword() {
const data = {
'new_password': document.getElementById('password-input').value
@@ -19,6 +23,13 @@ function changePassword() {
});
};
function updateLocale(e) {
const new_stor = JSON.parse(localStorage.getItem('MIND'));
new_stor.locale = e.target.value;
localStorage.setItem('MIND', JSON.stringify(new_stor));
window.location.reload();
};
function deleteAccount() {
fetch(`${url_prefix}/api/user?api_key=${api_key}`, {
'method': 'DELETE'
@@ -30,5 +41,8 @@ function deleteAccount() {
// code run on load
loadSettings();
document.getElementById('change-password-form').setAttribute('action', 'javascript:changePassword()');
document.getElementById('locale-input').addEventListener('change', updateLocale);
document.getElementById('delete-account-button').addEventListener('click', e => deleteAccount());

View File

@@ -94,7 +94,7 @@ function testReminder() {
const ns = [...
document.querySelectorAll('.notification-service-list input[type="checkbox"]:checked')
].map(c => parseInt(c.dataset.id))
if (!ns) {
if (!ns.length) {
input.classList.add('error-input');
input.title = 'No notification service set';
return
@@ -143,10 +143,17 @@ function deleteInfo() {
.then(response => {
if (!response.ok) return Promise.reject(response.status);
fillNotificationSelection();
fillReminders();
fillStaticReminders();
fillTemplates();
if (cl.contains('show-edit-reminder')) {
// Delete reminder
fillReminders();
} else if (cl.contains('show-edit-template')) {
// Delete template
fillTemplates();
loadTemplateSelection();
} else if (cl.contains('show-edit-static-reminder')) {
// Delete static reminder
fillStaticReminders();
};
hideWindow();
})
.catch(e => {
@@ -192,7 +199,7 @@ function submitInfo() {
data['time'] = (new Date(inputs.time.value) / 1000) + (new Date(inputs.time.value).getTimezoneOffset() * 60)
if (type_buttons.repeat_button.dataset.selected === 'true') {
data['repeat_quantity'] = type_buttons.repeat_quantity.value;
data['repeat_interval'] = type_buttons.repeat_interval.value
data['repeat_interval'] = parseInt(type_buttons.repeat_interval.value)
};
fetch_data.url = `${url_prefix}/api/reminders?api_key=${api_key}`;
fetch_data.method = 'POST';
@@ -218,7 +225,7 @@ function submitInfo() {
data['time'] = (new Date(inputs.time.value) / 1000) + (new Date(inputs.time.value).getTimezoneOffset() * 60)
if (type_buttons.repeat_button.dataset.selected === 'true') {
data['repeat_quantity'] = type_buttons.repeat_quantity.value;
data['repeat_interval'] = type_buttons.repeat_interval.value
data['repeat_interval'] = parseInt(type_buttons.repeat_interval.value)
};
fetch_data.url = `${url_prefix}/api/reminders/${e_id}?api_key=${api_key}`;
fetch_data.method = 'PUT';

View File

@@ -6,6 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta id="url_prefix" data-value="{{url_prefix}}">
<link rel="shortcut icon" href="{{ url_for('static', filename='img/favicon.svg') }}" type="image/x-icon">
<link rel="stylesheet" href="{{ url_for('static', filename='css/general.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
@@ -14,7 +16,7 @@
</head>
<body>
<header>
<h1>MIND Reminders</h1>
<img src="{{ url_for('static', filename='img/favicon.svg') }}" alt="">
</header>
<main>
<div class="form-container">

View File

@@ -5,6 +5,8 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="{{ url_for('static', filename='img/favicon.svg') }}" type="image/x-icon">
<link rel="stylesheet" href="/static/css/general.css">
<link rel="stylesheet" href="/static/css/page_not_found.css">
@@ -12,7 +14,7 @@
</head>
<body>
<header>
<h1>MIND Reminders</h1>
<img src="{{ url_for('static', filename='img/favicon.svg') }}" alt="">
</header>
<main>
<h2>MIND</h1>

View File

@@ -6,6 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta id="url_prefix" data-value="{{url_prefix}}">
<link rel="shortcut icon" href="{{ url_for('static', filename='img/favicon.svg') }}" type="image/x-icon">
<link rel="stylesheet" href="{{ url_for('static', filename='css/general.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/info.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/library.css') }}">
@@ -33,7 +35,7 @@
</g>
</svg>
</button>
<h1>MIND Reminders</h1>
<img src="{{ url_for('static', filename='img/favicon.svg') }}" alt="">
</div>
</header>
<div class="nav-divider">
@@ -81,71 +83,66 @@
<button data-target="static-reminder-tab">Static Reminders</button>
<button data-target="template-tab">Templates</button>
</div>
<div id="reminder-tab">
<div class="search-container">
<noscript>Javascript is disabled. The web-ui of MIND does not work with JavaScript disabled.</noscript>
<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 add-entry" id="add-reminder" 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>
<div class="search-container">
<noscript>Javascript is disabled. The web-ui of MIND does not work with JavaScript disabled.</noscript>
<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>
<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>
<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>
<select id="sort-input"></select>
</div>
</form>
</div>
<div id="reminder-tab">
<button class="entry add-entry" id="add-reminder" 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>
</svg>
<p>Add a notification service first!</p>
</button>
</div>
</g>
</svg>
<p>Add a notification service first!</p>
</button>
</div>
<div id="static-reminder-tab" class="hidden">
<div id="static-reminder-list">
<button class="entry add-entry" id="add-static-reminder" aria-label="Add static reminder" title="Add static 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">
<button class="entry add-entry" id="add-static-reminder" aria-label="Add static reminder" title="Add static 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>
<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>
<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>
</svg>
</button>
</div>
</g>
</svg>
</button>
</div>
<div id="template-tab" class="hidden">
<div id="template-list">
<button class="entry add-entry" id="add-template" aria-label="Add template" title="Add template">
<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">
<button class="entry add-entry" id="add-template" aria-label="Add template" title="Add template">
<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>
<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>
<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>
</svg>
</button>
</div>
</g>
</svg>
</button>
</div>
</div>
@@ -272,9 +269,72 @@
<input type="password" id="password-input" autocomplete="new-password" required>
<button type="submit">Change</button>
</form>
<h3>Locale</h3>
<select id="locale-input">
<option value="ar-SA">ar-SA</option>
<option value="bn-BD">bn-BD</option>
<option value="bn-IN">bn-IN</option>
<option value="cs-CZ">cs-CZ</option>
<option value="da-DK">da-DK</option>
<option value="de-AT">de-AT</option>
<option value="de-CH">de-CH</option>
<option value="de-DE">de-DE</option>
<option value="el-GR">el-GR</option>
<option value="en-AU">en-AU</option>
<option value="en-CA">en-CA</option>
<option value="en-GB" selected>en-GB</option>
<option value="en-IE">en-IE</option>
<option value="en-IN">en-IN</option>
<option value="en-NZ">en-NZ</option>
<option value="en-US">en-US</option>
<option value="en-ZA">en-ZA</option>
<option value="es-AR">es-AR</option>
<option value="es-CL">es-CL</option>
<option value="es-CO">es-CO</option>
<option value="es-ES">es-ES</option>
<option value="es-MX">es-MX</option>
<option value="es-US">es-US</option>
<option value="fi-FI">fi-FI</option>
<option value="fr-BE">fr-BE</option>
<option value="fr-CA">fr-CA</option>
<option value="fr-CH">fr-CH</option>
<option value="fr-FR">fr-FR</option>
<option value="he-IL">he-IL</option>
<option value="hi-IN">hi-IN</option>
<option value="hu-HU">hu-HU</option>
<option value="id-ID">id-ID</option>
<option value="it-CH">it-CH</option>
<option value="it-IT">it-IT</option>
<option value="ja-JP">ja-JP</option>
<option value="ko-KR">ko-KR</option>
<option value="nl-BE">nl-BE</option>
<option value="nl-NL">nl-NL</option>
<option value="no-NO">no-NO</option>
<option value="pl-PL">pl-PL</option>
<option value="pt-BR">pt-BR</option>
<option value="pt-PT">pt-PT</option>
<option value="ro-RO">ro-RO</option>
<option value="ru-RU">ru-RU</option>
<option value="sk-SK">sk-SK</option>
<option value="sv-SE">sv-SE</option>
<option value="ta-IN">ta-IN</option>
<option value="ta-LK">ta-LK</option>
<option value="th-TH">th-TH</option>
<option value="tr-TR">tr-TR</option>
<option value="zh-CN">zh-CN</option>
<option value="zh-HK">zh-HK</option>
<option value="zh-TW">zh-TW</option>
</select>
<h3>Delete Account</h3>
<button id="delete-account-button">Delete Account</button>
</div>
<h2>Contact and Donation</h2>
<div class="contact-list">
<a href="https://ko-fi.com/casvt">Donate to MIND</a>
<a href="https://casvt.github.io/MIND/">Documentation</a>
<a href="https://github.com/Casvt/MIND/issues">Report an issue</a>
<a href="https://discord.gg/nMNdgG7vsE">Discord server</a>
</div>
</div>
</main>
</div>

View File

@@ -0,0 +1,6 @@
wheel>=0.38.4
mkdocs-material>=8.5.11
mkdocs-redirects>=1.2.0
mkdocs-git-revision-date-localized-plugin>=1.1.0
Pygments>=2.13.0
pymdown-extensions>=9.9

View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
#-*- coding: utf-8 -*-
from sys import path
from os.path import dirname
path.insert(0, dirname(path[0]))
from subprocess import run
from typing import Union
from frontend.api import (DataSource, NotificationServiceNotFound,
ReminderNotFound, TemplateNotFound, api_docs)
from MIND import _folder_path, api_prefix
url_var_map = {
'int:n_id': NotificationServiceNotFound,
'int:r_id': ReminderNotFound,
'int:t_id': TemplateNotFound,
'int:s_id': ReminderNotFound
}
result = f"""# API
Below is the API documentation. Report an issue on [GitHub](https://github.com/Casvt/MIND/issues).
All endpoints have the `{api_prefix}` prefix. That means, for example, that `/auth/login` can be reached at `{api_prefix}/auth/login`.
## Authentication
Authentication is done using an API key.
To log in, make a POST request to the [`{api_prefix}/auth/login`](#authlogin) endpoint.
You'll receive an API key, which you can then use in your requests to authenticate.
Supply it via the url parameter `api_key`.
This API key is valid for one hour after which the key expires, any further requests return 401 'APIKeyExpired' and you are required to log in again.
If no `api_key` is supplied or it is invalid, 401 `APIKeyInvalid` is returned.
For example:
```bash
curl -sSL 'http://192.168.2.15:8080{api_prefix}/reminders?api_key=ABCDEFG'
```
## Supplying data
Often, data needs to be supplied with a request.
If the parameters need to be supplied via `url`, add them to the url as url parameters.
If the parameters need to be supplied via `body`, add them to the body as a json object and supply the `Content-Type: application/json` header.
For example:
```bash
# URL parameter
curl -sSL 'http://192.168.2.15:8080{api_prefix}/reminders/search?api_key=ABCDEFG&query=Fountain&sort_by=time_reversed'
# Body parameter
curl -sSLX POST \\
-H 'Content-Type: application/json' \\
-d '{{"title": "Test service", "url": "test://fake/url"}}' \\
'http://192.168.2.15:8080{api_prefix}/notificationservices?api_key=ABCDEFG'
```
## Endpoints
The following is automatically generated. Please report any issues on [GitHub](https://github.com/Casvt/MIND/issues).
"""
for rule, data in api_docs.items():
result += f"""### `{rule}`
| Requires being logged in | Description |
| ------------------------ | ----------- |
| {'Yes' if data['requires_auth'] else 'No'} | {data['description']} |
"""
url_var = rule.replace('<', '>').split('>')
url_var: Union[str, None] = None if len(url_var) == 1 else url_var[1]
if url_var:
result += f"""
Replace `<{url_var}>` with the ID of the entry. For example: `{rule.replace(f'<{url_var}>', '2')}`.
"""
for method in data['methods']:
result += f"\n??? {method}\n"
if method in data['method_descriptions']:
result += f"\n {data['method_descriptions'][method]}\n"
var_types = {
'url': list(var for var in data['input_variables'].get(method, []) if var.source == DataSource.VALUES),
'body': list(var for var in data['input_variables'].get(method, []) if var.source == DataSource.DATA)
}
for var_type, entries in var_types.items():
if entries:
entries = [e('') for e in entries]
result += f"""
**Parameters ({var_type})**
| Name | Required | Description | Allowed values |
| ---- | -------- | ----------- | -------------- |
"""
for entry in entries:
result += f" {entry}\n"
result += f"""
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| {201 if method == 'POST' else 200}| N/A | Success |
"""
url_exception = [url_var_map[url_var]] if url_var in url_var_map else []
variable_exceptions = [e for v in data['input_variables'].get(method, []) for e in v.related_exceptions]
related_exceptions = sorted(
(e() for e in set(variable_exceptions + url_exception)),
key=lambda e: (e.api_response['code'], e.api_response['error'])
)
for related_exception in related_exceptions:
ar = related_exception.api_response
result += f" | {ar['code']} | {ar['error']} | {related_exception.__doc__} |\n"
result += '\n'
with open(_folder_path('docs', 'api.md'), 'r') as f:
current_content = f.read()
if current_content == result:
print('Nothing changed')
else:
with open(_folder_path('docs', 'api.md'), 'w+') as f:
f.write(result)
run(["git", "config", "--global", "user.email", '"casvantijn@gmail.com"'])
run(["git", "config", "--global", "user.name", '"CasVT"'])
run(["git", "checkout", "Development"])
run(["git", "add", _folder_path('docs', 'api.md')])
run(["git", "commit", "-m", "Updated API docs"])
run(["git", "push"])

View File

@@ -0,0 +1,85 @@
# Site Details
site_name: MIND Docs
site_description: The official guide for MIND
site_author: Casvt
repo_url: https://github.com/Casvt/MIND
edit_uri: blob/Development/docs/
repo_name: Casvt/MIND
docs_dir: ../docs
extra_css:
- assets/css/extra.css
# Site appearance (logos, colours, icons)
theme:
name: material
language: en
code_wrap: true
features:
- content.tabs.link
- content.code.copy
- header.autohide
- navigation.expand
- navigation.indexes
- navigation.instant
- navigation.sections
- navigation.tabs
- navigation.tabs.sticky
- navigation.top
- navigation.tracking
favicon: assets/img/favicon.svg
logo: assets/img/favicon.svg
palette:
# Light mode
- media: "(prefers-color-scheme: light)"
scheme: MIND
toggle:
icon: material/toggle-switch-off-outline
name: Switch to dark mode
# Dark mode
- media: "(prefers-color-scheme: dark)"
scheme: MIND-dark
toggle:
icon: material/toggle-switch
name: Switch to light mode
# Markdown extensions
markdown_extensions:
- abbr
- attr_list
- meta
- pymdownx.details
- pymdownx.highlight:
guess_lang: true
anchor_linenums: true
- pymdownx.inlinehilite
- pymdownx.keys
- pymdownx.saneheaders
- pymdownx.smartsymbols
- pymdownx.snippets
- pymdownx.superfences
- pymdownx.tabbed:
alternate_style: true
- sane_lists
- toc:
permalink: true
toc_depth: 5
- tables
# mkdocs function extensions
plugins:
- search
- git-revision-date-localized:
type: timeago
locale: en
fallback_to_build_date: false
# Navigation Layout
nav:
- Home: index.md
- Getting Started:
- Installation: installation.md
- Setup After Installation: setup_after_installation.md
- Other Docs:
- API: api.md
- Backup Your Data: backup_your_data.md

View File

@@ -3,7 +3,7 @@ import unittest
from flask import Blueprint
from backend.custom_exceptions import *
from frontend.api import api, auth, error_handler, extract_key, return_api
from frontend.api import api, return_api
class Test_API(unittest.TestCase):
def test_blueprint(self):
@@ -22,41 +22,4 @@ class Test_API(unittest.TestCase):
self.assertEqual(result[1], case['code'])
else:
self.assertEqual(result[1], 200)
def test_auth(self):
method = lambda x: x
result = auth(method)
self.assertEqual(result.__name__, method.__name__)
def _raise_exception(self, e, *args):
raise e(*args)
def test_error_handler(self):
result = error_handler(self._raise_exception)
self.assertEqual(result.__name__, self._raise_exception.__name__)
self.assertEqual(result(UsernameTaken), return_api(**UsernameTaken.api_response))
self.assertEqual(result(UsernameInvalid), return_api(**UsernameInvalid.api_response))
self.assertEqual(result(UserNotFound), return_api(**UserNotFound.api_response))
self.assertEqual(result(AccessUnauthorized), return_api(**AccessUnauthorized.api_response))
self.assertEqual(result(ReminderNotFound), return_api(**ReminderNotFound.api_response))
self.assertEqual(result(NotificationServiceNotFound), return_api(**NotificationServiceNotFound.api_response))
self.assertEqual(result(InvalidTime), return_api(**InvalidTime.api_response))
self.assertEqual(result(InvalidURL), return_api(**InvalidURL.api_response))
self.assertEqual(result(NotificationServiceInUse, 'test'), return_api(**NotificationServiceInUse('test').api_response))
self.assertEqual(result(KeyNotFound, 'test'), return_api(**KeyNotFound('test').api_response))
self.assertEqual(result(InvalidKeyValue, 'test', 'value'), return_api(**InvalidKeyValue('test', 'value').api_response))
with self.assertRaises(TypeError):
result(TypeError)
with self.assertRaises(KeyError):
result(KeyError)
def test_extract_key(self):
with self.assertRaises(KeyNotFound):
extract_key({'test': 'value'}, 'no_key')
self.assertIsNone(extract_key({'test': 'value'}, 'no_key', check_existence=False))
with self.assertRaises(InvalidKeyValue):
extract_key({'time': ''}, 'time')
self.assertIsInstance(extract_key({'time': '1'}, 'time'), int)

View File

@@ -7,9 +7,19 @@ import backend.custom_exceptions
class Test_Custom_Exceptions(unittest.TestCase):
def test_type(self):
defined_exceptions: List[Exception] = map(lambda c: c[1], getmembers(modules['backend.custom_exceptions'], isclass))
defined_exceptions: List[Exception] = filter(
lambda c: c.__module__ == 'backend.custom_exceptions' and c is not backend.custom_exceptions.CustomException,
map(
lambda c: c[1],
getmembers(modules['backend.custom_exceptions'], isclass)
)
)
for defined_exception in defined_exceptions:
self.assertEqual(getmro(defined_exception)[1], Exception)
self.assertEqual(
getmro(defined_exception)[1],
backend.custom_exceptions.CustomException
)
result = defined_exception().api_response
self.assertIsInstance(result, dict)
result['error']

View File

@@ -8,5 +8,3 @@ class Test_DB(unittest.TestCase):
DBConnection.file = _folder_path(*DB_FILENAME)
instance = DBConnection(timeout=20.0)
self.assertEqual(instance.cursor().execute("PRAGMA foreign_keys;").fetchone()[0], 1)

View File

@@ -11,10 +11,9 @@ class Test_Reminder_Handler(unittest.TestCase):
def test_filter_function(self):
p = {
'title': 'TITLE',
'text': 'TEXT',
'notification_service_title': 'NOTIFICATION_SERVICE_TITLE'
'text': 'TEXT'
}
for test_case in ('', 'title', 'service', 'ex'):
for test_case in ('', 'title', 'ex'):
self.assertTrue(filter_function(test_case, p))
for test_case in (' ', 'Hello'):
self.assertFalse(filter_function(test_case, p))