summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDavid T. Sadler <davidtsadler@googlemail.com>2021-11-13 09:22:25 +0000
committerDavid T. Sadler <davidtsadler@googlemail.com>2021-11-13 09:22:25 +0000
commit40997195b7ee07cb1bda978186c1804371e1f16e (patch)
tree17a6e4bd6eaca795cfa8d57b9aa6ea8ad7066593
parent35dc6c245d93560d7ac81df6323e8b22e571aa95 (diff)
Create site
-rw-r--r--autoload.php16
-rw-r--r--config.php8
-rw-r--r--deploy.php39
-rw-r--r--includes/functions.php15
-rw-r--r--public/create/index.php37
-rw-r--r--public/css/site.css121
-rw-r--r--public/delete/confirm/index.php32
-rw-r--r--public/delete/index.php37
-rw-r--r--public/edit/index.php45
-rw-r--r--public/fonts/ComicMono-Bold.ttfbin0 -> 20516 bytes
-rw-r--r--public/fonts/ComicMono.ttfbin0 -> 18724 bytes
-rw-r--r--public/images/favicon.pngbin0 -> 190 bytes
-rw-r--r--public/index.php38
-rw-r--r--public/robots.txt2
-rw-r--r--public/store/index.php52
-rw-r--r--public/update/index.php56
-rw-r--r--src/DTS/Errors.php30
-rw-r--r--src/DTS/Functions.php23
-rw-r--r--src/DTS/Old.php29
-rw-r--r--src/DTS/Session.php41
-rw-r--r--src/DTS/Template.php32
-rw-r--r--src/DTS/Todo.php16
-rw-r--r--src/DTS/TodoRepository.php162
-rw-r--r--src/DTS/Validated.php12
-rw-r--r--src/DTS/Validator.php59
-rw-r--r--src/templates/confirm_deletion.php23
-rw-r--r--src/templates/create.php22
-rw-r--r--src/templates/edit.php23
-rw-r--r--src/templates/form_fields.php8
-rw-r--r--src/templates/index.php35
30 files changed, 1013 insertions, 0 deletions
diff --git a/autoload.php b/autoload.php
new file mode 100644
index 0000000..e0f60ef
--- /dev/null
+++ b/autoload.php
@@ -0,0 +1,16 @@
+<?php
+
+declare(strict_types=1);
+
+$baseDir = __DIR__ . '/src/';
+
+spl_autoload_register(function (string $class) use ($baseDir)
+{
+ $file = $baseDir . str_replace('\\', '/', $class) . '.php';
+
+ if (file_exists($file)) {
+ require $file;
+ }
+});
+
+require "$baseDir/DTS/Functions.php";
diff --git a/config.php b/config.php
new file mode 100644
index 0000000..8260093
--- /dev/null
+++ b/config.php
@@ -0,0 +1,8 @@
+<?php
+
+error_reporting(E_ALL);
+
+return [
+ 'path_to_repository' => '/home/david/projects/temp/todo',
+ 'path_to_templates' => '/home/david/projects/todo.davidtsadler.com/src/templates/',
+];
diff --git a/deploy.php b/deploy.php
new file mode 100644
index 0000000..270e2ca
--- /dev/null
+++ b/deploy.php
@@ -0,0 +1,39 @@
+<?php
+namespace Deployer;
+
+require 'recipe/common.php';
+
+set('application', 'bookmarks.davidtsadler.com');
+
+set('repository', 'git@git.davidtsadler.com:bookmarks.davidtsadler.com.git');
+
+set('shared_files', [
+ 'config.php',
+ 'urls',
+]);
+
+set('shared_dirs', []);
+
+set('writable_dirs', []);
+
+set('allow_anonymous_stats', false);
+
+host('davidtsadler.com')
+ ->set('deploy_path', '/var/www/{{application}}');
+
+task('deploy', [
+ 'deploy:info',
+ 'deploy:prepare',
+ 'deploy:lock',
+ 'deploy:release',
+ 'deploy:update_code',
+ 'deploy:shared',
+ 'deploy:writable',
+ 'deploy:clear_paths',
+ 'deploy:symlink',
+ 'deploy:unlock',
+ 'cleanup',
+ 'success'
+]);
+
+after('deploy:failed', 'deploy:unlock');
diff --git a/includes/functions.php b/includes/functions.php
new file mode 100644
index 0000000..7146795
--- /dev/null
+++ b/includes/functions.php
@@ -0,0 +1,15 @@
+<?php declare(strict_types=1);
+
+function respondAndExit(int $responseCode, string $header, array $headers = []): void
+{
+ header($header, false, $responseCode);
+
+ foreach ($headers as $header) {
+ header($header);
+ }
+
+ header("Access-Control-Allow-Origin: *");
+ header('Content-Type: text/plain; charset=UTF-8');
+
+ exit();
+}
diff --git a/public/create/index.php b/public/create/index.php
new file mode 100644
index 0000000..8256ff5
--- /dev/null
+++ b/public/create/index.php
@@ -0,0 +1,37 @@
+<?php
+
+declare(strict_types=1);
+
+use DTS\Todo;
+use DTS\Errors;
+use DTS\Old;
+use DTS\Session;
+use DTS\Template;
+
+use function DTS\Functions\respondAndExit;
+
+require_once(__DIR__.'/../../autoload.php');
+
+$config = require_once(__DIR__.'/../../config.php');
+
+$session = Session::getInstance();
+
+if (filter_input(INPUT_SERVER, 'REQUEST_METHOD') !== 'GET') {
+ respondAndExit(405, 'Method Not Allowed');
+}
+
+$old = $session->get('old', new Old());
+
+$errors = $session->get('errors', new Errors());
+
+$template = new Template($config['path_to_templates']);
+
+$todo = new Todo();
+
+$html = $template->render('create', compact(
+ 'todo',
+ 'errors',
+ 'old'
+));
+
+respondAndExit(200, 'OK', $html);
diff --git a/public/css/site.css b/public/css/site.css
new file mode 100644
index 0000000..c1010a2
--- /dev/null
+++ b/public/css/site.css
@@ -0,0 +1,121 @@
+/*! modern-normalize v1.1.0 | MIT License | https://github.com/sindresorhus/modern-normalize */
+*,::after,::before{box-sizing:border-box}html{-moz-tab-size:4;tab-size:4}html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}body{font-family:system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji'}hr{height:0;color:inherit}abbr[title]{text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}::-moz-focus-inner{border-style:none;padding:0}:-moz-focusring{outline:1px dotted ButtonText}:-moz-ui-invalid{box-shadow:none}legend{padding:0}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}
+.hljs{display:block;overflow-x:auto;padding:.5em;background:#f0f0f0}.hljs,.hljs-subst{color:#444}.hljs-comment{color:#888}.hljs-attribute,.hljs-doctag,.hljs-keyword,.hljs-meta-keyword,.hljs-name,.hljs-selector-tag{font-weight:700}.hljs-deletion,.hljs-number,.hljs-quote,.hljs-selector-class,.hljs-selector-id,.hljs-string,.hljs-template-tag,.hljs-type{color:#800}.hljs-section,.hljs-title{color:#800;font-weight:700}.hljs-link,.hljs-regexp,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-symbol,.hljs-template-variable,.hljs-variable{color:#bc6060}.hljs-literal{color:#78a960}.hljs-addition,.hljs-built_in,.hljs-bullet,.hljs-code{color:#397300}.hljs-meta{color:#1f7199}.hljs-meta-string{color:#4d99bf}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}
+
+@font-face {
+ font-family: 'comicsans';
+ src: url('/fonts/ComicMono.ttf') format('truetype'),
+ url("/fonts/ComicMono.ttf") format("truetype");
+}
+
+body {
+ background-color: #1a202c;
+ font-family: "comicsans", monospace;
+ color: #ffffff;
+ font-size: 1.125rem;
+ line-height: 1.75rem;
+}
+
+h1,
+h2,
+label {
+ font-weight: bold;
+ font-size: 1.125rem;
+}
+
+h1 {
+ color: #BEF264;
+ text-align: center;
+ width: 100%;
+}
+
+h2 {
+ color: #FCD34D;
+}
+
+h2:before {
+ content: "## ";
+}
+
+section {
+ padding: 1rem;
+}
+
+a {
+ color: #93C5FD;
+ text-decoration: none;
+}
+
+a:hover {
+ color: #3730A3;
+}
+
+a:before {
+ content: "=> ";
+}
+
+a.no-decoration:before {
+ content: "";
+}
+
+ul,
+ol {
+ list-style-type: none;
+ padding: 0;
+}
+
+label {
+ display: block;
+ color: #86EFAC;
+ margin-top: 1rem;
+}
+
+input,
+button {
+ border-radius: .25rem;
+ display: block;
+ width: 100%;
+}
+
+input {
+ border: 2px solid #FFFFFF;
+ padding: .375rem .75rem;
+}
+
+button {
+ background-color: #86EFAC;
+ border: 1px solid transparent;
+ cursor: pointer;
+ font-weight: 400;
+ line-height: 1.5;
+ margin-top: 1rem;
+ padding: .375rem .75rem;
+ text-align: center;
+ vertical-align: middle;
+}
+
+input:focus-visible,
+button:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 0.25rem rgb(134 239 172 / 25%);
+}
+
+.message {
+ background-color: #93C5FD;
+ border-radius: .25rem;
+ border: 1px solid transparent;
+ color: #000000;
+ padding: .375rem .75rem;
+ text-align: center;
+}
+
+.tag {
+ color: #86EFAC;
+}
+
+@media (min-width: 1536px) {
+ section {
+ width: 80%;
+ margin: auto;
+ }
+}
diff --git a/public/delete/confirm/index.php b/public/delete/confirm/index.php
new file mode 100644
index 0000000..9903f14
--- /dev/null
+++ b/public/delete/confirm/index.php
@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+use DTS\TodoRepository;
+use DTS\Template;
+
+use function DTS\Functions\respondAndExit;
+
+require_once(__DIR__.'/../../../autoload.php');
+
+$config = require_once(__DIR__.'/../../../config.php');
+
+if (filter_input(INPUT_SERVER, 'REQUEST_METHOD') !== 'GET') {
+ respondAndExit(405, 'Method Not Allowed');
+}
+
+$id = filter_input(INPUT_GET, 'id');
+
+$todos = new TodoRepository($config['path_to_repository']);
+
+$template = new Template($config['path_to_templates']);
+
+$todo = $todos->find($id);
+
+if ($todo === null) {
+ respondAndExit(404, 'Not Found');
+}
+
+$html = $template->render('confirm_deletion', compact('todo'));
+
+respondAndExit(200, 'OK', $html);
diff --git a/public/delete/index.php b/public/delete/index.php
new file mode 100644
index 0000000..a19f568
--- /dev/null
+++ b/public/delete/index.php
@@ -0,0 +1,37 @@
+<?php
+
+declare(strict_types=1);
+
+use DTS\TodoRepository;
+use DTS\Session;
+
+use function DTS\Functions\respondAndExit;
+use function DTS\Functions\redirectAndExit;
+
+require_once(__DIR__.'/../../autoload.php');
+
+$config = require_once(__DIR__.'/../../config.php');
+
+$session = Session::getInstance();
+
+if (filter_input(INPUT_SERVER, 'REQUEST_METHOD') !== 'POST') {
+ respondAndExit(405, 'Method Not Allowed');
+}
+
+$id = filter_input(INPUT_POST, 'id');
+
+$todos = new TodoRepository($config['path_to_repository']);
+
+$todo = $todos->find($id);
+
+if ($todo === null) {
+ respondAndExit(404, 'Not Found');
+}
+
+if (!$todos->delete($todo)) {
+ respondAndExit(500, 'Internal Server Error');
+}
+
+$session->set('message', 'Todo Deleted');
+
+redirectAndExit('/');
diff --git a/public/edit/index.php b/public/edit/index.php
new file mode 100644
index 0000000..81aa121
--- /dev/null
+++ b/public/edit/index.php
@@ -0,0 +1,45 @@
+<?php
+
+declare(strict_types=1);
+
+use DTS\TodoRepository;
+use DTS\Errors;
+use DTS\Old;
+use DTS\Session;
+use DTS\Template;
+
+use function DTS\Functions\respondAndExit;
+
+require_once(__DIR__.'/../../autoload.php');
+
+$config = require_once(__DIR__.'/../../config.php');
+
+$session = Session::getInstance();
+
+if (filter_input(INPUT_SERVER, 'REQUEST_METHOD') !== 'GET') {
+ respondAndExit(405, 'Method Not Allowed');
+}
+
+$old = $session->get('old', new Old());
+
+$errors = $session->get('errors', new Errors());
+
+$id = filter_input(INPUT_GET, 'id');
+
+$todos = new TodoRepository($config['path_to_repository']);
+
+$template = new Template($config['path_to_templates']);
+
+$todo = $todos->find($id);
+
+if ($todo === null) {
+ respondAndExit(404, 'Not Found');
+}
+
+$html = $template->render('edit', compact(
+ 'todo',
+ 'errors',
+ 'old'
+));
+
+respondAndExit(200, 'OK', $html);
diff --git a/public/fonts/ComicMono-Bold.ttf b/public/fonts/ComicMono-Bold.ttf
new file mode 100644
index 0000000..e03f41e
--- /dev/null
+++ b/public/fonts/ComicMono-Bold.ttf
Binary files differ
diff --git a/public/fonts/ComicMono.ttf b/public/fonts/ComicMono.ttf
new file mode 100644
index 0000000..9bc7354
--- /dev/null
+++ b/public/fonts/ComicMono.ttf
Binary files differ
diff --git a/public/images/favicon.png b/public/images/favicon.png
new file mode 100644
index 0000000..4909792
--- /dev/null
+++ b/public/images/favicon.png
Binary files differ
diff --git a/public/index.php b/public/index.php
new file mode 100644
index 0000000..4e18b9a
--- /dev/null
+++ b/public/index.php
@@ -0,0 +1,38 @@
+<?php
+
+declare(strict_types=1);
+
+use DTS\TodoRepository;
+use DTS\Session;
+use DTS\Template;
+
+use function DTS\Functions\respondAndExit;
+
+require_once(__DIR__.'/../autoload.php');
+
+$config = require_once(__DIR__.'/../config.php');
+
+$session = Session::getInstance();
+
+$todos = new TodoRepository($config['path_to_repository']);
+
+$template = new Template($config['path_to_templates']);
+
+$sort = $_GET['sort'] ?? 'asc';
+
+$tag = $_GET['tag'] ?? null;
+
+if ($tag !== null) {
+ $todos->filter($tag);
+}
+
+$todos->sort($sort === 'asc');
+
+$message = $session->get('message');
+
+$html = $template->render('index', compact(
+ 'todos',
+ 'message'
+));
+
+respondAndExit(200, 'OK', $html);
diff --git a/public/robots.txt b/public/robots.txt
new file mode 100644
index 0000000..1f53798
--- /dev/null
+++ b/public/robots.txt
@@ -0,0 +1,2 @@
+User-agent: *
+Disallow: /
diff --git a/public/store/index.php b/public/store/index.php
new file mode 100644
index 0000000..c4d24fa
--- /dev/null
+++ b/public/store/index.php
@@ -0,0 +1,52 @@
+<?php
+
+declare(strict_types=1);
+
+use DTS\Todo;
+use DTS\TodoRepository;
+use DTS\Old;
+use DTS\Session;
+use DTS\Validator;
+
+use function DTS\Functions\redirectAndExit;
+use function DTS\Functions\respondAndExit;
+
+require_once(__DIR__.'/../../autoload.php');
+
+$config = require_once(__DIR__.'/../../config.php');
+
+$session = Session::getInstance();
+
+if (filter_input(INPUT_SERVER, 'REQUEST_METHOD') !== 'POST') {
+ respondAndExit(405, 'Method Not Allowed');
+}
+
+$old = new Old($_REQUEST);
+
+$session->set('old', $old);
+
+$validator = new Validator($_REQUEST);
+
+if ($validator->errors->count()) {
+ $session->set('errors', $validator->errors);
+
+ redirectAndExit('/create');
+}
+
+$validated = $validator->validated;
+
+$todos = new TodoRepository($config['path_to_repository']);
+
+$todo = new Todo();
+
+$todo->task = $validated->task;
+$todo->tag = $validated->tag;
+$todo->addedAt = date('Y-m-d H:i:s');
+
+if (!$todos->add($todo)) {
+ respondAndExit(500, 'Internal Server Error');
+}
+
+$session->set('message', 'Todo Added');
+
+redirectAndExit('/');
diff --git a/public/update/index.php b/public/update/index.php
new file mode 100644
index 0000000..82b3c3f
--- /dev/null
+++ b/public/update/index.php
@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+use DTS\TodoRepository;
+use DTS\Old;
+use DTS\Session;
+use DTS\Validator;
+
+use function DTS\Functions\respondAndExit;
+use function DTS\Functions\redirectAndExit;
+
+require_once(__DIR__.'/../../autoload.php');
+
+$config = require_once(__DIR__.'/../../config.php');
+
+$session = Session::getInstance();
+
+if (filter_input(INPUT_SERVER, 'REQUEST_METHOD') !== 'POST') {
+ respondAndExit(405, 'Method Not Allowed');
+}
+
+$id = filter_input(INPUT_POST, 'id');
+
+$todos = new TodoRepository($config['path_to_repository']);
+
+$todo = $todos->find($id);
+
+if ($todo === null) {
+ respondAndExit(404, 'Not Found');
+}
+
+$old = new Old($_REQUEST);
+
+$session->set('old', $old);
+
+$validator = new Validator($_REQUEST);
+
+if ($validator->errors->count()) {
+ $session->set('errors', $validator->errors);
+
+ redirectAndExit("/edit?id=$todo->id");
+}
+
+$validated = $validator->validated;
+
+$todo->task = $validated->task;
+$todo->tag = $validated->tag;
+
+if (!$todos->update($todo)) {
+ respondAndExit(500, 'Internal Server Error');
+}
+
+$session->set('message', 'Todo Updated');
+
+redirectAndExit('/');
diff --git a/src/DTS/Errors.php b/src/DTS/Errors.php
new file mode 100644
index 0000000..3c1dd5d
--- /dev/null
+++ b/src/DTS/Errors.php
@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+namespace DTS;
+
+class Errors
+{
+ private $errors = [];
+
+ public function add(string $key, string $value): void
+ {
+ $this->errors[$key][] = $value;
+ }
+
+ public function get(string $key): array
+ {
+ return $this->errors[$key];
+ }
+
+ public function has(string $key): bool
+ {
+ return array_key_exists($key, $this->errors);
+ }
+
+ public function count(): int
+ {
+ return count($this->errors);
+ }
+}
diff --git a/src/DTS/Functions.php b/src/DTS/Functions.php
new file mode 100644
index 0000000..2101cda
--- /dev/null
+++ b/src/DTS/Functions.php
@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+namespace DTS\Functions;
+
+function respondAndExit(int $responseCode, string $header, string $body = '', array $headers = []): void
+{
+ header($header, false, $responseCode);
+
+ foreach ($headers as $header) {
+ header($header);
+ }
+
+ echo $body;
+
+ exit();
+}
+
+function redirectAndExit(string $location): void
+{
+ respondAndExit(302, 'Found', '', ["Location: $location"]);
+}
diff --git a/src/DTS/Old.php b/src/DTS/Old.php
new file mode 100644
index 0000000..0641329
--- /dev/null
+++ b/src/DTS/Old.php
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace DTS;
+
+class Old
+{
+ const FIELDS = [
+ 'task',
+ 'tag',
+ ];
+
+ private array $old = [];
+
+ function __construct(array $request = [])
+ {
+ foreach(self::FIELDS as $field) {
+ if (array_key_exists($field, $request)) {
+ $this->old[$field] = $request[$field];
+ }
+ }
+ }
+
+ public function get(string $key, $default = null)
+ {
+ return $this->old[$key] ?? $default;
+ }
+}
diff --git a/src/DTS/Session.php b/src/DTS/Session.php
new file mode 100644
index 0000000..35be1fe
--- /dev/null
+++ b/src/DTS/Session.php
@@ -0,0 +1,41 @@
+<?php
+
+declare(strict_types=1);
+
+namespace DTS;
+
+class Session
+{
+ private static ?self $instance = null;
+
+ private array $session = [];
+
+ public static function getInstance()
+ {
+ if (!self::$instance) {
+ self::$instance = new self();
+ }
+
+ return self::$instance;
+ }
+
+ private function __construct()
+ {
+ session_start();
+
+ foreach ($_SESSION as $key => $value) {
+ $this->session[$key] = $value;
+ unset($_SESSION[$key]);
+ }
+ }
+
+ public function set(string $key, $value): void
+ {
+ $this->session[$key] = $_SESSION[$key] = $value;
+ }
+
+ public function get(string $key, $default = null)
+ {
+ return $this->session[$key] ?? $default;
+ }
+}
diff --git a/src/DTS/Template.php b/src/DTS/Template.php
new file mode 100644
index 0000000..89bd7f8
--- /dev/null
+++ b/src/DTS/Template.php
@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+namespace DTS;
+
+class Template
+{
+ private string $path;
+
+ function __construct(string $path)
+ {
+ $this->path = $path;
+ }
+
+ public function render(string $template, array $data = []): string
+ {
+ $file = "{$this->path}$template.php";
+
+ if (!is_file($file) || !is_readable($file)) {
+ throw new \Exception("Unable to locate template $file");
+ }
+
+ extract($data);
+
+ ob_start();
+
+ require_once($file);
+
+ return ob_get_clean();
+ }
+}
diff --git a/src/DTS/Todo.php b/src/DTS/Todo.php
new file mode 100644
index 0000000..9b08e56
--- /dev/null
+++ b/src/DTS/Todo.php
@@ -0,0 +1,16 @@
+<?php
+
+declare(strict_types=1);
+
+namespace DTS;
+
+class Todo
+{
+ public int $id;
+
+ public string $task = '';
+
+ public string $tag = '';
+
+ public string $addedAt;
+}
diff --git a/src/DTS/TodoRepository.php b/src/DTS/TodoRepository.php
new file mode 100644
index 0000000..5ec759f
--- /dev/null
+++ b/src/DTS/TodoRepository.php
@@ -0,0 +1,162 @@
+<?php
+
+declare(strict_types=1);
+
+namespace DTS;
+
+use DTS\Todo;
+
+class TodoRepository implements \Iterator
+{
+ private string $pathToRepository;
+
+ private array $repository = [];
+
+ private array $tags = [];
+
+ private int $position = 0;
+
+ function __construct(string $pathToRepository)
+ {
+ $this->pathToRepository = $pathToRepository;
+
+ $this->load();
+ }
+
+ public function sort(bool $asc = true): TodoRepository
+ {
+ usort($this->repository, function ($a, $b) use ($asc) {
+ return $asc ? $a->task <=> $b->task
+ : $b->task <=> $a->task;
+ });
+
+ return $this;
+ }
+
+ public function filter(string $tag): TodoRepository
+ {
+ $this->repository = array_filter(
+ $this->repository,
+ fn($todo) => $todo->tag === $tag
+ );
+
+ return $this;
+ }
+
+ public function find(string $id): ?Todo
+ {
+ return $this->repository[$id] ?? null;
+ }
+
+ public function add(Todo $todo): bool
+ {
+ $this->repository[] = $todo;
+
+ return $this->save();
+ }
+
+ public function update(Todo $todo): bool
+ {
+ if (array_key_exists($todo->id, $this->repository)) {
+ $this->repository[$todo->id] = $todo;
+
+ return $this->save();
+ }
+
+ return false;
+ }
+
+ public function delete(Todo $todo): bool
+ {
+ if (array_key_exists($todo->id, $this->repository)) {
+ unset($this->repository[$todo->id]);
+
+ return $this->save();
+ }
+
+ return false;
+ }
+
+ public function tags(): array
+ {
+ return $this->tags;
+ }
+
+ public function current()
+ {
+ return $this->repository[$this->position];
+ }
+
+ public function key()
+ {
+ return $this->position;
+ }
+
+ public function next(): void
+ {
+ ++$this->position;
+ }
+
+ public function rewind(): void
+ {
+ $this->position = 0;
+ }
+
+ public function valid(): bool
+ {
+ return isset($this->repository[$this->position]);
+ }
+
+ private function load(): void
+ {
+ if (!is_file($this->pathToRepository) || !is_readable($this->pathToRepository)) {
+ throw new \Exception("Unable to locate repository {$this->pathToRepository}");
+ }
+
+ if (($fp = fopen($this->pathToRepository, 'r')) === FALSE) {
+ throw new \Exception("Unable to read from repository {$this->pathToRepository}");
+ }
+
+ $id = 0;
+
+ while (($data = fgetcsv($fp)) !== FALSE) {
+ $todo = new Todo();
+
+ $todo->id = $id++;
+ $todo->task = $data[0];
+ $todo->tag = $data[1];
+ $todo->addedAt = $data[2];
+
+ $this->repository[] = $todo;
+
+ $this->tags[] = $todo->tag;
+ }
+
+ $this->tags = array_filter(array_unique($this->tags));
+
+ sort($this->tags);
+
+ fclose($fp);
+ }
+
+ private function save(): bool
+ {
+ if (($fp = fopen($this->pathToRepository, 'w')) === FALSE) {
+ throw new \Exception("Unable to open repository {$this->pathToRepository}");
+ }
+
+ $success = true;
+
+ foreach ($this->repository as $todo) {
+ $success = $success && fputcsv($fp, [
+ $todo->task,
+ $todo->tag,
+ $todo->addedAt,
+ ]) !== false;
+ }
+
+ fclose($fp);
+
+ return $success;
+ }
+}
diff --git a/src/DTS/Validated.php b/src/DTS/Validated.php
new file mode 100644
index 0000000..9610538
--- /dev/null
+++ b/src/DTS/Validated.php
@@ -0,0 +1,12 @@
+<?php
+
+declare(strict_types=1);
+
+namespace DTS;
+
+class Validated
+{
+ public string $task = '';
+
+ public string $tag = '';
+}
diff --git a/src/DTS/Validator.php b/src/DTS/Validator.php
new file mode 100644
index 0000000..2f9c255
--- /dev/null
+++ b/src/DTS/Validator.php
@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+namespace DTS;
+
+use DTS\Errors;
+use DTS\Validated;
+
+class Validator
+{
+ public Errors $errors;
+
+ public Validated $validated;
+
+ function __construct(array $request)
+ {
+ $this->errors = new Errors();
+
+ $this->validated = new Validated();
+
+ $this->validateTask($request['task'], 2, 256);
+
+ $this->validateTag($request['tag'], 2, 16);
+ }
+
+ private function validateTask(string $task, int $minLength, int $maxLength): void
+ {
+ $task = trim($task);
+
+ if (strlen($task) < $minLength || strlen($task) > $maxLength) {
+ $this->errors->add('task', "Must be between $minLength and $maxLength in characters in length");
+ }
+
+ if (!$this->errors->has('tite')) {
+ $this->validated->task = $task;
+ }
+ }
+
+ private function validateTag(string $tag, int $minLength, int $maxLength): void
+ {
+ $tag = trim($tag);
+
+ if ($tag === '') {
+ return;
+ }
+
+ if (strlen($tag) < $minLength || strlen($tag) > $maxLength) {
+ $this->errors->add('tag', "Must be between $minLength and $maxLength in characters in length");
+ }
+ if (preg_match('/\W/', $tag) === 1) {
+ $this->errors->add('tag', 'May only contain word characters');
+ }
+
+ if (!$this->errors->has('tag')) {
+ $this->validated->tag = strtolower($tag);
+ }
+ }
+}
diff --git a/src/templates/confirm_deletion.php b/src/templates/confirm_deletion.php
new file mode 100644
index 0000000..85cf924
--- /dev/null
+++ b/src/templates/confirm_deletion.php
@@ -0,0 +1,23 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Confirm - Todo</title>
+ <link rel="shortcut icon" href="/images/favicon.png">
+ <link rel="stylesheet" href="/css/site.css">
+ </head>
+ <body>
+ <section>
+ <h1>Todo</h1>
+ <h2>Confirm Deletion</h2>
+ <a href="/">Back</a>
+ <form action="/delete/" method="POST">
+ <input type="hidden" name="id" value="<?= $todo->id; ?>"/>
+ <?= htmlentities("$todo->url | $todo->task | $todo->tag"); ?>
+ <button type="submit">Delete</button>
+ </form>
+ <p>Copyright © 2021 David T. Sadler.</p>
+ </section>
+ </body>
+</html>
diff --git a/src/templates/create.php b/src/templates/create.php
new file mode 100644
index 0000000..81ec7f8
--- /dev/null
+++ b/src/templates/create.php
@@ -0,0 +1,22 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Add - Todo</title>
+ <link rel="shortcut icon" href="/images/favicon.png">
+ <link rel="stylesheet" href="/css/site.css">
+ </head>
+ <body>
+ <section>
+ <h1>Todo</h1>
+ <h2>Add</h2>
+ <a href="/">Back</a>
+ <form action="/store/" method="POST">
+ <?php require_once('form_fields.php'); ?>
+ <button type="submit">Add</button>
+ </form>
+ <p>Copyright © 2021 David T. Sadler.</p>
+ </section>
+ </body>
+</html>
diff --git a/src/templates/edit.php b/src/templates/edit.php
new file mode 100644
index 0000000..9a8a1a6
--- /dev/null
+++ b/src/templates/edit.php
@@ -0,0 +1,23 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Edit - Todo</title>
+ <link rel="shortcut icon" href="/images/favicon.png">
+ <link rel="stylesheet" href="/css/site.css">
+ </head>
+ <body>
+ <section>
+ <h1>Todo</h1>
+ <h2>Edit</h2>
+ <a href="/">Back</a>
+ <form action="/update/" method="POST">
+ <input type="hidden" name="id" value="<?= $todo->id; ?>"/>
+ <?php require_once('form_fields.php'); ?>
+ <button type="submit">Update</button>
+ </form>
+ <p>Copyright © 2021 David T. Sadler.</p>
+ </section>
+ </body>
+</html>
diff --git a/src/templates/form_fields.php b/src/templates/form_fields.php
new file mode 100644
index 0000000..505cf26
--- /dev/null
+++ b/src/templates/form_fields.php
@@ -0,0 +1,8 @@
+<label>Task<input type="text" name="task" maxlength="256" value="<?= htmlspecialchars($old->get('task', $todo->task)); ?>"></label>
+<?php if ($errors->has('task')) { ?>
+ <p><?= htmlentities(implode(', ', $errors->get('task'))); ?></p>
+<?php } ?>
+<label>Tag<input type="text" name="tag" maxlength="16" value="<?= htmlspecialchars($old->get('tag', $todo->tag)); ?>"></label>
+<?php if ($errors->has('tag')) { ?>
+ <p><?= htmlentities(implode(', ', $errors->get('tag'))); ?></p>
+<?php } ?>
diff --git a/src/templates/index.php b/src/templates/index.php
new file mode 100644
index 0000000..d309b2c
--- /dev/null
+++ b/src/templates/index.php
@@ -0,0 +1,35 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Todo</title>
+ <link rel="shortcut icon" href="/images/favicon.png">
+ <link rel="stylesheet" href="/css/site.css">
+ </head>
+ <body>
+ <section>
+ <h1>Todo</h1>
+ <?php if ($message) { ?>
+ <p class="message"><?= $message; ?></p>
+ <?php } ?>
+ <a href="/create/">Add Todo</a>
+ <h2>Tags</h2>
+ <ul>
+ <a href="/">All</a>
+ <?php foreach($todos->tags() as $tag) { ?>
+ <li><a href="/?tag=<?= htmlentities($tag); ?>"><?= htmlentities($tag); ?></a></li>
+ <?php } ?>
+ </ul>
+ <h2>Todo</h2>
+ <ol>
+ <?php foreach ($todos as $todo) { ?>
+ <li>
+ <?= htmlentities($todo->task); ?></a> | <?php if ($todo->tag) { ?><a class="no-decoration tag" href="/?tag=<?= htmlentities($todo->tag); ?>"><?= htmlentities($todo->tag); ?></a> | <?php } ?><a class="no-decoration" href="/edit/?id=<?= $todo->id; ?>">Edit</a> | <a class="no-decoration" href="/delete/confirm/?id=<?= $todo->id; ?>">Delete</a>
+ </li>
+ <?php } ?>
+ </ol>
+ <p>Copyright © 2021 David T. Sadler.</p>
+ </section>
+ </body>
+</html>