aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--assets/interactive.js293
-rw-r--r--assets/styles.css232
-rw-r--r--composer.json27
-rw-r--r--composer.lock1877
-rw-r--r--config.php6
-rw-r--r--index.php25
-rw-r--r--src/BoundVal.php36
-rw-r--r--src/DBError.php31
-rw-r--r--src/DbQuery.php197
-rw-r--r--src/Entrypoint.php10
-rw-r--r--src/Env.php80
-rw-r--r--src/Esc.php21
-rw-r--r--src/Handler.php10
-rw-r--r--src/Handlers/ApiTagsHandler.php16
-rw-r--r--src/Handlers/Index.php20
-rw-r--r--src/Handlers/JsonAPIHandler.php18
-rw-r--r--src/Handlers/JsonAPIResult.php24
-rw-r--r--src/Handlers/MethodNotAllowedHandler.php14
-rw-r--r--src/Handlers/NewNote.php40
-rw-r--r--src/Handlers/NotFoundHandler.php15
-rw-r--r--src/Handlers/NoteHandler.php39
-rw-r--r--src/Handlers/Search.php33
-rw-r--r--src/Log.php27
-rw-r--r--src/Main.php58
-rw-r--r--src/Models/Note.php250
-rw-r--r--src/Models/Tag.php37
-rw-r--r--src/Schema.php80
-rw-r--r--src/Search/AbstractFTSExpr.php31
-rw-r--r--src/Search/CharSource.php33
-rw-r--r--src/Search/FTSExpr.php30
-rw-r--r--src/Search/FTSLogicOp.php46
-rw-r--r--src/Search/FTSNotExpr.php29
-rw-r--r--src/Search/LogicOp.php78
-rw-r--r--src/Search/NotOp.php32
-rw-r--r--src/Search/Pagination.php14
-rw-r--r--src/Search/ParseError.php9
-rw-r--r--src/Search/Parser.php295
-rw-r--r--src/Search/SQLSearchExpr.php11
-rw-r--r--src/Search/SearchExpr.php14
-rw-r--r--src/Search/SearchResult.php160
-rw-r--r--src/Search/TagExpr.php42
-rw-r--r--src/Search/TrueExpr.php25
-rw-r--r--src/Tools/PopulateDevDb.php71
-rw-r--r--templates/index.twig13
-rw-r--r--templates/macros.twig50
-rw-r--r--templates/new_note.twig5
-rw-r--r--templates/note.twig15
-rw-r--r--templates/search.twig23
-rw-r--r--templates/skeleton.twig35
-rw-r--r--tests/Search/ParserTest.php120
51 files changed, 4698 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..48b8bf9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+vendor/
diff --git a/assets/interactive.js b/assets/interactive.js
new file mode 100644
index 0000000..05b7556
--- /dev/null
+++ b/assets/interactive.js
@@ -0,0 +1,293 @@
+$(function () {
+ function APIError(code, message) {
+ this.code = code;
+ this.message = message;
+ }
+
+ APIError.prototype.toString = function () {
+ return "APIError " + this.code + ": " + this.message;
+ };
+
+ function default_api_error_handler(data, code) {
+ throw new APIError(code, data);
+ }
+
+ function add_get_param(url, name, val) {
+ name = encodeURIComponent(name);
+ val = encodeURIComponent(val);
+
+ var separator = url.match(/\?/) === null ? "?" : "&";
+ return url + separator + name + "=" + val;
+ }
+
+ function add_get_params(url, params) {
+ for (var k in params) if (Object.prototype.hasOwnProperty.call(params, k))
+ url = add_get_param(url, k, params[k]);
+ return url;
+ }
+
+ function query_api(method, url, arguments, handler, error_handler) {
+ url = add_get_params(url, arguments);
+ error_handler = error_handler || default_api_error_handler;
+
+ var xhr = new XMLHttpRequest();
+
+ function wrap_handler(handler) {
+ return function () {
+ return handler(xhr.response, xhr.status, xhr);
+ }
+ }
+
+ xhr.addEventListener("load", wrap_handler(handler));
+ xhr.addEventListener("error", wrap_handler(error_handler));
+ xhr.open(method, url);
+ xhr.responseType = "json";
+ xhr.send();
+ }
+
+ function Autocomplete() {
+ var self = this;
+
+ this.root = $("<div>")
+ .addClass("autocomplete-root");
+ this.input = $("<input>")
+ .attr("type", "text")
+ .appendTo(this.root)
+ .on("keyup", function (ev) {
+ if (ev.key === "ArrowDown") self.change_selection(1);
+ if (ev.key === "ArrowUp") self.change_selection(-1);
+
+ self.change_listener();
+ })
+ .on("keydown", function (ev) {
+ if (ev.key === "Tab") {
+ if (self.complete_current())
+ ev.preventDefault();
+ }
+ })
+ .on("blur", function (ev) {
+ var visible = false;
+ if (ev.relatedTarget) {
+ visible = $(ev.relatedTarget).closest(".autocomplete-root").get(0) === self.root.get(0);
+ }
+ self.set_visible(visible);
+ });
+ this.options = $("<ul>").appendTo(this.root)
+ .on("mouseout", function () {
+ self.mark_active_by_el(null);
+ });
+
+ this.abort = function () {};
+ this.get_suggestions = function (_, cb) { cb([]); };
+ }
+
+ Autocomplete.prototype.set_visible = function (visible) {
+ this.root.toggleClass("show-suggestions", !!visible);
+ };
+
+ Autocomplete.prototype.mark_active_by_el = function (el) {
+ this.options.find("li").each(function () {
+ var $cur = $(this);
+ $cur.toggleClass("active", this === el);
+ });
+ };
+
+ Autocomplete.prototype.change_listener = function () {
+ var self = this;
+
+ this.abort();
+
+ var input_text = this.input.val();
+ if (input_text === "") {
+ this.set_visible(false);
+ return;
+ }
+
+ this.get_suggestions(input_text, function (suggestions) {
+ console.log(suggestions);
+ suggestions = Array.prototype.slice.call(suggestions, 0);
+ suggestions.sort();
+ var items = suggestions.map(function (s) {
+ var found;
+ self.options.find("li").each(function () {
+ var el = $(this);
+ if (el.text() === s) {
+ found = el;
+ return false;
+ }
+ });
+
+ if (found)
+ return found;
+
+ return $("<li>").text(s).attr("tabindex", 0)
+ .on("click", function (ev) {
+ ev.preventDefault();
+ self.apply_completion($(this).text());
+ })
+ .on("mouseover", function () {
+ self.mark_active_by_el(this);
+ });
+ });
+ self.options.empty();
+ for (var i = 0; i < items.length; i++)
+ self.options.append(items[i]);
+
+ if (!self.options.find("li.active").length)
+ self.options.find("li").first().addClass("active");
+
+ self.set_visible(self.options.find("li").length > 0);
+ });
+ };
+
+ Autocomplete.prototype.change_selection = function (d) {
+ var lis = this.options.find("li");
+ if (lis.length === 0)
+ return;
+
+ var idx = lis.index(lis.filter(".active"));
+ if (idx < 0)
+ idx = 0;
+ idx += d;
+ idx %= lis.length;
+ lis.removeClass("active");
+ lis.eq(idx).addClass("active");
+ };
+
+ Autocomplete.prototype.complete_current = function () {
+ var cur = this.options.find("li.active");
+ if (cur.length !== 1)
+ return false;
+
+ this.apply_completion(cur.text());
+ this.change_listener();
+ return true;
+ };
+
+ Autocomplete.prototype.apply_completion = function (text) {
+ this.input.val(text);
+ };
+
+ var Tags = (function () {
+ var tags;
+
+ function get(callback) {
+ if (tags !== undefined) {
+ callback(tags);
+ return;
+ }
+
+ query_api("GET", "/api/tags", {}, function (resp) {
+ if (Object.prototype.toString.call(resp) !== "[object Array]") {
+ throw "Unexpeced return value from /api/tags";
+ }
+ tags = resp;
+ callback(tags);
+ });
+ }
+
+ return {
+ get: get,
+ clear_cache: function () { tags = null; }
+ };
+ })();
+
+ function selectedTag(tag) {
+ return $("<div>")
+ .addClass("tag")
+ .text(tag)
+ .append($("<input>")
+ .attr({
+ name: "tag[]",
+ type: "hidden",
+ })
+ .val(tag)
+ )
+ .prepend($("<button>")
+ .attr("type", "button")
+ .addClass("delete")
+ .text("X")
+ .on("click", function () {
+ $(this).closest(".tag").remove();
+ })
+ );
+ }
+
+ $("fieldset.tags").each(function () {
+ var $this = $(this);
+ var label = $this.find("legend").text();
+
+ $this.find("input").map(function () { return $(this).val(); }).get().filter(x => !!x);
+
+ var out = $("<div>")
+ .addClass("tag-input labelled")
+ .append($("<label>").text(label).attr("for", "tag-input-field"));
+ var content = $("<div>").addClass("labelled-input").appendTo(out);
+ var tag_list = $("<div>").append($this.find("input")
+ .filter(function () {
+ return !!$(this).val();
+ })
+ .map(function () {
+ return selectedTag($(this).val());
+ }).get()
+ );
+ content.append(tag_list);
+
+ var ac = new Autocomplete();
+ ac.get_suggestions = function (text, callback) {
+ text = String.prototype.toLowerCase.call(text);
+ Tags.get(function (tags) {
+ console.log(["tags", tags]);
+ tags = Array.prototype.filter.call(tags, function (it) {
+ it = String.prototype.toLowerCase.call(it);
+ return it.indexOf(text) > -1;
+ });
+ console.log(["filtered", tags]);
+ callback(tags);
+ });
+ };
+ ac.apply_completion = function (text) {
+ ac.input.val("");
+ tag_list.append(selectedTag(text));
+ };
+
+ ac.input
+ .attr({
+ name: "tag[]",
+ id: "tag-input-field",
+ placeholder: "Another tag"
+ })
+ .addClass("tag-user-input")
+ .on("keydown", function (ev) {
+ if (ev.key === "Enter") {
+ ev.preventDefault();
+
+ if (ev.ctrlKey) {
+ $(this).closest("form").each(function () {
+ this.submit();
+ });
+ return;
+ }
+
+ var tag = $(this).val().trim();
+ if (tag === "")
+ return;
+
+ tag_list.append(selectedTag(tag));
+ $(this).val("");
+ } else if (ev.key === "Backspace") {
+ if ($(this).val() === "") {
+ tag_list.find(".tag").last().remove();
+ }
+ }
+ });
+ content.append(ac.root);
+
+ $this.after(out).remove();
+ });
+
+ $("button.confirm").on("click", function (ev) {
+ if (!window.confirm($(this).data("question")))
+ ev.preventDefault();
+ })
+}); \ No newline at end of file
diff --git a/assets/styles.css b/assets/styles.css
new file mode 100644
index 0000000..b9fca4d
--- /dev/null
+++ b/assets/styles.css
@@ -0,0 +1,232 @@
+html {
+ --tag-bgcolor: hsl(200, 87.8%, 35.5%);
+ --tag-fgcolor: white;
+ --fg: #f9f9f9;
+ --bg: #22222f;
+ --bg-2: #333;
+ --bg-2-highlight: #444;
+ --fg-link-normal: #7187d7;
+ --fg-link-active: #ff3f00;
+ --fg-link-visited: #9f71d7;
+ --col-separator: #aaa;
+}
+
+html {
+ background: var(--bg);
+ color: var(--fg);
+ margin: 0;
+ padding: 1rem;
+ font-family: sans-serif;
+}
+
+body {
+ width: 85%;
+ margin: 10px auto;
+ padding: 0;
+}
+
+@media screen and (max-width: 400px) {
+ body {
+ width: auto;
+ margin: 10px 4px;
+ }
+}
+
+a:link {
+ color: var(--fg-link-normal);
+ text-decoration: none;
+}
+a:link:hover {
+ text-decoration: underline;
+}
+a:link:active {
+ color: var(--fg-link-active);
+}
+a:link:visited {
+ color: var(--fg-link-visited);
+}
+
+.tag {
+ background: var(--tag-bgcolor);
+ color: var(--tag-fgcolor);
+ padding: 5px;
+ display: inline-block;
+ border-radius: 3px;
+ position: relative;
+ height: 1em;
+}
+
+.tag a {
+ color: inherit;
+ text-decoration: none;
+}
+
+.tag a:hover {
+ text-decoration: underline;
+}
+
+.tc-1 { font-size: 0.7em; }
+.tc-2 { font-size: 0.8em; }
+.tc-3 { font-size: 0.9em; }
+.tc-4 { font-size: 1em; }
+.tc-5 { font-size: 1.1em; }
+
+.tag-input .delete {
+ background: var(--tag-bgcolor);
+ border: 1px solid var(--tag-fgcolor);
+ margin: 2px 6px 2px 2px;
+ padding: 1px;
+ color: var(--tag-fgcolor);
+ border-radius: 3px;
+ display: inline-block;
+ font-size: 0.8em;
+ font-family: sans-serif;
+ transition: background 0.2s, color 0.2s;
+ cursor: pointer;
+}
+
+.tag-input .delete:hover {
+ background: var(--tag-fgcolor);
+ color: var(--tag-bgcolor);
+}
+
+.tag-input .labelled-input {
+ display: flex;
+ flex-wrap: wrap;
+ padding: 6px;
+ border-radius: 3px;
+ background-color: var(--bg-2);
+}
+
+.tag-input .labelled-input:hover {
+ background-color: var(--bg-2-highlight);
+}
+
+.tag-user-input {
+ flex-grow: 1;
+ min-width: 250px;
+ background: transparent;
+ border: none;
+ color: inherit;
+ font-size: inherit;
+ border-bottom: 1px solid var(--bg);
+}
+
+#note-content {
+ background: transparent;
+ color: inherit;
+ border: 1px solid var(--fg);
+ margin: 0;
+ padding: 5px;
+ width: calc(100% - 12px);
+ min-height: 5em;
+ height: 50vh;
+}
+
+@media screen and (max-width: 200px) {
+ .tag-user-input {
+ min-width: 90vw;
+ }
+}
+
+header {
+ margin: 0 0 10px;
+ padding: 0 0 10px;
+ border-bottom: 1px solid var(--col-separator);
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.mainmenu ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.mainmenu li {
+ display: inline-block;
+}
+
+.mainmenu li:not(:first-child) {
+ margin-left: 10px;
+}
+
+.mainmenu a {
+ color: var(--fg);
+}
+
+.homelink {
+ letter-spacing: 3px;
+}
+
+.s-search {
+ flex-grow: 1;
+}
+
+.search-form {
+ display: flex;
+}
+.search-input {
+ background: transparent;
+ color: inherit;
+ border: 0;
+ padding: 4px 10px;
+ border-bottom: 1px solid var(--col-separator);
+ transition: 0.3s border-bottom-color;
+}
+
+.search-input:hover, .search-input:focus {
+ border-bottom-color: var(--fg);
+}
+
+.search-input {
+ flex-grow: 1;
+}
+
+input:placeholder-shown {
+ font-style: italic;
+}
+
+.note-list {
+ list-style: none;
+ padding: 0;
+}
+
+.note-list > li {
+ background: #444;
+ margin: 10px 0;
+ padding: 3px 8px;
+ border-radius: 3px;
+}
+
+.autocomplete-root {
+ position: relative;
+ display: inline-block;
+}
+
+.autocomplete-root ul {
+ display: block;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ position: absolute;
+ z-index: 1;
+ background: var(--bg-2);
+ min-width: 200px;
+ border: 1px solid var(--bg);
+}
+
+.autocomplete-root li {
+ padding: 4px 10px;
+}
+
+.autocomplete-root li.active {
+ background: var(--fg);
+ color: var(--bg);
+}
+
+.autocomplete-root:not(.show-suggestions) ul {
+ display: none;
+} \ No newline at end of file
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..1d1013a
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,27 @@
+{
+ "name": "code.laria.me/micropoly",
+ "type": "project",
+ "require": {
+ "twig/twig": "^3.0",
+ "nikic/fast-route": "^1.3",
+ "components/jquery": "^3.4",
+ "php": "^7.4",
+ "ext-sqlite3": "*",
+ "monolog/monolog": "^2.0"
+ },
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Laria Carolin Chabowski",
+ "email": "laria@laria.me"
+ }
+ ],
+ "autoload": {
+ "psr-4": {
+ "Micropoly\\": "src"
+ }
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^8.5"
+ }
+}
diff --git a/composer.lock b/composer.lock
new file mode 100644
index 0000000..c86aa24
--- /dev/null
+++ b/composer.lock
@@ -0,0 +1,1877 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "8737ddb39e6b9fed8221a8b8178c682b",
+ "packages": [
+ {
+ "name": "components/jquery",
+ "version": "3.4.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/components/jquery.git",
+ "reference": "901828b7968b18319e377dc23d466f28426ee083"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/components/jquery/zipball/901828b7968b18319e377dc23d466f28426ee083",
+ "reference": "901828b7968b18319e377dc23d466f28426ee083",
+ "shasum": ""
+ },
+ "type": "component",
+ "extra": {
+ "component": {
+ "scripts": [
+ "jquery.js"
+ ],
+ "files": [
+ "jquery.min.js",
+ "jquery.min.map",
+ "jquery.slim.js",
+ "jquery.slim.min.js",
+ "jquery.slim.min.map"
+ ]
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "JS Foundation and other contributors"
+ }
+ ],
+ "description": "jQuery JavaScript Library",
+ "homepage": "http://jquery.com",
+ "time": "2019-10-23T05:15:13+00:00"
+ },
+ {
+ "name": "monolog/monolog",
+ "version": "2.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Seldaek/monolog.git",
+ "reference": "c861fcba2ca29404dc9e617eedd9eff4616986b8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Seldaek/monolog/zipball/c861fcba2ca29404dc9e617eedd9eff4616986b8",
+ "reference": "c861fcba2ca29404dc9e617eedd9eff4616986b8",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2",
+ "psr/log": "^1.0.1"
+ },
+ "provide": {
+ "psr/log-implementation": "1.0.0"
+ },
+ "require-dev": {
+ "aws/aws-sdk-php": "^2.4.9 || ^3.0",
+ "doctrine/couchdb": "~1.0@dev",
+ "elasticsearch/elasticsearch": "^6.0",
+ "graylog2/gelf-php": "^1.4.2",
+ "jakub-onderka/php-parallel-lint": "^0.9",
+ "php-amqplib/php-amqplib": "~2.4",
+ "php-console/php-console": "^3.1.3",
+ "phpspec/prophecy": "^1.6.1",
+ "phpunit/phpunit": "^8.3",
+ "predis/predis": "^1.1",
+ "rollbar/rollbar": "^1.3",
+ "ruflin/elastica": ">=0.90 <3.0",
+ "swiftmailer/swiftmailer": "^5.3|^6.0"
+ },
+ "suggest": {
+ "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB",
+ "doctrine/couchdb": "Allow sending log messages to a CouchDB server",
+ "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
+ "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
+ "ext-mbstring": "Allow to work properly with unicode symbols",
+ "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",
+ "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
+ "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
+ "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
+ "php-console/php-console": "Allow sending log messages to Google Chrome",
+ "rollbar/rollbar": "Allow sending log messages to Rollbar",
+ "ruflin/elastica": "Allow sending log messages to an Elastic Search server"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Monolog\\": "src/Monolog"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jordi Boggiano",
+ "email": "j.boggiano@seld.be",
+ "homepage": "http://seld.be"
+ }
+ ],
+ "description": "Sends your logs to files, sockets, inboxes, databases and various web services",
+ "homepage": "http://github.com/Seldaek/monolog",
+ "keywords": [
+ "log",
+ "logging",
+ "psr-3"
+ ],
+ "time": "2019-12-20T14:22:59+00:00"
+ },
+ {
+ "name": "nikic/fast-route",
+ "version": "v1.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nikic/FastRoute.git",
+ "reference": "181d480e08d9476e61381e04a71b34dc0432e812"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nikic/FastRoute/zipball/181d480e08d9476e61381e04a71b34dc0432e812",
+ "reference": "181d480e08d9476e61381e04a71b34dc0432e812",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.4.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.8.35|~5.7"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "FastRoute\\": "src/"
+ },
+ "files": [
+ "src/functions.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Nikita Popov",
+ "email": "nikic@php.net"
+ }
+ ],
+ "description": "Fast request router for PHP",
+ "keywords": [
+ "router",
+ "routing"
+ ],
+ "time": "2018-02-13T20:26:39+00:00"
+ },
+ {
+ "name": "psr/log",
+ "version": "1.1.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/log.git",
+ "reference": "446d54b4cb6bf489fc9d75f55843658e6f25d801"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/log/zipball/446d54b4cb6bf489fc9d75f55843658e6f25d801",
+ "reference": "446d54b4cb6bf489fc9d75f55843658e6f25d801",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Log\\": "Psr/Log/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "http://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for logging libraries",
+ "homepage": "https://github.com/php-fig/log",
+ "keywords": [
+ "log",
+ "psr",
+ "psr-3"
+ ],
+ "time": "2019-11-01T11:05:21+00:00"
+ },
+ {
+ "name": "symfony/polyfill-ctype",
+ "version": "v1.13.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-ctype.git",
+ "reference": "f8f0b461be3385e56d6de3dbb5a0df24c0c275e3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/f8f0b461be3385e56d6de3dbb5a0df24c0c275e3",
+ "reference": "f8f0b461be3385e56d6de3dbb5a0df24c0c275e3",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3"
+ },
+ "suggest": {
+ "ext-ctype": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.13-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Polyfill\\Ctype\\": ""
+ },
+ "files": [
+ "bootstrap.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Gert de Pagter",
+ "email": "BackEndTea@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for ctype functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "ctype",
+ "polyfill",
+ "portable"
+ ],
+ "time": "2019-11-27T13:56:44+00:00"
+ },
+ {
+ "name": "symfony/polyfill-mbstring",
+ "version": "v1.13.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-mbstring.git",
+ "reference": "7b4aab9743c30be783b73de055d24a39cf4b954f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/7b4aab9743c30be783b73de055d24a39cf4b954f",
+ "reference": "7b4aab9743c30be783b73de055d24a39cf4b954f",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3"
+ },
+ "suggest": {
+ "ext-mbstring": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.13-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Polyfill\\Mbstring\\": ""
+ },
+ "files": [
+ "bootstrap.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for the Mbstring extension",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "mbstring",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "time": "2019-11-27T14:18:11+00:00"
+ },
+ {
+ "name": "twig/twig",
+ "version": "v3.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/twigphp/Twig.git",
+ "reference": "9b58bb8ac7a41d72fbb5a7dc643e07923e5ccc26"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/twigphp/Twig/zipball/9b58bb8ac7a41d72fbb5a7dc643e07923e5ccc26",
+ "reference": "9b58bb8ac7a41d72fbb5a7dc643e07923e5ccc26",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2.9",
+ "symfony/polyfill-ctype": "^1.8",
+ "symfony/polyfill-mbstring": "^1.3"
+ },
+ "require-dev": {
+ "psr/container": "^1.0",
+ "symfony/debug": "^3.4|^4.2|^5.0",
+ "symfony/phpunit-bridge": "^4.4@dev|^5.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Twig\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com",
+ "homepage": "http://fabien.potencier.org",
+ "role": "Lead Developer"
+ },
+ {
+ "name": "Twig Team",
+ "homepage": "https://twig.symfony.com/contributors",
+ "role": "Contributors"
+ },
+ {
+ "name": "Armin Ronacher",
+ "email": "armin.ronacher@active-4.com",
+ "role": "Project Founder"
+ }
+ ],
+ "description": "Twig, the flexible, fast, and secure template language for PHP",
+ "homepage": "https://twig.symfony.com",
+ "keywords": [
+ "templating"
+ ],
+ "time": "2019-11-15T20:38:32+00:00"
+ }
+ ],
+ "packages-dev": [
+ {
+ "name": "doctrine/instantiator",
+ "version": "1.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine/instantiator.git",
+ "reference": "ae466f726242e637cebdd526a7d991b9433bacf1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine/instantiator/zipball/ae466f726242e637cebdd526a7d991b9433bacf1",
+ "reference": "ae466f726242e637cebdd526a7d991b9433bacf1",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "^6.0",
+ "ext-pdo": "*",
+ "ext-phar": "*",
+ "phpbench/phpbench": "^0.13",
+ "phpstan/phpstan-phpunit": "^0.11",
+ "phpstan/phpstan-shim": "^0.11",
+ "phpunit/phpunit": "^7.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.2.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Marco Pivetta",
+ "email": "ocramius@gmail.com",
+ "homepage": "http://ocramius.github.com/"
+ }
+ ],
+ "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors",
+ "homepage": "https://www.doctrine-project.org/projects/instantiator.html",
+ "keywords": [
+ "constructor",
+ "instantiate"
+ ],
+ "time": "2019-10-21T16:45:58+00:00"
+ },
+ {
+ "name": "myclabs/deep-copy",
+ "version": "1.9.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/myclabs/DeepCopy.git",
+ "reference": "579bb7356d91f9456ccd505f24ca8b667966a0a7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/579bb7356d91f9456ccd505f24ca8b667966a0a7",
+ "reference": "579bb7356d91f9456ccd505f24ca8b667966a0a7",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1"
+ },
+ "replace": {
+ "myclabs/deep-copy": "self.version"
+ },
+ "require-dev": {
+ "doctrine/collections": "^1.0",
+ "doctrine/common": "^2.6",
+ "phpunit/phpunit": "^7.1"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "DeepCopy\\": "src/DeepCopy/"
+ },
+ "files": [
+ "src/DeepCopy/deep_copy.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Create deep copies (clones) of your objects",
+ "keywords": [
+ "clone",
+ "copy",
+ "duplicate",
+ "object",
+ "object graph"
+ ],
+ "time": "2019-12-15T19:12:40+00:00"
+ },
+ {
+ "name": "phar-io/manifest",
+ "version": "1.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/manifest.git",
+ "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/manifest/zipball/7761fcacf03b4d4f16e7ccb606d4879ca431fcf4",
+ "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-phar": "*",
+ "phar-io/version": "^2.0",
+ "php": "^5.6 || ^7.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
+ "time": "2018-07-08T19:23:20+00:00"
+ },
+ {
+ "name": "phar-io/version",
+ "version": "2.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/version.git",
+ "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/version/zipball/45a2ec53a73c70ce41d55cedef9063630abaf1b6",
+ "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^5.6 || ^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Library for handling version information and constraints",
+ "time": "2018-07-08T19:19:57+00:00"
+ },
+ {
+ "name": "phpdocumentor/reflection-common",
+ "version": "2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpDocumentor/ReflectionCommon.git",
+ "reference": "63a995caa1ca9e5590304cd845c15ad6d482a62a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/63a995caa1ca9e5590304cd845c15ad6d482a62a",
+ "reference": "63a995caa1ca9e5590304cd845c15ad6d482a62a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "~6"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "phpDocumentor\\Reflection\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jaap van Otterdijk",
+ "email": "opensource@ijaap.nl"
+ }
+ ],
+ "description": "Common reflection classes used by phpdocumentor to reflect the code structure",
+ "homepage": "http://www.phpdoc.org",
+ "keywords": [
+ "FQSEN",
+ "phpDocumentor",
+ "phpdoc",
+ "reflection",
+ "static analysis"
+ ],
+ "time": "2018-08-07T13:53:10+00:00"
+ },
+ {
+ "name": "phpdocumentor/reflection-docblock",
+ "version": "4.3.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
+ "reference": "da3fd972d6bafd628114f7e7e036f45944b62e9c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/da3fd972d6bafd628114f7e7e036f45944b62e9c",
+ "reference": "da3fd972d6bafd628114f7e7e036f45944b62e9c",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0",
+ "phpdocumentor/reflection-common": "^1.0.0 || ^2.0.0",
+ "phpdocumentor/type-resolver": "~0.4 || ^1.0.0",
+ "webmozart/assert": "^1.0"
+ },
+ "require-dev": {
+ "doctrine/instantiator": "^1.0.5",
+ "mockery/mockery": "^1.0",
+ "phpdocumentor/type-resolver": "0.4.*",
+ "phpunit/phpunit": "^6.4"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "phpDocumentor\\Reflection\\": [
+ "src/"
+ ]
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mike van Riel",
+ "email": "me@mikevanriel.com"
+ }
+ ],
+ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
+ "time": "2019-12-28T18:55:12+00:00"
+ },
+ {
+ "name": "phpdocumentor/type-resolver",
+ "version": "1.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpDocumentor/TypeResolver.git",
+ "reference": "2e32a6d48972b2c1976ed5d8967145b6cec4a4a9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/2e32a6d48972b2c1976ed5d8967145b6cec4a4a9",
+ "reference": "2e32a6d48972b2c1976ed5d8967145b6cec4a4a9",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1",
+ "phpdocumentor/reflection-common": "^2.0"
+ },
+ "require-dev": {
+ "ext-tokenizer": "^7.1",
+ "mockery/mockery": "~1",
+ "phpunit/phpunit": "^7.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "phpDocumentor\\Reflection\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mike van Riel",
+ "email": "me@mikevanriel.com"
+ }
+ ],
+ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
+ "time": "2019-08-22T18:11:29+00:00"
+ },
+ {
+ "name": "phpspec/prophecy",
+ "version": "1.10.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpspec/prophecy.git",
+ "reference": "cbe1df668b3fe136bcc909126a0f529a78d4cbbc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpspec/prophecy/zipball/cbe1df668b3fe136bcc909126a0f529a78d4cbbc",
+ "reference": "cbe1df668b3fe136bcc909126a0f529a78d4cbbc",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/instantiator": "^1.0.2",
+ "php": "^5.3|^7.0",
+ "phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0|^5.0",
+ "sebastian/comparator": "^1.2.3|^2.0|^3.0",
+ "sebastian/recursion-context": "^1.0|^2.0|^3.0"
+ },
+ "require-dev": {
+ "phpspec/phpspec": "^2.5 || ^3.2",
+ "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.5 || ^7.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.10.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Prophecy\\": "src/Prophecy"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Konstantin Kudryashov",
+ "email": "ever.zet@gmail.com",
+ "homepage": "http://everzet.com"
+ },
+ {
+ "name": "Marcello Duarte",
+ "email": "marcello.duarte@gmail.com"
+ }
+ ],
+ "description": "Highly opinionated mocking framework for PHP 5.3+",
+ "homepage": "https://github.com/phpspec/prophecy",
+ "keywords": [
+ "Double",
+ "Dummy",
+ "fake",
+ "mock",
+ "spy",
+ "stub"
+ ],
+ "time": "2019-12-22T21:05:45+00:00"
+ },
+ {
+ "name": "phpunit/php-code-coverage",
+ "version": "7.0.10",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+ "reference": "f1884187926fbb755a9aaf0b3836ad3165b478bf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f1884187926fbb755a9aaf0b3836ad3165b478bf",
+ "reference": "f1884187926fbb755a9aaf0b3836ad3165b478bf",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-xmlwriter": "*",
+ "php": "^7.2",
+ "phpunit/php-file-iterator": "^2.0.2",
+ "phpunit/php-text-template": "^1.2.1",
+ "phpunit/php-token-stream": "^3.1.1",
+ "sebastian/code-unit-reverse-lookup": "^1.0.1",
+ "sebastian/environment": "^4.2.2",
+ "sebastian/version": "^2.0.1",
+ "theseer/tokenizer": "^1.1.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^8.2.2"
+ },
+ "suggest": {
+ "ext-xdebug": "^2.7.2"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "7.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+ "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
+ "keywords": [
+ "coverage",
+ "testing",
+ "xunit"
+ ],
+ "time": "2019-11-20T13:55:58+00:00"
+ },
+ {
+ "name": "phpunit/php-file-iterator",
+ "version": "2.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+ "reference": "050bedf145a257b1ff02746c31894800e5122946"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/050bedf145a257b1ff02746c31894800e5122946",
+ "reference": "050bedf145a257b1ff02746c31894800e5122946",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^7.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+ "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
+ "keywords": [
+ "filesystem",
+ "iterator"
+ ],
+ "time": "2018-09-13T20:33:42+00:00"
+ },
+ {
+ "name": "phpunit/php-text-template",
+ "version": "1.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-text-template.git",
+ "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/31f8b717e51d9a2afca6c9f046f5d69fc27c8686",
+ "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.3"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Simple template engine.",
+ "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+ "keywords": [
+ "template"
+ ],
+ "time": "2015-06-21T13:50:34+00:00"
+ },
+ {
+ "name": "phpunit/php-timer",
+ "version": "2.1.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-timer.git",
+ "reference": "1038454804406b0b5f5f520358e78c1c2f71501e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/1038454804406b0b5f5f520358e78c1c2f71501e",
+ "reference": "1038454804406b0b5f5f520358e78c1c2f71501e",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^7.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Utility class for timing",
+ "homepage": "https://github.com/sebastianbergmann/php-timer/",
+ "keywords": [
+ "timer"
+ ],
+ "time": "2019-06-07T04:22:29+00:00"
+ },
+ {
+ "name": "phpunit/php-token-stream",
+ "version": "3.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-token-stream.git",
+ "reference": "995192df77f63a59e47f025390d2d1fdf8f425ff"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/995192df77f63a59e47f025390d2d1fdf8f425ff",
+ "reference": "995192df77f63a59e47f025390d2d1fdf8f425ff",
+ "shasum": ""
+ },
+ "require": {
+ "ext-tokenizer": "*",
+ "php": "^7.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^7.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Wrapper around PHP's tokenizer extension.",
+ "homepage": "https://github.com/sebastianbergmann/php-token-stream/",
+ "keywords": [
+ "tokenizer"
+ ],
+ "time": "2019-09-17T06:23:10+00:00"
+ },
+ {
+ "name": "phpunit/phpunit",
+ "version": "8.5.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/phpunit.git",
+ "reference": "7870c78da3c5e4883eaef36ae47853ebb3cb86f2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/7870c78da3c5e4883eaef36ae47853ebb3cb86f2",
+ "reference": "7870c78da3c5e4883eaef36ae47853ebb3cb86f2",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/instantiator": "^1.2.0",
+ "ext-dom": "*",
+ "ext-json": "*",
+ "ext-libxml": "*",
+ "ext-mbstring": "*",
+ "ext-xml": "*",
+ "ext-xmlwriter": "*",
+ "myclabs/deep-copy": "^1.9.1",
+ "phar-io/manifest": "^1.0.3",
+ "phar-io/version": "^2.0.1",
+ "php": "^7.2",
+ "phpspec/prophecy": "^1.8.1",
+ "phpunit/php-code-coverage": "^7.0.7",
+ "phpunit/php-file-iterator": "^2.0.2",
+ "phpunit/php-text-template": "^1.2.1",
+ "phpunit/php-timer": "^2.1.2",
+ "sebastian/comparator": "^3.0.2",
+ "sebastian/diff": "^3.0.2",
+ "sebastian/environment": "^4.2.2",
+ "sebastian/exporter": "^3.1.1",
+ "sebastian/global-state": "^3.0.0",
+ "sebastian/object-enumerator": "^3.0.3",
+ "sebastian/resource-operations": "^2.0.1",
+ "sebastian/type": "^1.1.3",
+ "sebastian/version": "^2.0.1"
+ },
+ "require-dev": {
+ "ext-pdo": "*"
+ },
+ "suggest": {
+ "ext-soap": "*",
+ "ext-xdebug": "*",
+ "phpunit/php-invoker": "^2.0.0"
+ },
+ "bin": [
+ "phpunit"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "8.5-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "The PHP Unit Testing framework.",
+ "homepage": "https://phpunit.de/",
+ "keywords": [
+ "phpunit",
+ "testing",
+ "xunit"
+ ],
+ "time": "2019-12-25T14:49:39+00:00"
+ },
+ {
+ "name": "sebastian/code-unit-reverse-lookup",
+ "version": "1.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
+ "reference": "4419fcdb5eabb9caa61a27c7a1db532a6b55dd18"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/4419fcdb5eabb9caa61a27c7a1db532a6b55dd18",
+ "reference": "4419fcdb5eabb9caa61a27c7a1db532a6b55dd18",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^5.6 || ^7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^5.7 || ^6.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Looks up which function or method a line of code belongs to",
+ "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
+ "time": "2017-03-04T06:30:41+00:00"
+ },
+ {
+ "name": "sebastian/comparator",
+ "version": "3.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/comparator.git",
+ "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/5de4fc177adf9bce8df98d8d141a7559d7ccf6da",
+ "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1",
+ "sebastian/diff": "^3.0",
+ "sebastian/exporter": "^3.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^7.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@2bepublished.at"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides the functionality to compare PHP values for equality",
+ "homepage": "https://github.com/sebastianbergmann/comparator",
+ "keywords": [
+ "comparator",
+ "compare",
+ "equality"
+ ],
+ "time": "2018-07-12T15:12:46+00:00"
+ },
+ {
+ "name": "sebastian/diff",
+ "version": "3.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/diff.git",
+ "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/720fcc7e9b5cf384ea68d9d930d480907a0c1a29",
+ "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^7.5 || ^8.0",
+ "symfony/process": "^2 || ^3.3 || ^4"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Kore Nordmann",
+ "email": "mail@kore-nordmann.de"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Diff implementation",
+ "homepage": "https://github.com/sebastianbergmann/diff",
+ "keywords": [
+ "diff",
+ "udiff",
+ "unidiff",
+ "unified diff"
+ ],
+ "time": "2019-02-04T06:01:07+00:00"
+ },
+ {
+ "name": "sebastian/environment",
+ "version": "4.2.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/environment.git",
+ "reference": "464c90d7bdf5ad4e8a6aea15c091fec0603d4368"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/464c90d7bdf5ad4e8a6aea15c091fec0603d4368",
+ "reference": "464c90d7bdf5ad4e8a6aea15c091fec0603d4368",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^7.5"
+ },
+ "suggest": {
+ "ext-posix": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.2-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides functionality to handle HHVM/PHP environments",
+ "homepage": "http://www.github.com/sebastianbergmann/environment",
+ "keywords": [
+ "Xdebug",
+ "environment",
+ "hhvm"
+ ],
+ "time": "2019-11-20T08:46:58+00:00"
+ },
+ {
+ "name": "sebastian/exporter",
+ "version": "3.1.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/exporter.git",
+ "reference": "68609e1261d215ea5b21b7987539cbfbe156ec3e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/68609e1261d215ea5b21b7987539cbfbe156ec3e",
+ "reference": "68609e1261d215ea5b21b7987539cbfbe156ec3e",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0",
+ "sebastian/recursion-context": "^3.0"
+ },
+ "require-dev": {
+ "ext-mbstring": "*",
+ "phpunit/phpunit": "^6.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.1.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ }
+ ],
+ "description": "Provides the functionality to export PHP variables for visualization",
+ "homepage": "http://www.github.com/sebastianbergmann/exporter",
+ "keywords": [
+ "export",
+ "exporter"
+ ],
+ "time": "2019-09-14T09:02:43+00:00"
+ },
+ {
+ "name": "sebastian/global-state",
+ "version": "3.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/global-state.git",
+ "reference": "edf8a461cf1d4005f19fb0b6b8b95a9f7fa0adc4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/edf8a461cf1d4005f19fb0b6b8b95a9f7fa0adc4",
+ "reference": "edf8a461cf1d4005f19fb0b6b8b95a9f7fa0adc4",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2",
+ "sebastian/object-reflector": "^1.1.1",
+ "sebastian/recursion-context": "^3.0"
+ },
+ "require-dev": {
+ "ext-dom": "*",
+ "phpunit/phpunit": "^8.0"
+ },
+ "suggest": {
+ "ext-uopz": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Snapshotting of global state",
+ "homepage": "http://www.github.com/sebastianbergmann/global-state",
+ "keywords": [
+ "global state"
+ ],
+ "time": "2019-02-01T05:30:01+00:00"
+ },
+ {
+ "name": "sebastian/object-enumerator",
+ "version": "3.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+ "reference": "7cfd9e65d11ffb5af41198476395774d4c8a84c5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/7cfd9e65d11ffb5af41198476395774d4c8a84c5",
+ "reference": "7cfd9e65d11ffb5af41198476395774d4c8a84c5",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0",
+ "sebastian/object-reflector": "^1.1.1",
+ "sebastian/recursion-context": "^3.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^6.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Traverses array structures and object graphs to enumerate all referenced objects",
+ "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
+ "time": "2017-08-03T12:35:26+00:00"
+ },
+ {
+ "name": "sebastian/object-reflector",
+ "version": "1.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-reflector.git",
+ "reference": "773f97c67f28de00d397be301821b06708fca0be"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/773f97c67f28de00d397be301821b06708fca0be",
+ "reference": "773f97c67f28de00d397be301821b06708fca0be",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^6.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Allows reflection of object attributes, including inherited and non-public ones",
+ "homepage": "https://github.com/sebastianbergmann/object-reflector/",
+ "time": "2017-03-29T09:07:27+00:00"
+ },
+ {
+ "name": "sebastian/recursion-context",
+ "version": "3.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/recursion-context.git",
+ "reference": "5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8",
+ "reference": "5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^6.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ }
+ ],
+ "description": "Provides functionality to recursively process PHP variables",
+ "homepage": "http://www.github.com/sebastianbergmann/recursion-context",
+ "time": "2017-03-03T06:23:57+00:00"
+ },
+ {
+ "name": "sebastian/resource-operations",
+ "version": "2.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/resource-operations.git",
+ "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/4d7a795d35b889bf80a0cc04e08d77cedfa917a9",
+ "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides a list of PHP built-in functions that operate on resources",
+ "homepage": "https://www.github.com/sebastianbergmann/resource-operations",
+ "time": "2018-10-04T04:07:39+00:00"
+ },
+ {
+ "name": "sebastian/type",
+ "version": "1.1.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/type.git",
+ "reference": "3aaaa15fa71d27650d62a948be022fe3b48541a3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/3aaaa15fa71d27650d62a948be022fe3b48541a3",
+ "reference": "3aaaa15fa71d27650d62a948be022fe3b48541a3",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^8.2"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Collection of value objects that represent the types of the PHP type system",
+ "homepage": "https://github.com/sebastianbergmann/type",
+ "time": "2019-07-02T08:10:15+00:00"
+ },
+ {
+ "name": "sebastian/version",
+ "version": "2.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/version.git",
+ "reference": "99732be0ddb3361e16ad77b68ba41efc8e979019"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/99732be0ddb3361e16ad77b68ba41efc8e979019",
+ "reference": "99732be0ddb3361e16ad77b68ba41efc8e979019",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.6"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that helps with managing the version number of Git-hosted PHP projects",
+ "homepage": "https://github.com/sebastianbergmann/version",
+ "time": "2016-10-03T07:35:21+00:00"
+ },
+ {
+ "name": "theseer/tokenizer",
+ "version": "1.1.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/theseer/tokenizer.git",
+ "reference": "11336f6f84e16a720dae9d8e6ed5019efa85a0f9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/theseer/tokenizer/zipball/11336f6f84e16a720dae9d8e6ed5019efa85a0f9",
+ "reference": "11336f6f84e16a720dae9d8e6ed5019efa85a0f9",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlwriter": "*",
+ "php": "^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+ "time": "2019-06-13T22:48:21+00:00"
+ },
+ {
+ "name": "webmozart/assert",
+ "version": "1.6.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/webmozart/assert.git",
+ "reference": "573381c0a64f155a0d9a23f4b0c797194805b925"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/webmozart/assert/zipball/573381c0a64f155a0d9a23f4b0c797194805b925",
+ "reference": "573381c0a64f155a0d9a23f4b0c797194805b925",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^5.3.3 || ^7.0",
+ "symfony/polyfill-ctype": "^1.8"
+ },
+ "conflict": {
+ "vimeo/psalm": "<3.6.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.8.36 || ^7.5.13"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Webmozart\\Assert\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ }
+ ],
+ "description": "Assertions to validate method input/output with nice error messages.",
+ "keywords": [
+ "assert",
+ "check",
+ "validate"
+ ],
+ "time": "2019-11-24T13:36:37+00:00"
+ }
+ ],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": [],
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": {
+ "php": "^7.4",
+ "ext-sqlite3": "*"
+ },
+ "platform-dev": []
+}
diff --git a/config.php b/config.php
new file mode 100644
index 0000000..5b7a17f
--- /dev/null
+++ b/config.php
@@ -0,0 +1,6 @@
+<?php
+return [
+ "templates_path" => __DIR__ . "/templates",
+ "templates_cache" => false, //__DIR__ . "/.templates_cache",
+ "sqlitedb" => __DIR__ . "/notes.db",
+];
diff --git a/index.php b/index.php
new file mode 100644
index 0000000..cf4c3cc
--- /dev/null
+++ b/index.php
@@ -0,0 +1,25 @@
+<?php
+
+use Micropoly\Entrypoint;
+use Micropoly\Main;
+
+function micropoly_main()
+{
+ if (php_sapi_name() == 'cli-server') {
+ if (preg_match('/^\/(assets|vendor\/components)/', $_SERVER["REQUEST_URI"]))
+ return false;
+ }
+
+ require_once "vendor/autoload.php";
+ $cls = Main::class;
+ if (php_sapi_name() === "cli" && isset($GLOBALS["argv"][1]))
+ $cls = $GLOBALS["argv"][1];
+
+ $obj = new $cls();
+ if (!($obj instanceof Entrypoint))
+ throw new Exception("$cls is not a " . Entrypoint::class);
+
+ $obj->run(\Micropoly\Env::fromConfig(require "config.php"));
+}
+
+return micropoly_main(); \ No newline at end of file
diff --git a/src/BoundVal.php b/src/BoundVal.php
new file mode 100644
index 0000000..7a3560a
--- /dev/null
+++ b/src/BoundVal.php
@@ -0,0 +1,36 @@
+<?php
+
+
+namespace Micropoly;
+
+
+use SQLite3Stmt;
+
+class BoundVal
+{
+ private $val;
+ private $type = null;
+
+ public function __construct($val, $type = null)
+ {
+ $this->val = $val;
+ $this->type = $type;
+ }
+
+ public function getVal() { return $this->val; }
+ public function getType() { return $this->type; }
+
+ public static function ofInt($val): self { return new self($val, SQLITE3_INTEGER); }
+ public static function ofFloat($val): self { return new self($val, SQLITE3_FLOAT); }
+ public static function ofText($val): self { return new self($val, SQLITE3_TEXT); }
+ public static function ofBlob($val): self { return new self($val, SQLITE3_BLOB); }
+ public static function ofNull($val): self { return new self($val, SQLITE3_NULL); }
+
+ public function bind(SQLite3Stmt $stmt, $where): void
+ {
+ if ($this->type === null)
+ $stmt->bindValue($where, $this->val);
+ else
+ $stmt->bindValue($where, $this->val, $this->type);
+ }
+} \ No newline at end of file
diff --git a/src/DBError.php b/src/DBError.php
new file mode 100644
index 0000000..4860d2f
--- /dev/null
+++ b/src/DBError.php
@@ -0,0 +1,31 @@
+<?php
+
+
+namespace Micropoly;
+
+
+use Exception;
+
+class DBError extends Exception
+{
+ private string $msg;
+ private string $sql;
+
+ /**
+ * DBError constructor.
+ * @param string $msg
+ * @param string $sql
+ */
+ public function __construct(string $msg, string $sql)
+ {
+ $this->msg = $msg;
+ $this->sql = $sql;
+
+ parent::__construct($this->buildMessage());
+ }
+
+ private function buildMessage(): string
+ {
+ return "{$this->msg}. SQL was: {$this->sql}";
+ }
+} \ No newline at end of file
diff --git a/src/DbQuery.php b/src/DbQuery.php
new file mode 100644
index 0000000..6fd32a1
--- /dev/null
+++ b/src/DbQuery.php
@@ -0,0 +1,197 @@
+<?php
+
+
+namespace Micropoly;
+
+
+use InvalidArgumentException;
+use Iterator;
+use SQLite3;
+use SQLite3Result;
+
+class DbQuery
+{
+ private string $query;
+
+ /** @var BoundVal[] */
+ private array $boundVals = [];
+
+ public function __construct(string $query)
+ {
+ $this->query = $query;
+ }
+
+ /**
+ * @param int|array $values
+ * @return string
+ */
+ public static function valueListPlaceholders($values): string
+ {
+ if (is_array($values))
+ $num = count($values);
+ elseif (is_int($values))
+ $num = $values;
+ else
+ throw new InvalidArgumentException("\$values must be an int or an array");
+
+ return implode(",", array_fill(0, $num, "?"));
+ }
+
+ public static function insert(SQLite3 $db, string $table, array $fields, array $records)
+ {
+ if (empty($records) || empty($fields))
+ return;
+
+ $recordTemplate = "(" . implode(",", array_fill(0, count($fields), "?")) . ")";
+ $query = new self("INSERT INTO $table (" . implode(',', $fields) . ") VALUES " . implode(",", array_fill(0, count($records), $recordTemplate)));
+
+ $i = 1;
+ $fieldCount = count($fields);
+ foreach ($records as $record) {
+ if (count($record) !== $fieldCount)
+ throw new InvalidArgumentException("count of all record fields must match field count!");
+
+ foreach ($record as $v) {
+ $query->bind($i, $v);
+ $i++;
+ }
+ }
+
+ $query->exec($db);
+ }
+
+ public static function insertKV(SQLite3 $db, string $table, array $kv)
+ {
+ self::insert($db, $table, array_keys($kv), [array_values($kv)]);
+ }
+
+ /**
+ * @param mixed $where Name/Index of parameter
+ * @param BoundVal|mixed $val
+ * @return $this
+ */
+ public function bind($where, $val): self
+ {
+ if (!($val instanceof BoundVal))
+ $val = new BoundVal($val, null);
+
+
+ $this->boundVals[$where] = $val;
+ return $this;
+ }
+
+ private function bindMulti(array $vals, $type, int $offset): self
+ {
+ foreach ($vals as $i => $v)
+ $this->bind($i + $offset, new BoundVal($v, $type));
+
+ return $this;
+ }
+
+ public function bindMultiAuto(array $vals, int $offset = 1): self { return $this->bindMulti($vals, null, $offset); }
+ public function bindMultiInt(array $vals, int $offset = 1): self { return $this->bindMulti($vals, SQLITE3_INTEGER, $offset); }
+ public function bindMultiFloat(array $vals, int $offset = 1): self { return $this->bindMulti($vals, SQLITE3_FLOAT, $offset); }
+ public function bindMultiText(array $vals, int $offset = 1): self { return $this->bindMulti($vals, SQLITE3_TEXT, $offset); }
+ public function bindMultiBlob(array $vals, int $offset = 1): self { return $this->bindMulti($vals, SQLITE3_BLOB, $offset); }
+ public function bindMultiNull(array $vals, int $offset = 1): self { return $this->bindMulti($vals, SQLITE3_NULL, $offset); }
+
+ /**
+ * @param SQLite3 $db
+ * @param callable|null $cb
+ * @return mixed Result of callback or null, if none given
+ * @throws DBError
+ */
+ public function exec(SQLite3 $db, ?callable $cb = null)
+ {
+ $stmt = $db->prepare($this->query);
+ if ($stmt === false)
+ throw new DBError("Prepare failed", $this->query);
+ foreach ($this->boundVals as $where => $boundVal)
+ $boundVal->bind($stmt, $where);
+
+ $res = $stmt->execute();
+ if ($res === false) {
+ throw new DBError("execute failed", $this->query);
+ }
+
+ $out = $cb ? $cb($res) : null;
+
+ $res->finalize();
+ $stmt->close();
+
+ return $out;
+ }
+
+ public function fetchRow(SQLite3 $db, int $fetchMode = SQLITE3_NUM): ?array
+ {
+ return $this->exec($db, static function (SQLite3Result $res) use ($fetchMode) {
+ return $res->numColumns() ? $res->fetchArray($fetchMode) : null;
+ });
+ }
+
+ public function fetchRowAssoc(SQLite3 $db): ?array { return $this->fetchRow($db, SQLITE3_ASSOC); }
+
+ public function fetchRows(SQLite3 $db, int $fetchMode = SQLITE3_NUM): array
+ {
+ return $this->exec($db, static function (SQLite3Result $res) use ($fetchMode) {
+ if (!$res->numColumns())
+ return [];
+
+ $out = [];
+
+ while (($row = $res->fetchArray($fetchMode)))
+ $out[] = $row;
+
+ return $out;
+ });
+ }
+
+ public function fetchRowsAssoc(SQLite3 $db): array { return $this->fetchRows($db, SQLITE3_ASSOC); }
+
+ public function fetchIndexedRows(SQLite3 $db, ...$keys): array
+ {
+ return $this->exec($db, static function (SQLite3Result $res) use ($keys) {
+ if (!$res->numColumns())
+ return [];
+
+ $out = [];
+
+ while (($row = $res->fetchArray(SQLITE3_ASSOC))) {
+ $cursor =& $out;
+
+ foreach ($keys as $k)
+ $cursor =& $cursor[$row[$k]];
+
+ $cursor = $row;
+ }
+
+ return $out;
+ });
+ }
+
+ public function fetchIndexedValues(SQLite3 $db, $val, ...$keys): array
+ {
+ return array_map(fn ($row) => $row[$val] ?? null, $this->fetchIndexedRows($db, ...$keys));
+ }
+
+ public function fetchIndexedAllRows(SQLite3 $db, ...$keys): array
+ {
+ return $this->exec($db, static function (SQLite3Result $res) use ($keys) {
+ if (!$res->numColumns())
+ return [];
+
+ $out = [];
+
+ while (($row = $res->fetchArray(SQLITE3_ASSOC))) {
+ $cursor =& $out;
+
+ foreach ($keys as $k)
+ $cursor =& $cursor[$row[$k]];
+
+ $cursor[] = $row;
+ }
+
+ return $out;
+ });
+ }
+} \ No newline at end of file
diff --git a/src/Entrypoint.php b/src/Entrypoint.php
new file mode 100644
index 0000000..6fe3409
--- /dev/null
+++ b/src/Entrypoint.php
@@ -0,0 +1,10 @@
+<?php
+
+
+namespace Micropoly;
+
+
+interface Entrypoint
+{
+ public function run(Env $env);
+} \ No newline at end of file
diff --git a/src/Env.php b/src/Env.php
new file mode 100644
index 0000000..34b9f1f
--- /dev/null
+++ b/src/Env.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace Micropoly;
+
+use SQLite3;
+use Twig\Environment;
+use Twig\Loader\FilesystemLoader;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+
+class Env
+{
+ private array $config;
+
+ private function __construct() { }
+
+ private array $lazyLoaded = [];
+
+ private function lazy(string $ident, callable $callback)
+ {
+ if (!isset($this->lazyLoaded[$ident])) {
+ $this->lazyLoaded[$ident] = $callback();
+ }
+ return $this->lazyLoaded[$ident];
+ }
+
+ public static function fromConfig(array $config)
+ {
+ $env = new self;
+ $env->config = $config;
+ return $env;
+ }
+
+ public function documentRoot(): string { return "/"; }
+
+ public function twig(): Environment
+ {
+ return $this->lazy("twig", function () {
+ $loader = new FilesystemLoader($this->config["templates_path"]);
+ $env = new Environment($loader, [
+ "cache" => $this->config["templates_cache"],
+ ]);
+
+ $env->addFunction(new TwigFunction("url", function (string $url, ...$args) {
+ return $this->documentRoot() . sprintf($url, ...$args);
+ }, ["is_variadic" => true]));
+
+ $env->addFilter(new TwigFilter("search_escape", static function (string $s) {
+ $s = str_replace("\\", "\\\\", $s);
+ $s = str_replace("#", "\\#", $s);
+ $s = str_replace(" ", "\\ ", $s);
+ $s = str_replace("\t", "\\\t", $s);
+ $s = str_replace("(", "\\(", $s);
+ $s = str_replace(")", "\\)", $s);
+ return $s;
+ }));
+
+ return $env;
+ });
+ }
+
+ public function rawDbCon(): SQLite3
+ {
+ return $this->lazy("rawDbCon", function () {
+ return new SQLite3($this->config["sqlitedb"]);
+ });
+ }
+
+ public function db(): SQLite3
+ {
+ return $this->lazy("db", function () {
+ $db = $this->rawDbCon();
+ $db->exec("PRAGMA foreign_keys = ON");
+
+ (new Schema($db))->migrate();
+
+ return $db;
+ });
+ }
+}
diff --git a/src/Esc.php b/src/Esc.php
new file mode 100644
index 0000000..5c290b6
--- /dev/null
+++ b/src/Esc.php
@@ -0,0 +1,21 @@
+<?php
+
+
+namespace Micropoly;
+
+
+class Esc
+{
+ public const HTML = 1;
+ public const NL2BR = 2;
+ public const HTML_WITH_BR = self::HTML | self::NL2BR;
+
+ public static function e(string $s, int $flags = self::HTML): string
+ {
+ if ($flags & self::HTML)
+ $s = htmlspecialchars($s);
+ if ($flags & self::NL2BR)
+ $s = nl2br($s);
+ return $s;
+ }
+} \ No newline at end of file
diff --git a/src/Handler.php b/src/Handler.php
new file mode 100644
index 0000000..be6cad9
--- /dev/null
+++ b/src/Handler.php
@@ -0,0 +1,10 @@
+<?php
+
+namespace Micropoly;
+
+use Micropoly\Env;
+
+interface Handler
+{
+ public function handle(Env $env, array $variables);
+}
diff --git a/src/Handlers/ApiTagsHandler.php b/src/Handlers/ApiTagsHandler.php
new file mode 100644
index 0000000..af9fb7b
--- /dev/null
+++ b/src/Handlers/ApiTagsHandler.php
@@ -0,0 +1,16 @@
+<?php
+
+
+namespace Micropoly\Handlers;
+
+
+use Micropoly\Env;
+use Micropoly\Models\Tag;
+
+class ApiTagsHandler extends JsonAPIHandler
+{
+ protected function handleAPIRequest(Env $env, array $variables): JsonAPIResult
+ {
+ return new JsonAPIResult(array_keys(Tag::getTagCounts($env->db())));
+ }
+} \ No newline at end of file
diff --git a/src/Handlers/Index.php b/src/Handlers/Index.php
new file mode 100644
index 0000000..8d0896b
--- /dev/null
+++ b/src/Handlers/Index.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Micropoly\Handlers;
+
+use Micropoly\Env;
+use Micropoly\Handler;
+use Micropoly\Models\Tag;
+
+class Index implements Handler
+{
+
+ public function handle(Env $env, array $variables)
+ {
+ echo $env->twig()->render("/index.twig", [
+ "title" => "hello",
+ "msg" => "Johoo <script>alert(1)</script>",
+ "tagcloud" => Tag::calcTagCloud(Tag::getTagCounts($env->db())),
+ ]);
+ }
+}
diff --git a/src/Handlers/JsonAPIHandler.php b/src/Handlers/JsonAPIHandler.php
new file mode 100644
index 0000000..cc6aa61
--- /dev/null
+++ b/src/Handlers/JsonAPIHandler.php
@@ -0,0 +1,18 @@
+<?php
+
+
+namespace Micropoly\Handlers;
+
+
+use Micropoly\Env;
+use Micropoly\Handler;
+
+abstract class JsonAPIHandler implements Handler
+{
+ abstract protected function handleAPIRequest(Env $env, array $variables): JsonAPIResult;
+
+ public function handle(Env $env, array $variables)
+ {
+ $this->handleAPIRequest($env, $variables)->send();
+ }
+} \ No newline at end of file
diff --git a/src/Handlers/JsonAPIResult.php b/src/Handlers/JsonAPIResult.php
new file mode 100644
index 0000000..905599c
--- /dev/null
+++ b/src/Handlers/JsonAPIResult.php
@@ -0,0 +1,24 @@
+<?php
+
+
+namespace Micropoly\Handlers;
+
+
+class JsonAPIResult
+{
+ public $data;
+ public int $statuscode = 200;
+
+ public function __construct($data, int $statuscode = 200)
+ {
+ $this->data = $data;
+ $this->statuscode = $statuscode;
+ }
+
+ public function send(): void
+ {
+ http_response_code($this->statuscode);
+ header("Content-Type: application/json; charset=UTF-8");
+ echo json_encode($this->data);
+ }
+} \ No newline at end of file
diff --git a/src/Handlers/MethodNotAllowedHandler.php b/src/Handlers/MethodNotAllowedHandler.php
new file mode 100644
index 0000000..53ddb0e
--- /dev/null
+++ b/src/Handlers/MethodNotAllowedHandler.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace Micropoly\Handlers;
+
+use Micropoly\Env;
+use Micropoly\Handler;
+
+class MethodNotAllowedHandler implements Handler
+{
+ public function handle(\Micropoly\Env $env, array $variables)
+ {
+
+ }
+}
diff --git a/src/Handlers/NewNote.php b/src/Handlers/NewNote.php
new file mode 100644
index 0000000..9c60757
--- /dev/null
+++ b/src/Handlers/NewNote.php
@@ -0,0 +1,40 @@
+<?php
+
+
+namespace Micropoly\Handlers;
+
+
+use Micropoly\Env;
+use Micropoly\Esc;
+use Micropoly\Handler;
+use Micropoly\Models\Note;
+
+class NewNote implements Handler
+{
+ private static function getPostedContent(): ?string
+ {
+ if (empty($_POST["content"]))
+ return null;
+
+ $content = trim((string)$_POST["content"]);
+ return empty($content) ? null : $content;
+ }
+
+ public function handle(Env $env, array $variables)
+ {
+ $content = self::getPostedContent();
+ if ($content !== null) {
+ $note = new Note();
+ $note->setContent($content);
+ $note->setTags($_POST["tag"]);
+ $note->save($env->db());
+
+ $url = $env->documentRoot() . "n/" . $note->getId();
+ http_response_code(303);
+ header("Location: {$url}");
+ echo 'Note created: <a href="' . Esc::e($url) . '">';
+ }
+
+ echo $env->twig()->render("/new_note.twig", []);
+ }
+} \ No newline at end of file
diff --git a/src/Handlers/NotFoundHandler.php b/src/Handlers/NotFoundHandler.php
new file mode 100644
index 0000000..1827995
--- /dev/null
+++ b/src/Handlers/NotFoundHandler.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace Micropoly\Handlers;
+
+use Micropoly\Env;
+use Micropoly\Handler;
+
+class NotFoundHandler implements Handler
+{
+ public function handle(Env $env, array $variables)
+ {
+ http_response_code(404);
+ echo "404";
+ }
+}
diff --git a/src/Handlers/NoteHandler.php b/src/Handlers/NoteHandler.php
new file mode 100644
index 0000000..afdabb5
--- /dev/null
+++ b/src/Handlers/NoteHandler.php
@@ -0,0 +1,39 @@
+<?php
+
+
+namespace Micropoly\Handlers;
+
+
+use Micropoly\Env;
+use Micropoly\Handler;
+use Micropoly\Models\Note;
+
+class NoteHandler implements Handler
+{
+ public function handle(Env $env, array $variables)
+ {
+ $db = $env->db();
+
+ $note = Note::byId($db, $variables["id"]);
+ if ($note === null) {
+ (new NotFoundHandler())->handle($env, []);
+ return;
+ }
+
+ if ($_SERVER["REQUEST_METHOD"] === "POST") {
+ if ($_POST["delete"] === "delete") {
+ $note->delete($db);
+ http_response_code(303);
+ $url = $env->documentRoot();
+ header("Location: {$url}");
+ return;
+ }
+
+ $note->setContent($_POST["content"]);
+ $note->setTags($_POST["tag"]);
+ $note->save($db);
+ }
+
+ echo $env->twig()->render("/note.twig", ["note" => $note]);
+ }
+} \ No newline at end of file
diff --git a/src/Handlers/Search.php b/src/Handlers/Search.php
new file mode 100644
index 0000000..30311ea
--- /dev/null
+++ b/src/Handlers/Search.php
@@ -0,0 +1,33 @@
+<?php
+
+
+namespace Micropoly\Handlers;
+
+use Micropoly\Env;
+use Micropoly\Handler;
+use Micropoly\Models\Note;
+use Micropoly\Search\ParseError;
+use Micropoly\Search\Parser;
+use Micropoly\Search\SearchResult;
+use Micropoly\Search\TrueExpr;
+
+class Search implements Handler
+{
+ public function handle(Env $env, array $variables)
+ {
+ $vars = ["query" => $_GET["q"] ?? ""];
+
+ try {
+ $expr = isset($_GET["q"])
+ ? (Parser::parse($_GET["q"]) ?? new TrueExpr())
+ : new TrueExpr();
+
+ $results = SearchResult::search($env->db(), $expr);
+ $vars["results"] = $results;
+ } catch (ParseError $e) {
+ $vars["error"] = $e->getMessage();
+ }
+
+ echo $env->twig()->render("/search.twig", $vars);
+ }
+} \ No newline at end of file
diff --git a/src/Log.php b/src/Log.php
new file mode 100644
index 0000000..fce455f
--- /dev/null
+++ b/src/Log.php
@@ -0,0 +1,27 @@
+<?php
+
+
+namespace Micropoly;
+
+
+use Monolog\Handler\ErrorLogHandler;
+use Monolog\Logger;
+use Psr\Log\LoggerInterface;
+
+class Log
+{
+ public static function logger(): LoggerInterface
+ {
+ static $logger = null;
+ if ($logger === null)
+ $logger = self::initLogger();
+ return $logger;
+ }
+
+ private static function initLogger(): Logger
+ {
+ $logger = new Logger("logger");
+ $logger->pushHandler(new ErrorLogHandler());
+ return $logger;
+ }
+} \ No newline at end of file
diff --git a/src/Main.php b/src/Main.php
new file mode 100644
index 0000000..443960b
--- /dev/null
+++ b/src/Main.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Micropoly;
+
+use Closure;
+use FastRoute\Dispatcher;
+use FastRoute\RouteCollector;
+use Micropoly\Handlers\ApiTagsHandler;
+use Micropoly\Handlers\Index;
+use Micropoly\Handlers\MethodNotAllowedHandler;
+use Micropoly\Handlers\NewNote;
+use Micropoly\Handlers\NoteHandler;
+use Micropoly\Handlers\NotFoundHandler;
+
+use Micropoly\Handlers\Search;
+use function FastRoute\simpleDispatcher;
+
+class Main implements Entrypoint
+{
+ private static function buildRoutes(RouteCollector $r)
+ {
+ $r->addRoute(["GET"], "/", Index::class);
+ $r->addRoute(["GET", "POST"], "/new-note", NewNote::class);
+ $r->addRoute(["GET"], "/search", Search::class);
+ $r->addRoute(["GET", "POST"], "/n/{id}", NoteHandler::class);
+ $r->addRoute(["GET"], "/api/tags", ApiTagsHandler::class);
+ }
+
+ public function run(Env $env)
+ {
+ $disp = simpleDispatcher(Closure::fromCallable([self::class, "buildRoutes"]));
+
+ $uri = preg_replace('/\?.*$/', "", $_SERVER["REQUEST_URI"]);
+ $result = $disp->dispatch($_SERVER["REQUEST_METHOD"], $uri);
+ switch ($result[0]) {
+ case Dispatcher::NOT_FOUND:
+ $handlerCls = NotFoundHandler::class;
+ $vars = [];
+ break;
+ case Dispatcher::FOUND:
+ [, $handlerCls, $vars] = $result;
+ break;
+ case Dispatcher::METHOD_NOT_ALLOWED:
+ $handlerCls = MethodNotAllowedHandler::class;
+ $vars = ["allowed" => $result[1]];
+ break;
+ default:
+ throw new \DomainException("Unexpected routing result: {$result[0]}");
+ }
+
+ $handler = new $handlerCls();
+ if (!($handler instanceof Handler)) {
+ throw new \DomainException("handler is not an instance of ".Handler::class);
+ }
+
+ $handler->handle($env, $vars);
+ }
+}
diff --git a/src/Models/Note.php b/src/Models/Note.php
new file mode 100644
index 0000000..901f5aa
--- /dev/null
+++ b/src/Models/Note.php
@@ -0,0 +1,250 @@
+<?php
+
+
+namespace Micropoly\Models;
+
+
+use Micropoly\BoundVal;
+use Micropoly\DbQuery;
+use Micropoly\Search\SearchExpr;
+use SQLite3;
+
+class Note
+{
+ private bool $savedToDb = false;
+ private string $id;
+ private string $content;
+ private array $tags = [];
+ private bool $trash = false;
+
+ /**
+ * Note constructor.
+ */
+ public function __construct()
+ {
+ $this->id = uniqid("", true);
+ }
+
+ /**
+ * @param SQLite3 $db
+ * @param DbQuery $query
+ * @return self[]
+ */
+ private static function fromQuery(SQLite3 $db, DbQuery $query): array
+ {
+ $out = [];
+
+ foreach ($query->fetchRowsAssoc($db) as $row) {
+ $note = new self();
+
+ $note->savedToDb = true;
+ $note->id = $row["id"];
+ $note->content = $row["content"];
+ $note->trash = (bool)(int)$row["trash"];
+
+ $out[$row["id"]] = $note;
+ }
+
+ if (!empty($out)) {
+ $q = (new DbQuery("SELECT tag, note_id FROM tags WHERE note_id IN (" . DbQuery::valueListPlaceholders($out) . ")"))
+ ->bindMultiText(array_keys($out));
+
+ foreach ($q->fetchRows($db) as [$tag, $id]) {
+ $out[$id]->tags[] = $tag;
+ }
+ }
+
+ return $out;
+ }
+
+ /**
+ * @param SQLite3 $db
+ * @param array $ids
+ * @return self[] indexes by id
+ */
+ public static function byIds(SQLite3 $db, array $ids): array
+ {
+ if (empty($ids))
+ return [];
+
+ $query = (new DbQuery("
+ SELECT note.id, content.content, note.trash
+ FROM notes note
+ INNER JOIN note_contents content
+ ON content.rowid = note.content_row
+ WHERE id IN (" . DbQuery::valueListPlaceholders($ids) . ")
+ "))->bindMultiText($ids);
+
+ return self::fromQuery($db, $query);
+ }
+
+ public static function byId(SQLite3 $db, string $id): ?self
+ {
+ return self::byIds($db, [$id])[$id] ?? null;
+ }
+
+ /**
+ * @return string
+ */
+ public function getId(): string
+ {
+ return $this->id;
+ }
+
+ /**
+ * @return string
+ */
+ public function getContent(): string
+ {
+ return $this->content;
+ }
+
+ /**
+ * @param string $content
+ * @return Note
+ */
+ public function setContent(string $content): Note
+ {
+ $this->content = $content;
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ public function getTags(): array
+ {
+ return $this->tags;
+ }
+
+ /**
+ * @param array $tags
+ * @return Note
+ */
+ public function setTags(array $tags): Note
+ {
+ $tags = array_map("trim", $tags);
+ $tags = array_filter($tags);
+ $tags = array_unique($tags);
+
+ $this->tags = $tags;
+ return $this;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isTrash(): bool
+ {
+ return $this->trash;
+ }
+
+ /**
+ * @param bool $trash
+ * @return Note
+ */
+ public function setTrash(bool $trash): Note
+ {
+ $this->trash = $trash;
+ return $this;
+ }
+
+ private function deleteContent(SQLite3 $db)
+ {
+ (new DbQuery("DELETE FROM note_contents WHERE rowid IN (SELECT content_row FROM notes WHERE id = ?)"))
+ ->bind(1, BoundVal::ofText($this->id))
+ ->exec($db);
+ }
+
+ private function deleteTags(SQLite3 $db)
+ {
+ (new DbQuery("DELETE FROM tags WHERE note_id = ?"))
+ ->bind(1, BoundVal::ofText($this->id))
+ ->exec($db);
+ }
+
+ public function save(SQLite3 $db)
+ {
+ if ($this->savedToDb)
+ $this->update($db);
+ else
+ $this->insert($db);
+ }
+
+ private function insert(SQLite3 $db)
+ {
+ $db->exec("BEGIN");
+
+ $this->deleteContent($db);
+
+ DbQuery::insertKV($db, "note_contents", ["content" => BoundVal::ofText($this->content)]);
+ $rowid = (new DbQuery("SELECT last_insert_rowid()"))->fetchRow($db)[0];
+
+ DbQuery::insertKV($db, "notes", [
+ "id" => BoundVal::ofText($this->id),
+ "content_row" => BoundVal::ofInt($rowid),
+ "trash" => BoundVal::ofInt($this->trash ? 0 : 1),
+ ]);
+
+ $this->writeTags($db);
+
+ $db->exec("COMMIT");
+ }
+
+ private function update(SQLite3 $db)
+ {
+ $db->exec("BEGIN");
+
+ $this->deleteTags($db);
+
+ (new DbQuery("
+ UPDATE note_contents
+ SET content = :content
+ WHERE rowid = (
+ SELECT content_row
+ FROM notes
+ WHERE id = :id
+ )
+ "))
+ ->bind("content", BoundVal::ofText($this->content))
+ ->bind("id", BoundVal::ofText($this->id))
+ ->exec($db);
+
+ $this->writeTags($db);
+
+ (new DbQuery("
+ UPDATE notes
+ SET changed_at = CURRENT_TIMESTAMP,
+ trash = :trash
+ WHERE id = :id
+ "))
+ ->bind("id", BoundVal::ofText($this->id))
+ ->bind("trash", BoundVal::ofInt($this->trash ? 0 : 1))
+ ->exec($db);
+
+ $db->exec("COMMIT");
+ }
+
+ /**
+ * @param SQLite3 $db
+ */
+ private function writeTags(SQLite3 $db): void
+ {
+ $this->deleteTags($db);
+ DbQuery::insert($db,
+ "tags",
+ ["note_id", "tag"],
+ array_map(fn($t) => [BoundVal::ofText($this->id), BoundVal::ofText($t)], $this->tags)
+ );
+ }
+
+ public function delete(SQLite3 $db): void
+ {
+ $this->deleteTags($db);
+ $this->deleteContent($db);
+ (new DbQuery("DELETE FROM notes WHERE id = ?"))
+ ->bind(1, BoundVal::ofText($this->id))
+ ->exec($db);
+ $this->savedToDb = false;
+ }
+} \ No newline at end of file
diff --git a/src/Models/Tag.php b/src/Models/Tag.php
new file mode 100644
index 0000000..b119fe8
--- /dev/null
+++ b/src/Models/Tag.php
@@ -0,0 +1,37 @@
+<?php
+
+
+namespace Micropoly\Models;
+
+
+use Micropoly\DbQuery;
+
+class Tag
+{
+ private const TAGCLOUD_MAGNITUDES = 5;
+
+ /**
+ * Calculates a tag cloud array to be used as an input to the tagcloud macro
+ * @param array $tagCounts [string tag => int count]
+ * @return array
+ */
+ public static function calcTagCloud(array $tagCounts): array
+ {
+ $tagCounts = array_map("intval", $tagCounts);
+ $tagCounts = array_filter($tagCounts, fn ($count) => $count !== 0);
+
+ if (empty($tagCounts))
+ return [];
+
+ $maxCount = max(array_values($tagCounts));
+ $tagCounts = array_map(fn ($count) => floor($count / ($maxCount+1) * self::TAGCLOUD_MAGNITUDES) + 1, $tagCounts);
+ ksort($tagCounts);
+ return $tagCounts;
+ }
+
+ public static function getTagCounts(\SQLite3 $db): array
+ {
+ return (new DbQuery("SELECT tag, num FROM tagcloud"))
+ ->fetchIndexedValues($db, "num", "tag");
+ }
+} \ No newline at end of file
diff --git a/src/Schema.php b/src/Schema.php
new file mode 100644
index 0000000..bbb47de
--- /dev/null
+++ b/src/Schema.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace Micropoly;
+
+use SQLite3;
+
+class Schema
+{
+ private SQLite3 $db;
+
+ /**
+ * @param SQLite3 $db
+ */
+ public function __construct(SQLite3 $db) { $this->db = $db; }
+
+ private function getSchemaVersion(): int
+ {
+ $n = $this->db->querySingle("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_meta'");
+ if ($n !== "schema_meta")
+ return 0;
+
+ return (int)$this->db->querySingle("SELECT value FROM schema_meta WHERE key = 'version'");
+ }
+
+ private function setSchemaVersion(int $v): void
+ {
+ (new DbQuery("REPLACE INTO schema_meta (key, value) VALUES ('version', :v)"))
+ ->bind(":v", $v)
+ ->exec($this->db);
+ }
+
+ public function migrate()
+ {
+ $version = $this->getSchemaVersion();
+
+ switch ($version) {
+ case 0:
+ $this->v1();
+ $this->setSchemaVersion(1);
+ }
+ }
+
+ private function v1()
+ {
+ $this->db->exec("
+ CREATE TABLE schema_meta (
+ key VARCHAR(100) NOT NULL PRIMARY KEY,
+ value
+ ) WITHOUT ROWID
+ ");
+ $this->db->exec("
+ CREATE VIRTUAL TABLE note_contents USING fts4 (content TEXT)
+ ");
+ $this->db->exec("
+ CREATE TABLE notes (
+ id VARCHAR(23) NOT NULL PRIMARY KEY,
+ content_row INT NOT NULL,
+ created_at BIGINT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ changed_at BIGINT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ trash INT NOT NULL DEFAULT 0
+ ) WITHOUT ROWID
+ ");
+ $this->db->exec("
+ CREATE TABLE tags (
+ note_id VARCHAR(23) NOT NULL REFERENCES notes (id) ON UPDATE CASCADE ON DELETE CASCADE,
+ tag TEXT NOT NULL,
+ PRIMARY KEY (note_id, tag)
+ ) WITHOUT ROWID
+ ");
+ $this->db->exec("CREATE INDEX tag ON tags (tag)");
+ $this->db->exec("
+ CREATE VIEW tagcloud AS
+ SELECT
+ tag,
+ COUNT(*) AS num
+ FROM tags
+ GROUP BY tag
+ ");
+ }
+}
diff --git a/src/Search/AbstractFTSExpr.php b/src/Search/AbstractFTSExpr.php
new file mode 100644
index 0000000..b72b1b6
--- /dev/null
+++ b/src/Search/AbstractFTSExpr.php
@@ -0,0 +1,31 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+abstract class AbstractFTSExpr implements SearchExpr
+{
+ abstract protected function fts4Query(): string;
+
+ public function toSQL(string $bindPrefix, bool $singleFTS): SQLSearchExpr
+ {
+ $sqlex = new SQLSearchExpr();
+
+ $sqlex->sql = $singleFTS
+ ? "nc.note_contents MATCH :{$bindPrefix}match"
+ : "n.content_row IN (
+ SELECT rowid
+ FROM note_contents
+ WHERE note_contents MATCH :{$bindPrefix}match
+ )";
+ $sqlex->bindings["{$bindPrefix}match"] = $this->fts4Query();
+
+ return $sqlex;
+ }
+
+ public function countFTSQueries(): int
+ {
+ return 1;
+ }
+} \ No newline at end of file
diff --git a/src/Search/CharSource.php b/src/Search/CharSource.php
new file mode 100644
index 0000000..165e538
--- /dev/null
+++ b/src/Search/CharSource.php
@@ -0,0 +1,33 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+class CharSource
+{
+ private string $s;
+ private int $i = 0;
+ private int $len;
+
+ public function __construct(string $s)
+ {
+ $this->s = $s;
+ $this->len = mb_strlen($s);
+ }
+
+ public function getNext(): ?string
+ {
+ if ($this->i >= $this->len)
+ return null;
+
+ $c = mb_substr($this->s, $this->i, 1);
+ $this->i++;
+ return $c;
+ }
+
+ public function unget(): void
+ {
+ $this->i = max(0, $this->i - 1);
+ }
+} \ No newline at end of file
diff --git a/src/Search/FTSExpr.php b/src/Search/FTSExpr.php
new file mode 100644
index 0000000..1123cf3
--- /dev/null
+++ b/src/Search/FTSExpr.php
@@ -0,0 +1,30 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+class FTSExpr extends AbstractFTSExpr
+{
+ private string $term;
+
+ public function __construct(string $term)
+ {
+ $this->term = $term;
+ }
+
+ public function getTerm(): string
+ {
+ return $this->term;
+ }
+
+ protected function fts4Query(): string
+ {
+ return '"' . str_replace('"', '""', $this->term) . '"';
+ }
+
+ public function toString(): string
+ {
+ return '"' . preg_replace_callback('/(["\\\\])/', fn($s) => "\\$s", $this->term) . '"';
+ }
+} \ No newline at end of file
diff --git a/src/Search/FTSLogicOp.php b/src/Search/FTSLogicOp.php
new file mode 100644
index 0000000..452f63b
--- /dev/null
+++ b/src/Search/FTSLogicOp.php
@@ -0,0 +1,46 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+class FTSLogicOp extends AbstractFTSExpr
+{
+ private string $op;
+ private AbstractFTSExpr $a;
+ private AbstractFTSExpr $b;
+
+ /**
+ * FTSLogicOp constructor.
+ * @param string $op
+ * @param AbstractFTSExpr $a
+ * @param AbstractFTSExpr $b
+ */
+ public function __construct(string $op, AbstractFTSExpr $a, AbstractFTSExpr $b)
+ {
+ if (!LogicOp::checkOp($op))
+ throw new \DomainException("{$op} is not a valid operator");
+
+ $this->op = $op;
+ $this->a = $a;
+ $this->b = $b;
+ }
+
+ private const FTSOPS = [
+ LogicOp::OP_AND => "",
+ LogicOp::OP_OR => "OR",
+ ];
+
+ protected function fts4Query(): string
+ {
+ $ftsop = self::FTSOPS[$this->op];
+ assert($ftsop);
+
+ return "({$this->a->fts4Query()} {$ftsop} {$this->b->fts4Query()})";
+ }
+
+ public function toString(): string
+ {
+ return "({$this->a->toString()} FTS-{$this->op} {$this->b->toString()})";
+ }
+} \ No newline at end of file
diff --git a/src/Search/FTSNotExpr.php b/src/Search/FTSNotExpr.php
new file mode 100644
index 0000000..a4aa219
--- /dev/null
+++ b/src/Search/FTSNotExpr.php
@@ -0,0 +1,29 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+class FTSNotExpr extends AbstractFTSExpr
+{
+ private AbstractFTSExpr $expr;
+
+ /**
+ * FTSNotExpr constructor.
+ * @param AbstractFTSExpr $expr
+ */
+ public function __construct(AbstractFTSExpr $expr)
+ {
+ $this->expr = $expr;
+ }
+
+ protected function fts4Query(): string
+ {
+ return "-{$this->expr->fts4Query()}";
+ }
+
+ public function toString(): string
+ {
+ return "(FTS-NOT {$this->expr->toString()})";
+ }
+} \ No newline at end of file
diff --git a/src/Search/LogicOp.php b/src/Search/LogicOp.php
new file mode 100644
index 0000000..85fb8fa
--- /dev/null
+++ b/src/Search/LogicOp.php
@@ -0,0 +1,78 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+class LogicOp implements SearchExpr
+{
+ public const OP_AND = "and";
+ public const OP_OR = "or";
+
+ private const SQLOPS = [
+ self::OP_AND => "AND",
+ self::OP_OR => "OR",
+ ];
+
+ private string $op;
+ private SearchExpr $a;
+ private SearchExpr $b;
+
+ public function __construct(string $op, SearchExpr $a, SearchExpr $b)
+ {
+ if (!self::checkOp($op))
+ throw new \DomainException("{$op} is not a valid operator");
+
+ $this->op = $op;
+ $this->a = $a;
+ $this->b = $b;
+ }
+
+ public static function build(string $op, SearchExpr $a, SearchExpr $b): SearchExpr
+ {
+ return $a instanceof AbstractFTSExpr && $b instanceof AbstractFTSExpr
+ ? new FTSLogicOp($op, $a, $b)
+ : new self($op, $a, $b);
+ }
+
+ /**
+ * @param string $op
+ * @return bool
+ */
+ public static function checkOp(string $op): bool
+ {
+ return in_array($op, [
+ self::OP_AND,
+ self::OP_OR,
+ ]);
+ }
+
+ public function getA(): SearchExpr { return $this->a; }
+ public function getB(): SearchExpr { return $this->b; }
+ public function getOp(): string { return $this->op; }
+
+ public function toString(): string
+ {
+ return "({$this->a->toString()}) {$this->op} ({$this->b->toString()})";
+ }
+
+ public function toSQL($bindPrefix, bool $singleFTS): SQLSearchExpr
+ {
+ $sqlex = new SQLSearchExpr();
+
+ $a = $this->a->toSQL("a_$bindPrefix", $singleFTS);
+ $b = $this->b->toSQL("b_$bindPrefix", $singleFTS);
+ $sqlop = self::SQLOPS[$this->op];
+ assert($sqlop);
+
+ $sqlex->sql = "(({$a->sql}) {$sqlop} ({$b->sql}))";
+ $sqlex->bindings = array_merge($a->bindings, $b->bindings);
+
+ return $sqlex;
+ }
+
+ public function countFTSQueries(): int
+ {
+ return $this->a->countFTSQueries() + $this->b->countFTSQueries();
+ }
+} \ No newline at end of file
diff --git a/src/Search/NotOp.php b/src/Search/NotOp.php
new file mode 100644
index 0000000..35fcf1e
--- /dev/null
+++ b/src/Search/NotOp.php
@@ -0,0 +1,32 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+class NotOp implements SearchExpr
+{
+ private SearchExpr $expr;
+
+ public function __construct(SearchExpr $expr)
+ {
+ $this->expr = $expr;
+ }
+
+ public function toString(): string
+ {
+ return "not ({$this->expr->toString()})";
+ }
+
+ public function toSQL(string $bindPrefix, bool $singleFTS): SQLSearchExpr
+ {
+ $sqlex = $this->expr->toSQL($bindPrefix, $singleFTS);
+ $sqlex->sql = "(NOT ({$sqlex->sql}))";
+ return $sqlex;
+ }
+
+ public function countFTSQueries(): int
+ {
+ return $this->expr->countFTSQueries();
+ }
+} \ No newline at end of file
diff --git a/src/Search/Pagination.php b/src/Search/Pagination.php
new file mode 100644
index 0000000..b4b2447
--- /dev/null
+++ b/src/Search/Pagination.php
@@ -0,0 +1,14 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+class Pagination
+{
+ public const DEFAULT_PER_PAGE = 25;
+
+ private int $page = 1;
+
+
+} \ No newline at end of file
diff --git a/src/Search/ParseError.php b/src/Search/ParseError.php
new file mode 100644
index 0000000..1b987d7
--- /dev/null
+++ b/src/Search/ParseError.php
@@ -0,0 +1,9 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+use Exception;
+
+class ParseError extends Exception { } \ No newline at end of file
diff --git a/src/Search/Parser.php b/src/Search/Parser.php
new file mode 100644
index 0000000..a8efdfd
--- /dev/null
+++ b/src/Search/Parser.php
@@ -0,0 +1,295 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+use Generator;
+use Iterator;
+
+class Parser
+{
+ public const TOK_PAROPEN = "(";
+ public const TOK_PARCLOSE = ")";
+ public const TOK_TAG = "#";
+ public const TOK_WORD = '"';
+ public const TOK_OP = "op";
+ public const TOK_PROP = ":";
+
+ private static function iterChars(string $input): Iterator
+ {
+ for ($i = 0; $i < mb_strlen($input); $i++)
+ yield mb_substr($input, $i, 1);
+ }
+
+ /**
+ * @param string $input
+ * @return Iterator
+ * @throws ParseError
+ */
+ public static function tokenize(string $input): Iterator
+ {
+ $chars = new CharSource($input);
+ yield from self::tokenize_normal($chars);
+ }
+
+ private static function getItemAndAdvance(Iterator $input)
+ {
+ if (!$input->valid())
+ return null;
+ $out = $input->current();
+ $input->next();
+ return $out;
+ }
+
+ /**
+ * @return Iterator
+ * @throws ParseError
+ */
+ private static function tokenize_normal(CharSource $input): Iterator
+ {
+ $buf = "";
+
+ $yieldBufAndClear = function () use (&$buf) {
+ if ($buf !== "") {
+ switch ($buf) {
+ case "and":
+ case "or":
+ case "not":
+ yield [self::TOK_OP, $buf];
+ break;
+ default:
+ yield [self::TOK_WORD, $buf];
+ }
+ }
+ $buf = "";
+ };
+
+ for (;;) {
+ $c = $input->getNext();
+ if ($c === null) {
+ break;
+ }
+
+ switch ($c) {
+ case '\\':
+ $next = $input->getNext();
+ if ($next === null) {
+ $buf .= $c;
+ break 2;
+ }
+ $buf .= $next;
+ break;
+
+ case ' ':
+ case "\t":
+ yield from $yieldBufAndClear();
+ break;
+
+ case '"':
+ yield from $yieldBufAndClear();
+ yield from self::tokenize_string($input);
+ break;
+
+ case ':':
+ if ($buf !== "") {
+ yield [self::TOK_PROP, $buf];
+ $buf = "";
+ }
+ break;
+
+ case '(':
+ yield from $yieldBufAndClear();
+ yield [self::TOK_PAROPEN, null];
+ break;
+
+ case ')':
+ yield from $yieldBufAndClear();
+ yield [self::TOK_PARCLOSE, null];
+ break;
+
+ case '#':
+ yield from $yieldBufAndClear();
+ yield from self::tokenize_tag($input);
+ break;
+
+ default:
+ $buf .= $c;
+ }
+ }
+
+ yield from $yieldBufAndClear();
+ return;
+ }
+
+ /**
+ * @param string $input
+ * @return SearchExpr|null
+ * @throws ParseError
+ */
+ public static function parse(string $input): ?SearchExpr
+ {
+ $tokens = self::tokenize($input);
+
+ $stack = [];
+ $cur = null;
+ $binOp = null;
+ $negated = false;
+
+ $putExpr = function (SearchExpr $expr) use (&$cur, &$binOp, &$negated) {
+ if ($negated) {
+ $expr = new NotOp($expr);
+ }
+
+ $cur = $cur === null
+ ? $expr
+ : LogicOp::build($binOp ?? LogicOp::OP_AND, $cur, $expr);
+
+ $binOp = null;
+ $negated = false;
+ };
+
+ $setBinOp = function ($op) use (&$binOp) {
+ if ($binOp !== null)
+ throw new ParseError("Unexpected logic operator $op");
+
+ $binOp = $op;
+ };
+
+ for (;;) {
+ $token = self::getItemAndAdvance($tokens);
+ if ($token === null)
+ break;
+
+ [$ttyp, $tdata] = $token;
+
+ switch ($ttyp) {
+
+ case self::TOK_TAG:
+ $putExpr(new TagExpr($tdata));
+ break;
+ case self::TOK_OP:
+ switch ($tdata) {
+ case "and":
+ $setBinOp(LogicOp::OP_AND);
+ break;
+ case "or":
+ $setBinOp(LogicOp::OP_OR);
+ break;
+ case "not":
+ $negated = !$negated;
+ break;
+ default:
+ throw new \DomainException("Unexpected data for TOK_OP: $tdata");
+ }
+ break;
+ case self::TOK_WORD:
+ $putExpr(new FTSExpr($tdata));
+ break;
+ case self::TOK_PROP:
+ // TODO(laria): Implement this
+ throw new ParseError("Not yet supported");
+ case self::TOK_PAROPEN:
+ $stack[] = [$cur, $binOp, $negated];
+ $cur = $binOp = $negated = null;
+ break;
+ case self::TOK_PARCLOSE:
+ if (empty($stack))
+ throw new ParseError("Unexpected closing parenthesis");
+
+ $parContent = $cur;
+ [$cur, $binOp, $negated] = array_pop($stack);
+ $putExpr($parContent);
+ break;
+ }
+ }
+
+ if (!empty($stack))
+ throw new ParseError("Unclosed parenthesis");
+
+ return $cur;
+ }
+
+ /**
+ * @param CharSource $input
+ * @return Generator
+ * @throws ParseError
+ */
+ private static function tokenize_string(CharSource $input): Generator
+ {
+ $content = "";
+ for (;;) {
+ $c = $input->getNext();
+ if ($c === null)
+ throw new ParseError("Unclosed string encountered");
+
+ switch ($c) {
+ case '\\':
+ $next = $input->getNext();
+ if ($next === null)
+ throw new ParseError("Unclosed string encountered");
+
+ $content .= $next;
+ break;
+
+ case '"':
+ yield [self::TOK_WORD, $content];
+ return;
+
+ default:
+ $content .= $c;
+ }
+ }
+ }
+
+ /**
+ * @param CharSource $input
+ * @return Iterator
+ */
+ private static function tokenize_tag(CharSource $input): Iterator
+ {
+ $tag = "";
+
+ $yieldTag = function () use (&$tag) {
+ if ($tag === "")
+ yield [self::TOK_WORD, "#"];
+ else
+ yield [self::TOK_TAG, $tag];
+ };
+
+ for (;;) {
+ $c = $input->getNext();
+ if ($c === null) {
+ yield from $yieldTag();
+ return;
+ }
+
+ switch ($c) {
+ case '\\':
+ $next = $input->getNext();
+ if ($c === null) {
+ $tag .= '\\';
+ yield [self::TOK_TAG, $tag];
+ return;
+ }
+ $tag .= $next;
+ break;
+
+ case ' ':
+ case "\t":
+ yield from $yieldTag();
+ return;
+
+ case '(':
+ case ')':
+ case '#':
+ $input->unget();
+ yield from $yieldTag();
+ return;
+
+ default:
+ $tag .= $c;
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Search/SQLSearchExpr.php b/src/Search/SQLSearchExpr.php
new file mode 100644
index 0000000..76306ce
--- /dev/null
+++ b/src/Search/SQLSearchExpr.php
@@ -0,0 +1,11 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+class SQLSearchExpr
+{
+ public string $sql;
+ public array $bindings = [];
+} \ No newline at end of file
diff --git a/src/Search/SearchExpr.php b/src/Search/SearchExpr.php
new file mode 100644
index 0000000..fbf2a40
--- /dev/null
+++ b/src/Search/SearchExpr.php
@@ -0,0 +1,14 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+interface SearchExpr
+{
+ public function toString(): string;
+
+ public function toSQL(string $bindPrefix, bool $singleFTS): SQLSearchExpr;
+
+ public function countFTSQueries(): int;
+} \ No newline at end of file
diff --git a/src/Search/SearchResult.php b/src/Search/SearchResult.php
new file mode 100644
index 0000000..1abbb86
--- /dev/null
+++ b/src/Search/SearchResult.php
@@ -0,0 +1,160 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+use LogicException;
+use Micropoly\DbQuery;
+use Micropoly\Esc;
+use Micropoly\Models\Note;
+use SQLite3;
+
+class SearchResult
+{
+ private Note $note;
+ private array $highlights = [];
+
+ private function __construct(Note $note, array $highlights)
+ {
+ $this->note = $note;
+ $this->highlights = $highlights;
+ }
+
+ /**
+ * @param SQLite3 $db
+ * @param SearchExpr $expr
+ * @return self[]
+ */
+ public static function search(SQLite3 $db, SearchExpr $expr): array
+ {
+ return $expr->countFTSQueries() === 1
+ ? self::searchFTS($db, $expr)
+ : self::searchComplex($db, $expr);
+ }
+
+ private static function searchComplex(SQLite3 $db, SearchExpr $expr): array
+ {
+ $sqlSearchExpr = $expr->toSQL("", false);
+
+ $query = new DbQuery("
+ SELECT
+ n.id
+ FROM notes n
+ INNER JOIN note_contents nc
+ ON nc.rowid = n.content_row
+ WHERE {$sqlSearchExpr->sql}
+ ");
+
+ foreach ($sqlSearchExpr->bindings as $k => $v)
+ $query->bind($k, $v);
+
+ $ids = array_map(fn ($row) => $row[0], $query->fetchRows($db));
+ $notes = Note::byIds($db, $ids);
+ return array_map(fn ($note) => new self($note, []), $notes);
+ }
+
+ private static function highlightRangeContains(array $range, int $point): bool
+ {
+ [$start, $end] = $range;
+ return $start <= $point && $point <= $end;
+ }
+
+ private static function areHighlightsOverlapping(array $a, array $b): bool
+ {
+ [$aStart, $aEnd] = $a;
+ [$bStart, $bEnd] = $b;
+
+ return self::highlightRangeContains($a, $bStart)
+ || self::highlightRangeContains($a, $bEnd)
+ || self::highlightRangeContains($b, $aStart)
+ || self::highlightRangeContains($b, $aEnd);
+ }
+
+ private static function parseOffsetsToHighlights(string $offsets): array
+ {
+ $offsets = explode(" ", $offsets);
+ $offsets = array_map("intval", $offsets);
+
+ $phraseMatches = count($offsets) / 4;
+
+ $highlights = [];
+ for ($i = 0; $i < $phraseMatches; $i++) {
+ $off = $offsets[$i * 4 + 2];
+ $len = $offsets[$i * 4 + 3];
+
+ if ($off < 0 || $len === 0)
+ continue;
+
+ $highlights[] = [$off, $off+$len-1];
+ }
+
+ usort($highlights, fn ($a, $b) => ($a[0] <=> $b[0]) ?: ($b[1] <=> $a[1]));
+
+ // merge overlapping areas
+ for ($i = count($highlights)-1; $i >= 0; $i--) {
+ for ($j = $i-1; $j >= 0; $j--) {
+ if (self::areHighlightsOverlapping($highlights[$i], $highlights[$j])) {
+ [$iStart, $iEnd] = $highlights[$i];
+ [$jStart, $jEnd] = $highlights[$j];
+
+ $highlights[$j] = [min($iStart, $jStart), max($iEnd, $jEnd)];
+ unset($highlights[$i]);
+ break;
+ }
+ }
+ }
+
+ return array_merge($highlights); // array_merge here renumbers the keys
+ }
+
+ private static function searchFTS(SQLite3 $db, SearchExpr $expr)
+ {
+ $sqlSearchExpr = $expr->toSQL("", true);
+ $query = new DbQuery("
+ SELECT
+ n.id,
+ offsets(nc.note_contents) AS offsets
+ FROM notes n
+ INNER JOIN note_contents nc
+ ON nc.rowid = n.content_row
+ WHERE {$sqlSearchExpr->sql}
+ ");
+ foreach ($sqlSearchExpr->bindings as $k => $v)
+ $query->bind($k, $v);
+
+
+ $offsets = $query->fetchIndexedValues($db, "offsets", "id");
+
+ $notes = Note::byIds($db, array_keys($offsets));
+
+ $out = [];
+ foreach ($offsets as $id => $offString) {
+ if (!isset($notes[$id]))
+ throw new LogicException("Note '{$id}' not loaded but found?");
+
+ $out[] = new self($notes[$id], self::parseOffsetsToHighlights($offString));
+ }
+
+ return $out;
+ }
+
+ public function renderHighlightedContent(): string
+ {
+ $out = "";
+ $content = $this->note->getContent();
+ $lastOff = 0;
+ foreach ($this->highlights as [$start, $end]) {
+ $out .= Esc::e(substr($content, $lastOff, $start - $lastOff), Esc::HTML_WITH_BR);
+ $out .= '<b>' . Esc::e(substr($content, $start, $end - $start + 1), Esc::HTML_WITH_BR) . '</b>';
+
+ $lastOff = $end + 1;
+ }
+
+ $out .= Esc::e(substr($content, $lastOff), Esc::HTML_WITH_BR);
+
+ return $out;
+ }
+
+ public function getNote(): Note { return $this->note; }
+} \ No newline at end of file
diff --git a/src/Search/TagExpr.php b/src/Search/TagExpr.php
new file mode 100644
index 0000000..b117bbe
--- /dev/null
+++ b/src/Search/TagExpr.php
@@ -0,0 +1,42 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+class TagExpr implements SearchExpr
+{
+ private string $tag;
+
+ public function __construct(string $tag)
+ {
+ $this->tag = $tag;
+ }
+
+ public function getTag(): string { return $this->tag; }
+
+ public function toString(): string
+ {
+ return "#{$this->tag}";
+ }
+
+ public function toSQL(string $bindPrefix, bool $singleFTS): SQLSearchExpr
+ {
+ $sqlex = new SQLSearchExpr();
+
+ $sqlex->sql = "EXISTS (
+ SELECT 1
+ FROM tags t
+ WHERE t.tag = :{$bindPrefix}tag
+ AND t.note_id = n.id
+ )";
+ $sqlex->bindings["{$bindPrefix}tag"] = $this->tag;
+
+ return $sqlex;
+ }
+
+ public function countFTSQueries(): int
+ {
+ return 0;
+ }
+} \ No newline at end of file
diff --git a/src/Search/TrueExpr.php b/src/Search/TrueExpr.php
new file mode 100644
index 0000000..5f25c7e
--- /dev/null
+++ b/src/Search/TrueExpr.php
@@ -0,0 +1,25 @@
+<?php
+
+
+namespace Micropoly\Search;
+
+
+class TrueExpr implements SearchExpr
+{
+ public function toString(): string
+ {
+ return "<TrueExpr>";
+ }
+
+ public function toSQL(string $bindPrefix, bool $singleFTS): SQLSearchExpr
+ {
+ $sqlSearchExpr = new SQLSearchExpr();
+ $sqlSearchExpr->sql = "1";
+ return $sqlSearchExpr;
+ }
+
+ public function countFTSQueries(): int
+ {
+ return 0;
+ }
+} \ No newline at end of file
diff --git a/src/Tools/PopulateDevDb.php b/src/Tools/PopulateDevDb.php
new file mode 100644
index 0000000..8f1b2b9
--- /dev/null
+++ b/src/Tools/PopulateDevDb.php
@@ -0,0 +1,71 @@
+<?php
+
+
+namespace Micropoly\Tools;
+
+
+use Micropoly\Entrypoint;
+use Micropoly\Env;
+use Micropoly\Models\Note;
+use SQLite3;
+
+class PopulateDevDb implements Entrypoint
+{
+ private const NUM_NOTES = 1000;
+ private const WORDS_FILE = "/usr/share/dict/cracklib-small";
+ private const TAGS_MIN_RAND = 0;
+ private const TAGS_MAX_RAND = 6;
+ private const CHANCE_TRASH = 0.1;
+ private const CHANCE_INBOX = 0.4;
+ private const CONTENT_MIN_WORDS = 3;
+ private const CONTENT_MAX_WORDS = 200;
+
+ private array $words = [];
+
+ private function readWords()
+ {
+ $words = file_get_contents(self::WORDS_FILE);
+ $words = explode("\n", $words);
+ $words = array_map("trim", $words);
+ $words = array_filter($words);
+
+ $this->words = $words;
+ }
+
+ public function run(Env $env)
+ {
+ $this->readWords();
+
+ $db = $env->db();
+ for ($i = 0; $i < self::NUM_NOTES; $i++)
+ $this->createTestNote($db);
+ }
+
+ private function randomWords(int $min, int $max): array
+ {
+ $words = [];
+ $num = mt_rand($min, $max);
+ for ($i = 0; $i < $num; $i++)
+ $words[] = $this->words[mt_rand(0, count($this->words)-1)];
+
+ return $words;
+ }
+
+ private static function byChance(float $chance): bool
+ {
+ return mt_rand() / mt_getrandmax() <= $chance;
+ }
+
+ private function createTestNote(SQLite3 $db): void
+ {
+ $note = new Note();
+ $tags = $this->randomWords(self::TAGS_MIN_RAND, self::TAGS_MAX_RAND);
+ if (self::byChance(self::CHANCE_INBOX))
+ $tags[] = "inbox";
+ $note->setTags($tags);
+ $note->setContent(implode(" ", $this->randomWords(self::CONTENT_MIN_WORDS, self::CONTENT_MAX_WORDS)));
+ $note->setTrash(self::byChance(self::CHANCE_TRASH));
+
+ $note->save($db);
+ }
+} \ No newline at end of file
diff --git a/templates/index.twig b/templates/index.twig
new file mode 100644
index 0000000..7f905ee
--- /dev/null
+++ b/templates/index.twig
@@ -0,0 +1,13 @@
+{% extends "skeleton.twig" %}
+{% import "macros.twig" as macros %}
+
+{% block body %}
+ {% if tagcloud %}
+ <section class="tagcloud-outer">
+ <h2>Tagcloud</h2>
+ {{ macros.tagcloud(tagcloud) }}
+ </section>
+ {% endif %}
+
+ {{ macros.new_note() }}
+{% endblock %}
diff --git a/templates/macros.twig b/templates/macros.twig
new file mode 100644
index 0000000..dc7e721
--- /dev/null
+++ b/templates/macros.twig
@@ -0,0 +1,50 @@
+{% macro taglink(tag) %}
+ <a href="{{ url("search") ~ "?q=" ~ ("#" ~ (tag|search_escape))|e("url") }}">{{ tag }}</a>
+{% endmacro %}
+
+{% macro tagcloud(tagcloud) %}
+ <ul class="tagcloud">{% for tag, magnitude in tagcloud %}
+ <li class="tag tc-{{ magnitude }}">{{ _self.taglink(tag) }}</li>
+ {% endfor %}</ul>
+{% endmacro %}
+
+{% macro searchbox(query="") %}
+ <form action="{{ url("search") }}" class="search-form">
+ <input
+ type="text"
+ name="q"
+ placeholder="foo bar, #tag1 #tag2, &quot;full text&quot; and (#tag1 or #tag2), ..."
+ value="{{ query }}"
+ class="search-input"
+ />
+ <button type="submit" class="search-btn">Search</button>
+ </form>
+{% endmacro %}
+
+{% macro note_form_content(note) %}
+ <div class="labelled">
+ <label for="note-content">Content</label>
+ <div class="labelled-content">
+ <textarea id="note-content" name="content">{{ note.content }}</textarea>
+ </div>
+ </div>
+ <fieldset class="tags">
+ <legend>Tags</legend>
+ {% for tag in note.tags %}
+ <input type="text" name="tag[]" value="{{ tag }}" />
+ {% endfor %}
+ {% for i in 0..10 %}
+ <input type="text" name="tag[]" />
+ {% endfor %}
+ </fieldset>
+{% endmacro %}
+
+{% macro new_note() %}
+ <section class="new-note">
+ <h2>New Note</h2>
+ <form action="{{ url("new-note") }}" method="post">
+ {{ _self.note_form_content({}) }}
+ <button type="submit">Create</button>
+ </form>
+ </section>
+{% endmacro %} \ No newline at end of file
diff --git a/templates/new_note.twig b/templates/new_note.twig
new file mode 100644
index 0000000..df9a320
--- /dev/null
+++ b/templates/new_note.twig
@@ -0,0 +1,5 @@
+{% extends "skeleton.twig" %}
+{% import "macros.twig" as macros %}
+{% block body %}
+ {{ macros.new_note() }}
+{% endblock %} \ No newline at end of file
diff --git a/templates/note.twig b/templates/note.twig
new file mode 100644
index 0000000..14989aa
--- /dev/null
+++ b/templates/note.twig
@@ -0,0 +1,15 @@
+{% extends "skeleton.twig" %}
+{% import "macros.twig" as macros %}
+{% block body %}
+ <form action="{{ url("n/%s", note.id) }}" method="post">
+ {{ macros.note_form_content(note) }}
+ <button type="submit">Save</button>
+ <button
+ type="submit"
+ name="delete"
+ value="delete"
+ class="confirm"
+ data-question="Do you really want to delete the note?"
+ >Delete</button>
+ </form>
+{% endblock %} \ No newline at end of file
diff --git a/templates/search.twig b/templates/search.twig
new file mode 100644
index 0000000..be8ce54
--- /dev/null
+++ b/templates/search.twig
@@ -0,0 +1,23 @@
+{% extends "skeleton.twig" %}
+{% import "macros.twig" as macros %}
+
+{% block body %}
+ {% if error %}
+ <div class="error">{{ error }}</div>
+ {% else %}
+ <ul class="search-result note-list">
+ {% for result in results %}
+ <li>
+ <a href="{{ url("n/%s", result.note.id) }}">[e]</a>
+ <div class="content">{{ result.renderHighlightedContent()|raw }}</div>
+ <ul class="tags">
+ {% for tag in result.note.tags %}
+ <li class="tag">{{ macros.taglink(tag) }}</li>
+ {% endfor %}
+ </ul>
+ </li>
+ {% endfor %}
+ </ul>
+ {% endif %}
+
+{% endblock %}
diff --git a/templates/skeleton.twig b/templates/skeleton.twig
new file mode 100644
index 0000000..e91223f
--- /dev/null
+++ b/templates/skeleton.twig
@@ -0,0 +1,35 @@
+<!DOCTYPE html>
+{% import "macros.twig" as macros %}
+<html lang="en">
+<head>
+{% block head %}
+ <meta charset="UTF-8">
+ <meta name="viewport"
+ content="width=device-width, initial-scale=1.0">
+ <meta http-equiv="X-UA-Compatible" content="ie=edge">
+
+ <link type="text/css" rel="stylesheet" href="{{ url("assets/styles.css") }}">
+
+ <script src="{{ url("vendor/components/jquery/jquery.min.js") }}"></script>
+ <script src="{{ url("assets/interactive.js") }}"></script>
+ <title>{% block title %}micropoly{% endblock %}</title>
+{% endblock %}
+</head>
+<body>
+ {% block header %}
+ <header>
+ <nav class="mainmenu">
+ <ul>
+ <li><a href="{{ url("") }}" class="homelink">micropoly</a> </li>
+ <li><a href="{{ url("new-note") }}">Add note</a></li>
+ </ul>
+ </nav>
+ <section class="s-search">
+ {{ macros.searchbox(query) }}
+ </section>
+ </header>
+ {% endblock %}
+ {% block body %}
+ {% endblock %}
+</body>
+</html> \ No newline at end of file
diff --git a/tests/Search/ParserTest.php b/tests/Search/ParserTest.php
new file mode 100644
index 0000000..9a6aeef
--- /dev/null
+++ b/tests/Search/ParserTest.php
@@ -0,0 +1,120 @@
+<?php
+
+namespace Micropoly\Search;
+
+use PHPUnit\Framework\TestCase;
+
+class ParserTest extends TestCase
+{
+
+ /**
+ * @covers \Micropoly\Search\Parser::tokenize
+ * @dataProvider tokenizeDataProvider
+ */
+ public function testTokenize($input, $want)
+ {
+ $have = [];
+ foreach (Parser::tokenize($input) as $tok)
+ $have[] = $tok;
+
+ $this->assertSame($want, $have);
+ }
+
+ public function tokenizeDataProvider()
+ {
+ return [
+ ["", []],
+ ["hello", [
+ [Parser::TOK_WORD, "hello"],
+ ]],
+ ["hello world", [
+ [Parser::TOK_WORD, "hello"],
+ [Parser::TOK_WORD, "world"],
+ ]],
+ ['"hello world"', [
+ [Parser::TOK_WORD, "hello world"],
+ ]],
+ ['"hello\\"quote\\\\"', [
+ [Parser::TOK_WORD , 'hello"quote\\'],
+ ]],
+ ["foo\\ bar", [
+ [Parser::TOK_WORD, "foo bar"],
+ ]],
+ ['foo\\\\bar\\"baz', [
+ [Parser::TOK_WORD, 'foo\\bar"baz'],
+ ]],
+ ['foo\\', [
+ [Parser::TOK_WORD, 'foo\\'],
+ ]],
+ ["#foo #bar", [
+ [Parser::TOK_TAG, "foo"],
+ [Parser::TOK_TAG, "bar"],
+ ]],
+ ["#foo\\ bar", [
+ [Parser::TOK_TAG, "foo bar"],
+ ]],
+ ["and or not ()( )", [
+ [Parser::TOK_OP, "and"],
+ [Parser::TOK_OP, "or"],
+ [Parser::TOK_OP, "not"],
+ [Parser::TOK_PAROPEN, null],
+ [Parser::TOK_PARCLOSE, null],
+ [Parser::TOK_PAROPEN, null],
+ [Parser::TOK_PARCLOSE, null],
+ ]],
+ ["(#foo)", [
+ [Parser::TOK_PAROPEN, null],
+ [Parser::TOK_TAG, "foo"],
+ [Parser::TOK_PARCLOSE, null],
+ ]],
+ ["foo:bar", [
+ [Parser::TOK_PROP, "foo"],
+ [Parser::TOK_WORD, "bar"],
+ ]],
+ ];
+ }
+
+ public function testTokenizeFailUnclosedString()
+ {
+ $this->expectException(ParseError::class);
+ foreach (Parser::tokenize('foo "bar') as $_);
+ }
+
+ /**
+ * @param string $input
+ * @param bool|null|SearchExpr $exprOrFalseForErr
+ * @dataProvider parseDataProvider
+ */
+ public function testParse(string $input, $exprOrFalseForErr)
+ {
+ if ($exprOrFalseForErr === false)
+ $this->expectException(ParseError::class);
+
+ $have = Parser::parse($input);
+ if ($have !== null)
+ $have = $have->toString();
+
+ $want = $exprOrFalseForErr === null ? null : $exprOrFalseForErr->toString();
+
+ $this->assertSame($want, $have);
+ }
+
+ public function parseDataProvider()
+ {
+ return [
+ ["", null],
+ ["(", false],
+ [")", false],
+ ["foo", new FTSExpr("foo")],
+ ["foo #bar", new LogicOp(LogicOp::OP_AND, new FTSExpr("foo"), new TagExpr("bar"))],
+ ["(foo and #bar) or not baz", new LogicOp(
+ LogicOp::OP_OR, new LogicOp(
+ LogicOp::OP_AND,
+ new FTSExpr("foo"),
+ new TagExpr("bar")
+ ), new NotOp(new FTSExpr("baz"))
+ )],
+ ["foo bar", new FTSLogicOp(LogicOp::OP_AND, new FTSExpr("foo"), new FTSExpr("bar"))],
+ ];
+ }
+}