diff options
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, "full text" 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"))], + ]; + } +} |