Merge pull request #13 from Casvt/Development

Release V1.0.0
This commit is contained in:
Casvt
2023-01-19 22:58:57 +01:00
committed by GitHub
38 changed files with 1721 additions and 289 deletions

13
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: casvt
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

33
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,33 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug, enhancement
assignees: ''
---
**Description of the bug**
<!-- A clear and concise description of what the bug is. -->
**To Reproduce**
<!-- Clear and concise steps to reproduce the behaviour. -->
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behaviour**
<!-- A clear and concise description of what you expected to happen. -->
**Screenshots**
<!-- If applicable, add screenshots to help explain your problem. -->
**Version info**
- OS: [e.g. iOS]
- Browser: [e.g. chrome, safari]
- Noted Version: [e.g. V1.0.0]
- Python Version: [e.g. V3.7]
**Additional context**
<!-- Add any other context about the problem here. -->

View File

@@ -0,0 +1,12 @@
---
name: Contribute request
about: Announce that you want to contribute to the project
title: ''
labels: contributing
assignees: ''
---
**What do you want to contribute? (e.g. add a feature, fix a bug, improve documentation)**
**How long do you think it will take to finish it? (e.g. one week, one month, three months)**

View File

@@ -0,0 +1,17 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
**Describe the solution you'd like**
<!-- A clear and concise description of what you want to happen. -->
**Additional context**
<!-- Add any other context or screenshots about the feature request here. -->

4
.gitignore vendored
View File

@@ -133,4 +133,6 @@ dmypy.json
# VS code
*.code-workspace
.vscode/
# Docker
Dockerfile

11
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"python.testing.unittestArgs": [
"-v",
"-s",
"./tests",
"-p",
"*_test.py"
],
"python.testing.pytestEnabled": false,
"python.testing.unittestEnabled": true
}

128
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
casvantijn@gmail.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

