Merge pull request #12 from Casvt:Casvt/issue8

Fixes issue #8
This commit is contained in:
Casvt
2023-01-19 21:31:54 +01:00
committed by GitHub
16 changed files with 679 additions and 52 deletions

View File

@@ -28,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"""
@@ -58,3 +64,7 @@ class InvalidKeyValue(Exception):
@property
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

@@ -105,6 +105,16 @@ def setup_db() -> None:
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

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 = ?",

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,6 +6,7 @@ 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 = ['reminders','api']
@@ -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

View File

@@ -15,6 +15,7 @@ from backend.custom_exceptions import (AccessUnauthorized, InvalidKeyValue,
from backend.notification_service import (NotificationService,
NotificationServices)
from backend.reminders import Reminders, reminder_handler
from backend.templates import Template, Templates
from backend.users import User, register_user
api = Blueprint('api', __name__)
@@ -451,7 +452,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):
@@ -515,3 +516,108 @@ def api_get_reminder(r_id: int):
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

@@ -40,6 +40,7 @@
gap: .75rem;
border: 2px solid var(--color-gray);
box-shadow: var(--default-shadow);
font-size: 1.1rem;
}
@@ -62,6 +63,7 @@
border: 2px solid var(--color-gray);
border-radius: 4px;
box-shadow: var(--default-shadow);
}
.repeat-bar > *,

View File

@@ -264,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

@@ -46,7 +46,8 @@
}
/* REMINDER LIST */
#reminder-list {
#reminder-list,
#template-list {
--gap: 1rem;
--entry-width: 13rem;
max-width: 43rem;
@@ -60,13 +61,15 @@
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;
}
@@ -95,7 +98,8 @@
background-color: var(--color-gray);
}
div.entry.fit {
div.entry.fit,
button.entry.fit {
flex-grow: 1;
}
@@ -160,6 +164,11 @@ div.entry.fit {
opacity: 1;
}
#delete-template {
border-color: var(--color-error);
color: var(--color-error);
}
@media (max-width: 543px) {
header > div {
transform: translateX(0);

View File

@@ -52,6 +52,7 @@ function addReminder() {
function showAdd() {
if (!document.getElementById('add-entry').classList.contains('error')) {
loadTemplates(force=false);
showWindow('add');
} else {
showWindow('notification');
@@ -61,6 +62,7 @@ function showAdd() {
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;
@@ -89,6 +91,7 @@ function toggleRepeated() {
// 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

@@ -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

@@ -134,3 +134,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,49 +75,71 @@
</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>
@@ -270,6 +293,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

@@ -40,9 +40,9 @@ class Test_API(unittest.TestCase):
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(NotificationServiceInUse), return_api(**NotificationServiceInUse.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):