diff --git a/backend/custom_exceptions.py b/backend/custom_exceptions.py index 49578ab..9ddad62 100644 --- a/backend/custom_exceptions.py +++ b/backend/custom_exceptions.py @@ -57,4 +57,4 @@ class InvalidKeyValue(Exception): @property def api_response(self) -> Dict[str, Any]: - return {'error': 'KeyNotFound', 'result': {'key': self.key, 'value': self.value}, 'code': 400} + return {'error': 'InvalidKeyValue', 'result': {'key': self.key, 'value': self.value}, 'code': 400} diff --git a/backend/db.py b/backend/db.py index c47319d..be316e7 100644 --- a/backend/db.py +++ b/backend/db.py @@ -85,6 +85,10 @@ 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) diff --git a/backend/reminders.py b/backend/reminders.py index 00dfeb7..3f999f8 100644 --- a/backend/reminders.py +++ b/backend/reminders.py @@ -1,5 +1,6 @@ #-*- coding: utf-8 -*- +from datetime import datetime from sqlite3 import IntegrityError from threading import Thread from time import sleep @@ -7,9 +8,10 @@ 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 @@ -20,6 +22,18 @@ filter_function = lambda query, p: ( 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 """ @@ -33,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;" @@ -65,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() @@ -122,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 @@ -140,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 @@ -151,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 @@ -164,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() @@ -230,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 @@ -278,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 @@ -287,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) diff --git a/frontend/api.py b/frontend/api.py index a4e8a8b..2087975 100644 --- a/frontend/api.py +++ b/frontend/api.py @@ -82,10 +82,22 @@ 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) else: if key == 'sort_by': @@ -405,11 +417,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']) @@ -484,11 +500,16 @@ 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': diff --git a/frontend/static/css/add_edit.css b/frontend/static/css/add_edit.css index 79c8faf..861e828 100644 --- a/frontend/static/css/add_edit.css +++ b/frontend/static/css/add_edit.css @@ -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,69 @@ } .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); + + 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; +} + +.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); diff --git a/frontend/static/js/add.js b/frontend/static/js/add.js index 8239a7e..238e260 100644 --- a/frontend/static/js/add.js +++ b/frontend/static/js/add.js @@ -5,6 +5,15 @@ 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() { const data = { 'title': inputs.title.value, @@ -12,6 +21,11 @@ function addReminder() { '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'}, @@ -50,11 +64,31 @@ function closeAdd() { 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('normal-button').addEventListener('click', e => toggleNormal()); +document.getElementById('repeat-button').addEventListener('click', e => toggleRepeated()); document.getElementById('close-add').addEventListener('click', e => closeAdd()); diff --git a/frontend/static/js/edit.js b/frontend/static/js/edit.js index e84d465..60095e4 100644 --- a/frontend/static/js/edit.js +++ b/frontend/static/js/edit.js @@ -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,26 @@ 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', ''); +}; + // 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()); diff --git a/frontend/static/js/reminders.js b/frontend/static/js/reminders.js index 8d8e0a9..8a82012 100644 --- a/frontend/static/js/reminders.js +++ b/frontend/static/js/reminders.js @@ -14,6 +14,16 @@ 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); @@ -119,7 +129,7 @@ function deleteReminder(id) { // code run on load fillList(); -// setInterval(fillList, 60000); +setInterval(fillList, 60000); document.getElementById('search-form').setAttribute('action', 'javascript:search();'); document.getElementById('clear-button').addEventListener('click', e => clearSearch()); diff --git a/frontend/templates/reminders.html b/frontend/templates/reminders.html index ee1131e..85b0a7d 100644 --- a/frontend/templates/reminders.html +++ b/frontend/templates/reminders.html @@ -122,6 +122,36 @@ +
+ + +
+
@@ -139,6 +169,36 @@
+
+ + +
+
+

Repeat every

+ + +
diff --git a/requirements.txt b/requirements.txt index 755b576..262de54 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ apprise>=0.9.9 +python-dateutil>=2.8.2 Flask>=2.1.2 waitress>=2.1.2 \ No newline at end of file