34
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,34 @@
# Contributing to Noted
## General steps
Contributing to Noted consists of 5 steps, listed hereunder.
1. Make a [contributing request](https://github.com/Casvt/Noted/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).
3. When done, create a pull request to the Development branch, where you mention again what you've changed/added and give a link to the original contributing request issue.
4. The PR will be reviewed and if requested, changes will need to be made before it is accepted.
5. When everything is okay, the PR will be accepted and you'll be done!
## 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).
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:
```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).
## Styling guide
The code of Noted is written in such way that it follows the following rules. Your code should too.
1. Compatible with python 3.7 .
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.

View File

@@ -2,32 +2,33 @@
#-*- coding: utf-8 -*-
from os import urandom
from os.path import dirname, join
from os.path import dirname, join, abspath
from sys import version_info
from flask import Flask, render_template, request
from waitress.server import create_server
from backend.db import close_db, setup_db
from backend.db import close_db, setup_db, DBConnection
from frontend.api import api, reminder_handler
from frontend.ui import ui
HOST = '0.0.0.0'
PORT = '8080'
THREADS = 10
DB_FILENAME = 'Noted.db'
def _folder_path(*folders) -> str:
"""Turn filepaths relative to the project folder into absolute paths
Returns:
str: The absolute filepath
"""
return join(dirname(__file__), *folders)
return join(dirname(abspath(__file__)), *folders)
def _create_app() -> Flask:
"""Create a Flask app instance
Returns:
Flask: The created app instance
"""
"""
app = Flask(
__name__,
template_folder=_folder_path('frontend','templates'),
@@ -78,6 +79,7 @@ def Noted() -> None:
# Register web server
app = _create_app()
with app.app_context():
DBConnection.file = _folder_path(DB_FILENAME)
setup_db()
reminder_handler._find_next_reminder()

View File

@@ -1,4 +1,32 @@
# Noted
# Noted Reminders
A simple self hosted reminder platform that uses push to send notifications to your device. Set the reminder and forget about it! 📢
See the [wiki](https://github.com/Casvt/Noted/wiki) for instructions on how to install and setup Noted.
Noted 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
![noted-reminders-dashboard-cards](https://user-images.githubusercontent.com/57927413/212755016-05b99226-3f6c-48b7-b99a-253e15c82947.png)
![noted-reminders-add-notification-services](https://user-images.githubusercontent.com/57927413/212755314-1104531e-7feb-4e59-af1d-927576e47152.png)
![noted-reminders-settings](https://user-images.githubusercontent.com/57927413/212755327-b45da53c-72f7-480c-9a77-eaad28803fbb.png)
## Core Features
* Basic auth
* Utilizes Apprise
* Create and delete reminders
* Schedule reminders
## Planned Features
You can see our planned features in our [Project board](https://github.com/users/Casvt/projects/3).
## Installation
```
sudo apt-get install git python3-pip
sudo git clone https://github.com/Casvt/Noted.git /opt/Noted
cd /opt/Noted
python3 -m pip install -r requirements.txt
python3 Noted.py
```
You can see the [wiki](https://github.com/Casvt/Noted/wiki) for instructions on how to install using other OS.

View File

@@ -1,5 +1,7 @@
#-*- coding: utf-8 -*-
from typing import Any, Dict
class UsernameTaken(Exception):
"""The username is already taken"""
api_response = {'error': 'UsernameTaken', 'result': {}, 'code': 400}
@@ -26,7 +28,13 @@ class NotificationServiceNotFound(Exception):
class NotificationServiceInUse(Exception):
"""The notification service is wished to be deleted but a reminder is still using it"""
api_response = {'error': 'NotificationServiceInUse', 'result': {}, 'code': 400}
def __init__(self, type: str=''):
self.type = type
super().__init__(self.type)
@property
def api_response(self) -> Dict[str, Any]:
return {'error': 'NotificationServiceInUse', 'result': {'type': self.type}, 'code': 400}
class InvalidTime(Exception):
"""The time given is in the past"""
@@ -43,7 +51,7 @@ class KeyNotFound(Exception):
super().__init__(self.key)
@property
def api_response(self):
def api_response(self) -> Dict[str, Any]:
return {'error': 'KeyNotFound', 'result': {'key': self.key}, 'code': 400}
class InvalidKeyValue(Exception):
@@ -54,5 +62,9 @@ class InvalidKeyValue(Exception):
super().__init__(self.key)
@property
def api_response(self):
return {'error': 'KeyNotFound', 'result': {'key': self.key, 'value': self.value}, 'code': 400}
def api_response(self) -> Dict[str, Any]:
return {'error': 'InvalidKeyValue', 'result': {'key': self.key, 'value': self.value}, 'code': 400}
class TemplateNotFound(Exception):
"""The template was not found"""
api_response = {'error': 'TemplateNotFound', 'result': {}, 'code': 404}

View File

@@ -6,21 +6,24 @@ from typing import Union
from flask import g
NOTED_DB_FILE = 'Noted.db'
__DATABASE_VERSION__ = 1
class Singleton(type):
_instances = {}
def __call__(cls, *args, **kwargs):
i = f'{cls}{current_thread()}'
if i not in cls._instances:
cls._instances[i] = super(Singleton, cls).__call__(*args, **kwargs)
_instances = {}
def __call__(cls, *args, **kwargs):
i = f'{cls}{current_thread()}'
if i not in cls._instances:
cls._instances[i] = super(Singleton, cls).__call__(*args, **kwargs)
return cls._instances[i]
return cls._instances[i]
class DBConnection(Connection, metaclass=Singleton):
def __init__(self, file: str, timeout: float):
super().__init__(file, timeout=timeout)
super().cursor().execute("PRAGMA foreign_keys = ON;")
file = ''
def __init__(self, timeout: float) -> None:
super().__init__(self.file, timeout=timeout)
super().cursor().execute("PRAGMA foreign_keys = ON;")
return
def get_db(output_type: Union[dict, tuple]=tuple):
"""Get a database cursor instance. Coupled to Flask's g.
@@ -34,7 +37,7 @@ def get_db(output_type: Union[dict, tuple]=tuple):
try:
cursor = g.cursor
except AttributeError:
db = DBConnection(NOTED_DB_FILE, timeout=20.0)
db = DBConnection(timeout=20.0)
cursor = g.cursor = db.cursor()
if output_type is dict:
@@ -57,6 +60,16 @@ def close_db(e=None) -> None:
pass
return
def migrate_db(current_db_version: int) -> None:
"""
Migrate a Noted database from it's current version
to the newest version supported by the Noted version installed.
"""
print('Migrating database to newer version...')
cursor = get_db()
return
def setup_db() -> None:
"""Setup the database
"""
@@ -84,10 +97,43 @@ def setup_db() -> None:
text TEXT,
time INTEGER NOT NULL,
notification_service INTEGER NOT NULL,
repeat_quantity VARCHAR(15),
repeat_interval INTEGER,
original_time INTEGER,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (notification_service) REFERENCES notification_services(id)
);
CREATE TABLE IF NOT EXISTS templates(
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
title VARCHAR(255) NOT NULL,
text TEXT,
notification_service INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (notification_service) REFERENCES notification_services(id)
);
CREATE TABLE IF NOT EXISTS config(
key VARCHAR(255) PRIMARY KEY,
value TEXT NOT NULL
);
""")
cursor.execute("""
INSERT OR IGNORE INTO config(key, value)
VALUES ('database_version', ?);
""",
(__DATABASE_VERSION__,)
)
current_db_version = int(cursor.execute("SELECT value FROM config WHERE key = 'database_version' LIMIT 1;").fetchone()[0])
if current_db_version < __DATABASE_VERSION__:
migrate_db(current_db_version)
cursor.execute(
"UPDATE config SET value = ? WHERE key = 'database_version' LIMIT 1;",
(__DATABASE_VERSION__,)
)
return

View File

@@ -80,11 +80,19 @@ class NotificationService:
# Check if no reminders exist with this service
cursor = get_db()
cursor.execute(
"SELECT id FROM reminders WHERE notification_service = ? LIMIT 1",
"SELECT 1 FROM reminders WHERE notification_service = ? LIMIT 1;",
(self.id,)
)
if cursor.fetchone():
raise NotificationServiceInUse
raise NotificationServiceInUse('reminder')
# Check if no templates exist with this service
cursor.execute(
"SELECT 1 FROM templates WHERE notification_service = ? LIMIT 1;",
(self.id,)
)
if cursor.fetchone():
raise NotificationServiceInUse('template')
cursor.execute(
"DELETE FROM notification_services WHERE id = ?",

View File

@@ -1,18 +1,39 @@
#-*- coding: utf-8 -*-
from datetime import datetime
from sqlite3 import IntegrityError
from threading import Thread
from time import sleep, time as epoch_time
from time import sleep
from time import time as epoch_time
from typing import List, Literal
from apprise import Apprise
from dateutil.relativedelta import relativedelta
from flask import Flask
from backend.custom_exceptions import (InvalidTime,
from backend.custom_exceptions import (InvalidKeyValue, InvalidTime,
NotificationServiceNotFound,
ReminderNotFound)
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(
original_time: int,
repeat_quantity: Literal["year", "month", "week", "day", "hours", "minutes"],
repeat_interval: int
) -> int:
td = relativedelta(**{repeat_quantity: repeat_interval})
new_time = datetime.fromtimestamp(original_time)
current_time = datetime.fromtimestamp(epoch_time())
while new_time <= current_time:
new_time += td
return int(new_time.timestamp())
class ReminderHandler():
"""Run in a thread to handle the set reminders
"""
@@ -26,7 +47,6 @@ class ReminderHandler():
def _find_next_reminder(self) -> None:
"""Note when next reminder is (could be in the past) or otherwise None
"""
with self.context():
next_timestamp = get_db().execute(
"SELECT time FROM reminders ORDER BY time LIMIT 1;"
@@ -58,26 +78,45 @@ class ReminderHandler():
while not self.stop:
if self.next_reminder and self.next_reminder <= epoch_time():
with self.context():
cursor = get_db()
cursor = get_db(dict)
# Get all reminders for the timestamp
reminders = cursor.execute(
"SELECT notification_service, title, text FROM reminders WHERE time = ?",
reminders = cursor.execute("""
SELECT
id,
notification_service, title, text,
repeat_quantity, repeat_interval, original_time
FROM reminders
WHERE time = ?;
""",
(self.next_reminder,)
).fetchall()
# Send of each reminder
for reminder in reminders:
# Send of reminder
a = Apprise()
url = cursor.execute(
"SELECT url FROM notification_services WHERE id = ?",
(reminder[0],)
).fetchone()[0]
(reminder["notification_service"],)
).fetchone()["url"]
a.add(url)
a.notify(title=reminder[1], body=reminder[2])
a.notify(title=reminder["title"], body=reminder["text"])
if reminder['repeat_quantity'] is None:
# Delete the reminders from the database
cursor.execute("DELETE FROM reminders WHERE id = ?", (reminder['id'],))
else:
# Set next time
new_time = _find_next_time(
reminder['original_time'],
reminder['repeat_quantity'],
reminder['repeat_interval']
)
self.submit_next_reminder(new_time)
cursor.execute(
"UPDATE reminders SET time = ? WHERE id = ?;",
(new_time, reminder['id'])
)
# Delete the reminders from the database
cursor.execute("DELETE FROM reminders WHERE time = ?", (self.next_reminder,))
# Note when next reminder is (could be in the past) or otherwise None
self._find_next_reminder()
@@ -115,7 +154,9 @@ class Reminder:
r.title, r.text,
r.time,
r.notification_service,
ns.title AS notification_service_title
ns.title AS notification_service_title,
r.repeat_quantity,
r.repeat_interval
FROM
reminders r
INNER JOIN notification_services ns
@@ -133,7 +174,9 @@ class Reminder:
title: str = None,
time: int = None,
notification_service: int = None,
text: str = None
text: str = None,
repeat_quantity: Literal["year", "month", "week", "day", "hours", "minutes"] = None,
repeat_interval: int = None
) -> dict:
"""Edit the reminder
@@ -144,11 +187,20 @@ class Reminder:
text (str, optional): The new body of the reminder. Defaults to None.
Returns:
dict: The new password info
dict: The new reminder info
"""
cursor = get_db()
# Validate data
if time < epoch_time():
raise InvalidTime
if repeat_quantity is None and repeat_interval is not None:
raise InvalidKeyValue('repeat_quantity', repeat_quantity)
elif repeat_quantity is not None and repeat_interval is None:
raise InvalidKeyValue('repeat_interval', repeat_interval)
repeated_reminder = repeat_quantity is not None and repeat_interval is not None
if not repeated_reminder:
if time < epoch_time():
raise InvalidTime
time = round(time)
# Get current data and update it with new values
@@ -157,28 +209,50 @@ class Reminder:
'title': title,
'time': time,
'notification_service': notification_service,
'text': text
'text': text,
'repeat_quantity': repeat_quantity,
'repeat_interval': repeat_interval
}
for k, v in new_values.items():
if v is not None:
if k in ('repeat_quantity', 'repeat_interval') or v is not None:
data[k] = v
# Update database
try:
get_db().execute("""
UPDATE reminders
SET title=?, text=?, time=?, notification_service=?
WHERE id = ?;
""", (
data["title"],
data["text"],
data["time"],
data["notification_service"],
self.id
))
if not repeated_reminder:
next_time = data["time"]
cursor.execute("""
UPDATE reminders
SET title=?, text=?, time=?, notification_service=?, repeat_quantity=?, repeat_interval=?
WHERE id = ?;
""", (
data["title"],
data["text"],
data["time"],
data["notification_service"],
data["repeat_quantity"],
data["repeat_interval"],
self.id
))
else:
next_time = _find_next_time(data["time"], data["repeat_quantity"], data["repeat_interval"])
cursor.execute("""
UPDATE reminders
SET title=?, text=?, time=?, notification_service=?, repeat_quantity=?, repeat_interval=?, original_time=?
WHERE id = ?;
""", (
data["title"],
data["text"],
next_time,
data["notification_service"],
data["repeat_quantity"],
data["repeat_interval"],
data["time"],
self.id
))
except IntegrityError:
raise NotificationServiceNotFound
reminder_handler.submit_next_reminder(time)
reminder_handler.submit_next_reminder(next_time)
return self.get()
@@ -190,7 +264,7 @@ class Reminder:
return
class Reminders:
"""Represents the reminder vault of the user account
"""Represents the reminder library of the user account
"""
sort_functions = {
'title': (lambda r: (r['title'], r['time']), False),
@@ -223,7 +297,9 @@ class Reminders:
r.title, r.text,
r.time,
r.notification_service,
ns.title AS notification_service_title
ns.title AS notification_service_title,
r.repeat_quantity,
r.repeat_interval
FROM
reminders r
INNER JOIN notification_services ns
@@ -249,16 +325,11 @@ class Reminders:
List[dict]: All reminders that match. Similar output to self.fetchall
"""
query = query.lower()
passwords = self.fetchall()
passwords = list(filter(
lambda p: (
query in p["title"].lower()
or query in p["text"].lower()
or query in p["notification_service_title"].lower()
),
passwords
reminders = list(filter(
lambda p: filter_function(query, p),
self.fetchall()
))
return passwords
return reminders
def fetchone(self, id: int) -> Reminder:
"""Get one reminder
@@ -276,7 +347,9 @@ class Reminders:
title: str,
time: int,
notification_service: int,
text: str = ''
text: str = '',
repeat_quantity: Literal["year", "month", "week", "day", "hours", "minutes"] = None,
repeat_interval: int = None
) -> Reminder:
"""Add a reminder
@@ -285,22 +358,34 @@ class Reminders:
time (int): The epoch timestamp the the reminder should be send.
notification_service (int): The id of the notification service to use to send the reminder.
text (str, optional): The body of the reminder. Defaults to ''.
repeat_quantity (Literal["year", "month", "week", "day", "hours", "minutes"], optional): The quantity of the repeat specified for the reminder. Defaults to None.
repeat_interval (int, optional): The amount of repeat_quantity, like "5" (hours). Defaults to None.
Returns:
dict: The info about the reminder
"""
# Validate data
if time < epoch_time():
raise InvalidTime
time = round(time)
# Insert into db
if repeat_quantity is None and repeat_interval is not None:
raise InvalidKeyValue('repeat_quantity', repeat_quantity)
elif repeat_quantity is not None and repeat_interval is None:
raise InvalidKeyValue('repeat_interval', repeat_interval)
try:
id = get_db().execute("""
INSERT INTO reminders(user_id, title, text, time, notification_service)
VALUES (?,?,?,?,?);
""", (self.user_id, title, text, time, notification_service,)
).lastrowid
if repeat_quantity is None and repeat_interval is None:
id = get_db().execute("""
INSERT INTO reminders(user_id, title, text, time, notification_service)
VALUES (?,?,?,?,?);
""", (self.user_id, title, text, time, notification_service)
).lastrowid
else:
id = get_db().execute("""
INSERT INTO reminders(user_id, title, text, time, notification_service, repeat_quantity, repeat_interval, original_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?);
""", (self.user_id, title, text, time, notification_service, repeat_quantity, repeat_interval, time)
).lastrowid
except IntegrityError:
raise NotificationServiceNotFound
reminder_handler.submit_next_reminder(time)

View File

@@ -9,7 +9,7 @@ def get_hash(salt: bytes, data: str) -> bytes:
"""Hash a string using the supplied salt
Args:
salt (bytes): The salt to use wwhen hashing
salt (bytes): The salt to use when hashing
data (str): The data to hash
Returns:

151
backend/templates.py Normal file
View File

@@ -0,0 +1,151 @@
#-*- coding: utf-8 -*-
from sqlite3 import IntegrityError
from typing import List
from backend.custom_exceptions import (NotificationServiceNotFound,
TemplateNotFound)
from backend.db import get_db
class Template:
"""Represents a template
"""
def __init__(self, template_id: int):
self.id = template_id
exists = get_db().execute("SELECT 1 FROM templates WHERE id = ? LIMIT 1;", (self.id,)).fetchone()
if not exists:
raise TemplateNotFound
def get(self) -> dict:
"""Get info about the template
Returns:
dict: The info about the template
"""
template = get_db(dict).execute("""
SELECT
id,
title, text,
notification_service
FROM templates
WHERE id = ?;
""",
(self.id,)
).fetchone()
return dict(template)
def update(self,
title: str = None,
notification_service: int = None,
text: str = None
) -> dict:
"""Edit the template
Args:
title (str): The new title of the entry. Defaults to None.
notification_service (int): The new id of the notification service to use to send the reminder. Defaults to None.
text (str, optional): The new body of the template. Defaults to None.
Returns:
dict: The new template info
"""
cursor = get_db()
data = self.get()
new_values = {
'title': title,
'notification_service': notification_service,
'text': text
}
for k, v in new_values.items():
if v is not None:
data[k] = v
try:
cursor.execute("""
UPDATE templates
SET title=?, notification_service=?, text=?
WHERE id = ?;
""", (
data['title'],
data['notification_service'],
data['text'],
self.id
))
except IntegrityError:
raise NotificationServiceNotFound
return self.get()
def delete(self) -> None:
"""Delete the template
"""
get_db().execute("DELETE FROM templates WHERE id = ?;", (self.id,))
return
class Templates:
"""Represents the template library of the user account
"""
def __init__(self, user_id: int):
self.user_id = user_id
def fetchall(self) -> List[dict]:
"""Get all templates
Returns:
List[dict]: The id, title, text and notification_service
"""
templates: list = list(map(dict, get_db(dict).execute("""
SELECT
id,
title, text,
notification_service
FROM templates
WHERE user_id = ?
ORDER BY title, id;
""",
(self.user_id,)
).fetchall()))
return templates
def fetchone(self, id: int) -> Template:
"""Get one template
Args:
id (int): The id of the template to fetch
Returns:
Template: A Template instance
"""
return Template(id)
def add(
self,
title: str,
notification_service: int,
text: str = ''
) -> Template:
"""Add a template
Args:
title (str): The 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 ''.
Returns:
Template: The info about the template
"""
try:
id = get_db().execute("""
INSERT INTO templates(user_id, title, text, notification_service)
VALUES (?,?,?,?);
""",
(self.user_id, title, text, notification_service)
).lastrowid
except IntegrityError:
raise NotificationServiceNotFound
return self.fetchone(id)

View File

@@ -6,9 +6,10 @@ from backend.db import get_db
from backend.notification_service import NotificationServices
from backend.reminders import Reminders
from backend.security import generate_salt_hash, get_hash
from backend.templates import Templates
ONEPASS_USERNAME_CHARACTERS = 'abcedfghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.!@$'
ONEPASS_INVALID_USERNAMES = ['users','api']
ONEPASS_INVALID_USERNAMES = ['reminders','api']
class User:
"""Represents an user account
@@ -52,6 +53,17 @@ class User:
self.notification_services_instance = NotificationServices(self.user_id)
return self.notification_services_instance
@property
def templates(self) -> Templates:
"""Get access to the templates of the user account
Returns:
Templates: Templates instance that can be used to access the templates of the user account
"""
if not hasattr(self, 'templates_instance'):
self.templates_instance = Templates(self.user_id)
return self.templates_instance
def edit_password(self, new_password: str) -> None:
"""Change the password of the account
@@ -106,7 +118,7 @@ def register_user(username: str, password: str) -> int:
Returns:
user_id (int): The id of the new user. User registered successful
"""
#check if username is valid
# Check if username is valid
_check_username(username)
cursor = get_db()

View File

@@ -6,13 +6,16 @@ from typing import Any, Tuple
from flask import Blueprint, g, request
from backend.custom_exceptions import (AccessUnauthorized, InvalidKeyValue, InvalidTime, InvalidURL,
KeyNotFound, NotificationServiceInUse, NotificationServiceNotFound, ReminderNotFound,
UsernameInvalid, UsernameTaken,
UserNotFound)
from backend.custom_exceptions import (AccessUnauthorized, InvalidKeyValue,
InvalidTime, InvalidURL, KeyNotFound,
NotificationServiceInUse,
NotificationServiceNotFound,
ReminderNotFound, UsernameInvalid,
UsernameTaken, UserNotFound)
from backend.notification_service import (NotificationService,
NotificationServices)
from backend.reminders import reminder_handler, Reminders
from backend.reminders import Reminders, reminder_handler
from backend.templates import Template, Templates
from backend.users import User, register_user
api = Blueprint('api', __name__)
@@ -80,10 +83,27 @@ def extract_key(values: dict, key: str, check_existence: bool=True) -> Any:
value = int(value)
except (ValueError, TypeError):
raise InvalidKeyValue(key, value)
elif key == 'repeat_interval':
try:
value = int(value)
if value <= 0:
raise ValueError
except (ValueError, TypeError):
raise InvalidKeyValue(key, value)
elif key == 'sort_by':
if not value in Reminders.sort_functions:
raise InvalidKeyValue(key, value)
elif key == 'repeat_quantity':
if not value in ("year", "month", "week", "day", "hours", "minutes"):
raise InvalidKeyValue(key, value)
elif key in ('username', 'password', 'new_password', 'title', 'url',
'text', 'query'):
if not isinstance(value, str):
raise InvalidKeyValue(key, value)
else:
if key == 'sort_by':
@@ -358,7 +378,7 @@ def api_notification_service(n_id: int):
return return_api({})
#===================
# Vault endpoints
# Library endpoints
#===================
@api.route('/reminders', methods=['GET','POST'])
@@ -376,7 +396,7 @@ def api_reminders_list():
sort_by: how to sort the result. Allowed values are 'title', 'title_reversed', 'time' and 'time_reversed'
Returns:
200:
The id, title, url and username of every reminder
The id, title, text, time, notification_service, notification_service_title, repeat_quantity and repeat_interval of each reminder
POST:
Description: Add a reminder
Parameters (body (content-type: application/json)):
@@ -384,9 +404,11 @@ def api_reminders_list():
time (required): the epoch timestamp that the reminder should be sent at
notification_service (required): the id of the notification service to use to send the notification
text: the body of the reminder
repeat_quantity ('year', 'month', 'week', 'day', 'hours', 'minutes'): The quantity of the repeat_interval
repeat_interval: The number of the interval
Returns:
200:
The id of the new reminder entry
The info about the new reminder entry
400:
KeyNotFound: One of the required parameters was not given
"""
@@ -403,11 +425,15 @@ def api_reminders_list():
time = extract_key(data, 'time')
notification_service = extract_key(data, 'notification_service')
text = extract_key(data, 'text', check_existence=False)
repeat_quantity = extract_key(data, 'repeat_quantity', check_existence=False)
repeat_interval = extract_key(data, 'repeat_interval', check_existence=False)
result = reminders.add(title=title,
time=time,
notification_service=notification_service,
text=text)
text=text,
repeat_quantity=repeat_quantity,
repeat_interval=repeat_interval)
return return_api(result.get(), code=201)
@api.route('/reminders/search', methods=['GET'])
@@ -433,7 +459,7 @@ def api_reminders_query():
result = g.user_data.reminders.search(query)
return return_api(result)
@api.route('/reminders/<r_id>', methods=['GET','PUT','DELETE'])
@api.route('/reminders/<int:r_id>', methods=['GET','PUT','DELETE'])
@error_handler
@auth
def api_get_reminder(r_id: int):
@@ -458,6 +484,8 @@ def api_get_reminder(r_id: int):
time: The new epoch timestamp the the reminder should be send.
notification_service: The new id of the notification service to use to send the reminder.
text: The new body of the reminder.
repeat_quantity ('year', 'month', 'week', 'day', 'hours', 'minutes'): The quantity of the repeat_interval
repeat_interval: The number of the interval
Returns:
200:
Reminder updated successfully
@@ -482,13 +510,123 @@ def api_get_reminder(r_id: int):
time = extract_key(data, 'time', check_existence=False)
notification_service = extract_key(data, 'notification_service', check_existence=False)
text = extract_key(data, 'text', check_existence=False)
repeat_quantity = extract_key(data, 'repeat_quantity', check_existence=False)
repeat_interval = extract_key(data, 'repeat_interval', check_existence=False)
result = reminders.fetchone(r_id).update(title=title,
time=time,
notification_service=notification_service,
text=text)
text=text,
repeat_quantity=repeat_quantity,
repeat_interval=repeat_interval)
return return_api(result)
elif request.method == 'DELETE':
reminders.fetchone(r_id).delete()
return return_api({})
#===================
# Template endpoints
#===================
@api.route('/templates', methods=['GET', 'POST'])
@error_handler
@auth
def api_get_templates():
"""
Endpoint: /templates
Description: Manage the templates
Requires being logged in: Yes
Methods:
GET:
Description: Get a list of all templates
Returns:
200:
The id, title, notification_service and text of every template
POST:
Description: Add a template
Parameters (body (content-type: application/json)):
title (required): the title of the template
notification_service (required): the id of the notification service to use to send the notification
text: the body of the template
Returns:
200:
The info about the new template entry
400:
KeyNotFound: One of the required parameters was not given
"""
templates: Templates = g.user_data.templates
if request.method == 'GET':
result = templates.fetchall()
return return_api(result)
elif request.method == 'POST':
data = request.get_json()
title = extract_key(data, 'title')
notification_service = extract_key(data, 'notification_service')
text = extract_key(data, 'text', check_existence=False)
result = templates.add(title=title,
notification_service=notification_service,
text=text)
return return_api(result.get(), code=201)
@api.route('/templates/<int:t_id>', methods=['GET', 'PUT', 'DELETE'])
@error_handler
@auth
def api_get_template(t_id: int):
"""
Endpoint: /templates/<t_id>
Description: Manage a specific template
Requires being logged in: Yes
URL Parameters:
<t_id>:
The id of the template
Methods:
GET:
Returns:
200:
All info about the template
404:
No template found with the given id
PUT:
Description: Edit the template
Parameters (body (content-type: application/json)):
title: The new title of the entry.
notification_service: The new id of the notification service to use to send the reminder.
text: The new body of the template.
Returns:
200:
Template updated successfully
404:
No template found with the given id
DELETE:
Description: Delete the template
Returns:
200:
Template deleted successfully
404:
No template found with the given id
"""
template: Template = g.user_data.templates.fetchone(t_id)
if request.method == 'GET':
result = template.get()
return return_api(result)
elif request.method == 'PUT':
data = request.get_json()
title = extract_key(data, 'title', check_existence=False)
notification_service = extract_key(data, 'notification_service', check_existence=False)
text = extract_key(data, 'text', check_existence=False)
result = template.update(title=title,
notification_service=notification_service,
text=text)
return return_api(result)
elif request.method == 'DELETE':
template.delete()
return return_api({})

View File

@@ -12,7 +12,8 @@
.form-container > form input,
.form-container > form select,
.form-container > form textarea {
.form-container > form textarea,
.form-container > form button {
padding: 1rem;
}
@@ -27,11 +28,71 @@
}
.sub-inputs > input,
.sub-inputs > select {
.sub-inputs > select,
.sub-inputs > button {
width: calc(50% - (var(--gap) / 2));
}
.options > button {
.sub-inputs > button {
display: flex;
justify-content: center;
align-items: center;
gap: .75rem;
border: 2px solid var(--color-gray);
box-shadow: var(--default-shadow);
font-size: 1.1rem;
}
.sub-inputs > button > svg {
width: 1rem;
height: 1rem;
}
.sub-inputs > button[data-selected="false"] > svg {
opacity: 0;
}
.repeat-bar,
.repeat-edit-bar {
display: flex;
justify-content: space-between;
align-items: center;
overflow-x: auto;
border: 2px solid var(--color-gray);
border-radius: 4px;
box-shadow: var(--default-shadow);
}
.repeat-bar > *,
.repeat-edit-bar > * {
border: 0;
box-shadow: none;
}
.repeat-bar > p,
.repeat-edit-bar > p {
min-width: 7rem;
padding: 1rem;
padding-right: .25rem;
}
.repeat-bar > input[type="number"],
.repeat-edit-bar > input[type="number"] {
min-width: 5rem;
padding-left: .25rem;
}
.repeat-bar > select,
.repeat-edit-bar > select {
min-width: 8rem;
}
div.options > button {
width: 6rem;
border: 2px solid var(--color-gray);
@@ -46,6 +107,11 @@
background-color: var(--color-gray);
}
#delete-reminder {
border-color: var(--color-error);
color: var(--color-error);
}
@media (max-width: 460px) {
.sub-inputs > input,
.sub-inputs > select {

View File

@@ -2,6 +2,8 @@
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
font-size: 1rem;
}
:root {
@@ -262,6 +264,27 @@ nav > div > button svg {
color: var(--color-light);
}
.tab-selector {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
padding-top: 1rem;
}
.tab-selector > button {
border: 2px solid var(--color-gray);
padding: .5rem 1rem;
transition: background-color .3s ease-in-out;
}
.tab-selector > button[data-selected="true"] {
background-color: var(--color-gray);
}
@media (max-width: 543px) {
.window-container {
margin-left: 0;

View File

@@ -5,7 +5,6 @@
flex-direction: column;
align-items: center;
gap: 1rem;
overflow-x: auto;
}
#add-service-button {
@@ -18,66 +17,63 @@
width: 1rem;
}
.table-container > table {
.overflow-container {
margin-inline: auto;
width: 100%;
max-width: 50rem;
overflow-x: auto;
}
.overflow-container > table {
border-spacing: 0px;
}
.table-container > table th,
.table-container > table td {
.overflow-container > table th,
.overflow-container > table td {
text-align: left;
}
.table-container > table th {
padding-block: .5rem;
.overflow-container > table th {
padding: .5rem;
}
#services-list > tr > td {
border-top: 1px solid var(--color-gray);
}
#services-list > tr.edit > td {
padding-block: .5rem;
padding: .25rem;
}
.title-column {
width: 8rem;
min-width: 9.5rem;
width: 25%;
padding-left: 1.5rem;
padding-right: 1rem;
}
.url-column {
width: 21rem;
min-width: 26rem;
width: 65%;
}
#services-list input {
min-width: 6rem;
border-radius: 2px;
padding: .5rem;
padding: .25rem;
box-shadow: none;
}
.url-column > input {
min-width: 10rem;
}
#services-list input:read-only {
border-color: transparent;
padding: 0;
}
#services-list > tr.edit > td.action-column {
padding-block: 1rem;
}
.action-column {
#services-list > tr > td.action-column {
min-width: 4rem;
width: 20%;
display: flex;
gap: .5rem;
padding: .5rem;
padding: calc(.5rem + 2px);
padding-right: 1.5rem;
}
@@ -92,6 +88,6 @@
}
#services-list > tr.edit > td.action-column > button[data-type="edit"],
#services-list > tr:not(.edit) > td.action-column > button[data-type="save"] {
#services-list > tr:not(.edit):not(#add-row) > td.action-column > button[data-type="save"] {
display: none;
}

View File

@@ -46,25 +46,30 @@
}
/* REMINDER LIST */
#reminder-list {
#reminder-list,
#template-list {
--gap: 1rem;
--entry-width: 13rem;
max-width: 43rem;
margin-inline: auto;
display: flex;
justify-content: center;
gap: 1rem;
justify-content: left;
gap: var(--gap);
flex-wrap: wrap;
padding: 1rem;
}
#add-entry {
#add-entry,
#add-template {
justify-content: center;
align-items: center;
gap: .5rem;
}
#add-entry svg {
#add-entry svg,
#add-template svg {
height: 2rem;
width: 2rem;
}
@@ -79,67 +84,41 @@
}
.entry {
width: 13rem;
width: var(--entry-width);
height: 6rem;
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: .1rem;
padding: 1rem;
border-radius: 4px;
padding: .75rem;
background-color: var(--color-gray);
}
button.entry.fit {
flex-grow: 1;
}
.entry.expand {
width: calc(var(--entry-width) * 2 + var(--gap));
}
.entry h2 {
overflow-y: hidden;
}
.entry-overlay {
--height: 1.7rem;
height: 100%;
opacity: 0;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: var(--height);
display: flex;
justify-content: flex-end;
align-items: center;
gap: .5rem;
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
padding: .5rem;
background-color: rgb(0 0 0 / 25%);
transition: opacity .2s ease-in;
text-align: left;
font-size: 1.25em;
line-height: 1.18;
font-weight: 500;
}
.entry-overlay::before {
content: '';
position: absolute;
height: 25px;
width: 100%;
background: linear-gradient(transparent, rgb(0 0 0 / 25%));
left: 0;
bottom: var(--height);
}
.entry-overlay > button {
background-color: transparent;
}
.entry-overlay > button > svg {
width: .9rem;
height: .9rem;
}
.entry:hover > .entry-overlay {
opacity: 1;
#delete-template {
border-color: var(--color-error);
color: var(--color-error);
}
@media (max-width: 543px) {

View File

@@ -5,13 +5,30 @@ const inputs = {
'text': document.getElementById('text-input')
};
const type_buttons = {
'normal-button': document.getElementById('normal-button'),
'repeat-button': document.getElementById('repeat-button'),
'repeat-bar': document.querySelector('.repeat-bar'),
'repeat-interval': document.getElementById('repeat-interval'),
'repeat-quantity': document.getElementById('repeat-quantity')
};
function addReminder() {
inputs.time.classList.remove('error-input');
inputs.time.removeAttribute('title');
const data = {
'title': inputs.title.value,
'time': new Date(inputs.time.value).getTime() / 1000,
'notification_service': inputs.notification_service.value,
'text': inputs.text.value
};
if (type_buttons['repeat-button'].dataset.selected === 'true') {
data['repeat_quantity'] = type_buttons['repeat-quantity'].value;
data['repeat_interval'] = type_buttons['repeat-interval'].value
};
fetch(`/api/reminders?api_key=${api_key}`, {
'method': 'POST',
'headers': {'Content-Type': 'application/json'},
@@ -30,6 +47,9 @@ function addReminder() {
.catch(e => {
if (e === 401) {
window.location.href = '/';
} else if (e === 400) {
inputs.time.classList.add('error-input');
inputs.time.title = 'Time is in the past';
} else {
console.log(e);
};
@@ -38,21 +58,46 @@ function addReminder() {
function showAdd() {
if (!document.getElementById('add-entry').classList.contains('error')) {
loadTemplates(force=false);
showWindow('add');
} else {
showWindow('notification');
};
};
function closeAdd() {
hideWindow();
setTimeout(() => {
document.getElementById('template-selection').value = document.querySelector('#template-selection option[selected]').value;
inputs.title.value = '';
inputs.time.value = '';
inputs.notification_service.value = document.querySelector('#notification-service-input option[selected]').value;
toggleNormal();
inputs.text.value = '';
}, 500);
};
function toggleNormal() {
type_buttons['normal-button'].dataset.selected = 'true';
type_buttons['repeat-button'].dataset.selected = 'false';
type_buttons['repeat-bar'].classList.add('hidden');
type_buttons['repeat-interval'].removeAttribute('required');
type_buttons['repeat-interval'].value = '';
};
function toggleRepeated() {
type_buttons['normal-button'].dataset.selected = 'false';
type_buttons['repeat-button'].dataset.selected = 'true';
type_buttons['repeat-bar'].classList.remove('hidden');
type_buttons['repeat-interval'].setAttribute('required', '');
};
// code run on load
document.getElementById('add-form').setAttribute('action', 'javascript:addReminder();');
document.getElementById('template-selection').addEventListener('change', e => loadTemplate());
document.getElementById('normal-button').addEventListener('click', e => toggleNormal());
document.getElementById('repeat-button').addEventListener('click', e => toggleRepeated());
document.getElementById('close-add').addEventListener('click', e => closeAdd());

View File

@@ -5,14 +5,30 @@ const edit_inputs = {
'text': document.getElementById('text-edit-input')
};
const edit_type_buttons = {
'normal-edit-button': document.getElementById('normal-edit-button'),
'repeat-edit-button': document.getElementById('repeat-edit-button'),
'repeat-edit-bar': document.querySelector('.repeat-edit-bar'),
'repeat-edit-interval': document.getElementById('repeat-edit-interval'),
'repeat-edit-quantity': document.getElementById('repeat-edit-quantity')
};
function editReminder() {
const id = document.getElementById('edit-form').dataset.id;
const data = {
'title': edit_inputs.title.value,
'time': new Date(edit_inputs.time.value).getTime() / 1000,
'notification_service': edit_inputs.notification_service.value,
'text': edit_inputs.text.value
'text': edit_inputs.text.value,
'repeat_quantity': null,
'repeat_interval': null
};
if (edit_type_buttons['repeat-edit-button'].dataset.selected === 'true') {
data['repeat_quantity'] = edit_type_buttons['repeat-edit-quantity'].value;
data['repeat_interval'] = edit_type_buttons['repeat-edit-interval'].value;
};
fetch(`/api/reminders/${id}?api_key=${api_key}`, {
'method': 'PUT',
'headers': {'Content-Type': 'application/json'},
@@ -50,11 +66,22 @@ function showEdit(id) {
})
.then(json => {
edit_inputs.title.value = json.result.title;
edit_inputs.time.value = new Date(
(json.result.time + new Date(json.result.time * 1000).getTimezoneOffset() * -60) * 1000
).toISOString().slice(0, 16);
edit_inputs.notification_service.value = json.result.notification_service;
if (json.result['repeat_interval'] === null) {
toggleEditNormal();
} else {
toggleEditRepeated();
edit_type_buttons['repeat-edit-interval'].value = json.result['repeat_interval'];
edit_type_buttons['repeat-edit-quantity'].value = json.result['repeat_quantity'];
};
edit_inputs.text.value = json.result.text !== null ? json.result.text : '';
showWindow('edit');
})
.catch(e => {
@@ -68,7 +95,53 @@ function showEdit(id) {
});
};
function toggleEditNormal() {
edit_type_buttons['normal-edit-button'].dataset.selected = 'true';
edit_type_buttons['repeat-edit-button'].dataset.selected = 'false';
edit_type_buttons['repeat-edit-bar'].classList.add('hidden');
edit_type_buttons['repeat-edit-interval'].removeAttribute('required');
edit_type_buttons['repeat-edit-interval'].value = '';
};
function toggleEditRepeated() {
edit_type_buttons['normal-edit-button'].dataset.selected = 'false';
edit_type_buttons['repeat-edit-button'].dataset.selected = 'true';
edit_type_buttons['repeat-edit-bar'].classList.remove('hidden');
edit_type_buttons['repeat-edit-interval'].setAttribute('required', '');
};
function deleteReminder() {
const id = document.getElementById('edit-form').dataset.id;
fetch(`/api/reminders/${id}?api_key=${api_key}`, {
'method': 'DELETE'
})
.then(response => {
// catch errors
if (!response.ok) {
return Promise.reject(response.status);
};
fillList();
hideWindow();
return;
})
.catch(e => {
if (e === 401) {
window.location.href = '/';
} else if (e === 404) {
fillList();
} else {
console.log(e);
};
});
};
// code run on load
document.getElementById('edit-form').setAttribute('action', 'javascript:editReminder();');
document.getElementById('normal-edit-button').addEventListener('click', e => toggleEditNormal());
document.getElementById('repeat-edit-button').addEventListener('click', e => toggleEditRepeated());
document.getElementById('close-edit').addEventListener('click', e => hideWindow());
document.getElementById('delete-reminder').addEventListener('click', e => deleteReminder());

View File

@@ -30,6 +30,23 @@ function hideWindow() {
});
};
function showTab(tab_id, button_id, load_function=null) {
document.querySelectorAll(`.tab-selector > button`).forEach(e => {
if (e.id === button_id) {
e.dataset.selected = "true"
} else {
e.dataset.selected = "false"
};
});
document.querySelectorAll(`#home > div:not(.tab-selector)`).forEach(e => {
e.classList.add('hidden');
});
document.getElementById(tab_id).classList.remove('hidden');
if (load_function !== null) {
load_function(force=false);
};
};
// code run on load
const api_key = sessionStorage.getItem('api_key');
@@ -44,6 +61,9 @@ document.getElementById('notification-services-button').addEventListener('click'
document.getElementById('settings-button').addEventListener('click', e => showWindow('settings'));
document.getElementById('logout-button').addEventListener('click', e => logout());
document.getElementById('reminders-selector').addEventListener('click', e => showTab('reminder-tab', 'reminders-selector'));
document.getElementById('templates-selector').addEventListener('click', e => showTab('template-tab', 'templates-selector', loadTemplates));
const edit_icon = '<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 24 24" style="enable-background:new 0 0 512 512" xml:space="preserve"><g><g id="_01_align_center" data-name="01 align center"><path d="M22.94,1.06a3.626,3.626,0,0,0-5.124,0L0,18.876V24H5.124L22.94,6.184A3.627,3.627,0,0,0,22.94,1.06ZM4.3,22H2V19.7L15.31,6.4l2.3,2.3ZM21.526,4.77,19.019,7.277l-2.295-2.3L19.23,2.474a1.624,1.624,0,0,1,2.3,2.3Z"></path></g></g></svg>';
const delete_icon = '<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 24 24" style="enable-background:new 0 0 512 512" xml:space="preserve"><g><g id="_01_align_center" data-name="01 align center"><path d="M22,4H17V2a2,2,0,0,0-2-2H9A2,2,0,0,0,7,2V4H2V6H4V21a3,3,0,0,0,3,3H17a3,3,0,0,0,3-3V6h2ZM9,2h6V4H9Zm9,19a1,1,0,0,1-1,1H7a1,1,0,0,1-1-1V6H18Z"></path><rect x="9" y="10" width="2" height="8"></rect><rect x="13" y="10" width="2" height="8"></rect></g></g></svg>';
const save_icon = '<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 24 24" style="enable-background:new 0 0 512 512" xml:space="preserve"><g><path d="M12,10a4,4,0,1,0,4,4A4,4,0,0,0,12,10Zm0,6a2,2,0,1,1,2-2A2,2,0,0,1,12,16Z"></path><path d="M22.536,4.122,19.878,1.464A4.966,4.966,0,0,0,16.343,0H5A5.006,5.006,0,0,0,0,5V19a5.006,5.006,0,0,0,5,5H19a5.006,5.006,0,0,0,5-5V7.657A4.966,4.966,0,0,0,22.536,4.122ZM17,2.08V3a3,3,0,0,1-3,3H10A3,3,0,0,1,7,3V2h9.343A2.953,2.953,0,0,1,17,2.08ZM22,19a3,3,0,0,1-3,3H5a3,3,0,0,1-3-3V5A3,3,0,0,1,5,2V3a5.006,5.006,0,0,0,5,5h4a4.991,4.991,0,0,0,4.962-4.624l2.16,2.16A3.02,3.02,0,0,1,22,7.657Z"></path></g></svg>';

View File

@@ -11,8 +11,12 @@ function fillNotificationSelection() {
.then(json => {
if (json.result.length) {
document.getElementById('add-entry').classList.remove('error', 'error-icon');
[document.getElementById('notification-service-input'),
document.getElementById('notification-service-edit-input')].forEach(options => {
[
document.getElementById('notification-service-input'),
document.getElementById('notification-service-edit-input'),
document.getElementById('notification-service-template-input'),
document.getElementById('notification-service-template-edit-input')
].forEach(options => {
options.innerHTML = '';
json.result.forEach(service => {
const entry = document.createElement('option');
@@ -95,10 +99,11 @@ function deleteService(id) {
fetch(`/api/notificationservices/${id}?api_key=${api_key}`, {
'method': 'DELETE'
})
.then(response => {
.then(response => response.json())
.then(json => {
// catch errors
if (!response.ok) {
return Promise.reject(response.status);
if (json.error !== null) {
return Promise.reject(json);
};
row.remove();
@@ -107,12 +112,12 @@ function deleteService(id) {
};
})
.catch(e => {
if (e === 401) {
if (e.error === 'ApiKeyExpired' || e.error === 'ApiKeyInvalid') {
window.location.href = '/';
} else if (e === 400) {
} else if (e.error === 'NotificationServiceInUse') {
const delete_button = row.querySelector('button[title="Delete"]');
delete_button.classList.add('error-icon');
delete_button.title = 'The notification service is still in use by a reminder';
delete_button.title = `The notification service is still in use by a ${e.result.type}`;
} else {
console.log(e);
};

View File

@@ -1,11 +1,12 @@
function fillTable(result) {
const table = document.getElementById('reminder-list');
table.querySelectorAll('button:not(#add-entry)').forEach(e => e.remove());
table.querySelectorAll('button.entry:not(#add-entry)').forEach(e => e.remove());
result.forEach(reminder => {
const entry = document.createElement('button');
entry.classList.add('entry');
entry.dataset.id = reminder.id;
entry.addEventListener('click', e => showEdit(reminder.id));
const title = document.createElement('h2');
title.innerText = reminder.title;
@@ -14,29 +15,26 @@ function fillTable(result) {
const time = document.createElement('p');
var d = new Date(reminder.time * 1000);
var formatted_date = d.toLocaleString('en-CA').slice(0,10) + ' ' + d.toTimeString().slice(0,5);
if (reminder.repeat_interval !== null) {
if (reminder.repeat_interval === 1) {
var quantity = reminder.repeat_quantity.endsWith('s') ? reminder.repeat_quantity.slice(0, -1) : reminder.repeat_quantity;
var interval_text = ` (each ${quantity})`;
} else {
var quantity = reminder.repeat_quantity.endsWith('s') ? reminder.repeat_quantity : reminder.repeat_quantity + 's';
var interval_text = ` (every ${reminder.repeat_interval} ${quantity})`;
};
formatted_date += interval_text;
};
time.innerText = formatted_date;
entry.appendChild(time);
const options = document.createElement('div');
options.classList.add('entry-overlay');
entry.appendChild(options);
const edit_entry = document.createElement('button');
edit_entry.addEventListener('click', e => showEdit(reminder.id));
edit_entry.innerHTML = edit_icon;
edit_entry.title = 'Edit reminder';
edit_entry.setAttribute('aria-label', 'Edit reminder');
options.appendChild(edit_entry);
const delete_entry = document.createElement('button');
delete_entry.addEventListener('click', e => deleteReminder(reminder.id));
delete_entry.innerHTML = delete_icon;
delete_entry.title = 'Delete reminder';
delete_entry.setAttribute('aria-label', 'Delete reminder');
options.appendChild(delete_entry);
table.appendChild(entry);
if (title.clientHeight < title.scrollHeight) {
entry.classList.add('expand');
};
});
table.querySelectorAll('button.entry:not(#add-entry)').forEach(reminder => reminder.classList.add('fit'));
};
function fillList() {
@@ -87,30 +85,6 @@ function clearSearch() {
fillList();
}
function deleteReminder(id) {
const entry = document.querySelector(`button.entry[data-id="${id}"]`);
entry.remove();
fetch(`/api/reminders/${id}?api_key=${api_key}`, {
'method': 'DELETE'
})
.then(response => {
// catch errors
if (!response.ok) {
return Promise.reject(response.status);
};
})
.catch(e => {
if (e === 401) {
window.location.href = '/';
} else if (e === 404) {
fillList();
} else {
console.log(e);
};
});
};
// code run on load
fillList();
@@ -119,3 +93,4 @@ setInterval(fillList, 60000);
document.getElementById('search-form').setAttribute('action', 'javascript:search();');
document.getElementById('clear-button').addEventListener('click', e => clearSearch());
document.getElementById('add-entry').addEventListener('click', e => showAdd());
document.getElementById('add-template').addEventListener('click', e => showWindow('add-template'));

View File

@@ -0,0 +1,217 @@
const template_inputs = {
'title': document.getElementById('title-template-input'),
'notification-service': document.getElementById('notification-service-template-input'),
'text': document.getElementById('text-template-input')
};
const edit_template_inputs = {
'title': document.getElementById('title-template-edit-input'),
'notification-service': document.getElementById('notification-service-template-edit-input'),
'text': document.getElementById('text-template-edit-input')
};
function loadTemplates(force=true) {
const table = document.getElementById('template-list');
if (!force && !!table.querySelector('button:not(#add-template)')) {
return
};
fetch(`/api/templates?api_key=${api_key}`)
.then(response => {
// catch errors
if (!response.ok) {
return Promise.reject(response.status);
};
return response.json();
})
.then(json => {
const select_list = document.getElementById('template-selection');
select_list.querySelectorAll('option:not([selected])').forEach(e => e.remove());
json.result.forEach(template => {
const entry = document.createElement('option');
entry.value = template.id;
entry.innerText = template.title;
select_list.appendChild(entry);
});
table.querySelectorAll('button:not(#add-template)').forEach(e => e.remove());
json.result.forEach(template => {
const entry = document.createElement('button');
entry.classList.add('entry');
entry.addEventListener('click', e => showEditTemplate(template.id));
const title = document.createElement('h2');
title.innerText = template.title;
entry.appendChild(title);
table.appendChild(entry);
if (title.clientHeight < title.scrollHeight) {
entry.classList.add('expand');
};
});
table.querySelectorAll('button:not(#add-template)').forEach(template => template.classList.add('fit'));
})
.catch(e => {
if (e === 401) {
window.location.href = '/';
} else {
console.log(e);
};
});
};
function loadTemplate() {
const id = document.getElementById('template-selection').value;
if (id === "0") {
inputs.title.value = '';
inputs.notification_service.value = document.querySelector('#notification-service-input option[selected]').value;
inputs.text.value = '';
} else {
fetch(`/api/templates/${id}?api_key=${api_key}`)
.then(response => {
// catch errors
if (!response.ok) {
return Promise.reject(response.status);
};
return response.json();
})
.then(json => {
inputs.title.value = json.result.title;
inputs.notification_service.value = json.result.notification_service;
inputs.text.value = json.result.text;
})
.catch(e => {
if (e === 401) {
window.location.href = '/';
} else {
console.log(e);
};
});
};
};
function addTemplate() {
const data = {
'title': template_inputs.title.value,
'notification_service': template_inputs["notification-service"].value,
'text': template_inputs.text.value
};
fetch(`/api/templates?api_key=${api_key}`, {
'method': 'POST',
'headers': {'Content-Type': 'application/json'},
'body': JSON.stringify(data)
})
.then(response => {
// catch errors
if (!response.ok) {
return Promise.reject(response.status);
};
loadTemplates();
closeAddTemplate();
return
})
.catch(e => {
if (e === 401) {
window.location.href = '/';
} else {
console.log(e);
};
});
};
function closeAddTemplate() {
hideWindow();
setTimeout(() => {
template_inputs.title.value = '';
template_inputs['notification-service'].value = document.querySelector('#notification-service-template-input option[selected]').value;
template_inputs.text.value = '';
}, 500);
};
function showEditTemplate(id) {
fetch(`/api/templates/${id}?api_key=${api_key}`)
.then(response => {
// catch errors
if (!response.ok) {
return Promise.reject(response.status);
};
return response.json();
})
.then(json => {
document.getElementById('template-edit-form').dataset.id = id;
edit_template_inputs.title.value = json.result.title;
edit_template_inputs['notification-service'].value = json.result.notification_service;
edit_template_inputs.text.value = json.result.text;
showWindow('edit-template');
})
.catch(e => {
if (e === 401) {
window.location.href = '/';
} else {
console.log(e);
};
});
};
function saveTemplate() {
const id = document.getElementById('template-edit-form').dataset.id;
const data = {
'title': edit_template_inputs.title.value,
'notification_service': edit_template_inputs['notification-service'].value,
'text': edit_template_inputs.text.value
};
fetch(`/api/templates/${id}?api_key=${api_key}`, {
'method': 'PUT',
'headers': {'Content-Type': 'application/json'},
'body': JSON.stringify(data)
})
.then(response => {
// catch errors
if (!response.ok) {
return Promise.reject(response.status);
};
loadTemplates();
hideWindow();
})
.catch(e => {
if (e === 401) {
window.location.href = '/';
} else {
console.log(e);
};
});
};
function deleteTemplate() {
const id = document.getElementById('template-edit-form').dataset.id;
fetch(`/api/templates/${id}?api_key=${api_key}`, {
'method': 'DELETE'
})
.then(response => {
// catch errors
if (!response.ok) {
return Promise.reject(response.status);
};
loadTemplates();
hideWindow();
})
.catch(e => {
if (e === 401) {
window.location.href = '/';
} else {
console.log(e);
};
});
};
// code run on load
document.getElementById('template-form').setAttribute('action', 'javascript:addTemplate();');
document.getElementById('close-template').addEventListener('click', e => closeAddTemplate());
document.getElementById('template-edit-form').setAttribute('action', 'javascript:saveTemplate()');
document.getElementById('close-edit-template').addEventListener('click', e => hideWindow());
document.getElementById('delete-template').addEventListener('click', e => deleteTemplate());

View File

@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/static/css/general.css">
<link rel="stylesheet" href="/static/css/reminders.css">
<link rel="stylesheet" href="/static/css/reminders_templates.css">
<link rel="stylesheet" href="/static/css/add_edit.css">
<link rel="stylesheet" href="/static/css/notification.css">
<link rel="stylesheet" href="/static/css/settings.css">
@@ -16,6 +16,7 @@
<script src="/static/js/edit.js" defer></script>
<script src="/static/js/notification.js" defer></script>
<script src="/static/js/settings.js" defer></script>
<script src="/static/js/templates.js" defer></script>
<title>Reminders - Noted</title>
</head>
@@ -74,54 +75,106 @@
</nav>
<main class="window-container">
<div id="home">
<div class="search-container">
<noscript>Javascript is disabled. The web-ui of Noted 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 class="tab-selector">
<button id="reminders-selector" data-selected="true">Reminders</button>
<button id="templates-selector" data-selected="false">Templates</button>
</div>
<div id="reminder-list">
<button class="entry" id="add-entry" aria-label="Add reminder" title="Add reminder">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve">
<g>
<div id="reminder-tab">
<div class="search-container">
<noscript>Javascript is disabled. The web-ui of Noted 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" id="add-entry" aria-label="Add reminder" title="Add reminder">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve">
<g>
<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>
</g>
</g>
</svg>
<p>Add a notification service first!</p>
</button>
</svg>
<p>Add a notification service first!</p>
</button>
</div>
</div>
<div id="template-tab" class="hidden">
<div id="template-list">
<button class="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>
<path d="M480,224H288V32c0-17.673-14.327-32-32-32s-32,14.327-32,32v192H32c-17.673,0-32,14.327-32,32s14.327,32,32,32h192v192 c0,17.673,14.327,32,32,32s32-14.327,32-32V288h192c17.673,0,32-14.327,32-32S497.673,224,480,224z"></path>
</g>
</g>
</svg>
</button>
</div>
</div>
</div>
<div id="add">
<h2>Add a reminder</h2>
<div class="form-container">
<form id="add-form">
<select id="template-selection">
<option value="0" selected>No template</option>
</select>
<input type="text" id="title-input" placeholder="Title" required>
<div class="sub-inputs">
<input type="datetime-local" id="time-input" required>
<select id="notification-service-input" required></select>
</div>
<div class="sub-inputs">
<button type="button" id="normal-button" data-selected="true">
<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 507.506 507.506" style="enable-background:new 0 0 512 512" xml:space="preserve">
<g>
<path d="M163.865 436.934a54.228 54.228 0 0 1-38.4-15.915L9.369 304.966c-12.492-12.496-12.492-32.752 0-45.248 12.496-12.492 32.752-12.492 45.248 0l109.248 109.248L452.889 79.942c12.496-12.492 32.752-12.492 45.248 0 12.492 12.496 12.492 32.752 0 45.248L202.265 421.019a54.228 54.228 0 0 1-38.4 15.915z"></path>
</g>
</svg>
Normal
</button>
<button type="button" id="repeat-button" data-selected="false">
<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 507.506 507.506" style="enable-background:new 0 0 512 512" xml:space="preserve">
<g>
<path d="M163.865 436.934a54.228 54.228 0 0 1-38.4-15.915L9.369 304.966c-12.492-12.496-12.492-32.752 0-45.248 12.496-12.492 32.752-12.492 45.248 0l109.248 109.248L452.889 79.942c12.496-12.492 32.752-12.492 45.248 0 12.492 12.496 12.492 32.752 0 45.248L202.265 421.019a54.228 54.228 0 0 1-38.4 15.915z"></path>
</g>
</svg>
Repeated
</button>
</div>
<div class="repeat-bar hidden">
<p>Repeat every </p>
<input type="number" id="repeat-interval" placeholder="interval" min="1" step="1" oninput="validity.valid || (value='');">
<select id="repeat-quantity">
<option value="minutes">Minute(s)</option>
<option value="hours">Hour(s)</option>
<option value="day" selected>Day(s)</option>
<option value="week">Week(s)</option>
<option value="month">Month(s)</option>
<option value="year">Year(s)</option>
</select>
</div>
<textarea id="text-input" cols="30" rows="10" placeholder="Text (optional)"></textarea>
<div class="options">
<button type="button" id="close-add">Cancel</button>
@@ -139,9 +192,40 @@
<input type="datetime-local" id="time-edit-input" required>
<select id="notification-service-edit-input" required></select>
</div>
<div class="sub-inputs">
<button type="button" id="normal-edit-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 507.506 507.506" style="enable-background:new 0 0 512 512" xml:space="preserve">
<g>
<path d="M163.865 436.934a54.228 54.228 0 0 1-38.4-15.915L9.369 304.966c-12.492-12.496-12.492-32.752 0-45.248 12.496-12.492 32.752-12.492 45.248 0l109.248 109.248L452.889 79.942c12.496-12.492 32.752-12.492 45.248 0 12.492 12.496 12.492 32.752 0 45.248L202.265 421.019a54.228 54.228 0 0 1-38.4 15.915z"></path>
</g>
</svg>
Normal
</button>
<button type="button" id="repeat-edit-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 507.506 507.506" style="enable-background:new 0 0 512 512" xml:space="preserve">
<g>
<path d="M163.865 436.934a54.228 54.228 0 0 1-38.4-15.915L9.369 304.966c-12.492-12.496-12.492-32.752 0-45.248 12.496-12.492 32.752-12.492 45.248 0l109.248 109.248L452.889 79.942c12.496-12.492 32.752-12.492 45.248 0 12.492 12.496 12.492 32.752 0 45.248L202.265 421.019a54.228 54.228 0 0 1-38.4 15.915z"></path>
</g>
</svg>
Repeated
</button>
</div>
<div class="repeat-edit-bar">
<p>Repeat every </p>
<input type="number" id="repeat-edit-interval" placeholder="interval" min="1" step="1" oninput="validity.valid || (value='');">
<select id="repeat-edit-quantity">
<option value="minutes">Minute(s)</option>
<option value="hours">Hour(s)</option>
<option value="day" selected>Day(s)</option>
<option value="week">Week(s)</option>
<option value="month">Month(s)</option>
<option value="year">Year(s)</option>
</select>
</div>
<textarea id="text-edit-input" cols="30" rows="10" placeholder="Text (optional)"></textarea>
<div class="options">
<button type="button" id="close-edit">Cancel</button>
<button type="button" id="delete-reminder">Delete</button>
<button type="submit">Save</button>
</div>
</form>
@@ -160,40 +244,42 @@
</g>
</svg>
</button>
<table>
<thead>
<tr>
<th class="title-column">Title</th>
<th class="url-column">Apprise URL</th>
<th title="Actions" aria-label="Actions" class="action-column">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 24 24" style="enable-background:new 0 0 512 512" xml:space="preserve">
<g>
<path d="M11.24,24a2.262,2.262,0,0,1-.948-.212,2.18,2.18,0,0,1-1.2-2.622L10.653,16H6.975A3,3,0,0,1,4.1,12.131l3.024-10A2.983,2.983,0,0,1,10,0h3.693a2.6,2.6,0,0,1,2.433,3.511L14.443,8H17a3,3,0,0,1,2.483,4.684l-6.4,10.3A2.2,2.2,0,0,1,11.24,24ZM10,2a1,1,0,0,0-.958.71l-3.024,10A1,1,0,0,0,6.975,14H12a1,1,0,0,1,.957,1.29L11.01,21.732a.183.183,0,0,0,.121.241A.188.188,0,0,0,11.4,21.9l6.4-10.3a1,1,0,0,0,.078-1.063A.979.979,0,0,0,17,10H13a1,1,0,0,1-.937-1.351l2.19-5.84A.6.6,0,0,0,13.693,2Z"></path>
</g>
</svg>
</th>
</tr>
</thead>
<tbody id="services-list">
<tr id="add-row" class="hidden">
<td class="title-column">
<input type="text">
</td>
<td class="url-column">
<input type="text">
</td>
<td class="action-column">
<button data-type="save" title="Add" aria-label="Add">
<div class="overflow-container">
<table>
<thead>
<tr>
<th class="title-column">Title</th>
<th class="url-column">Apprise URL</th>
<th title="Actions" aria-label="Actions" class="action-column">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 24 24" style="enable-background:new 0 0 512 512" xml:space="preserve">
<g>
<path d="M12,10a4,4,0,1,0,4,4A4,4,0,0,0,12,10Zm0,6a2,2,0,1,1,2-2A2,2,0,0,1,12,16Z"></path><path d="M22.536,4.122,19.878,1.464A4.966,4.966,0,0,0,16.343,0H5A5.006,5.006,0,0,0,0,5V19a5.006,5.006,0,0,0,5,5H19a5.006,5.006,0,0,0,5-5V7.657A4.966,4.966,0,0,0,22.536,4.122ZM17,2.08V3a3,3,0,0,1-3,3H10A3,3,0,0,1,7,3V2h9.343A2.953,2.953,0,0,1,17,2.08ZM22,19a3,3,0,0,1-3,3H5a3,3,0,0,1-3-3V5A3,3,0,0,1,5,2V3a5.006,5.006,0,0,0,5,5h4a4.991,4.991,0,0,0,4.962-4.624l2.16,2.16A3.02,3.02,0,0,1,22,7.657Z"></path>
<path d="M11.24,24a2.262,2.262,0,0,1-.948-.212,2.18,2.18,0,0,1-1.2-2.622L10.653,16H6.975A3,3,0,0,1,4.1,12.131l3.024-10A2.983,2.983,0,0,1,10,0h3.693a2.6,2.6,0,0,1,2.433,3.511L14.443,8H17a3,3,0,0,1,2.483,4.684l-6.4,10.3A2.2,2.2,0,0,1,11.24,24ZM10,2a1,1,0,0,0-.958.71l-3.024,10A1,1,0,0,0,6.975,14H12a1,1,0,0,1,.957,1.29L11.01,21.732a.183.183,0,0,0,.121.241A.188.188,0,0,0,11.4,21.9l6.4-10.3a1,1,0,0,0,.078-1.063A.979.979,0,0,0,17,10H13a1,1,0,0,1-.937-1.351l2.19-5.84A.6.6,0,0,0,13.693,2Z"></path>
</g>
</svg>
</button>
</td>
</tr>
</tbody>
</table>
</th>
</tr>
</thead>
<tbody id="services-list">
<tr id="add-row" class="hidden">
<td class="title-column">
<input type="text">
</td>
<td class="url-column">
<input type="text">
</td>
<td class="action-column">
<button data-type="save" title="Add" aria-label="Add">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 24 24" style="enable-background:new 0 0 512 512" xml:space="preserve">
<g>
<path d="M12,10a4,4,0,1,0,4,4A4,4,0,0,0,12,10Zm0,6a2,2,0,1,1,2-2A2,2,0,0,1,12,16Z"></path><path d="M22.536,4.122,19.878,1.464A4.966,4.966,0,0,0,16.343,0H5A5.006,5.006,0,0,0,0,5V19a5.006,5.006,0,0,0,5,5H19a5.006,5.006,0,0,0,5-5V7.657A4.966,4.966,0,0,0,22.536,4.122ZM17,2.08V3a3,3,0,0,1-3,3H10A3,3,0,0,1,7,3V2h9.343A2.953,2.953,0,0,1,17,2.08ZM22,19a3,3,0,0,1-3,3H5a3,3,0,0,1-3-3V5A3,3,0,0,1,5,2V3a5.006,5.006,0,0,0,5,5h4a4.991,4.991,0,0,0,4.962-4.624l2.16,2.16A3.02,3.02,0,0,1,22,7.657Z"></path>
</g>
</svg>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="settings">
@@ -208,6 +294,35 @@
<button id="delete-account-button">Delete Account</button>
</div>
</div>
<div id="add-template">
<h2>Add a template</h2>
<div class="form-container">
<form id="template-form">
<input type="text" id="title-template-input" placeholder="Title" required>
<select id="notification-service-template-input" required></select>
<textarea id="text-template-input" cols="30" rows="10" placeholder="Text (optional)"></textarea>
<div class="options">
<button type="button" id="close-template">Cancel</button>
<button type="submit">Add</button>
</div>
</form>
</div>
</div>
<div id="edit-template">
<h2>Edit a template</h2>
<div class="form-container">
<form id="template-edit-form">
<input type="text" id="title-template-edit-input" placeholder="Title" required>
<select id="notification-service-template-edit-input" required></select>
<textarea id="text-template-edit-input" cols="30" rows="10" placeholder="Text (optional)"></textarea>
<div class="options">
<button type="button" id="close-edit-template">Cancel</button>
<button type="button" id="delete-template">Delete</button>
<button type="submit">Save</button>
</div>
</form>
</div>
</div>
</main>
</div>
</body>

View File

@@ -1,3 +1,4 @@
apprise>=0.9.9
python-dateutil>=2.8.2
Flask>=2.1.2
waitress>=2.1.2

20
tests/Noted_test.py Normal file
View File

@@ -0,0 +1,20 @@
import unittest
from flask import Flask
from frontend.api import api
from frontend.ui import ui
from Noted import _create_app
class Test_Noted(unittest.TestCase):
def test_create_app(self):
result = _create_app()
self.assertIsInstance(result, Flask)
self.assertEqual(result.blueprints.get('ui'), ui)
self.assertEqual(result.blueprints.get('api'), api)
handlers = result.error_handler_spec[None].keys()
required_handlers = 404, 400, 405, 500
for handler in required_handlers:
self.assertIn(handler, handlers)

62
tests/api_test.py Normal file
View File

@@ -0,0 +1,62 @@
import unittest
from flask import Blueprint
from backend.custom_exceptions import *
from frontend.api import api, auth, error_handler, extract_key, return_api
class Test_API(unittest.TestCase):
def test_blueprint(self):
self.assertIsInstance(api, Blueprint)
def test_return_api(self):
for case in ({'result': {}, 'error': 'Error', 'code': 201},
{'result': ''}):
result = return_api(**case)
self.assertEqual(result[0]['result'], case['result'])
if case.get('error'):
self.assertEqual(result[0]['error'], case['error'])
else:
self.assertIsNone(result[0]['error'])
if case.get('code'):
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

@@ -0,0 +1,18 @@
import unittest
from inspect import getmembers, getmro, isclass
from sys import modules
from typing import List
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))
for defined_exception in defined_exceptions:
self.assertEqual(getmro(defined_exception)[1], Exception)
result = defined_exception().api_response
self.assertIsInstance(result, dict)
result['error']
result['result']
result['code']
self.assertIsInstance(result['code'], int)

12
tests/db_test.py Normal file
View File

@@ -0,0 +1,12 @@
import unittest
from backend.db import DBConnection
from Noted import DB_FILENAME
class Test_DB(unittest.TestCase):
def test_foreign_key(self):
DBConnection.file = DB_FILENAME
instance = DBConnection(timeout=20.0)
self.assertEqual(instance.cursor().execute("PRAGMA foreign_keys;").fetchone()[0], 1)

29
tests/reminders_test.py Normal file
View File

@@ -0,0 +1,29 @@
import unittest
from threading import Thread
from backend.reminders import filter_function, ReminderHandler
class Test_Reminder_Handler(unittest.TestCase):
def test_starting_stopping(self):
context = 'test'
instance = ReminderHandler(context)
self.assertIs(context, instance.context)
self.assertIsInstance(instance.thread, Thread)
self.assertFalse(instance.stop)
with self.assertRaises(RuntimeError):
instance.stop_handling()
self.assertTrue(instance.stop)
def test_filter_function(self):
p = {
'title': 'TITLE',
'text': 'TEXT',
'notification_service_title': 'NOTIFICATION_SERVICE_TITLE'
}
for test_case in ('', 'title', 'service', 'ex'):
self.assertTrue(filter_function(test_case, p))
for test_case in (' ', 'Hello'):
self.assertFalse(filter_function(test_case, p))

10
tests/security_test.py Normal file
View File

@@ -0,0 +1,10 @@
import unittest
from backend.security import generate_salt_hash, get_hash
class Test_Security(unittest.TestCase):
def test_hash(self):
for test_case in ('test', ''):
result = generate_salt_hash(test_case)
self.assertEqual(result[1], get_hash(result[0], test_case))

20
tests/ui_test.py Normal file
View File

@@ -0,0 +1,20 @@
import unittest
from flask import Blueprint, Flask
from frontend.ui import methods, ui
class Test_UI(unittest.TestCase):
def test_methods(self):
self.assertEqual(len(methods), 1)
self.assertEqual(methods[0], 'GET')
def test_blueprint(self):
self.assertIsInstance(ui, Blueprint)
def test_route_methods(self):
temp_app = Flask(__name__)
temp_app.register_blueprint(ui)
for rule in temp_app.url_map.iter_rules():
self.assertEqual(len(rule.methods), 3)
self.assertIn(methods[0], rule.methods)

14
tests/users_test.py Normal file
View File

@@ -0,0 +1,14 @@
import unittest
from backend.custom_exceptions import UsernameInvalid
from backend.users import ONEPASS_INVALID_USERNAMES, _check_username
class Test_Users(unittest.TestCase):
def test_username_check(self):
for test_case in ('', 'test'):
_check_username(test_case)
for test_case in (' ', ' ', '0', 'api', *ONEPASS_INVALID_USERNAMES):
with self.assertRaises(UsernameInvalid):
_check_username(test_case)