aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/BoundVal.php36
-rw-r--r--src/DBError.php31
-rw-r--r--src/DbQuery.php197
-rw-r--r--src/Entrypoint.php10
-rw-r--r--src/Env.php80
-rw-r--r--src/Esc.php21
-rw-r--r--src/Handler.php10
-rw-r--r--src/Handlers/ApiTagsHandler.php16
-rw-r--r--src/Handlers/Index.php20
-rw-r--r--src/Handlers/JsonAPIHandler.php18
-rw-r--r--src/Handlers/JsonAPIResult.php24
-rw-r--r--src/Handlers/MethodNotAllowedHandler.php14
-rw-r--r--src/Handlers/NewNote.php40
-rw-r--r--src/Handlers/NotFoundHandler.php15
-rw-r--r--src/Handlers/NoteHandler.php39
-rw-r--r--src/Handlers/Search.php33
-rw-r--r--src/Log.php27
-rw-r--r--src/Main.php58
-rw-r--r--src/Models/Note.php250
-rw-r--r--src/Models/Tag.php37
-rw-r--r--src/Schema.php80
-rw-r--r--src/Search/AbstractFTSExpr.php31
-rw-r--r--src/Search/CharSource.php33
-rw-r--r--src/Search/FTSExpr.php30
-rw-r--r--src/Search/FTSLogicOp.php46
-rw-r--r--src/Search/FTSNotExpr.php29
-rw-r--r--src/Search/LogicOp.php78
-rw-r--r--src/Search/NotOp.php32
-rw-r--r--src/Search/Pagination.php14
-rw-r--r--src/Search/ParseError.php9
-rw-r--r--src/Search/Parser.php295
-rw-r--r--src/Search/SQLSearchExpr.php11
-rw-r--r--src/Search/SearchExpr.php14
-rw-r--r--src/Search/SearchResult.php160
-rw-r--r--src/Search/TagExpr.php42
-rw-r--r--src/Search/TrueExpr.php25
-rw-r--r--src/Tools/PopulateDevDb.php71
37 files changed, 1976 insertions, 0 deletions
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