commit 563d9cc96da9f5aa394ba0da7b7fffd86bd8b428 Author: Matt Jenkins Date: Thu Jun 4 11:13:34 2026 +0100 Initial import diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a479e11 --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +.PHONEY: all component live +all: + ./node_modules/webpack/bin/webpack.js + +component: + @if [ -z "$NAME" ]; then echo "Specify NAME="; exit 10 ; fi + @cp src/Template.vue src/${NAME}.vue + +live: all + rsync -avPp * ../docman/ diff --git a/app.php b/app.php new file mode 100755 index 0000000..fa25a5a --- /dev/null +++ b/app.php @@ -0,0 +1,172 @@ +connect( + Config::get("DB_USER"), + Config::get("DB_PASS"), + Config::get("DB_HOST"), + Config::get("DB_DATABASE") +); + +_add_code_dir(ROOT . "/app"); +//$dir = opendir(ROOT . "/app"); +//while ($file = readdir($dir)) { +// if (str_ends_with($file, ".php")) { +// require_once(ROOT . "/app/" . $file); +// } +//} +//closedir($dir); + +require_once("lib/App.php"); + +require_once("routes/api.php"); +require_once("routes/web.php"); + +Model::cache_connect("memcache", 11211); + +if (!is_terminal()) { + $blade = new Blade(ROOT . '/views', ROOT . '/cache'); + $req = new Request; + App::dispatch($req); +} + +function is_terminal() { + return ($_SERVER['argc'] > 0); +} + +function blade($name, $args = []) { + global $blade; + + $args['flash_error'] = Session::get("flash_error"); + $args['flash_warn'] = Session::get("flash_warn"); + $args['flash_success'] = Session::get("flash_info"); + $args['flash_info'] = Session::get("flash_success"); + + Session::unset("flash_error"); + Session::unset("flash_warn"); + Session::unset("flash_info"); + Session::unset("flash_success"); + + return $blade->render($name, $args); +} + +function get_user() { + $uid = Session::get("user"); + if ($uid) { + return new User($uid); + } + return false; +} + +function flash($type, $message) { + if ($_SERVER['argc'] == 0) { + Session::set("flash_" . $type, $message); + } else { + print("$type: $message\n"); + } +} + +function fmt_date($month, $year) { + if (($year < 1900) && ($year > 50)) { + $year = 1900 + $year; + } + + if (($year < 1900) && ($year <= 50)) { + $year = 2000 + $year; + } + + if ($month > 0) { + $dt = DateTime::createFromFormat("!m/Y", $month . "/" . $year); + return $dt->format("F Y"); + } else { + $dt = DateTime::createFromFormat("!Y", $year); + return $dt->format("Y"); + } +} + +function format_size($s) { + if ($s < 102400) return sprintf("%.1fkB", $s / 1024); + if ($s < 10485760) return sprintf("%.1fMB", $s / (1024 * 1024)); + return sprintf("%dMB", $s / (1024 * 1024)); +} + +function redirect($url) { + return [ + 302, "", [ + "Location" => $url + ] + ]; + +// if ($_SERVER['argc'] == 0) { +// header("Location: $url"); +// exit(0); +// } +} + +function jsredirect($url) { + print(''); + exit(0); +} + +function back() { + return redirect($_SERVER['HTTP_REFERER']); +} + +function __unref($v) { + if ($v instanceof Model) { + return $v->id; + } + return $v; +} + +function _add_code_dir($path) { + $dir = opendir($path); + while ($file = readdir($dir)) { + if (str_starts_with($file, ".")) { + continue; + } + + if (is_dir($path . "/" . $file)) { + _add_code_dir($path . "/" . $file); + continue; + } + + if (str_ends_with($file, ".php")) { + require_once($path . "/" . $file); + } + } + closedir($dir); +} + +function strtolower_null($a) { + if (!is_string($a)) return $a; + return strtolower($a); +} diff --git a/app/controllers/CoverController.php b/app/controllers/CoverController.php new file mode 100644 index 0000000..41f8121 --- /dev/null +++ b/app/controllers/CoverController.php @@ -0,0 +1,66 @@ +path(); + + $pdf = new PDF($path . "/doc.pdf"); + if (!$pdf->exists()) { + return [404, "Not Found"]; + } + + if (!$size) { + $imgpath = sprintf("%s/cover.jpg", $path); + } else { + $imgpath = sprintf("%s/cover-%d.jpg", $path, $size); + } + + $file = new File($imgpath); + + if ($file->exists()) { + $h = $file->hash(); + $h = "\"$h\""; + if ($h == $_request->header("If-None-Match")) { + return [304, "Not Modified", [ + "ETag" => $h, + "Cache-Control" => "public, max-age=86400, must-revalidate", + ]]; + } + return new Image($file); + } + + $cover = new Image($path . "/cover.jpg"); + if (!$cover->exists()) { + $pdf->extract_page(0, $cover->path()); + $cover = new Image($path . "/cover.jpg"); + } + + if (!$cover->exists()) { + return [404, "Not Found"]; + } + + if ($size === false) $size = $cover->width(); + + if ($size >= $cover->width()) { + $h = $cover->hash(); + $h = "\"$h\""; + if ($h == $_request->header("If-None-Match")) { + return [304, "Not Modified", [ + "ETag" => $h, + "Cache-Control" => "public, max-age=86400, must-revalidate", + ]]; + } + return $cover; + } + + $cover->scale($size); + $img = $cover->save($imgpath, "image/jpeg"); + return $img; + } + +} diff --git a/app/controllers/DocumentController.php b/app/controllers/DocumentController.php new file mode 100644 index 0000000..639a5ea --- /dev/null +++ b/app/controllers/DocumentController.php @@ -0,0 +1,561 @@ + $product]); + } + + public static function show($id) { + $doc = new Document($id); + $doc->load(); + + return blade("document", [ + "doc" => $doc + ]); + } + + public static function api_get($id) { + return new Document($id); + } + + public static function api_set($id) { + $doc = new Document($id); + if ($doc->valid()) { + foreach ($_POST as $k=>$v) { + if ($k != "products") { + $doc->$k = trim($v); + } + } + $doc->save(); + + $pids = explode(",", $_POST['products']); + + $dpl = DocProduct::find([["document", "=", $id]])->all();; + + foreach ($dpl as $dp) { + $e = array_search($dp->product, $pids); + if ($e !== false) { + unset($pids[$e]); + continue; + } + $dp->delete(); + } + + foreach ($pids as $pid) { + $dp = new DocProduct; + $dp->document = $id; + $dp->product = $pid; + $dp->save(); + } + + $doc->cache_invalidate("products"); + + } + return $doc; + } + + public static function api_get_products($id) { + $doc = new Document($id); + return $doc->products; + } + + public static function api_move($id) { + $doc = new Document($id); + $from = $_POST['from']; + $to = $_POST['to']; + + $dp = DocProduct([["document", "=", $id], ["product", "=", $from]])->first(); + if ($dp) { + $dp->product = $to; + $dp->save(); + } + + $doc->cache_invalidate("products"); + + return []; + } + + public static function del_docproduct($doc, $prod) { + $dp = DocProduct::find([["document", "=", $doc], ["product", "=", $prod]])->first(); + if ($dp) { + $dp->delete(); + } + return back(); + } + + public static function get_by_id($id) { + return Document::find([["internal_id", "=", trim($id)]])->first(); + } + + public static function api_merge($id) { + $from = new Document($id); + $to = new Document($_POST['to']); + + if ($from->id == $to->id) return []; + + if ($from && $to) { + $revs = Revision::find([["document", "=", $from->id]]); + for ($rev = $revs->first(); $rev = $revs->next(); ) { + $rev->document = $to->id; + $rev->save(); + } + + $from->delete(); + } + return new Document($to->id); // Force refresh + } + + + + public static function api_drag_drop($_request) { + $src_id = $_request->post("src_id"); + $dst_id = $_request->post("dst_id"); + + $src_type = $_request->post("src_type"); + $dst_type = $_request->post("dst_type"); + + $src_extra = $_request->post("src_extra"); + $dst_extra = $_request->post("dst_extra"); + + $copy = $_request->post("copy") == "true"; + + if (($src_type == "document") && ($dst_type == "document")) { + // Merge src into dst + $src = new Document($src_id); + $dst = new Document($dst_id); + + if ($src->id == $dst->id) return []; + + foreach ($src->revisions as $r) { + $r->document = $dst->id; + $r->save(); + } + + $src->delete(); + $dst->cache_invalidate("revisions"); + } + + if (($src_type == "document") && ($dst_type == "product")) { + + if ($copy) { + $dp = new DocProduct(); + $dp->document = $src_id; + $dp->product = $dst_id; + $dp->save(); + } else { + + // Move document into product + $dpl = DocProduct::find([["document", "=", $src_id], ["product", "=", $src_extra]])->all(); + foreach ($dpl as $dp) { + $dp->product = $dst_id; + $dp->save(); + } + $prod = new Product($src_extra); + $prod->cache_invalidate("documents"); + } + $doc = new Document($src_id); + $doc->cache_invalidate("products"); + $prod = new Product($dst_id); + $prod->cache_invalidate("documents"); + } + + if (($src_type == "product") && ($dst_type == "product")) { + + if ($src_id == $dst_id) return []; + // Move product into product + $sp = new Product($src_id); + $dp = new Product($dst_id); + $sp->parent = $dp->id; + $sp->save(); + $sp->cache_invalidate("parent"); + $sp->cache_invalidate("children"); + $dp->cache_invalidate("parent"); + $dp->cache_invalidate("children"); + } + + + return [200, ["didit" => "true"]]; + } + + public static function merge($id) { + + $doc = new Document($id); + + $proc = new Process("pdftk"); + + foreach ($doc->revisions as $rev) { + $proc->arg($rev->path() . "/doc.pdf"); + } + + $nr = new Revision; + $nr->document = $doc->id; + $nr->revno = "NEW"; + $nr->save(); + + + $out = new File($nr->path() . "/doc.pdf"); + $out->parent()->mkdir(); + + $proc->arg("output"); + $proc->arg((string)$out); + $r = $proc->execute(); + + if ($r != 0) { + print("
");
+			print_r($proc->stderr());
+			print("
"); + exit(0); + } + + return redirect("/document/" . $doc->id); + } + + public static function create_overview($id, $move) { + + $job = new GeminiJob($id, "document:$id"); + $job->move = ($move == "move"); + $jobid = $job->queue(); + flash("success", "Job queued as ID " . $jobid); + + return redirect("/document/" . $id); + } + + public static function api_get_title_fragment($_request) { + $q = $_request->post("title"); + $db = DB::getInstance(); + $q1 = $db->query(" + SELECT + DISTINCT title + FROM ( + SELECT + DISTINCT title + FROM + document + WHERE + title LIKE :s + UNION SELECT + DISTINCT subtitle AS title + FROM + document + WHERE + subtitle LIKE :s + UNION SELECT + DISTINCT subsubtitle AS title + FROM + document + WHERE subsubtitle LIKE :s + ) AS DERIVED", ["s" => $q . "%"]); + $o = $db->all($q1); + + if ($o->count() == 0) { + return [404, "Not Found"]; + } + if ($o->count() != 1) { + return [413, "Content Too Large"]; + } + return $o[0]->title; + } + + public static function separate($id) { + + $doc = new Document($id); + $firstprod = $doc->products[0]; + + $count = 0; + + foreach ($doc->revisions as $rev) { + $count++; + if ($count == 1) { + continue; + } + + $newdoc = $doc->duplicate(); + $newdoc->subsubtitle .= " - $count"; + $rev->document = $newdoc->id; + $rev->save(); + } + + + return redirect("/documents/" . $firstprod); + } + + public static function api_get_metadata($id) { + return DocMeta::find([["document", "=", $id]])->orderBy("metadata")->all(); + } + + public static function api_new_metadata($_request, $id) { + $doc = new Document($id); + $doc->set_metadata($_request->put("item_id"), ""); + return DocMeta::find([["document", "=", $id]])->orderBy("metadata")->all(); + } + + public static function api_set_metadata($_request, $id, $metadata) { + $doc = new Document($id); + $doc->set_metadata($metadata, $_request->post('data')); + return DocMeta::find([["document", "=", $id]])->orderBy("metadata")->all(); + } + + public static function api_delete_metadata($id, $metadata) { + $doc = new Document($id); + $doc->remove_metadata($metadata); + return DocMeta::find([["document", "=", $id]])->orderBy("metadata")->all(); + } + + public static function api_available_metadata($id) { + $exist = DocMeta::find([["document", "=", $id]])->orderBy("metadata")->all(); + $metas = MetaType::find()->all(); + + + $out = new Collection(); + + foreach ($metas as $meta) { + $e = false; + foreach ($exist as $ex) { + if ($ex->metadata == $meta->id) { + $e = true; + break; + } + } + + if (!$e) { + $cl = new stdClass; + $cl->key = $meta->id; + $cl->value = $meta->name; + $out->push($cl); + } + } + + $out->sort("value"); + + return $out; + } + + public static function delete_metadata($id, $metadata) { + $doc = new Document($id); + $doc->remove_metadata($metadata); + return redirect("/document/" . $id); + } + + public static function api_guess_docid($id) { + $doc = new Document($id); + $docid = $doc->guess_docid(); + + return new Collection(["id" => $id, "docid" => $docid]); + } + + public static function download_attachment($id, $filename) { + $doc = new Document($id); + $atts = $doc->get_attachments(); + foreach ($atts as $f) { + if ($f->basename() == $filename) { + $f->set_header("Content-Disposition", "attachment; filename=\"$filename\""); + + return $f; + } + } + return false; + } + + public static function upload_attachment($id) { + $doc = new Document($id); + return blade("upload.attachment", ["doc" => $doc]); + } + + public static function do_upload_attachment($_request, $id) { + $doc = new Document($id); + mkdir(ROOT . "/attachments/" . $doc->id, 0777); + + $f = 0; + while (($file = $_request->file("file", $f)) !== false) { + $nf = new File($file['tmp_name']); + $nf->rename(ROOT . "/attachments/" . $doc->id . "/" . $file['name']); + $f++; + } + return redirect("/document/" . $doc->id); + } + + public static function upload_document($_request) { + return blade("upload.document"); + } + + public static function do_upload_document($_request) { + $i = 0; + while ($f = $_request->file("file", $i)) { + + $file = new File($f['tmp_name']); + $sha256 = $file->hash(); + + $rev = Revision::find([["sha256", "=", $sha256]])->first(); + + if ($rev) { + return redirect("/uploads/duplicate/" . $rev->id); + } + + $u = new Upload(); + $u->filename = $f['name']; + $u->sha256 = $sha256; + $u->ocr = "N"; + $u->ai = "N"; + $u->owner = get_user()->id; + $u->save(); + copy($f['tmp_name'], ROOT . "/uploads/" . $u->id . ".pdf"); + $j = new ImportGeminiJob($u->id); + $j->queue(); + $j = new ImportOCRJob($u->id); + $j->queue(); + $i++; + } + return redirect("/uploads"); + } + + public static function rerun_upload($id) { + $u = new Upload($id); + if ($u->ocr == "F") { + $u->ocr = "N"; + $u->save(); + $j = new ImportOCRJob($u->id); + $j->queue(); + } + + if ($u->ai == "F") { + $u->ai = "N"; + $u->save(); + $j = new ImportGeminiJob($u->id); + $j->queue(); + } + return redirect("/uploads"); + } + + public static function rerun_all() { + $ai = Upload::find([["ai", "=", "F"], ["owner", "=", get_user()->id]])->all(); + foreach ($ai as $u) { + $u->ai = "N"; + $u->save(); + $j = new ImportGeminiJob($u->id); + $j->queue(); + } + $ocr = Upload::find([["ocr", "=", "F"], ["owner", "=", get_user()->id]])->all(); + foreach ($ocr as $u) { + $u->ocr = "N"; + $u->save(); + $j = new ImportOCRJob($u->id); + $j->queue(); + } + return redirect("/uploads"); + } + + public static function uploads($_request) { + return DocumentController::uploads_page($_request, 0); + } + + public static function uploads_page($_request, $page) { + $uploads = Upload::find([["owner", "=", get_user()->id]])->orderBy("id")->limit($page * 20, 20)->all(); + return blade("upload.list", ["uploads" => $uploads, "page" => $page]); + } + + public static function discard_upload($_request, $id) { + $u = new Upload($id); + if ($u->owner != get_user()->id) { + return [403, "Not yours, punk!"]; + } + + DB::getInstance()->query("delete from job where source=:source", ["source" => "upload:" . $u->id]); + + unlink($u->getPDF()); + $u->delete(); + return redirect("/uploads"); + } + + public static function import($_request, $id, $source) { + $u = new Upload($id); + if ($u->owner != get_user()->id) { + return [403, "Not yours, punk!"]; + } + + $doc = false; + + if ($source == "ai") { + $doc = Document::find_by_docid($u->ai_docid); + } else if ($source == "ocr") { + $doc = Document::find_by_docid($u->ocr_docid); + } + + if (!$doc) { + $doc = new Document; + if ($source == "ai") { + $doc->internal_id = $u->ai_docid; + } else if ($source == "ocr") { + $doc->internal_id = $u->ocr_docid; + } else { + $doc->internal_id = "XX-XXXXX-XX"; + } + $doc->title = $u->title; + $doc->subtitle = $u->subtitle; + $doc->subsubtitle = $u->subsubtitle; + $doc->overview = $u->overview; + $doc->save(); + + $incoming = Product::find([["title", "=", "Incoming"]])->first(); + $dp = new DocProduct(); + $dp->document = $doc->id; + $dp->product = $incoming->id; + $dp->save(); + } + + + $rev = new Revision(); + $rev->document = $doc->id; + $rev->revid=""; + $rev->sha256 = $u->sha256; + $rev->origtitle = $u->source; + $rev->save(); + + + $ocr = new OCR(); + $ocr->revision = $rev->id; + $ocr->body = $u->body; + $ocr->save(); + + $out = new File($rev->path() . "/doc.pdf"); + $out->parent()->mkdir(); + + $pdf = $u->getPDF(); + $pdf->rename($out->path()); + + $db = DB::getInstance(); + $db->query("DELETE FROM job WHERE source=:source", ["source" => "upload:" . $u->id]); + $u->delete(); + + $rev->info = $rev->getPDF()->info(); + $rev->save(); + + $j = new IndexJob($rev->id); + $j->queue(); + + + //return redirect("/uploads"); + return redirect("/document/" . $doc->id); + + } + + public static function delete_upload($_request, $id) { + $u = new Upload($id); + if ($u->owner != get_user()->id) { + return [403, "Not yours, punk!"]; + } + } + + public static function upload_duplicate($id) { + $r = new Revision($id); + return blade("upload.duplicate", ["rev" => $r]); + } + + public static function view_upload($id) { + $u = new Upload($id); + return $u->getPDF(); + } +} diff --git a/app/controllers/DownloadController.php b/app/controllers/DownloadController.php new file mode 100644 index 0000000..bcd4cde --- /dev/null +++ b/app/controllers/DownloadController.php @@ -0,0 +1,31 @@ +id]])->orderBy("queued")->limit(20); + return $s->all(); + } + + public static function api_add_download($_request) { + $url = $_request->put("url"); + $d = new DownloadJob; + $d->url = $url; + $d->queued = time(); + $d->started = 0; + $d->finished = 0; + $d->processed = 0; + $d->owner = get_user()->id; + $d->file = sprintf("download/file-%08X-%08X", rand(), time()); + + $d->save(); + return DownloadController::api_downloads(); + } + +} diff --git a/app/controllers/FavouriteController.php b/app/controllers/FavouriteController.php new file mode 100644 index 0000000..013b879 --- /dev/null +++ b/app/controllers/FavouriteController.php @@ -0,0 +1,33 @@ +set_favourite($doc); + } + return redirect("/document/" . $doc); + } + + public static function user_unset_favourite($doc) { + $user = get_user(); + if ($user) { + $user->unset_favourite($doc); + } + return redirect("/document/" . $doc); + } + + public static function favourites() { + $user = get_user(); + if (!$user) return [403, "Huh... yeah right"]; + $f = $user->favourites(); + $docs = new Collection; + foreach ($f as $fav) { + $doc = new Document($fav->document); + $docs->add($doc); + } + return blade("/favourites", ["docs" => $docs]); + } + +} diff --git a/app/controllers/HomeController.php b/app/controllers/HomeController.php new file mode 100644 index 0000000..98ff380 --- /dev/null +++ b/app/controllers/HomeController.php @@ -0,0 +1,7 @@ +orderBy("queued")->limit(50)->all(); + foreach ($is as $i) { + $i->load("revision"); + } + return $is; + } + + public static function api_delete_import($id) { + $d = new ProcessJob($id); + if ($d->valid()) { + $d->delete(); + } + return ImportController::api_imports(); + } + + public static function api_set_import($_request, $id) { + $d = new ProcessJob($id); + if ($d->valid()) { + foreach ($_POST as $k=>$v) { + $d->$k = $v; + } + $d->save(); + } + return ImportController::api_imports(); + } + + public static function api_add_document($_request, $id) { + + $job = new ProcessJob($id); + + $doc = Document::find([["internal_id", "=", $_request->put('internal_id')]])->first(); + if (!$doc) { + $doc = new Document; + $doc->internal_id = trim($_request->put('internal_id')); + $doc->title = trim($_request->put('title')); + $doc->subtitle = trim($_request->put('subtitle')); + $doc->subsubtitle = trim($_request->put('subsubtitle')); + $doc->overview = trim($_request->put('overview')); + $doc->owner = get_user()->id; + $doc->save(); + + $prods = explode(",", $_request->put('products')); + foreach ($prods as $product) { + $dp = new DocProduct; + $dp->document = $doc->id; + $dp->product = $product; + $dp->save(); + } + } + + $job->document = $doc->id; + + $rev = new Revision($job->revision); + $rev->document = $doc->id; + $rev->revno = trim($_request->put('revno')); + $rev->month = $_request->put('month'); + $rev->year = $_request->put('year'); + $rev->owner = get_user()->id; + $rev->save(); + + $job->imported = time(); + $job->save(); + + return ImportController::api_imports(); + } +} diff --git a/app/controllers/JobController.php b/app/controllers/JobController.php new file mode 100644 index 0000000..47e08f1 --- /dev/null +++ b/app/controllers/JobController.php @@ -0,0 +1,23 @@ +query("select * from job where source=:source order by queued", ["source" => $source]); + + $c = new Collection(); + + while ($r = $db->nextRecord($q)) { + $c->push($r); + } + return $c; + } + + public static function api_delete_job($id) { + $db = DB::getInstance(); + $q = $db->query("select * from job where id=:id", ["id" => $id]); + $r = $db->nextRecord($q); + $q = $db->query("delete from job where id=:id", ["id" => $id]); + return JobController::api_get_jobs($r->source); + } +} diff --git a/app/controllers/PDFController.php b/app/controllers/PDFController.php new file mode 100644 index 0000000..6d64788 --- /dev/null +++ b/app/controllers/PDFController.php @@ -0,0 +1,57 @@ +path(); + + $rev->downloads++; + $rev->last_download = time(); + $rev->save(); + + $disp = "attachment"; + $mime = "application/octet-stream"; + + $file = null; + + switch ($type) { + case "view": + $file = new PDF($path . "/doc.pdf"); + break; + case "download": + $file = new PDF($path . "/doc.pdf"); + $file->force_download(); + break; + case "viewocr": + $file = new PDF($path . "/ocr.pdf"); + break; + case "downloadocr": + $file = new PDF($path . "/ocr.pdf"); + $file->force_download(); + break; + } + $file->fake_filename($filename); + + if ($file == null) { + return [404, "Not Found 1"]; + } + + if (!$file->exists()) { + return [404, "Not Found 2"]; + } + + $file->set_header("link", "<" . Config::get("URL") . "/pdf/" . $id . "/download/" . $filename . '>; rel="canonical"'); + + return $file; + } + + public static function get_page($id, $page) { + $rev = new Revision($id); + $page = $rev->get_page($page, 150); + if ($page) { + return $page; + } + return [404, "Not Found"]; + } +} diff --git a/app/controllers/ProductController.php b/app/controllers/ProductController.php new file mode 100644 index 0000000..a04a8ce --- /dev/null +++ b/app/controllers/ProductController.php @@ -0,0 +1,164 @@ +query("select * from product where id=:id", ["id" => $pid]); + if ($r = $db->nextRecord($q)) { + $out->push($r); + } + } + return $out; + } + + public static function api_get_mru() { + $out = new Collection; + $mru = Session::get("mru"); + if (!$mru) { + $mru = []; + } + foreach ($mru as $p) { + $prod = new Product($p); + if ($prod) { + $out->push($prod); + } + } + return $out; + } + + public static function api_add_mru($id) { + $mru = Session::get("mru"); + if (!$mru) { + $mru = []; + } + array_unshift($mru, $id); + $mru = array_unique($mru); + while (count($mru) > 5) { + array_pop($mru); + } + Session::set("mru", $mru); + return ProductController::api_get_mru(); + } + + public static function api_search() { + + return Product::find([["full_path", "like", "%" . $_POST['search'] . "%"]])->orderBy("full_path")->all(); + } + + public static function api_add_child($_request, $id) { + $p = new Product; + $p->parent = $id; + $p->title = $_request->put("title"); + $p->save(); + return $p; + } + + public static function api_set($id) { + $p = new Product($id); + + $data = []; + foreach ($_POST as $k=>$v) { + $p->$k = $v; + } + $p->save(); + + return $p; + } + + public static function api_move($id) { + $p = new Product($id); + $p->parent = $_POST['to']; + $p->save(); + return $p; + } + + public static function api_delete($id) { + $product = new Product($id); + if ($product->valid()) { + $laf = Product::find([["title", "=", "Lost and Found"]])->first(); + + DB::getInstance()->query("delete from docproduct where product=:pid", ["pid" => $product->id]); + + foreach ($product->children as $c) { + $c->parent = $laf; + $c->save(); + } + $product->load("parent"); + $par = $product->parent; + $product->delete(); + return $par; + } + return []; + } + + public static function api_empty_trash($id) { + $prod = new Product($id); + $docs = $prod->documents; + + foreach ($docs as $doc) { + $revs = $doc->revisions; + foreach ($revs as $rev) { + $rev->delete(); + } + + $dpl = DocProduct::find([["document", "=", $doc->id]])->all(); + foreach ($dpl as $dp) { + $dp->delete(); + } + + $doc->delete(); + } + + $prod->invalidate("documents"); + + + return []; + + } + + public static function api_available_metadata($id) { + $prod = new Product($id); + $exist = $prod->meta(); + $metas = MetaType::find()->all(); + + $out = new Collection(); + + foreach ($metas as $meta) { + if (!in_array($meta->id, $exist)) { + $cl = new stdClass; + $cl->key = $meta->id; + $cl->value = $meta->name; + $out->push($cl); + } + } + + $out->sort("value"); + + return $out; + } + + public static function api_add_metadata($_request, $id) { + $prod = new Product($id); + $prod->add_meta($_request->put('item_id')); + return Collection::from_array($prod->meta()); + } + + public static function api_gemini_all($id, $move) { + $prod = new Product($id); + + $c = new Collection(); + + foreach ($prod->documents as $doc) { + if ($prod->oneliner == "") { + $job = new GeminiJob($doc->id, "document:" . $doc->id); + $job->move = ($move == "move"); + $jobid = $job->queue(); + $c->push(["document" => $doc->id, "job" => $jobid]); + } + } + return $c; + } +} diff --git a/app/controllers/RevisionController.php b/app/controllers/RevisionController.php new file mode 100644 index 0000000..a7fc02f --- /dev/null +++ b/app/controllers/RevisionController.php @@ -0,0 +1,107 @@ +load("document"); + + + if (!@$rev->info->Pages) { + $rev->info = $rev->getPDF()->info(); + $rev->save(); + } + + return blade("revision", ["rev" => $rev]); + } + + public static function api_set($id) { + $r = new Revision($id); + if ($r !== false) { + foreach ($_POST as $k=>$v) { + $r->$k = trim($v); + } + $r->save(); + } + + return $r; + } + + public static function delete($id) { + $r = new Revision($id); + $r->load("document"); + $doc = $r->document; + $r->delete(); + return redirect("/document/" . $doc->id); + } + + public static function redownload($id) { + $r = new Revision($id); + $j = new RedownloadJob($r->id, $r->origtitle, $r->path() . "/doc.pdf"); + $jobid = $j->queue(); + flash("success", "Job queued as ID " . $jobid); + return redirect("/revision/" . $id); + } + + public static function purge($id) { + $r = new Revision($id); + $r->purge(); + return redirect("/revision/" . $id); + } + + public static function recompress($id) { + $r = new Revision($id); + $j = new RecompressJob($r->id); + $j->queue(); + flash("info", "Recompression job queued"); + return redirect("/revision/" . $id); + } + + public static function ocr($id) { + $j = new OCRJob($id); + $j->queue(); + return redirect("/revision/" . $id); + } + + public static function rate_revision($id, $rating) { + $user = get_user(); + if (!$user) { + return new Revision($id); + } + $db = DB::getInstance(); + $db->query("INSERT INTO revision_rating (owner,revision,rating) values (:owner,:revision,:rating) ON DUPLICATE KEY UPDATE rating=:rating", [ + "owner" => $user->id, + "revision" => $id, + "rating" => $rating + ]); + $db->query("UPDATE revision SET rating=(SELECT AVG(rating) FROM revision_rating WHERE revision=:revision) WHERE id=:revision", ["revision" => $id]); + + return new Revision($id); + + } + + public static function split_revision($id) { + $r = new Revision($id); + $r->load("document"); + $doc = new Document(); + $doc->title = $r->document->title; + $doc->subtitle = $r->document->subtitle; + $doc->subsubtitle = $r->document->subsubtitle; + $doc->overview = $r->document->overview; + $doc->oneliner = $r->document->oneliner; + $doc->internal_id = $r->document->internal_id; + $doc->save(); + foreach ($r->document->products as $prod) { + $dp = new DocProduct(); + $dp->document = $doc->id; + $dp->product = $prod->id; + $dp->save(); + } + $r->document = $doc->id; + $r->save(); + + return redirect("/document/" . $doc->id); + } + +} diff --git a/app/controllers/SearchController.php b/app/controllers/SearchController.php new file mode 100644 index 0000000..1d037a6 --- /dev/null +++ b/app/controllers/SearchController.php @@ -0,0 +1,76 @@ +query(" + select + id, internal_id, title, subtitle, subsubtitle, + match (internal_id, title, subtitle, subsubtitle, overview) + against (:search in boolean mode) + as rel + from + document + where + match (internal_id, title, subtitle, subsubtitle, overview) + against (:search in boolean mode) + order by + rel desc + limit + 10 + ", ["search" => $_POST['search']]); + while ($r = DB::getInstance()->nextRecord($q)) { + $o = new Document($r->id); + $out->push($o); + } + return $out; + } + + static public function search($page = 0) { + if (array_key_exists("search", $_POST)) { + $q = DB::getInstance()->query(" + select + revision.id as id, + match(ocr.body) against (:search) as relevance + from + revision,ocr + where + match(ocr.body) against (:search) and + ocr.revision = revision.id and + not revision.document is null + order by + relevance desc + + ", ["search" => $_POST['search']]); + + $slog = []; + + while ($r = DB::getInstance()->nextRecord($q)) { + $slog[] = $r->id; + } + + Session::set("search", json_encode($slog)); + } + + $rpp = 8; + + $offset = $page * $rpp; + + $out = []; + + $slog = json_decode(Session::get("search")); + + for ($i = 0; $i < $rpp; $i++) { + if ($offset + $i < count($slog)) { + $rev = new Revision($slog[$offset + $i]); + if ($rev) { + $out[] = $rev; + } + } + } + + return blade("search", ["page" => $page, "count" => count($slog), "results" => $out, "pages" => ceil(count($slog) / $rpp)]); + } +} + diff --git a/app/controllers/SitemapController.php b/app/controllers/SitemapController.php new file mode 100644 index 0000000..20a18f4 --- /dev/null +++ b/app/controllers/SitemapController.php @@ -0,0 +1,9 @@ +all(); + $revs = Revision::find()->all(); + return blade("sitemap", ["docs" => $docs, "revs" => $revs]); + } +} diff --git a/app/controllers/SpiderController.php b/app/controllers/SpiderController.php new file mode 100644 index 0000000..56d50b6 --- /dev/null +++ b/app/controllers/SpiderController.php @@ -0,0 +1,83 @@ +orderBy("id")->limit(20)->all(); + + return $pdfs; + } + + public static function api_pages() { + $pages = SpiderPage::find([["status", "=", "O"], ["title", "!=", ""]])->orderBy("title")->limit(20)->all(); + + return $pages; + } + + public static function api_reject_pdf($id = null) { + if ($id == null) return []; + + $i = explode(",", $id); + foreach ($i as $id) { + $pdf = new Spider($id); + if ($pdf) { + $pdf->status = "B"; + $pdf->save(); + } + } + return SpiderController::api_pdfs(); + } + + public static function api_accept_pdf($_request, $id = null) { + if ($id == null) return []; + + $i = explode(",", $id); + foreach ($i as $id) { + $pdf = new Spider($id); + if ($pdf) { + $pdf->status = "D"; + $pdf->save(); + + $job = new DownloadJob($pdf->url); + $job->queue(get_user()->id); + } + } + return SpiderController::api_pdfs(); + } + + public static function api_reject_page($_request, $id) { + $i = explode(",", $id); + foreach ($i as $id) { + $page = new SpiderPage($id); + if ($page) { + $page->status = "B"; + $page->save(); + } + } + return SpiderController::api_pages(); + } + + public static function api_accept_page($id) { + + $i = explode(",", $id); + foreach ($i as $id) { + $page = new SpiderPage($id); + if ($page) { + $page->status = "N"; + $page->save(); + } + } + return SpiderController::api_pages(); + } +} + + + diff --git a/app/controllers/SystemController.php b/app/controllers/SystemController.php new file mode 100644 index 0000000..1f80bb9 --- /dev/null +++ b/app/controllers/SystemController.php @@ -0,0 +1,140 @@ + "Blacklisted", + "N" => "Pending", + "F" => "Failed", + "D" => "Done", + "Y" => "Done", + "W" => "Postponed", + "P" => "Processing", + "X" => "Deleted", + "O" => "Off-site", + "Q" => "Postponed", + "R" => "Redirect", + ]; + + $q = DB::getInstance()->query("select count(id) as c, status from pages group by status order by status"); + $spider = []; + while ($r = DB::getInstance()->nextRecord($q)) { + $spider[@$status[$r->status]] = $r->c; + } + + $q = DB::getInstance()->query("select count(id) as c, status from spider group by status order by status"); + $pdf = []; + while ($r = DB::getInstance()->nextRecord($q)) { + $pdf[@$status[$r->status]] = $r->c; + } + + $q = DB::getInstance()->query("select count(id) as c, ocr from revision group by ocr order by ocr"); + $ocr = []; + while ($r = DB::getInstance()->nextRecord($q)) { + $ocr[@$status[$r->ocr]] = $r->c; + } + + $q = DB::getInstance()->query("select count(id) as c, idx from revision group by idx order by idx"); + $idx = []; + while ($r = DB::getInstance()->nextRecord($q)) { + $idx[@$status[$r->idx]] = $r->c; + } + + return blade("status", ["spider" => $spider, "pdf" => $pdf, "ocr" => $ocr, "idx" => $idx]); + } + + + public static function api_get_idmatches() { + return IDMatch::find([["id", ">=", 0]])->orderBy("weight")->all(); + } + + public static function api_add_idmatch($_request) { + $i = new IDMatch; + $i->example = $_request->put('example'); + $i->regex = $_request->put('regex'); + $i->weight = $_request->put('weight'); + $i->save(); + return SystemController::api_get_idmatches(); + } + + public static function api_set_idmatch($id) { + $i = new IDMatch($id); + if ($i) { + $i->example = $_POST['example']; + $i->regex = $_POST['regex']; + $i->weight = $_POST['weight']; + $i->save(); + } + return SystemController::api_get_idmatches(); + } + + public static function api_del_idmatch($id) { + $i = new IDMatch($id); + if ($i) { + $i->delete(); + } + return SystemController::api_get_idmatches(); + } + + + + public static function systems() { + $u = get_user(); + $sys = System::find([["owner", "=", $u->id]])->all(); + return blade("systems", ["systems" => $sys]); + } + + public static function system($id) { + $sys = new System($id); + $sys->load("documents"); + return blade("system", ["sys" => $sys]); + } + + public static function systems_add() { + return blade("systems_add"); + } + + public static function systems_do_add($_request) { + $sys = new System; + $sys->name = $_request->post("name"); + $sys->model = $_request->post("model"); + $sys->notes = $_request->post("notes"); + $sys->owner = get_user()->id; + $sys->save(); + return redirect("/system/" . $sys->id); + } + + public static function system_add_doc($id, $doc) { + $sys = new System($id); + if ($sys->owner == get_user()->id) { + $exist = SystemDoc::find([ + ["system", "=", $sys->id], + ["document", "=", $doc] + ])->first(); + if (!$exist) { + $sd = new SystemDoc; + $sd->system = $sys->id; + $sd->document = $doc; + $sd->save(); + } + return redirect("/system/" . $id); + } + return [403, "Forbidden"]; + } + + public static function system_del_doc($id, $doc) { + $sys = new System($id); + if ($sys->owner == get_user()->id) { + $sd = SystemDoc::find([ + ["system", "=", $sys->id], + ["document", "=", $doc] + ])->first(); + if ($sd) { + $sd->delete(); + } + return redirect("/system/" . $id); + } + return [403, "Forbidden"]; + } +} + diff --git a/app/jobs/DownloadJob.php b/app/jobs/DownloadJob.php new file mode 100644 index 0000000..89a38f0 --- /dev/null +++ b/app/jobs/DownloadJob.php @@ -0,0 +1,87 @@ +from = $from; + parent::__construct("download"); + } + + public function generate_to() { + $filename = ROOT . "/download/file-" . time() . "-" . rand() . ".pdf"; + return $filename; + } + + public function run() { + + + $this->to = $this->generate_to(); + $ch = curl_init(); + $this->status("Downloading: 0%"); + $fd = fopen($this->to, "w"); + + curl_setopt($ch, CURLOPT_URL, $this->from); + curl_setopt($ch, CURLOPT_USERAGENT, "Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.2454.85 Safari/537.36"); + curl_setopt($ch, CURLOPT_HEADER, 0); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15); + curl_setopt($ch, CURLOPT_PRIVATE, $this); + curl_setopt($ch, CURLOPT_TIMEOUT, 3600); + curl_setopt($ch, CURLOPT_FILETIME, true); + curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, [$this, 'download_progress']); + curl_setopt($ch, CURLOPT_NOPROGRESS, false); + curl_setopt($ch, CURLOPT_FILE, $fd); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + + print("Running...\n"); + $r = curl_exec($ch); + fclose($fd); + + $file = new File($this->to); + $sha256 = $file->hash(); + + $rev = Revision::find([["sha256", "=", $sha256]])->first(); + + if ($rev) { + $this->status("Duplicate file"); + $this->fail(); + print("Finished (duplicate)\n"); + return; + } + + $u = new Upload(); + $u->filename = basename($this->from); + $u->sha256 = $sha256; + $u->source = $this->from; + $u->ocr = "N"; + $u->ai = "N"; + $u->owner = $this->getOwner(); + $u->save(); + $file->rename(ROOT . "/uploads/" . $u->id . ".pdf"); + $j = new ImportGeminiJob($u->id); + $j->queue($u->owner); + $j = new ImportOCRJob($u->id); + $j->queue($u->owner); + + $this->status("Finished"); + print("Finished\n"); + $this->finish(); + } + + + + function download_progress($ch, $download_size, $downloaded, $upload_size, $uploaded) { + if ($download_size == 0) return; + $pct = round($downloaded / $download_size * 100); + if ($pct != $this->_pct) { + $this->status("Downloading: " . $pct . "%"); + $this->_pct = $pct; + } + } +} diff --git a/app/jobs/GeminiJob.php b/app/jobs/GeminiJob.php new file mode 100644 index 0000000..f1d1738 --- /dev/null +++ b/app/jobs/GeminiJob.php @@ -0,0 +1,211 @@ +docid = $docid; + } + + public function run() { + + $pdf = false; + + $subs = [ + "Installation and Operating Information", + "Installation and Configuration", + "Installing and Getting Started", + "Installation/Operator's Manual", + "Installation/Operator's Guide", + "Programmer's Reference Guide", + "Field Maintenance Print Set", + "Illustrated Parts Breakdown", + "Installation/Owner's Guide", + "Installation Information", + "Installation/User Guide", + "User Documentation Kit", + "Programmer Information", + "Technical Description", + "Operator Information", + "Configuration Guide", + "Upgrade Information", + "Installation Manual", + "Service Information", + "Installation Guide", + "Programming Manual", + "Maintenance Manual", + "Maintenance Guide", + "Technical Summary", + "Operator's Guide", + "System Reference", + "User Information", + "Technical Manual", + "Language Manual", + "Service Manual", + "Service Guide", + "Read Me First", + "Owner's Guide", + "Release Notes", + "Options Guide", + "Users' Manual", + "User's Manual", + "HiTest Notes", + "User's Guide", + "Design Guide", + "User Manual", + "User Guide", + + ]; + + $doc = new Document($this->docid); + + $minsize = 999999999999999; + + foreach ($doc->revisions as $r) { + $p = new PDF($r->path() . "/doc.pdf"); + if ($p->size() < $minsize) { + $minsize = $p->size(); + $rev = $r; + $pdf = $p; + } + } + + if (!$pdf) { + $this->fail("No files to upload"); + return; + } + + $prods = []; + $db = DB::getInstance(); + $q = $db->query("select id, full_path from product"); + while ($r = $db->nextRecord($q)) { + $prods[] = $r->id . "," . $r->full_path; + } + + if ($doc->is_incoming()) { + $this->move = true; + } + + file_put_contents("/tmp/category-list.csv", implode("\n", $prods) . "\n"); + + $this->status("Processing document with Gemini"); + try { + $gemini = new Gemini(); +// $gemini->verbose = true; + + $p = new GeminiParameter("title", "object", "The title of the document (note: none of these parts should include the order number)", true); + $p->add_child("main_title", "string", "The primary portion of the title, properly recapitalized to fit English grammar", true); + $p->add_child("subtitle", "string", "Any portion of the title that could be used as a subtitle, such as 'user guide' or 'maintenance guide' etc. Remove this from the main title. Should not be the order number."); + $p->add_child("subsubtitle", "string", "Any part of the title that could be extra information such as version number or language. Should not be the order number."); + $gemini->add_parameter($p); + + $p = new GeminiParameter("category", "integer", "The category the document best fits in from the provided list of categories in the file category-list.csv", true); + $gemini->add_parameter($p); + + $gemini->add_parameter(new GeminiParameter("order_number", "string", "The document order number, often in the format XX-YYYYY-ZZ where YYYYY is based on the product code and ZZ is the type of document. Never more than 15 characters long.")); + $gemini->add_parameter(new GeminiParameter("overview", "string", "A brief one-line overview of the document content in plain text format", true)); + $gemini->add_parameter(new GeminiParameter("summary", "string", "A longer summary of the document content in markdown format", true)); + $gemini->upload_callback([$this, "uploadcb"]); + $gemini->process_callback([$this, "processcb"]); + + $gemini->attach($pdf->basename(), $pdf); + $gemini->attach("category-list.csv", new File("/tmp/category-list.csv")); + + $lines = $gemini->geminiOverview(); + } catch (Exception $e) { + $this->status($e->getMessage()); + if ($e->getMessage() == "The request timed out. Please try again.") { + $this->retry("timeout"); + } else if ($e->getMessage() == "HTTP Error 0 requesting AI assistance") { + $this->retry("error 0"); + } else if (str_starts_with($e->getMessage(), "You exceeded your current quota, please check your plan and billing details.")) { + $this->retries++; + if ($this->retries < 5) { + $this->retry("quota exceeded"); + } else { + $this->fail("Too many retries"); + } + } else if ($e->getMessage() == "This model is currently experiencing high demand. Spikes in demand are usually temporary. Please try again later.") { + $this->retry("overload"); + } else { + $this->fail(); + } + return; + } + + $data = json_decode($lines, true); + + if (!$data) { + $this->fail("No response from AI"); + return; + } + + // Reload document since this took a while + $doc = new Document($this->docid); + + if (array_key_exists("title", $data)) { + $doc->subtitle = ""; + $doc->subsubtitle = ""; + if (array_key_exists("main_title", $data["title"])) { $doc->title = $data['title']['main_title']; } + if (array_key_exists("subtitle", $data["title"])) { $doc->subtitle = $data['title']['subtitle']; } + if (array_key_exists("subsubtitle", $data["title"])) { $doc->subsubtitle = $data['title']['subsubtitle']; } + if ($doc->subtitle != "") { + $doc->title = str_replace(" " . $doc->subtitle, "", $doc->title); + } + } + if (array_key_exists("order_number", $data)) { $doc->internal_id = substr($data['order_number'], 0, 20); } + if (array_key_exists("summary", $data)) { $doc->overview = $data['summary']; } + if (array_key_exists("overview", $data)) { $doc->oneliner = $data['overview']; } + + + + $doc->save(); + + if (array_key_exists("category", $data)) { + $cat = $data['category']; + $q = $db->query("select * from product where id=:id", ["id" => $cat]); + if ($r = $db->nextRecord($q)) { + if ($this->move) { + $q = $db->query("delete from docproduct where document=:id", ["id" => $doc->id]); + } + $edp = DocProduct::find([ + ["product", "=", $r->id], + ["document", "=", $doc->id] + ])->first(); + + if (!$edp) { + $dp = new DocProduct; + $dp->document = $doc->id; + $dp->product = $r->id; + $dp->save(); + } + } else { + print("--- Incorrect category suggested: " . $cat . "\n"); + } + } + + $this->status("Finished"); + $this->finish(); + return; + } + + + public function uploadcb($percent) { + $this->status("Uploading: " . $percent . "% complete"); + } + + public function processcb($message) { + $this->status($message); + } + + function cleanup($txt) { + $txt = str_replace("Ø", "0", $txt); + $txt = str_replace(".", " ", $txt); + return $txt; + } +} diff --git a/app/jobs/ImportGeminiJob.php b/app/jobs/ImportGeminiJob.php new file mode 100644 index 0000000..b92bceb --- /dev/null +++ b/app/jobs/ImportGeminiJob.php @@ -0,0 +1,182 @@ +upload_id = $upload_id; + } + + public function run() { + + Config::refresh(); + + $pdf = false; + + $subs = [ + "Installation and Operating Information", + "Installation and Configuration", + "Installing and Getting Started", + "Installation/Operator's Manual", + "Installation/Operator's Guide", + "Programmer's Reference Guide", + "Field Maintenance Print Set", + "Illustrated Parts Breakdown", + "Installation/Owner's Guide", + "Installation Information", + "Installation/User Guide", + "User Documentation Kit", + "Programmer Information", + "Technical Description", + "Operator Information", + "Configuration Guide", + "Upgrade Information", + "Installation Manual", + "Service Information", + "Installation Guide", + "Programming Manual", + "Maintenance Manual", + "Maintenance Guide", + "Technical Summary", + "Operator's Guide", + "System Reference", + "User Information", + "Technical Manual", + "Language Manual", + "Service Manual", + "Service Guide", + "Read Me First", + "Owner's Guide", + "Release Notes", + "Options Guide", + "Users' Manual", + "User's Manual", + "HiTest Notes", + "User's Guide", + "Design Guide", + "User Manual", + "User Guide", + + ]; + + $doc = new Upload($this->upload_id); + $doc->ai = "P"; + $doc->save(); + $pdf = $doc->getPDF(); + + if (!$pdf) { + $this->fail(); + $doc->ai = "F"; + $this->status("PDF File Missing"); + $doc->save(); + return; + } + + if ($pdf->size() > 50000000) { + $this->status("File too large"); + $doc->ai = "F"; + $doc->save(); + $this->fail(); + return; + } + + $this->status("Processing " . $doc->filename . " with Gemini"); + try { + $gemini = new Gemini(); + $gemini->upload_callback([$this, "uploadcb"]); + $gemini->process_callback([$this, "processcb"]); + $lines = $gemini->geminiOverview($pdf); + } catch (Exception $e) { + $mess = $e->getMessage(); + print($mess . "\n"); + + switch ($mess) { + case "This model is currently experiencing high demand. Spikes in demand are usually temporary. Please try again later.": + case "The request timed out. Please try again.": + case "HTTP Error 0 requesting AI assistance": + $this->retry(); + print("Retrying\n"); + break; + default: + $this->fail(); + $this->status(substr($mess, 0, 250)); + $doc->overview = $mess; + $doc->ai = "F"; + $doc->save(); + } + return; + } + + $this->status("Postprocessing returned data"); + $ld = explode("\n", $lines); + if (preg_match('/^\{(.*)\}$/', trim($ld[0]), $m)) { + $title = trim($m[1]); + + $title = str_replace("’", "'", $title); + + $newsub = ""; + $newsubsub = ""; + + + $b = explode(":", $title); + if (count($b) > 1) { + $title = trim(array_shift($b)); + $newsub = trim(array_shift($b)); + $newsubsub = implode(": ", $b); + } + + if ($newsub == "") { + foreach ($subs as $sub) { + if (str_ends_with($title, $sub)) { + $newsub = $sub; + $title = substr($title, 0, 0 - (strlen($sub) + 1)); + break; + } + } + } + $doc->title = $title; + $doc->subtitle = $newsub; + $doc->subsubtitle = $newsubsub; + + array_shift($ld); + if (trim($ld[0]) == "") { + array_shift($ld); + } + } + if (preg_match('/^\[(.*)\]$/', trim($ld[0]), $m)) { + + $iid = IDMatch::find_docid($this->cleanup($m[1])); + + if ($iid) { + $doc->ai_docid = $iid[0]; + } + array_shift($ld); + } + $doc->overview = implode("\n", $ld); + $doc->ai = "Y"; + + $doc->save(); + $this->status("Finished"); + $this->finish(); + return; + } + + + public function uploadcb($percent) { + $this->status("Uploading: " . $percent . "% complete"); + } + + public function processcb($message) { + $this->status($message); + } + + function cleanup($txt) { + $txt = str_replace("Ø", "0", $txt); + $txt = str_replace(".", " ", $txt); + return $txt; + } +} diff --git a/app/jobs/ImportOCRJob.php b/app/jobs/ImportOCRJob.php new file mode 100644 index 0000000..3e94e41 --- /dev/null +++ b/app/jobs/ImportOCRJob.php @@ -0,0 +1,67 @@ +upload_id = $upload_id; + } + + public function run() { + + $pdf = false; + + $doc = new Upload($this->upload_id); + $doc->ocr = "P"; + $doc->save(); + $pdf = $doc->getPDF(); + + if (!$pdf) { + $this->fail(); + $doc->ocr = "F"; + $this->status("PDF File Missing"); + $doc->save(); + return; + } + + $this->status("Processing " . $doc->filename . " with OCR"); + + $body = $pdf->text()->get_text(); + if ($body == "") { + $this->status("Running OCR on PDF file"); + $ocr = new PDF($pdf->path() . "-ocr.pdf"); + $pdf->ocr($ocr); + $ocr->rename($pdf->path(), true); + $pdf = $doc->getPDF(); + $body = $pdf->text()->get_text(); + } + + $doc->body = $body; + + $docid = IDMatch::find_docid($doc->filename . " " . $body); + if ($docid) { + $doc->ocr_docid = $docid[0]; + } + + $doc->ocr = "Y"; + $doc->save(); + + $this->status("Finished"); + $this->finish(); + return; + } + + + public function uploadcb($percent) { + $this->status("Uploading: " . $percent . "% complete"); + } + + public function processcb($message) { + $this->status($message); + } + +} diff --git a/app/jobs/IndexJob.php b/app/jobs/IndexJob.php new file mode 100644 index 0000000..c819835 --- /dev/null +++ b/app/jobs/IndexJob.php @@ -0,0 +1,47 @@ +revision = $rev; + } + + public function run() { + + $db = DB::getInstance(); + + $rev = new Revision($this->revision); + $rev->load("body"); + $body = $rev->body; + + if ($body) { + $body->counted = 'P'; + $body->save(); + + + $db->query("DELETE FROM word_index WHERE revision=:rev", ["rev" => $rev->id]); + + $words = $body->count_words(); + + foreach ($words as $word=>$hits) { + $db->query("INSERT INTO word_index (revision, word, hits) VALUES (:rev, :word, :hits)", [ + "rev" => $rev->id, + "word" => substr($word, 0, 20), + "hits" => $hits + ]); + } + $body->counted = 'Y'; + $body->save(); + $this->finish(); + } else { + $this->fail(); + $this->status("No body to count"); + } + } +} diff --git a/app/jobs/OCRJob.php b/app/jobs/OCRJob.php new file mode 100644 index 0000000..4a834af --- /dev/null +++ b/app/jobs/OCRJob.php @@ -0,0 +1,43 @@ +revision = $rev; + } + + public function run() { + $ocr = OCR::find([["revision", "=", $this->revision]])->first(); + if (!$ocr) { + $ocr = new OCR; + $ocr->revision = $this->revision; + $ocr->save(); + } + + $this->status("Processing"); + + $rev = new Revision($this->revision); + $pdf = $rev->getPDF(); + $text = $pdf->text()->get_text(); + + print("[" . $text . "]\n"); + + if ($text == "") { + $this->status("Running OCR on PDF"); + $newpdf = new PDF($pdf->parent() . "/ocr.pdf"); + $pdf->ocr($newpdf); + $text = $newpdf->text()->get_text(); + } + + $ocr->body = $text; + $ocr->save(); + $j = new IndexJob($this->revision); + $j->queue(); + $this->status("Complete"); + $this->finish(); + } +} diff --git a/app/jobs/ProcessJob.php b/app/jobs/ProcessJob.php new file mode 100644 index 0000000..b511e54 --- /dev/null +++ b/app/jobs/ProcessJob.php @@ -0,0 +1,18 @@ +docid = $docid; + } + + public function run() { + return false; + } + +} diff --git a/app/jobs/RecompressJob.php b/app/jobs/RecompressJob.php new file mode 100644 index 0000000..5793597 --- /dev/null +++ b/app/jobs/RecompressJob.php @@ -0,0 +1,35 @@ +RevisionID = $id; + } + public function run() { + $this->status("Recompressing"); + $rev = new Revision($this->RevisionID); + $crev = new Revision; + + $crev->revid = $rev->revid; + $crev->save(); + + $from = $rev->getPDF(); + $to = $crev->getPDF(); + + $to->parent()->mkdir(); + $from->recompress($to->path()); + + $crev->document = $rev->document; + $crev->info = $to->info(); + $crev->sha256 = $to->hash(); + $crev->save(); + + $this->status("New revision " . $crev->id); + $this->finish(); + } +} diff --git a/app/jobs/RedownloadJob.php b/app/jobs/RedownloadJob.php new file mode 100644 index 0000000..daa116b --- /dev/null +++ b/app/jobs/RedownloadJob.php @@ -0,0 +1,59 @@ +from = $from; + $this->to = $to; + $this->revid = $revid; + parent::__construct("revision:" . $revid); + } + + public function run() { + $ch = curl_init(); + $this->status("Downloading: 0%"); + $fd = fopen($this->to, "w"); + + curl_setopt($ch, CURLOPT_URL, $this->from); + curl_setopt($ch, CURLOPT_USERAGENT, "Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.2454.85 Safari/537.36"); + curl_setopt($ch, CURLOPT_HEADER, 0); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15); + curl_setopt($ch, CURLOPT_PRIVATE, $this); + curl_setopt($ch, CURLOPT_TIMEOUT, 3600); + curl_setopt($ch, CURLOPT_FILETIME, true); + curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, [$this, 'download_progress']); + curl_setopt($ch, CURLOPT_NOPROGRESS, false); + curl_setopt($ch, CURLOPT_FILE, $fd); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + + print("Running...\n"); + $r = curl_exec($ch); + fclose($fd); + $sha = hash_file("sha256", $this->to); + $rev = new Revision($this->revid); + $rev->sha256 = $sha; + $rev->save(); + print("Finished\n"); + $this->finish(); + return; + } + + + + function download_progress($ch, $download_size, $downloaded, $upload_size, $uploaded) { + if ($download_size == 0) return; + $pct = round($downloaded / $download_size * 100); + if ($pct != $this->_pct) { + $this->status("Downloading: " . $pct . "%"); + $this->_pct = $pct; + } + } +} diff --git a/app/models/DocMeta.php b/app/models/DocMeta.php new file mode 100644 index 0000000..c5be432 --- /dev/null +++ b/app/models/DocMeta.php @@ -0,0 +1,13 @@ + "Document", + "metadata" => "MetaType" + ]; + + + + +} diff --git a/app/models/DocProduct.php b/app/models/DocProduct.php new file mode 100644 index 0000000..86f4305 --- /dev/null +++ b/app/models/DocProduct.php @@ -0,0 +1,17 @@ + "Document", + "product" => "Product" + ]; + + + protected $_model = [ + "id" => [MODEL_SERIAL], + "created" => [MODEL_BIGINT, 11], + "updated" => [MODEL_BIGINT, 11], + "document" => [MODEL_OBJECT, "Document"], + "product" => [MODEL_OBJECT, "Product"] + ]; +} diff --git a/app/models/Document.php b/app/models/Document.php new file mode 100644 index 0000000..80b3923 --- /dev/null +++ b/app/models/Document.php @@ -0,0 +1,209 @@ + "get_related", + "revisions" => "get_revisions", + "products" => "get_products", + "metadata" => "get_metadata", + "attachments" => "get_attachments", + ]; + + public function get_revisions() { + return Revision::find([["document", "=", $this->id]])->orderByDesc("rating")->all(); + } + + public function get_related() { + $revs = Revision::find([["document", "=", $this->id]])->all(); + $related = new Collection; + foreach ($revs as $rev) { + $q = DB::getInstance()->query(" + select distinct + id, + internal_id, + title, + subtitle, + subsubtitle + from + document + where + internal_id in ( + select + distinct words.word + from + words, + revwords + where + words.id=revwords.word + and + revwords.revision=:rev + and + words.word like '%-%-%' + )", array("rev" => $rev->id)); + while ($r = DB::getInstance()->nextRecord($q)) { + if ($r->id != $this->id) { + $related->push(new Document($r->id)); + } + } + } + return $related; + } + + public function overview_md() { + return \Michelf\Markdown::defaultTransform($this->overview); + } + + public function on_delete() { + DB::getInstance()->query("delete from docproduct where document=:id", ["id" => $this->id]); + } + + private $_products = null; + + public function get_products() { + if ($this->_products == null) { + + $this->_products = new Collection; + $dpl = DocProduct::find([["document", "=", $this->id]])->all(); + foreach ($dpl as $dp) { + $dp->load("product"); + $this->_products->push($dp->product); + } + } + + return $this->_products; + } + + public function duplicate() { + $newdoc = new Document(); + $newdoc->title = $this->title; + $newdoc->subtitle = $this->subtitle; + $newdoc->subsubtitle = $this->subsubtitle; + $newdoc->overview = $this->overview; + $newdoc->internal_id = $this->internal_id; + $newdoc->owner = $this->owner; + $newdoc->year = $this->year; + $newdoc->month = $this->month; + + $newdoc->save(); + + $dpl = DocProduct::find([["document", "=", $this->id]])->all(); + foreach ($dpl as $dp) { + $ndp = new DocProduct(); + $ndp->document = $newdoc->id; + $ndp->product = $dp->product; + $ndp->save(); + } + return $newdoc; + } + + public function remove_product($id) { + $dp = DocProduct::find([["document", "=", $this->id], ["product", "=", $id]])->first(); + if ($dp) { + $dp->delete(); + } + } + + public function set_metadata($metadata, $value) { + if (!$metadata) return false; + $m = DocMeta::find([["document", "=", $this->id], ["metadata", "=", $metadata]])->first(); + if (!$m) { + $m = new DocMeta(); + $m->document = $this->id; + $m->metadata = $metadata; + } + $m->data = $value; + $m->save(); + return $m->id; + } + + public function get_metadata() { + return DocMeta::find([["document", "=", $this->id]])->all(); + } + + public function remove_metadata($metadata) { + $m = DocMeta::find([["document", "=", $this->id], ["metadata", "=", $metadata]])->first(); + if ($m) { + $m->delete(); + } + } + + public function get_metadata_by_id($metadata) { + $m = DocMeta::find([["document", "=", $this->id], ["metadata", "=", $metadata]])->first(); + if (!$m) { + return ""; + } + return $m->data; + } + + public function guess_docid() { + $text = $this->title . " " . $this->subtitle . " " . $this->subsubtitle; + $text .= $this->overview; + + $words = $this->get_words($text); + + return IDMatch::find_docid($words)[0]; + } + + + function get_words($text) { + $text = str_replace("\r", " ", $text); + $text = str_replace("\n\n", "\n", $text); + $text = str_replace("\n\n", "\n", $text); + $text = str_replace("\n\n", "\n", $text); + $text = str_replace("\n\n", "\n", $text); + $text = str_replace("\n", " ", $text); + $text = str_replace("\"", "", $text); + $text = str_replace(",", " ", $text); + $text = str_replace("—", "-", $text); + $text = str_replace("~", "-", $text); + $text = str_replace("--", "-", $text); + + $text = preg_replace('/([^a-zA-Z\-0-9]+)/', ' ', $text); + + //$text = preg_replace("/([A-Za-z])(- )/", '$1', $text); + + $words = preg_split('/\s+/', $text); + return $words; + } + + + function get_attachments() { + $ap = ROOT . "/attachments/" . $this->id; + $files = new Collection; + if (!file_exists($ap)) { + return $files; + } + if (!is_dir($ap)) { + return $files; + } + + $dir = opendir($ap); + while ($f = readdir($dir)) { + if (substr($f, 0, 1) == ".") continue; + $f = new File($ap . "/" . $f); + $files->push($f); + } + + return $files; + } + + public static function find_by_docid($docid) { + $db = DB::getInstance(); + $q = $db->query("select id from document where internal_id=:docid", ["docid" => $docid]); + if ($r = $db->nextRecord($q)) { + return new Document($r->id); + } + return false; + } + + public function is_incoming() { + foreach ($this->get_products() as $prod) { + if (preg_match("/ Incoming /", $prod->full_path, $m)) { + return true; + } + } + return false; + } + +} diff --git a/app/models/Favourite.php b/app/models/Favourite.php new file mode 100644 index 0000000..3fe10d0 --- /dev/null +++ b/app/models/Favourite.php @@ -0,0 +1,4 @@ +orderBy("weight")->all(); + + foreach ($matches as $match) { + $preg = '/^' . $match->regex . '$/'; + + foreach ($words as $word) { + $word = strtoupper($word); + $word = str_replace("(", "C", $word); + $word = str_replace("=", "-", $word); + $word = str_replace("--", "-", $word); + + if (preg_match($preg, $word, $m)) { + return [$m[1], @$m[2]]; + } + } + } + return false; + } + + public static function get_words($text) { + $text = str_replace("\r", " ", $text); + $text = str_replace("\n\n", "\n", $text); + $text = str_replace("\n\n", "\n", $text); + $text = str_replace("\n\n", "\n", $text); + $text = str_replace("\n\n", "\n", $text); + $text = str_replace("\n", " ", $text); + $text = str_replace("\"", "", $text); + $text = str_replace(",", " ", $text); + $text = str_replace("–", "-", $text); + $text = str_replace("—", "-", $text); + $text = str_replace("~", "-", $text); + $text = str_replace("--", "-", $text); + + $text = preg_replace('/([^a-zA-Z\-0-9]+)/', ' ', $text); + + //$text = preg_replace("/([A-Za-z])(- )/", '$1', $text); + + $words = preg_split('/\s+/', $text); + return $words; + } + + +} diff --git a/app/models/MetaType.php b/app/models/MetaType.php new file mode 100644 index 0000000..501e9cf --- /dev/null +++ b/app/models/MetaType.php @@ -0,0 +1,11 @@ +name; + } +} diff --git a/app/models/OCR.php b/app/models/OCR.php new file mode 100644 index 0000000..9093a04 --- /dev/null +++ b/app/models/OCR.php @@ -0,0 +1,23 @@ +body); + return $t->words(); + } + + public function count_words() { + $words = $this->get_words(); + + $counted = []; + + foreach ($words as $word) { + if (strlen($word) > 2) { + @$counted[strtoupper($word)] ++; + } + } + + return $counted; + } +} diff --git a/app/models/Product.php b/app/models/Product.php new file mode 100644 index 0000000..b0eb593 --- /dev/null +++ b/app/models/Product.php @@ -0,0 +1,163 @@ + "Product" + ]; + + protected $_computed = [ + "documents" => "get_documents", + "children" => "get_children", + ]; + + protected $_triggers = [ + "parent" => "update_path", + "title" => "update_path", + ]; + + private $_children = null; + public function get_children() { + $c = $this->cache_get("children"); + if ($c) return $c; + + if ($this->_children == null) { + $this->_children = Product::find([["parent", "=", $this->id]])->orderBy("title")->all(); + } + $this->cache_set("children", $this->_children); + return $this->_children; + } + + public function get_full_title() { + $tree = $this->get_tree(); + + $n = []; + foreach ($tree as $t) { + $n[] = $t->title; + } + + $out = implode(" / ", $n); + return $out; + } + + public function update_path($ppath = null) { + if ($ppath == null) { + $this->full_path = $this->get_full_title(); + } else { + $this->full_path = $ppath . " / " . $this->title; + } + $this->full_path = str_replace("/ / ", "/ ", $this->full_path); + $this->save(); + foreach ($this->get_children() as $child) { + $child->update_path($this->fill_path); + } + } + + public function get_tree() { + $out = []; + if ($this->load("parent")) { + $p = $this->parent; + $out = $p->get_tree(); + } + array_push($out, $this); + + return $out; + } + + public function overview_md() { + return \Michelf\Markdown::defaultTransform($this->overview); + } + + public function on_delete() { + DB::getInstance()->query("delete from docproduct where product=:id", ["id" => $this->id]); + } + + private $_documents = null; + + public function get_documents() { + + $d = $this->cache_get("documents"); + if ($d) return $d; + + if ($this->_documents == null) { + //$dpl = DocProduct::find([["product", "=", $this->id]])->limit(100)->all(); + $dpl = DocProduct::find([["product", "=", $this->id]])->all(); + $this->_documents = new Collection; + foreach ($dpl as $dp) { + if ($dp->load("document")) { + $this->_documents->push($dp->document); + } + } + + $this->_documents->sort("subsubtitle", true); + $this->_documents->sort("subtitle", true); + $this->_documents->sort("title", true); + } + $this->cache_set("documents", $this->_documents); + return $this->_documents; + } + + public function add_document($doc) { + $dp = new DocProduct; + $dp->product = $this->id; + $dp->document = $doc->id; + $dp->save(); + } + + public function meta() { + if ($this->metadata == null) { + return []; + } + return explode(",", $this->metadata); + } + + public function add_meta($id, $save = true) { + $m = $this->meta(); + if (!in_array($id, $m)) { + $m[] = $id; + } + $this->metadata = implode(",", $m); + if ($save) $this->save(); + } + + public function del_meta($id, $save = true) { + $m = $this->meta(); + $o = []; + foreach ($m as $v) { + if ($v != $id) { + $o[] = $v; + } + } + $this->metadaya = implode(",", $o); + if ($save) $this->save(); + } + + public function documents_sorted_by_meta() { + + if ($this->metadata == null) { + $meta = []; + } else { + $meta = explode(",", $this->metadata); + } + + $docs = $this->get_documents(); + + + while (count($meta) > 0) { + $mid = array_pop($meta); + $docs->sort_with_function( function($a, $b) use ($mid) { + $va = $a->get_metadata_by_id($mid); + $vb = $b->get_metadata_by_id($mid); + + if ($va > $vb) return 1; + if ($va < $vb) return -1; + return 0; + }); + } + + + return $docs; + + } + +} diff --git a/app/models/Revision.php b/app/models/Revision.php new file mode 100644 index 0000000..904e710 --- /dev/null +++ b/app/models/Revision.php @@ -0,0 +1,123 @@ + "Document" + ]; + + protected $_computed = [ + "body" => "get_body", + ]; + + protected $transform = [ + "info" => "json" + ]; + + function path() { + $rs = sprintf("%011d", $this->id); + $d1 = substr($rs, 0, 2); + $d2 = substr($rs, 2, 3); + $d3 = substr($rs, 5, 3); + $d4 = substr($rs, 8, 3); + $p = sprintf("%s/pdf/%s/%s/%s/%s", ROOT, $d1, $d2, $d3, $d4); + return $p; + } + + function filename() { + if ($this->document) { + if (is_numeric($this->document)) { + $this->load("document"); + } + $out = $this->document->internal_id; + if (($this->revno != "") && ($this->revno != "0")) { + $out .= "-"; + $out .= $this->revno; + } + $out .= " "; + $out .= $this->document->title . " " . $this->document->subtitle . " " . $this->document->subsubtitle; + } else { + $out = "doc"; + } + $out = trim($out); + $out.= ".pdf"; + $out = str_replace(" ", "_", $out); + $out = str_replace("/", "_", $out); + return $out; + } + + function create_cover() { + $p = $this->path(); + $pdf = new PDF($p . "/doc.pdf"); + $cover = $p . "/cover.jpg"; + $pdf->extract_page(0, $cover); + } + + function cover($size = null) { + $f = new File($this->path() . "/cover.jpg"); + if (!$f->exists()) { + $this->create_cover(); + } + if ($size == null) { + return "/cover/" . $this->id . "/cover.jpg"; + } + return "/cover/" . $this->id . "/" . $size . "/cover.jpg"; + } + + private $_body = null; + + public function get_body() { + if ($this->_body == null) { + $ocr = OCR::find([["revision", "=", $this->id]])->first(); + if (!$ocr) { + return false; + } + $this->_body = $ocr; + } + return $this->_body; + } + + public function get_page($page, $dpi = 300) { + $file = new File(sprintf("%s/pages/%04d-%d.jpg", $this->path(), $page, $dpi)); + if ($file->exists()) { + return new Image($file); + } + + $dir = sprintf("%s/pages", $this->path()); + if (!file_exists($dir)) { + mkdir($dir, 0777); + } + + $pdf = new PDF(sprintf("%s/doc.pdf", $this->path())); + $img = $pdf->extract_page($page, $file->path(), $dpi); + + if ($img) { + return $img; + } + return null; + } + + public function purge() { + $dir = opendir($this->path()); + while ($file = readdir($dir)) { + if (str_ends_with($file, ".jpg")) { + unlink($this->path() . "/" . $file); + } + } + closedir($dir); + + $dir = opendir($this->path() . "/pages"); + while ($file = readdir($dir)) { + if (str_ends_with($file, ".jpg")) { + unlink($this->path() . "/pages/" . $file); + } + } + closedir($dir); + } + + public function getPDF() { + $p = $this->path(); + $pdf = new PDF($p . "/doc.pdf"); + return $pdf; + } +} diff --git a/app/models/Spider.php b/app/models/Spider.php new file mode 100644 index 0000000..c12634b --- /dev/null +++ b/app/models/Spider.php @@ -0,0 +1,4 @@ + "get_documents", + ]; + + public function get_documents() { + $docs = SystemDoc::find([["system", "=", $this->id]])->all(); + $dl = new Collection; + foreach ($docs as $d) { + $doc = new Document($d->document); + if ($doc) { + $dl->add($doc); + } + } + $dl->sort("title"); + return $dl; + } + +} diff --git a/app/models/SystemDoc.php b/app/models/SystemDoc.php new file mode 100644 index 0000000..4897d6a --- /dev/null +++ b/app/models/SystemDoc.php @@ -0,0 +1,5 @@ +id . ".pdf"); + } +} diff --git a/app/models/User.php b/app/models/User.php new file mode 100644 index 0000000..4954c6f --- /dev/null +++ b/app/models/User.php @@ -0,0 +1,43 @@ +id], + ["document", "=", $doc]])->first(); + if ($f) { + return true; + } + return false; + } + + public function set_favourite($doc) { + $f = Favourite::find([ + ["owner", "=", $this->id], + ["document", "=", $doc]])->first(); + if (!$f) { + $f = new Favourite; + $f->owner = $this->id; + $f->document = $doc; + $f->save(); + } + } + + public function unset_favourite($doc) { + $f = Favourite::find([ + ["owner", "=", $this->id], + ["document", "=", $doc]])->first(); + if ($f) { + $f->delete(); + } + } + + public function favourites() { + $f = Favourite::find([ + ["owner", "=", $this->id], + ])->all(); + return $f; + } +} diff --git a/backend/JobRunner b/backend/JobRunner new file mode 100755 index 0000000..cdd5891 --- /dev/null +++ b/backend/JobRunner @@ -0,0 +1,45 @@ +#!/usr/bin/env php +getJobClass() . " job " . $job->jobID() . "\n"); + try { + $job->run(); + } catch (PDOException $e) { + print_r($e); + } catch (Exception $e) { + print_r($e); + } + print("Job finished\n"); + } else { + sleep(1); + } + + if (file_exists("/tmp/dpr_jobrunner_quit")) { + unlink("/tmp/dpr_jobrunner_quit"); + $running = false; + } +} + +print("Job Runner Exited Cleanly\n"); diff --git a/backend/JobRunnerQuit b/backend/JobRunnerQuit new file mode 100755 index 0000000..ed076c1 --- /dev/null +++ b/backend/JobRunnerQuit @@ -0,0 +1,5 @@ +#!/usr/bin/env php + + + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dot.env.sample b/dot.env.sample new file mode 100644 index 0000000..87bd181 --- /dev/null +++ b/dot.env.sample @@ -0,0 +1,15 @@ + +URL=https://decpdf.site + +DEBUG=true + +DB_HOST=host +DB_USER=user +DB_PASS=pass +DB_DATABASE=db + +GEMINI_KEY=["3fefyhwp98eoyepoytpotywpertferyrtyueoru", "flcs8yli8ty89weylicu4ynkeuhgkdshei8rhcn"] +GEMINI_MODEL=gemini-3.1-flash-lite + +MAGICK=convert + diff --git a/lib/AntiSpam.php b/lib/AntiSpam.php new file mode 100755 index 0000000..13d42cc --- /dev/null +++ b/lib/AntiSpam.php @@ -0,0 +1,12 @@ +type(), $req->path()); + if ($apiroute !== false) { // It is an API route. Treat it as such. + $req->set_route($apiroute); + $out = $apiroute->call($req); + header("Content-type: application/json"); + if ($out instanceof Collection) { + print(json_encode($out->all())); + return; + } + if (is_array($out)) { // This is an array return type. Do things depending on the result key + + $status = $out[0]; + $data = $out[1]; + if (count($out) > 2) { + $headers = $out[2]; + } else { + $headers = []; + } + + header("HTTP/1.1 $status"); + foreach ($headers as $k=>$v) { + header("$k: $v"); + } + if (is_array($data)) { + print(json_encode($data)); + } else { + print($data); + } + return; + } + print(json_encode($out)); + return; + } + + $webroute = Routes::find_web($req->type(), $req->path()); + if ($webroute !== false) { // It is a WEB route. Treat it as such. + $req->set_route($webroute); + $out = $webroute->call($req); + if (is_array($out)) { // This is an array return type. Do things depending on the result key + + $status = $out[0]; + $data = $out[1]; + if (count($out) > 2) { + $headers = $out[2]; + } else { + $headers = []; + } + + header("HTTP/1.1 $status"); + foreach ($headers as $k=>$v) { + header("$k: $v"); + } + print($data); + return; + } + + if ($out instanceof File) { + $out->set_header("Content-Length", $out->size()); + $out->emit(); + return; + } + + print($out); + return; + } + + header("HTTP/1.1 404 Not Found"); + print(blade("404")); + + } +} diff --git a/lib/Auth.php b/lib/Auth.php new file mode 100755 index 0000000..9aae588 --- /dev/null +++ b/lib/Auth.php @@ -0,0 +1,183 @@ +post("current"); + $chash = hash("sha256", $current); + if ($chash != $user->password) { + flash("error", "Wrong password. Try again."); + return redirect("/account"); + } + + $p1 = $_request->post("pass1"); + $p2 = $_request->post("pass2"); + + if ($p1 != $p2) { + flash("error", "Your new passwords don't match."); + return redirect("/account"); + } + + if (strlen($p1) < 6) { + flash("error", "Your new password is too short. Pick one that is 6 characters or more."); + return redirect("/account"); + } + + + $user->password = hash("sha256", $p1); + $user->save(); + flash("success", "Your password has been changed."); + return redirect("/account"); + } + + static function account($tab = "account") { + $session = Session::all_data(); + return blade("account", ["tab" => $tab, "session" => $session, "user" => get_user()]); + } + + static function can_upload() { + $user = get_user(); + if (!$user) return false; + return $user->can_upload == "Y"; + } + + static function can_moderate() { + $user = get_user(); + if (!$user) return false; + return $user->can_moderate == "Y"; + } + + static function register() { + if (array_key_exists("HTTP_REFERER", $_SERVER)) { + Session::set("login_return", $_SERVER['HTTP_REFERER']); + } else { + Session::set("login_return", "/"); + } + return blade("register", ["username" => "", "email" => "", "password" => "", "confirm" => ""]); + } + + static function do_register($_request) { + $username = $_request->post("username"); + $email = $_request->post("email"); + $password = $_request->post("password"); + $confirm = $_request->post("confirm"); + + if ($username == "") { + flash("error", "You haven't provided a username."); + return blade("register", ["username" => $username, "email" => $email, "password" => $password, "confirm" => $confirm]); + } + if (!preg_match("#^[a-zA-Z0-9_\-]+$#", $username)) { + flash("error", "Username contains invalid characters. Use letters, numbers, _ and - only."); + return blade("register", ["username" => $username, "email" => $email, "password" => $password, "confirm" => $confirm]); + } + + if (strlen($username) > 20) { + flash("error", "Username is too long. Keep it under 20 characters."); + return blade("register", ["username" => $username, "email" => $email, "password" => $password, "confirm" => $confirm]); + } + + if ($email == "") { + flash("error", "You haven't provided an email address."); + return blade("register", ["username" => $username, "email" => $email, "password" => $password, "confirm" => $confirm]); + } + + if (strlen($password) < 6) { + flash("error", "You need to give a password of at least 6 characters."); + return blade("register", ["username" => $username, "email" => $email, "password" => $password, "confirm" => $confirm]); + } + + if ($password != $confirm) { + flash("error", "Your two passwords do not match."); + return blade("register", ["username" => $username, "email" => $email, "password" => $password, "confirm" => $confirm]); + } + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + flash("error", "Your email address doesn't appear to be valid."); + return blade("register", ["username" => $username, "email" => $email, "password" => $password, "confirm" => $confirm]); + } + + $exist = User::find([["username", "=", strtolower($username)]])->first(); + if ($exist) { + flash("error", "That username has already been taken. Pick another."); + return blade("register", ["username" => $username, "email" => $email, "password" => $password, "confirm" => $confirm]); + } + + $exist = User::find([["email", "=", $email]])->first(); + if ($exist) { + flash("error", "An account with that email address already exists."); + return blade("register", ["username" => $username, "email" => $email, "password" => $password, "confirm" => $confirm]); + } + + $u = new User; + $u->username = $username; + $u->email = $email; + $u->password = hash("sha256", $password); + $u->save(); + + Session::set("user", $u->id); + + + flash("success", "Registration successful. You have also been automatically logged in."); + $ret = Session::get("login_return"); + return redirect($ret); + + } + + static function logged_in() { + $user = get_user(); + return $user !== false; + } + + static function do_login() { + $username = $_POST['username']; + $password = $_POST['password']; + + if (!$username) return blade("login"); + if (!$password) return blade("login"); + + $user = User::find([["username", "=", $username]])->first(); + if (!$user) { + $user = User::find([["email", "=", $username]])->first(); + } + + $pwe = hash("sha256", $password); + if ($pwe == $user->password) { + Session::set("user", $user->id); + flash("success", "Log in successful"); + $ret = Session::get("login_return"); + return redirect($ret); + } + flash("error", "Invalid username or password."); + return blade("login"); + } + + public static function login() { + if (array_key_exists("HTTP_REFERER", $_SERVER)) { + Session::set("login_return", $_SERVER['HTTP_REFERER']); + } else { + Session::set("login_return", "/"); + } + return blade("login"); + } + + public static function logout() { + Session::unset("user"); + return redirect($_SERVER['HTTP_REFERER']); + } + +} + + diff --git a/lib/Collection.php b/lib/Collection.php new file mode 100755 index 0000000..373e2e3 --- /dev/null +++ b/lib/Collection.php @@ -0,0 +1,193 @@ +records = $data; + } else { + $this->records = []; + } + } + + public function add($val) { + $this->records[] = $val; + } + + public function first() { + if (count($this->records) == 0) return null; + return $this->records[0]; + } + + public function last() { + if (count($this->records) == 0) return null; + return $this->records[count($this->records)-1]; + } + + public function pop() { + if (count($this->records) == 0) return null; + return array_pop($this->records); + } + + public function shift() { + if (count($this->records) == 0) return null; + return array_shift($this->records); + } + + public function push($val) { + return array_push($this->records, $val); + } + + public function unshift($val) { + return array_unshift($this->records, $val); + } + + public function get($n) { + return isset($this->records[$offset]) ? $this->records[$offset] : null; + } + + public function all() { + return $this->records; + } + + // Countable + public function count() : int { + return count($this->records); + } + + // Iterator + public function rewind() : void { + $this->position = 0; + } + + public function current() : mixed { + return $this->records[$this->position]; + } + + public function key() : mixed { + return $this->position; + } + + public function next() : void { + ++$this->position; + } + + public function valid() : bool { + return isset($this->records[$this->position]); + } + + // ArrayAccess + public function offsetSet($offset, $value) : void { + if (is_null($offset)) { + $this->push($value); + } else { + $this->records[$offset] = $value; + } + } + + public function offsetExists($offset) : bool { + return isset($this->records[$offset]); + } + + public function offsetUnset($offset) : void { + unset($this->records[$offset]); + } + + public function offsetGet($offset) : mixed { + return isset($this->records[$offset]) ? $this->records[$offset] : null; + } + + public function each($f) { + if ($f instanceof Closure) { + foreach ($this->records as $r) { + $f->call($this, $r); + } + return; + } + + if (is_array($f)) { + if (method_exists($f[0], $f[1])) { + $cl = $f[0]; + $fun = $f[1]; + foreach ($this->records as $r) { + $cl::$fun($r); + } + } + return; + } + } + + public function sort($field, $ins=false) { + usort($this->records, function ($a, $b) use ($field,$ins) { + + $aa = $a->$field; + $bb = $b->$field; + + if ($ins) { + $aa = strtolower_null($aa); + $bb = strtolower_null($bb); + } + + if ($aa > $bb) { + return 1; + } + + if ($aa < $bb) { + return -1; + } + return 0; + }); + } + + public function sort_with_function($func) { + usort($this->records, $func); + } + + + public function range($start, $len = 0) { + + if ($len == 0) { + // 0 .. $start + return array_splice($this->records, 0, $start); + } + + return array_splice($this->records, $start, $len); + } + + public function glob($key, $pattern) { + $nc = new Collection; + + foreach ($this->records as $r) { + if (fnmatch(strtolower_null($pattern), strtolower_null($r->$key))) { + $nc->push($r); + } + } + return $nc; + } + + public function merge($other) { + $o = new Collection; + foreach ($this->records as $r) { + $o->push($r); + } + foreach ($other->all() as $r) { + $o->push($r); + } + return $o; + } + + public static function from_array($arr) { + $c = new Collection(); + foreach ($arr as $k=>$v) { + $ob = new stdClass; + $ob->key = $k; + $ob->value = $v; + $c->pusk($ob); + } + return $c; + } +} diff --git a/lib/Config.php b/lib/Config.php new file mode 100755 index 0000000..70ad5fc --- /dev/null +++ b/lib/Config.php @@ -0,0 +1,32 @@ +db = new \PDO("mysql:dbname=$dbname;host=$dbhost;charset=utf8mb4",$dbuser,$dbpass); + //$this->query("SET NAMES 'utf8' COLLATE 'utf8_general_ci'"); + } + + function query($query,$params = array()) { + $q = $this->db->prepare($query); + $q->execute($params); + $e = $q->errorInfo(); + if($e[0]!='00000') { + print ""; + print $e[2]; + print ""; + return false; + } + return $q; + } + + function nextRecord($query) { + $next = $query->fetchObject(); + return $next; + } + + function id() { + return $this->db->lastInsertId(); + } + + function update($table,$id,$data) { + $values = array(); + foreach($data as $k=>$v) { + $values[] = "`" . $k . "`" . "=:" . $k; + } + $query = sprintf("UPDATE `%s` set " . implode(",",$values) . " WHERE id=:id",$table); + $data['id'] = $id; + + $q = $this->query($query,$data); + $id = $this->id(); + return $id; + } + + function insert($table,$data) { + $fields = array(); + $values = array(); + foreach($data as $k=>$v) { + $fields[] = $k; + $values[] = ":" . $k; + } + $query = sprintf("INSERT IGNORE INTO `%s` (" . implode(",",$fields) . ") VALUES (" . implode(",",$values) . ")",$table); + $q = $this->query($query,$data); + $id = $this->id(); + return $id; + } + + function set($table,$record,$field,$value) { + $this->query("UPDATE `$table` SET `$field`=:f WHERE id=:i",array( + 'f'=>$value, + 'i'=>$record + )); + } + + function select($table,$record) { + $query = sprintf("SELECT * FROM `%s` WHERE id=:id",$table); + $q = $this->query($query,array("id" => $record)); + $r = $this->nextRecord($q); + return $r; + } + + function getTableStructure($table) { + $query = sprintf("DESCRIBE `%s`", $table); + $q = $this->query($query); + $fields = []; + + while ($r = $this->nextRecord($q)) { + $field = $r['Field']; + $type = $r['Type']; + $extra = $r['Extra']; + + $fingerprint = trim("$type $extra"); + + $f = new stdClass; + $f->name = $field; + + if (preg_match('/^(.*)\((\d+)\)(.*)$/', $fingerprint, $m)) { + $fingerprint = $m[1] . $m[3]; + $f->length = $m[2]; + } + + + $f->type == -1; + $f->unsigned = false; + + switch ($fingerprint) { + case "bigint unsigned auto_increment": $f->type = MODEL_SERIAL; break; + + case "text": $f->type = MODEL_TEXT; break; + case "longtext": $f->type = MODEL_TEXT; break; + + case "blob": $f->type = MODEL_TEXT; break; + case "longblob": $f->type = MODEL_TEXT; break; + + case "tinyint": $f->type = MODEL_TINYINT; break; + case "tinyint unsigned": $f->type = MODEL_TINYINT; $f->unsigned = true; break; + + case "int": $f->type = MODEL_INT; break; + case "int unsigned": $f->type = MODEL_INT; $f->unsigned = true; break; + + case "bigint": $f->type = MODEL_BIGINT; break; + case "bigint unsigned": $f->type = MODEL_BIGINT; $f->unsigned = true; break; + + case "char": $f->type = MODEL_CHAR; break; + case "varchar": $f->type = MODEL_VARCHAR; break; + } + } + } + + public function all($query) { + $o = new Collection; + while ($r = $this->nextRecord($query)) { + $o->push($r); + } + return $o; + } +} + diff --git a/lib/ErrorHandler.php b/lib/ErrorHandler.php new file mode 100755 index 0000000..77a3a45 --- /dev/null +++ b/lib/ErrorHandler.php @@ -0,0 +1,103 @@ + += count($l)) $end = count($l) -1; + + $out = []; + for ($i = $start; $i <= $end; $i++) { + $out[] = $l[$i]; + } + return $out; + } + + public static function handleException($e) { + header("Content-Type: text/html"); + header("X-Error-Message: " . $e->getMessage()); + header("X-Error-Line: " . $e->getLine()); + header("X-Error-File: " . $e->getFile()); + header("X-Error-Code: " . $e->getCode()); + $trace = $e->getTrace(); + array_shift($trace); + + $i = 0; + foreach ($trace as $t) { + $i++; + header("X-Error-Trace-" . $i . ": " . $t['file'] . "(" . $t['line'] . ")"); + } + + print("
"); + print("

" . $e->getMessage() . "

"); + print("At Line " . $e->getLine() . " of " . $e->getFile() . "
"); + + $line = $e->getLine() - 1; + + $pre = ErrorHandler::getFileLines($e->getFile(), $line - 5, $line - 1); + $line = ErrorHandler::getFileLines($e->getFile(), $line, $line); + $post = ErrorHandler::getFileLines($e->getFile(), $line + 1, $line + 5); + + print("
");
+
+		foreach ($pre as $l) {
+			print($l . "\n");
+		}
+		print($line[0] . " <---\n");
+		foreach ($post as $l) {
+			print($l . "\n");
+		}
+
+		print("
"); + print("
"); + + $trace = $e->getTrace(); + array_shift($trace); + + foreach ($trace as $t) { + if (array_key_exists("class", $t) && array_key_exists("function", $t)) { + print("
"); + print("
From " . $t['class'] . "::" . $t['function'] . "
"); + print("At Line " . $t['line'] . " of " . $t['file'] . "
"); + print("
"); + } + + } + + } + + public static function handleError($num, $str, $file, $line, $context = null) { + ErrorHandler::handleException(new ErrorException($str, 0, $num, $file, $line)); + } + + public static function hook() { + + if (Config::get("DEBUG")) { + ini_set("display_errors", "on"); + error_reporting(E_ALL); + } else { + ini_set("display_errors", "off"); + } +// register_shutdown_function("ErrorHandler::checkForFatalCrash"); +// set_error_handler("ErrorHandler::handleError"); +// set_exception_handler("ErrorHandler::handleException"); + } + +} diff --git a/lib/File.php b/lib/File.php new file mode 100755 index 0000000..b1d426c --- /dev/null +++ b/lib/File.php @@ -0,0 +1,128 @@ +_path = $path->path(); + } + + $this->_path = $path; + } + + public function basename() { + return pathinfo($this->_path, PATHINFO_BASENAME); + } + + public function dirname() { + return pathinfo($this->_path, PATHINFO_DIRNAME); + } + + public function extension() { + return pathinfo($this->_path, PATHINFO_EXTENSION); + } + + public function filename() { + return pathinfo($this->_path, PATHINFO_FILENAME); + } + + public function delete() { $this->unlink(); } + public function unlink() { + unlink($this->_path); + } + + public function content() { + return get_file_contents($this->_path); + } + + public function emit() { + $headers = $this->_headers; + + $headers['ETag'] = "\"" . $this->hash() . "\""; + + foreach ($headers as $k=>$v) { + header($k . ": " . $v); + } + readfile($this->_path); + } + + public function set_headers($data) { + $this->_headers = $data; + } + + public function get_headers() { + return $this->_headers; + } + + public function set_header($k, $v) { + $this->_headers[trim($k)] = trim($v); + } + + public function exists() { + return file_exists($this->_path); + } + + public function size() { + return stat($this->_path)["size"]; + } + + public function path() { + return $this->_path; + } + + public function mime() { + if (!file_exists($this->_path)) { + return false; + } + return mime_content_type($this->_path); + } + + public function __toString() { + return $this->_path; + } + + public function parent() { + $p = $this->dirname(); + if ($p == "") { + return false; + } + return new File($p); + } + + public function mkdir() { + $p = $this->parent(); + if ($p) { + if (!$p->exists()) { + $p->mkdir(); + } + } + + if (!$this->exists()) { + mkdir($this->_path, 0777); + } + } + + public function hash($type = "sha256") { + return hash_file($type, $this->_path); + } + + public function rename($to, $over=false) { + if ($to == $this->path()) return false; + if ((!$over) && file_exists($to)) return false; + copy($this->path(), $to); + if (!file_exists($to)) return false; + unlink($this->path()); + $this->_path = $to; + return true; + } + + public function get_chunk($from, $len) { + $f = fopen($this->_path, "r"); + fseek($f, $from); + $data = fread($f, $len); + fclose($f); + return $data; + } +} diff --git a/lib/Form.php b/lib/Form.php new file mode 100755 index 0000000..8b7666c --- /dev/null +++ b/lib/Form.php @@ -0,0 +1,44 @@ +$v) { + $at[] = "$k = \"$v\""; + } + + return blade("forms.input", [ + "title" => $title, + "name" => $name, + "value" => $value, + "attrs" => implode(" ", $at) + ]); + } + + public static function password($title, $name, $value, $attrs = []) { + $at = []; + foreach ($attrs as $k=>$v) { + $at[] = "$k = \"$v\""; + } + + return blade("forms.password", [ + "title" => $title, + "name" => $name, + "value" => $value, + "attrs" => implode(" ", $at) + ]); + } + + public static function submit($title, $attrs = []) { + $at = []; + foreach ($attrs as $k=>$v) { + $at[] = "$k = \"$v\""; + } + + return blade("forms.submit", [ + "title" => $title, + "attrs" => implode(" ", $at) + ]); + } +} diff --git a/lib/Gemini.php b/lib/Gemini.php new file mode 100644 index 0000000..35ed002 --- /dev/null +++ b/lib/Gemini.php @@ -0,0 +1,289 @@ +parameters[$param->name] = $param; + } + + public function get_parameters() { + $o = []; + foreach ($this->parameters as $name=>$param) { + $o[$name] = $param->get_object(); + } + return $o; + } + + public function get_required() { + $o = []; + foreach ($this->parameters as $name=>$param) { + if ($param->required) { + $o[] = $name; + } + } + return $o; + } + + public static function get_key() { + Config::refresh(); + $keys = Config::get("GEMINI_KEY"); + if (!is_array($keys)) return $keys; + return $keys[rand(0, count($keys)-1)]; + } + + public function attach($name, File $file) { + $o = new stdClass; + $o->uri = null; + $o->name = $name; + $o->file = $file; + $this->attachments[$name] = $o; + } + + public function upload_file($name, File $file, $key = false) { + + if ($file->size() > 50000000) { + throw new Exception("File too large"); + } + + set_time_limit(300); + + if ($this->verbose) { + print("Uploading file...\n"); + flush(); + } + $cb = false; + + if ($this->_upload_callback) { + $cb = $this->_upload_callback; + } + + if ($cb) $cb(0); + + + if ($key == false) { + $key = Gemini::get_key(); + } + + $json = "{'file': {'display_name': '" . $name . "'}}"; + + if ($this->verbose) { + print("Upload data: " . $json . "\n"); + flush(); + } + + + $h = new HTTPRequest(); + + $r = $h->post($this->root . "/upload/v1beta/files", $json, [ + "x-goog-api-key: " . $key, + "Content-Type: application/json", + "X-Goog-Upload-Protocol: resumable", + "X-Goog-Upload-Command: start", + "X-Goog-Upload-Header-Content-Length: " . $file->size(), + "X-Goog-Upload-Header-Content-Type: " . $file->mime() + ]); + + if ($this->verbose) { + print("Request data:\n"); + print_r($h); + flush(); + } + + if ($r != 200) { + throw new Exception("Error uploading file to Gemini", $r); + } + + $url = $h->headers['x-goog-upload-url']; + $chunksize = $h->headers['x-goog-upload-chunk-granularity']; + + $size = $file->size(); + $s = $size; + + $pos = 0; + + while ($s > 0) { + + $chunk = min($s, $chunksize); + + $pct = round($pos / $size * 100); + if ($cb) $cb($pct); + + if ($this->verbose) { + printf("***** %d%%\n", round($pct)); + flush(); + } + + $data = $file->get_chunk($pos, $chunk); + $s -= $chunk; + + $fin = $s == 0 ? ", finalize" : ""; + + $r = $h->post($url, $data, [ + "Content-Length: " . $chunk, + "X-Goog-Upload-Offset: " . $pos, + "X-Goog-Upload-Command: upload$fin" + ]); + + if ($r != 200) { + throw new Exception("Error uploading chunk $pos to Gemini", $r); + } + + if ($this->verbose) { + print_r($r); + flush(); + } + + $pos += $chunk; + } + + $d = json_decode($h->body); + + if ($this->verbose) { + print("Final returned body:\n"); + print_r($d); + } + + if ($cb) $cb(100); + return $d->file->uri; + } + + + public function geminiOverview() { + + $cb = false; + if ($this->_process_callback) $cb = $this->_process_callback; + + if ($cb) $cb("Uploading files"); + + if ($this->verbose) { + print("
");
+		}
+
+		$key = Gemini::get_key();
+
+		if (count($this->attachments) == 0) {
+			throw new Exception("No files to upload");
+		}
+
+		foreach ($this->attachments as $name=>$file) {
+			$file->uri = $this->upload_file($name, $file->file, $key);
+		}
+
+		$system = [];
+
+		$system[] = "Summarize the attached PDF document.";
+
+
+		$parts = [];
+		$text = new stdClass;
+		$text->text = implode("\n", $system);
+		$parts[] = $text;
+
+		foreach ($this->attachments as $name=>$file) {
+			$f = new stdClass;
+			$f->file_data = new stdClass;
+			$f->file_data->mime_type = $file->file->mime();
+			$f->file_data->file_uri = $file->uri;
+			$parts[] = $f;
+		}
+
+		$ob = [
+			"contents" => [[
+				"parts" => $parts
+			]],
+			"generationConfig" => [
+				"responseMimeType" => "application/json",
+				"responseSchema" => [
+					"type" => "object",
+					"properties" => $this->get_parameters(),
+					"required" => $this->get_required(),
+				]
+			]
+		];
+
+		$json = json_encode($ob, JSON_PRETTY_PRINT);
+
+		if ($this->verbose) {
+			print("AI reuqest: " . $json . "\n");
+			flush();
+		}
+		set_time_limit(300);
+
+
+		if ($cb) $cb("Sending request");
+
+		$h = new HTTPRequest();
+		$r = $h->post($this->root . "/v1beta/models/" . Config::get("GEMINI_MODEL") . ":generateContent", $json, [
+			"x-goog-api-key: " . $key,
+			"Content-Type: application/json"
+		]);
+
+		if ($cb) $cb("Processing response");
+
+		if ($this->verbose) {
+			print("AI Response:\n");
+			print_r($h);
+			flush();
+		}
+
+		$resp = json_decode($h->body);
+
+		if ($r != 200) {
+			if (@$resp->error) {
+				throw new Exception($resp->error->message, $resp->error->code);
+			} else {
+				throw new Exception("HTTP Error $r requesting AI assistance", $r);
+			}
+		}
+
+		if (property_exists($resp, "candidates")) {
+			$text = $resp->candidates[0]->content->parts[0]->text;
+			$lines = explode("\n", $text);
+
+			$lastLine = "";
+			$out = [];
+			foreach ($lines as $line) {
+				if (str_starts_with($line, "* ") and !str_starts_with($lastLine, "* ")) {
+					$lastLine = $line;
+					$line = "\n" . $line;
+				} else if (str_starts_with($line, "1. ") and (trim($lastLine) != "")) {
+					$lastLine = $line;
+					$line = "\n" . $line;
+				} else {
+					$lastLine = $line;
+				}
+
+				$out[] = $line;
+			}
+
+		
+			return implode("\n", $out);
+		}
+
+		throw new Exception("Content missing processing AI data", 0);
+	}
+
+	public function upload_callback(callable $cb) {
+		$this->_upload_callback = $cb;
+	}
+
+	public function process_callback(callable $cb) {
+		$this->_process_callback = $cb;
+	}
+}
diff --git a/lib/GeminiParameter.php b/lib/GeminiParameter.php
new file mode 100644
index 0000000..82613c3
--- /dev/null
+++ b/lib/GeminiParameter.php
@@ -0,0 +1,55 @@
+name = $n;
+		$this->type = $t;
+		$this->description = $d;
+		$this->required = $r;
+		$this->children = [];
+		$this->enum = [];
+	}
+
+	public function add_enum($e) {
+		$this->enum[] = $e;
+	}
+
+	public function add_child($n, $t, $d, $r = false) {
+		$p = new GeminiParameter($n, $t, $d, $r);
+		$this->children[$n] = $p;
+		return $p;
+	}
+
+	public function get_object() {
+		$o = new stdClass;
+		$o->type = $this->type;
+		$o->description = $this->description;
+		switch ($this->type) {
+			case "string":
+				if (count($this->enum) > 0) {
+					$o->format = "enum";
+					$o->enum = $this->enum;
+				}
+				break;
+			case "object":
+				$o->properties = [];
+				$o->required = [];
+				foreach ($this->children as $cn=>$c) {
+					$o->properties[$cn] = $c->get_object();
+					if ($c->required) {
+						$o->required[] = $cn;
+					}
+				}
+				break;
+			
+		}
+		return $o;
+	}
+}
diff --git a/lib/HTTPRequest.php b/lib/HTTPRequest.php
new file mode 100644
index 0000000..d367930
--- /dev/null
+++ b/lib/HTTPRequest.php
@@ -0,0 +1,112 @@
+ch = curl_init();
+	}
+
+	public function get($url, $headers = [], $headersonly = false) {
+		curl_reset($this->ch);
+	    curl_setopt($this->ch, CURLOPT_URL, $url);
+		curl_setopt($this->ch, CURLOPT_USERAGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0");
+		curl_setopt($this->ch, CURLOPT_FILETIME, true);
+		curl_setopt($this->ch, CURLOPT_CONNECTTIMEOUT, $this->connect_timeout);
+		curl_setopt($this->ch, CURLOPT_TIMEOUT, $this->transfer_timeout);
+        curl_setopt($this->ch, CURLOPT_HTTPHEADER, $headers);
+		curl_setopt($this->ch, CURLOPT_NOBODY, $headersonly);
+		curl_setopt($this->ch, CURLOPT_RETURNTRANSFER, true);
+		curl_setopt($this->ch, CURLOPT_HEADER, true);
+		curl_setopt($this->ch, CURLOPT_FOLLOWLOCATION, true);
+
+		$this->status = 0;
+		$response = curl_exec($this->ch);
+
+		$header_size = curl_getinfo($this->ch, CURLINFO_HEADER_SIZE);
+		$header = substr($response, 0, $header_size);
+		$this->body = substr($response, $header_size);
+		$this->headers = [];
+
+		foreach ($hl as $h) {
+			$h = trim($h);
+			if (preg_match('/^HTTP\/([^\s]+)\s+(\d+)\s*/', $h, $m)) {
+				$this->version = $m[1];
+				$this->status = $m[2];
+				continue;
+			}
+			if (str_starts_with($h, " ")) {
+				$this->headers[$curr] .= " " . trim($h);
+				continue;
+			}
+			if (preg_match('/^([^:]+):\s+(.*)$/', $h, $m)) {
+				$curr = strtolower($m[1]);
+				$this->headers[$curr] = $m[2];
+			}
+		}
+
+		return $this->status;
+
+	}
+
+
+	public function post($url, $data, $headers = [], $headersonly = false) {
+
+
+		curl_reset($this->ch);
+	    curl_setopt($this->ch, CURLOPT_URL, $url);
+		curl_setopt($this->ch, CURLOPT_USERAGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0");
+		curl_setopt($this->ch, CURLOPT_POST, true);
+		curl_setopt($this->ch, CURLOPT_FILETIME, true);
+		curl_setopt($this->ch, CURLOPT_CONNECTTIMEOUT, $this->connect_timeout);
+		curl_setopt($this->ch, CURLOPT_TIMEOUT, $this->transfer_timeout);
+        curl_setopt($this->ch, CURLOPT_HTTPHEADER, $headers);
+		curl_setopt($this->ch, CURLOPT_NOBODY, $headersonly);
+		curl_setopt($this->ch, CURLOPT_RETURNTRANSFER, true);
+		curl_setopt($this->ch, CURLOPT_HEADER, true);
+		curl_setopt($this->ch, CURLOPT_FOLLOWLOCATION, true);
+		curl_setopt($this->ch, CURLOPT_POSTFIELDS, $data);
+
+		$this->status = 0;
+
+		$response = curl_exec($this->ch);
+
+		$header_size = curl_getinfo($this->ch, CURLINFO_HEADER_SIZE);
+		$header = substr($response, 0, $header_size);
+		$this->body = substr($response, $header_size);
+		$this->headers = [];
+
+		$hl = explode("\n", $header);
+
+		foreach ($hl as $h) {
+			$h = trim($h);
+			if (preg_match('/^HTTP\/([^\s]+)\s+(\d+)\s*/', $h, $m)) {
+				$this->version = $m[1];
+				$this->status = $m[2];
+				continue;
+			}
+			if (str_starts_with($h, " ")) {
+				$this->headers[$curr] .= " " . trim($h);
+				continue;
+			}
+			if (preg_match('/^([^:]+):\s+(.*)$/', $h, $m)) {
+				$curr = strtolower($m[1]);
+				$this->headers[$curr] = $m[2];
+			}
+		}
+
+		return $this->status;
+
+	}
+
+
+}
diff --git a/lib/Image.php b/lib/Image.php
new file mode 100755
index 0000000..b5adb78
--- /dev/null
+++ b/lib/Image.php
@@ -0,0 +1,124 @@
+path();
+		}
+
+		if ($h === null) {
+			parent::__construct($w);
+			$this->_type = $this->mime();
+		} else {
+			$this->_img = ImageCreateTrueColor($w, $h);
+		}
+
+	}
+
+	public function getImage() {
+		if ($this->_img == null) {
+			if ($this->_type === false) {
+				$this->_img = ImageCreateTrueColor(1, 1);
+			} else {
+				switch ($this->_type) {
+					case "image/png":
+						$this->_img = ImageCreateFromPNG($this->path());
+						break;
+					case "image/jpeg":
+						$this->_img = ImageCreateFromJPEG($this->path());
+						break;
+					case "image/gif":
+						$this->_img = ImageCreateFromGIF($this->path());
+						break;
+				}
+			}
+		}
+		return $this->_img;
+	}
+
+	public function save($fn = null, $type = null) {
+		$newimg = true;
+		if ($fn == null) {
+			$fn = $this->path();
+			$newimg = false;
+		}
+		if ($type == null) {
+			$type = $this->_type;
+			$newimg = false;
+		}
+
+		switch ($type) {
+			case "image/png":
+				ImagePNG($this->getImage(), $fn);
+				break;
+			case "image/jpeg":
+				ImageJPEG($this->getImage(), $fn);
+				break;
+			case "image/gif":
+				ImageGIF($this->getImage(), $fn);
+				break;
+			default:
+				throw new Exception("Invalid mime type $type specified");
+		}
+		if ($newimg) {
+			return new Image($fn);
+		}
+		return $this;
+	}
+
+	public function width() {
+		return ImageSX($this->getImage());
+	}
+
+	public function height() {
+		return ImageSY($this->getImage());
+	}
+
+	public function scale($w, $h = 0) {
+
+		$sx = $this->width();
+		$sy = $this->height();
+		$dx = $w;
+
+		if ($h == 0) {
+			$aspect = $sx / $sy;
+			$dy = (int)($dx / $aspect);
+		} else {
+			$dh = $h;
+		}
+
+		$new = ImageCreateTrueColor($dx, $dy);
+		ImageCopyResampled($new, $this->getImage(), 0, 0, 0, 0, (int)$dx, (int)$dy, (int)$sx, (int)$sy);
+		ImageDestroy($this->getImage());
+
+		$this->_img = $new;
+			
+	}
+
+	public function emit() {
+		$this->set_header("Content-Type", $this->_type);
+		$this->set_header("Cache-Control", "public, max-age=86400, must-revalidate");
+		$this->set_header("Content-Disposition", "inline; filename=" . $this->basename());
+
+		parent::emit();
+	}
+
+	public function color($r, $g, $b) {
+		return ImageColorAllocate($this->getImage(), $r, $g, $b);
+	}
+
+	public function clear($c) {
+		ImageFilledRectangle($this->getImage(), 0, 0, $this->width(), $this->height(), $c);
+	}
+
+	public function text($x, $y, $t, $c, $f) {
+		ImageString($this->getImage(), $f, $x, $y, $t, $c);
+	}
+
+}
diff --git a/lib/Job.php b/lib/Job.php
new file mode 100644
index 0000000..de3a0bb
--- /dev/null
+++ b/lib/Job.php
@@ -0,0 +1,157 @@
+_source = $source;
+	}
+
+	// Implement this function to actually do the job
+	abstract public function run();
+
+	// Place the current job onto the queue ready for processing.
+	public function queue($owner = -1) {
+        $class = get_called_class();
+		$data = serialize($this);
+		$db = DB::getInstance();
+
+		if ($owner == -1) {
+			$user = get_user();
+			if ($user) {
+				$owner = $user->id;
+			}
+		}
+
+		$this->_jobid = $db->insert("job", [
+			"class" => $class,
+			"data" => $data,
+			"queued" => time(),
+			"source" => $this->_source,
+			"status" => "Queued",
+			"owner" => $owner
+		]);
+
+		return $this->_jobid;
+	}
+
+	public function getJobClass() {
+        $class = get_called_class();
+		return $class;
+	}
+
+	public function jobID() {
+		return $this->_jobid;
+	}
+
+	public function setJobID($id) {
+		$this->_jobid = $id;
+	}
+
+	// Set the current status message
+	public function status($txt) {
+		$db = DB::getInstance();
+		$db->update("job", $this->_jobid, [
+			"status" => substr($txt, 0, 200)
+		]);
+		if (is_terminal()) {
+			print("  => " . $txt . "\n");
+		}
+	}
+
+	// Mark the job as completed successfully
+	public function finish() {
+		$db = DB::getInstance();
+		$db->update("job", $this->_jobid, [
+			"finished" => time(),
+		]);
+	}
+
+	// Mark the job as failed miserably
+	public function fail($message = "") {
+		$db = DB::getInstance();
+		if ($message != "") {
+			$db->update("job", $this->_jobid, [
+				"status" => substr($message, 0, 200),
+				"failed" => time(),
+			]);
+		} else {
+			$db->update("job", $this->_jobid, [
+				"failed" => time(),
+			]);
+		}
+	}
+
+	// Restart the job.
+	public function retry($msg = "", $when = -1) {
+		if ($when == -1) {
+			$when = rand(60, 600);
+		}
+		$db = DB::getInstance();
+		$status = "Retrying " . date("Y-m-d H:i", time() + $when);
+		if ($msg != "") {
+			$status .= " ($msg)";
+		}
+
+		$db->update("job", $this->_jobid, [
+			"failed" => 0,
+			"finished" => 0,
+			"started" => 0,
+			"status" => $status,
+			"retry" => time() + $when
+		]);
+	}
+
+	// Restart the job and defer it to a later time.
+	public function defer($when) {
+		$db = DB::getInstance();
+		$db->update("job", $this->_jobid, [
+			"failed" => 0,
+			"finished" => 0,
+			"started" => 0,
+			"queued" => $when,
+			"status" => "Deferred until " . gmdate("Y-m-d\TH:i:s\Z", $when)
+		]);
+	}
+		
+	// Look for the next available job that optionally has 
+	// the requested class. Mark it as started, deserialize
+	// it, and return the job runner object.
+	// Returns false if no job available.
+	public static function consumeNextJob($class = null) {
+		$db = DB::getInstance();
+
+		$db->query("lock table job write");
+		if ($class == null) {
+			$q = $db->query("select * from job where started=0 and (retry=0 or retry < unix_timestamp(now())) and queued < unix_timestamp(now()) order by queued limit 1");
+		} else {
+			$q = $db->query("select * from job where started=0 and (retry=0 or retry < unix_timestamp(now())) and queued < unix_timestamp(now()) and class=:class order by queued limit 1", ["class" => $class]);
+		}
+		$r = $db->nextRecord($q);
+		if (!$r) {
+			$db->query("unlock tables");
+			return false;
+		} 
+
+		$db->update("job", $r->id, [
+			"started" => time()
+		]);
+		$db->query("unlock tables");
+
+		$ob = unserialize($r->data);
+		$ob->setJobID($r->id);
+		$ob->setOwner($r->owner);
+		return $ob;
+	}
+
+	public function getOwner() {
+		return $this->_owner;
+	}
+
+	public function setOwner($owner) {
+		$this->_owner = $owner;
+	}
+}
diff --git a/lib/Model.php b/lib/Model.php
new file mode 100755
index 0000000..517a0bd
--- /dev/null
+++ b/lib/Model.php
@@ -0,0 +1,501 @@
+_valid = false;
+		if ($id !== null) {
+			$this->_load_record($id);
+			$this->_loaded = $this->_valid;
+		} else {
+			$this->_get_fields();
+			$this->_loaded = false;
+			$this->_valid = true;
+		}
+	}
+
+	public static function cache_connect($host, $port, $weight = 50) {
+		if (Model::$_cache === null) {
+			Model::$_cache = new Memcached();
+		}
+		Model::$_cache->addServer($host, $port, $weight);
+	}
+
+	private function _load_record($id) {
+
+		$class = get_called_class();
+		if (property_exists($class, "table")) {
+			$v = get_class_vars($class);
+			$table = $v["table"];
+		} else {
+			$table = strtolower($class);
+		}
+
+		$r = DB::getInstance()->select($table, $id);
+
+		if ($r === false) {
+			$this->_valid = false;
+			return;
+		}
+
+
+		foreach ($r as $k=>$v) {
+			$this->_fields[] = $k;
+			$this->_virgin[$k] = $v;
+		}
+
+		if (property_exists($class, "transform")) {
+			foreach ($this->_fields as $k) {
+				if (array_key_exists($k, $this->transform)) {
+					$v = $this->transform[$k];
+					$f = "from_" . $v;
+					if (method_exists($this, $f)) {
+						$this->_data[$k] = $this->$f($this->_virgin[$k]);
+					} else {
+						$this->_data[$k] = $this->_virgin[$k];
+					}
+				} else {
+					$this->_data[$k] = $this->_virgin[$k];
+				}
+			}
+		} else {
+			foreach ($this->_fields as $k) {
+				$this->_data[$k] = $this->_virgin[$k];
+			}
+		}
+		$this->_valid = true;
+	}
+
+	public static function find($where = []) {
+		$class = get_called_class();
+		if (property_exists($class, "table")) {
+			$v = get_class_vars($class);
+			$table = $v["table"];
+		} else {
+			$table = strtolower($class);
+		}
+		return new Query($class, $table, $where);
+	}
+
+
+	private function _get_fields() {
+		$class = get_called_class();
+		if ($class == "Model") { // Light model being used for xfer
+			return;
+		}
+		if (property_exists($class, "table")) {
+			$v = get_class_vars($class);
+			$table = $v["table"];
+		} else {
+			$table = strtolower($class);
+		}
+
+		$this->_fields = [];
+
+		$q = DB::getInstance()->query("describe `" . $table . "`");
+		while ($r = DB::getInstance()->nextRecord($q)) {
+			$this->_fields[] = $r->Field;
+			$this->_data[$r->Field] = null;
+		}
+		$this->_valid = true;
+	}
+
+	public function from_json($data) {
+		if ($data) {
+			$d = json_decode($data);
+			if ($d) return $d;
+		}
+		return new stdClass;
+	}
+
+	public function to_json($data) {
+		return json_encode($data);
+	}
+
+	public function valid() {
+		return $this->_valid;
+	}
+
+	public function loaded() {
+		return $this->_loaded;
+	}
+
+	public function save() {
+		Model::raw_on();
+		$class = get_called_class();
+		if (property_exists($class, "table")) {
+			$v = get_class_vars($class);
+			$table = $v["table"];
+		} else {
+			$table = strtolower($class);
+		}
+
+		$_save = [];
+
+		if (property_exists($class, "transform")) {
+			foreach ($this->_fields as $k) {
+				if (array_key_exists($k, $this->transform)) {
+					$v = $this->transform[$k];
+					$f = "to_" . $v;
+					if (method_exists($this, $f)) {
+						$_save[$k] = $this->$f(@$this->$k);
+					} else {
+						$v = __unref($this->$k);
+						if (is_string($v)) $v = trim($v);
+						$_save[$k] = $v;
+					}
+				} else {
+					$v = __unref($this->$k);
+					if (is_string($v)) $v = trim($v);
+					$_save[$k] = $v;
+				}
+			}
+		} else {
+			foreach ($this->_fields as $k) {
+				$v = __unref($this->$k);
+				if (is_string($v)) $v = trim($v);
+				$_save[$k] = $v;
+			}
+		}
+
+		$_update = [];
+
+		foreach ($_save as $k=>$v) {
+			if (array_key_exists($k, $this->_virgin)) {
+				if ($_save[$k] != $this->_virgin[$k]) {
+					$_update[$k] = $v;
+					$this->_virgin[$k] = $v;
+				}
+			} else {
+				$_update[$k] = $v;
+				$this->_virgin[$k] = $v;
+			}
+		}
+
+		$triggers = [];
+		if ($this->_loaded) {
+			if (count($_update) > 0) {
+				if (in_array("updated", $this->_fields)) {
+					$_update['updated'] = time();
+				}
+	
+				DB::getInstance()->update($table, $this->id, $_update);
+			}
+		} else {
+			$_update = $this->_virgin;
+			if (in_array("created", $this->_fields)) {
+				$_update['created'] = time();
+			}
+			$this->id = DB::getInstance()->insert($table, $_update);
+			$this->_loaded = true;
+		}
+
+		foreach ($_update as $k=>$v) {
+			if (property_exists($class, "_triggers")) {
+				if (array_key_exists($k, $this->_triggers)) {
+					$triggers[$this->_triggers[$k]] = 1;
+				}
+			}
+		}
+
+		foreach ($triggers as $trigger=>$count) {
+
+                        if ($trigger instanceof \Closure) {
+				$trigger->call($this);
+				continue;
+                        }
+
+                        if (is_array($trigger)) {
+                                $c = $trigger[0];
+                                $f = $trigger[1];
+                                $c::$f();
+				continue;
+                        }
+
+			if (is_string($trigger)) {
+				$this->$trigger();
+				continue;
+			}
+		}
+
+		Model::raw_off();
+
+	}
+
+	public static function raw_on() {
+		Model::$_raw = true;
+	}
+
+	public static function raw_off() {
+		Model::$_raw = false;
+	}
+
+	static public function raw() {
+		return Model::$_raw;
+	}
+
+
+	public function delete() {
+        $class = get_called_class();
+        if (property_exists($class, "table")) {
+            $v = get_class_vars($class);
+            $table = $v["table"];
+        } else {
+            $table = strtolower($class);
+        }		
+
+		if (method_exists($this, "on_delete")) {
+			$this->on_delete();
+		}
+
+		DB::getInstance()->query("delete from `" . $table . "` where id=:id", ["id" => $this->_data["id"]]);
+	}
+
+
+	public function __toString() {
+		return "" . $this->_data["id"];
+	}
+
+	public function __toInt() {
+		return (int)$this->_data["id"];
+	}
+
+	public function jsonSerialize() : mixed {
+		
+		$out = [];
+	
+		foreach ($this->_fields as $f) {
+			$out[$f] = $this->_data["$f"];
+		}
+
+		foreach ($this->_fields as $f) {
+			if (array_key_exists($f, $this->_objects)) {
+				$out[$f] = $this->_objects["$f"];
+			}
+		}
+
+		if (property_exists($this, "_computed")) {
+			foreach ($this->_computed as $k=>$v) {
+				$out[$k] = $this->$v();
+			}
+		}
+
+		return $out;
+	}
+
+	private function __get_field_class($f) {
+		if (!property_exists($this, "_classes")) return null;
+		if (!array_key_exists($f, $this->_classes)) return null;
+		return $this->_classes[$f];
+	}
+
+	public function __get($k) {
+		if (property_exists($this, "_computed")) {
+			if (array_key_exists($k, $this->_computed)) {
+
+				$c = $this->cache_get($k);
+				if ($c) return $c;
+
+				$func = $this->_computed[$k];
+				$c = $this->$func();
+				$this->cache_set($k, $c);
+				return $c;
+			}
+		}
+
+
+		if (!in_array($k, $this->_fields)) {
+			return null;
+		}
+
+		$class = $this->__get_field_class($k);
+		if ($class != null) {
+			if (array_key_exists($k, $this->_objects)) return $this->_objects[$k];
+		}
+		return $this->_data[$k];
+	}
+
+	public function __set($key, $val) {
+		if (!in_array($key, $this->_fields)) return false;
+		$class = $this->__get_field_class($key);
+
+		if ($class != null) {
+			if ($val instanceof Model) {
+
+				if ($val instanceof $class) {
+					$this->_data[$val] = $val->id;
+					$this->_objects[$key] = $val;
+					return true;
+				}
+			
+				throw new Exception('Class mismatch');
+				return false;
+			}
+
+			if (is_numeric($val)) {
+				$this->_data[$key] = $val;
+				$this->_objects[$key] = new $class($val);
+				return true;
+			}
+
+			$val = (int)$val;
+			$this->_data[$key] = $val;
+			$this->_objects[$key] = new $class($val);
+			return true;
+		}
+
+		$this->_data[$key] = $val;
+		unset($this->_objects[$key]);
+		return true;
+	}
+
+	public function __isset($key) {
+		if (!in_array($key, $this->_fields)) return false;
+		return true;
+	}
+
+	public function __unset($key) {
+		if (!in_array($key, $this->_fields)) return;
+		$this->_data[$key] = null;
+		unset($this->_objects[$key]);
+	}
+
+	public function load($key = null) {
+
+		if ($key === null) {
+			foreach ($this->_fields as $key) {
+				$this->load($key);
+			}
+			return true;
+		}
+		if (!in_array($key, $this->_fields)) {
+			return false;
+		}
+		$class = $this->__get_field_class($key);
+		if ($class == null) {
+			return false;
+		}
+
+		if ($this->_data[$key] === null) {
+			return false;
+		}
+
+		$ob = new $class($this->_data[$key]);
+		if (!$ob->valid()) return false;
+		$this->_objects[$key] = $ob;
+		return true;
+	}
+
+	private function cache_key($key) {
+		$class = get_called_class();
+		return sprintf("%s[%d]::%s", $class, $this->id, $key);
+	}
+		
+	public function cache_set($key, $val) {
+		$key = $this->cache_key($key);
+		Model::$_cache->set($key, $val, $this->_timeout);
+	}
+
+	public function cache_get($key) {
+		$key = $this->cache_key($key);
+		return Model::$_cache->get($key);
+	}
+
+	public function cache_invalidate($key) {
+		$key = $this->cache_key($key);
+		Model::$_cache->delete($key);
+		
+	}
+	
+	public static function get_all_models() {
+		$res = [];
+		foreach (get_declared_classes() as $class) {
+			if (is_subclass_of($class, "Model")) {
+				$res[] = $class;
+			}
+		}
+	}
+
+	public function create_or_update_table() {
+		
+	}
+
+	public function get_table_name() {
+        $class = get_called_class();
+        if (property_exists($class, "table")) {
+            $v = get_class_vars($class);
+            return($v["table"]);
+        }
+		return (strtolower($class));
+	}
+
+	public function last_modified() {
+		if ($this->updated > 0) {
+			return date("Y-m-d H:i:s", $this->updated);
+		} else {
+			return date("Y-m-d H:i:s", $this->created);
+		}
+	}
+
+}
diff --git a/lib/PDF.php b/lib/PDF.php
new file mode 100755
index 0000000..0379200
--- /dev/null
+++ b/lib/PDF.php
@@ -0,0 +1,165 @@
+arg("-density"); $p->arg($dpi);
+		$p->arg(sprintf("%s[%d]", $this->path(), $page));
+		$p->arg("-alpha"); $p->arg("remove");
+		$p->arg($file);
+
+		$rv = $p->execute();
+
+		if ($rv != 0) {
+			$img = new Image(640, 640);
+
+			$y = 40;
+			$x = 10;
+
+			$b = $img->color(255,255,255);
+			$f = $img->color(0, 0, 0);
+			$img->clear($b);
+
+			$e = implode("\n", $p->stderr());
+			$e = wordwrap($e, 80);
+			$e = explode("\n", $e);
+			
+			foreach ($e as $l) {
+				$img->text($x, $y, $l, $f, 3);
+				$y += 20;
+			}
+
+			$img->save($file, "image/jpeg");
+			return $img;
+
+		}
+
+		return new Image($file);
+	}
+
+	public function force_download() {
+		$this->_force_download = true;
+	}
+
+	public function fake_filename($f) {
+		$this->_fake_filename = $f;
+	}
+
+	public function emit() {
+
+		$filename = $this->_fake_filename;
+		if ($filename == null) {
+			$filename = $this->basename();
+		}
+
+		if ($this->_force_download) {
+			$this->set_header("Content-Type", "application/octet-stream");
+        	$this->set_header("Cache-Control", "public, max-age=31560000, immutable");
+        	$this->set_header("Content-Disposition", "attachment; filename=\"$filename\"");
+		} else {
+			$this->set_header("Content-Type", "application/pdf");
+        	$this->set_header("Cache-Control", "public, max-age=31560000, immutable");
+        	$this->set_header("Content-Disposition", "inline; filename=\"$filename\"");
+		}
+
+		parent::emit();
+	}
+
+
+	public function info() {
+		$infolines = array();
+		$info = array();
+		$proc = new Process("pdfinfo");
+		$proc->arg($this->path());
+		$proc->execute();
+
+		$infolines = $proc->stdout();
+		foreach ($infolines as $line) {
+			if (preg_match('/^([^:]+):\s+(.*)$/', $line, $m)) {
+				$info[$m[1]] = $m[2];
+			}
+		}
+		return $info;
+	}
+
+
+	public function geminiOverview($prods = null, $verbose=false) {
+		$g = new Gemini();
+		$g->verbose = $verbose;
+		try {
+			return $g->geminiOverview($this, $prods);
+		} catch (Exception $e) {
+			throw $e;
+		}
+	}
+
+	public function recompress($dest, $size=PDF_SCREEN) {
+		$proc = new Process("gs");
+
+		$proc->arg("-sDEVICE=pdfwrite");
+		$proc->arg("-dCompatibilityLevel=1.6");
+		switch ($size) {	
+			case PDF_SCREEN:
+				$proc->arg("-dPDFSETTINGS=/screen");
+				break;
+			case PDF_EBOOK:
+				$proc->arg("-dPDFSETTINGS=/ebook");
+				break;
+			case PDF_PRINT:
+				$proc->arg("-dPDFSETTINGS=/printer");
+				break;
+			case PDF_PREPRESS:
+				$proc->arg("-dPDFSETTINGS=/prepress");
+				break;
+		}
+		$proc->arg("-dNOPAUSE");
+		$proc->arg("-dQUIET");
+		$proc->arg("-dBATCH");
+		$proc->arg("-sOutputFile=" . $dest);
+		$proc->arg($this->path());
+		$rv = $proc->execute();
+
+		if ($rv == 0) {
+			return new PDF($dest);
+		}
+		return false;
+	}
+
+	public function ocr(File $out) {
+		$proc = new Process("ocrmypdf");
+		$proc->arg($this->path());
+		$proc->arg($out->path());
+		$proc->execute();
+	}
+
+	public function text() {
+		$proc = new Process("pdftotext");
+#		$proc->arg("-layout");
+		$proc->arg("-enc");
+		$proc->arg("ASCII7");
+		$proc->arg($this->path());
+		$proc->arg("-");
+		$proc->execute();
+
+		$text = new Text(implode("\n", $proc->stdout()));
+		return $text;
+	}
+}
diff --git a/lib/Process.php b/lib/Process.php
new file mode 100755
index 0000000..e344b56
--- /dev/null
+++ b/lib/Process.php
@@ -0,0 +1,82 @@
+_command = $command;
+		$this->_env = $_ENV;
+	}
+
+	public function arg($v) {
+		$this->_args[] = $v;
+	}
+
+	public function env($k, $v) {
+		$this->_env[$k] = $v;
+	}
+
+	public function cwd($p) {
+		$this->_cwd = $p;
+	}
+
+	public function execute() {
+		$command = [];
+		$command[] = escapeshellcmd($this->_command);
+		foreach ($this->_args as $a) {
+			$command[] = escapeshellarg($a);
+		}
+
+		$desc = [
+			["pipe", "r"],
+			["pipe", "w"],
+			["pipe", "w"]
+		];
+
+		$this->_fd = proc_open(
+			implode(" ", $command),
+			$desc,
+			$this->_pipes,
+			$this->_cwd,
+			$this->_env
+		);
+
+		fclose($this->_pipes[0]); // stdin
+
+		$this->_status = proc_get_status($this->_fd);
+		while ($this->_status["running"]) {
+
+			$this->_stdout[] = stream_get_contents($this->_pipes[1]);
+			$this->_stderr[] = stream_get_contents($this->_pipes[2]);
+
+			$this->_status = proc_get_status($this->_fd);
+		}
+			
+		proc_close($this->_fd);
+
+		return $this->_status["exitcode"];
+	}
+
+
+	public function stdout() {
+		return explode("\n", implode("", $this->_stdout));
+	}
+
+	public function stderr() {
+		return explode("\n", implode("", $this->_stderr));
+	}
+
+
+}
diff --git a/lib/Query.php b/lib/Query.php
new file mode 100755
index 0000000..01624c7
--- /dev/null
+++ b/lib/Query.php
@@ -0,0 +1,112 @@
+where = $where;
+		$this->table = $table;
+		$this->class = $class;
+	}
+
+	public function where($where) {
+		foreach ($where as $w) {
+			$this->where[] = $w;
+		}
+		return $this;
+	}
+
+	public function orderBy($order) {
+		$this->order[] = $order;
+		return $this;
+	}
+
+	public function orderByDesc($order) {
+		$this->order[] = "$order desc";
+		return $this;
+	}
+
+	public function limit($a, $b=null) {
+		if ($b == null) {
+			$this->limit = $a;
+		} else {
+			$this->limit = "$a,$b";
+		}
+		return $this;
+	}
+
+	function all() {
+		$this->__run();
+		$cl = $this->class;
+
+		$out = new Collection;
+		while ($r = DB::getInstance()->nextRecord($this->query)) {
+			$ob = new $cl($r->id);
+			$out->push($ob);
+		}
+		return $out;
+	}
+
+	function first() {
+		$this->__run();
+		$cl = $this->class;
+
+		$out = new Collection;
+		if ($r = DB::getInstance()->nextRecord($this->query)) {
+			$ob = new $cl($r->id);
+			return $ob;
+		}
+		return false;
+	}
+
+	function next() {
+		$cl = $this->class;
+
+		$out = new Collection;
+		if ($r = DB::getInstance()->nextRecord($this->query)) {
+			$ob = new $cl($r->id);
+			return $ob;
+		}
+		return false;
+	}
+
+	// Run the query but don't retrieve anything.
+	private function __run() {
+		$args = [];
+		$ac = 1;
+		$q = "select id from `" . $this->table . "`";
+
+		if (count($this->where) > 0) {
+			$q .= " where";
+			
+			$first = true;
+			foreach ($this->where as $w) {
+				if (!$first) {
+					$q .= " and";
+				}
+				$q .= sprintf(" `%s` %s :arg%d", $w[0], $w[1], $ac);
+				$args["arg" . $ac] = $w[2];
+				$first = false;
+				$ac++;
+			}
+		}
+
+		if (count($this->order) > 0) {
+			$q .= " order by ";
+			$q .= implode(",", $this->order);
+		}
+
+		if ($this->limit != "") {
+			$q .= sprintf(" limit %s", $this->limit);
+		}
+
+		$this->query = DB::getInstance()->query($q, $args);
+	}
+}
diff --git a/lib/Request.php b/lib/Request.php
new file mode 100755
index 0000000..b06bbc2
--- /dev/null
+++ b/lib/Request.php
@@ -0,0 +1,112 @@
+get = $_GET;
+		$this->post = $_POST;
+		$this->files = $_FILES;
+		$this->type = strtoupper($_SERVER['REQUEST_METHOD']);
+		$this->peer = $_SERVER['REMOTE_ADDR'];
+		if (array_key_exists("REDIRECT_URL", $_SERVER)) {
+			$this->path = $_SERVER['REDIRECT_URL'];
+		} else {
+			$this->path = "/";
+		}
+
+		if ($this->type == "PUT") {
+			$data = file_get_contents("php://input");
+			$arr = [];
+			parse_str($data, $arr);
+			foreach ($arr as $k=>$v) {
+				$this->put[$k] = $v;
+			}
+		}
+
+		foreach ($_SERVER as $k=>$v) {
+			if (str_starts_with($k, "HTTP_")) {
+				$header = substr($k, 5);
+				$header = strtolower($header);
+				$header = str_replace("_", " ", $header);
+				$header = ucwords($header);
+				$header = str_replace(" ", "-", $header);
+				$this->headers[$header] = $v;
+			}
+		}
+
+		if (array_key_exists("HTTP_REFERER", $_SERVER)) {
+			$this->referer = $_SERVER['HTTP_REFERER'];
+		} else {
+			$this->referer = "";
+		}
+	}
+
+	public function type() {
+		return $this->type;
+	}
+
+	public function headers() {
+		return $this->headers;
+	}
+
+	public function header($h) {
+		if (array_key_exists($h, $this->headers)) {
+			return $this->headers[$h];
+		}
+		return false;
+	}
+
+	public function file($name, $num = false) {
+		if ($num === false) {
+			return $this->files[$name];
+		}
+		if ($num >= count($this->files[$name]["name"])) {
+			return false;
+		}
+		return [
+			"name" => $this->files[$name]["name"][$num],
+			"full_path" => $this->files[$name]["full_path"][$num],
+			"type" => $this->files[$name]["type"][$num],
+			"tmp_name" => $this->files[$name]["tmp_name"][$num],
+			"error" => $this->files[$name]["error"][$num],
+			"size" => $this->files[$name]["size"][$num],
+		];
+	}
+
+	public function get($k) {
+		if (!array_key_exists($k, $this->get)) return false;
+		return $this->get[$k];
+	}
+
+	public function post($k) {
+		if (!array_key_exists($k, $this->post)) return false;
+		return $this->post[$k];
+	}
+
+	public function put($k) {
+		if (!array_key_exists($k, $this->put)) return false;
+		return $this->put[$k];
+	}
+
+	public function set_route($r) {
+		$this->route = $r;
+	}
+
+	public function route() {
+		return $this->route;
+	}
+
+	public function path() {
+		return $this->path;
+	}
+}
diff --git a/lib/Route.php b/lib/Route.php
new file mode 100755
index 0000000..738bd6f
--- /dev/null
+++ b/lib/Route.php
@@ -0,0 +1,112 @@
+method = $m;
+		$this->pattern = $p;
+		$this->function = $f;
+		$this->auth = $a;
+	}
+
+	function matches($m, $path) {
+
+		if ($this->auth != false) {
+	
+			if ($this->auth instanceof \Closure) {
+				if (!$this->auth->call($this)) {
+					return false;
+				}
+			}
+
+			if (is_array($this->auth)) {
+				$c = $this->auth[0];
+				$f = $this->auth[1];
+				if (!$c::$f()) {
+					return false;
+				}
+			}
+
+		}
+
+		if (strtolower($m) != strtolower($this->method)) return false;
+		$src_parts = explode("/", $path);
+		$dst_parts = explode("/", $this->pattern);
+
+		if (count($src_parts) != count($dst_parts)) {
+			return false;
+		}
+
+		$this->args = [];
+		for ($i = 0; $i < count($src_parts); $i++) {
+			$sp = $src_parts[$i];
+			$dp = $dst_parts[$i];
+			if (preg_match('/^{(.*)}$/', $dp, $m)) {
+				$this->args[$m[1]] = $sp;
+				continue;
+			}
+			if ($sp != $dp) {
+				return false;
+			}
+		}
+		return true;
+	}
+
+	function get_args() {
+		return $this->args;
+	}
+
+	function call($req) {
+		Route::$current = $this;
+		if ($this->function instanceof \Closure) {
+
+//			$fargs = func_get_args($this->function);
+//			if (in_array("_request", $fargs)) {
+//				$this->args["_request"] = $req;
+//			}
+
+			$ref = new ReflectionFunction($this->function);
+			foreach ($ref->getParameters() as $arg) {
+				if ($arg->name == "_request") {
+					$this->args["_request"] = $req;
+				}
+			}
+
+			return $this->function->call($this, ...$this->args);
+		}
+
+		if (is_array($this->function)) {
+			$c = $this->function[0];
+			$f = $this->function[1];
+			$ref = new ReflectionMethod($c, $f);
+			foreach ($ref->getParameters() as $arg) {
+				if ($arg->name == "_request") {
+					$this->args["_request"] = $req;
+				}
+			}
+			
+
+			return $c::$f(...$this->args);
+		}
+
+		return [404, "Not Found"];
+	}
+
+	public static function canonical() {
+		$c = Route::$current;
+		if (!$c->path) {
+			return Config::get("URL");
+		}
+		$bits = parse_url($c->path);
+		return Config::get("URL") . $bits["path"];
+	}
+
+}
diff --git a/lib/Routes.php b/lib/Routes.php
new file mode 100755
index 0000000..84c573f
--- /dev/null
+++ b/lib/Routes.php
@@ -0,0 +1,37 @@
+matches($m, $path)) {
+				return $route;
+			}
+		}
+		return false;
+	}
+
+	static function find_web($m, $path) {
+		foreach (Routes::$web as $route) {
+			if ($route->matches($m, $path)) {
+				
+				$route->path = $path;
+				return $route;
+			}
+		}
+		return false;
+	}
+
+}
diff --git a/lib/Session.php b/lib/Session.php
new file mode 100755
index 0000000..9e31419
--- /dev/null
+++ b/lib/Session.php
@@ -0,0 +1,71 @@
+addServer($host, $port, $weight);
+	}
+
+	public static function get(string $key) : mixed {
+		$sid = Session::getID();
+
+		$key = "Session::" . $sid . "::" . $key;
+
+		$t = Session::$cache->get($key);
+		if ($t) {
+			return unserialize($t);
+		} else {
+			return false;
+		}
+	}	
+
+	public static function set(string $key, mixed $value) : void {
+		$sid = Session::getID();
+		$fkey = "Session::" . $sid . "::" . $key;
+		Session::$cache->set($fkey, serialize($value));//, 60*60*24*30);
+	}
+
+	public static function unset(string $key) : void {
+		$sid = Session::getID();
+		$fkey = "Session::" . $sid . "::" . $key;
+		Session::$cache->delete($fkey);
+	}
+
+	public static function all_data() {
+        	$sid = Session::getID();
+		$fkey = "Session::" . $sid . "::";
+		$keys = Session::$cache->getAllKeys();
+        	$data = [];
+		foreach ($keys as $k) {
+			if (str_starts_with($k, $fkey)) {
+				$d = Session::get($k);
+				if (!$d) {
+					$d = [];
+            			}		
+				$out[$k] = $data;
+			}
+        	}
+		return $out;
+	}
+}
diff --git a/lib/Text.php b/lib/Text.php
new file mode 100644
index 0000000..dee9480
--- /dev/null
+++ b/lib/Text.php
@@ -0,0 +1,39 @@
+text = $this->cleanup($text);
+	}
+
+	function cleanup($text) {
+        $text = str_replace("", " ", $text);
+        $text = str_replace("\t", " ", $text);
+        $text = str_replace("\r", " ", $text);
+        $text = str_replace("\n", " ", $text);
+        $text = str_replace("\"", "", $text);
+        $text = str_replace(",", " ", $text);
+        $text = str_replace("–", "-", $text);
+        $text = str_replace("—", "-", $text);
+        $text = str_replace("Ø", "0", $text);
+        $text = str_replace("~", "-", $text);
+        $text = str_replace("--", "-", $text);
+		$text = preg_replace('/\s\s+/', ' ', $text);
+		return trim($text);
+	}
+
+	public function get_text() {
+		return $this->text;
+	}
+
+	public function words() {
+        $text = preg_replace('/([^A-Z\-0-9]+)/', ' ', strtoupper($this->text));
+        $words = preg_split('/\s+/', $text);
+        return $words;
+	}
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..3e41fee
--- /dev/null
+++ b/package.json
@@ -0,0 +1,21 @@
+{
+  "dependencies": {
+    "@fortawesome/fontawesome-free": "^6.7.2",
+    "bootstrap": "^5.3.7",
+    "css-loader": "^7.1.2",
+    "expose-loader": "^5.0.1",
+    "jquery": "^3.7.1",
+    "jquery-simple-websocket": "github:jbloemendal/jquery-simple-websocket",
+    "js-image-zoom": "^0.7.0",
+    "marked": "^16.1.1",
+    "mini-css-extract-plugin": "^2.9.2",
+    "popper.js": "^1.16.1",
+    "style-loader": "^4.0.0",
+    "vue": "^3.5.17",
+    "vue-loader": "^17.4.2",
+    "webpack": "^5.99.9"
+  },
+  "devDependencies": {
+    "webpack-cli": "^6.0.1"
+  }
+}
diff --git a/public/d192.png b/public/d192.png
new file mode 100644
index 0000000..f325bc3
Binary files /dev/null and b/public/d192.png differ
diff --git a/public/d512.png b/public/d512.png
new file mode 100644
index 0000000..de0647f
Binary files /dev/null and b/public/d512.png differ
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..8e58593
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/index.php b/public/index.php
new file mode 100644
index 0000000..6c58795
--- /dev/null
+++ b/public/index.php
@@ -0,0 +1,3 @@
+");
+	print_r($_request);
+	exit(0);
+});
+
+
+Routes::add_web("GET", "/privacy", function() { return blade("privacy"); });
+
+
+Routes::add_web("GET", "/wordpress/wp-admin/setup-config.php", ["AntiSpam", "tarpit"]);
+Routes::add_web("GET", "/wp-admin/setup-config.php", ["AntiSpam", "tarpit"]);
+
+Routes::add_web("HEAD", "/", function() { return "OK"; });
+
+
+Routes::add_web("GET", "/document/{id}/metadata/{metadata}/delete", ["DocumentController", "delete_metadata"], ["Auth", "can_moderate"]);
+
+Routes::add_web("GET", "/delete/revision/{id}", ["RevisionController", "delete"], ["Auth", "can_moderate"]);
+
+Routes::add_web("GET", "/redownload/revision/{id}", ["RevisionController", "redownload"], ["Auth", "can_moderate"]);
+
+
+Routes::add_web("GET", "/purge/revision/{id}", ["RevisionController", "purge"], ["Auth", "can_moderate"]);
+
+Routes::add_web("GET", "/attachment/{id}/{filename}", ["DocumentController", "download_attachment"]);
+
+Routes::add_web("GET", "/upload", ["DocumentController", "upload_document"], ["Auth", "can_upload"]);
+Routes::add_web("POST", "/upload", ["DocumentController", "do_upload_document"], ["Auth", "can_upload"]);
+Routes::add_web("GET", "/upload/attachment/{id}", ["DocumentController", "upload_attachment"], ["Auth", "can_upload"]);
+Routes::add_web("POST", "/upload/attachment/{id}", ["DocumentController", "do_upload_attachment"], ["Auth", "can_upload"]);
+
+Routes::add_web("GET", "/uploads", ["DocumentController", "uploads"], ["Auth", "can_upload"]);
+Routes::add_web("GET", "/uploads/import/{id}/{source}", ["DocumentController", "import"], ["Auth", "can_upload"]);
+Routes::add_web("GET", "/uploads/duplicate/{id}", ["DocumentController", "upload_duplicate"], ["Auth", "can_upload"]);
+Routes::add_web("GET", "/uploads/rerunall", ["DocumentController", "rerun_all"], ["Auth", "can_upload"]);
+Routes::add_web("GET", "/uploads/rerun/{id}", ["DocumentController", "rerun_upload"], ["Auth", "can_upload"]);
+Routes::add_web("GET", "/uploads/discard/{id}", ["DocumentController", "discard_upload"], ["Auth", "can_upload"]);
+
+Routes::add_web("GET", "/uploads/{page}", ["DocumentController", "uploads_page"], ["Auth", "can_upload"]);
+
+
+
+Routes::add_web("GET", "/generate/sitemap.xml", ["SitemapController", "generate"]);
+
+
+Routes::add_web("GET", "/recompress/revision/{id}", ["RevisionController", "recompress"], ["Auth", "can_moderate"]);
+
+Routes::add_web("GET", "/ocr/revision/{id}", ["RevisionController", "ocr"], ["Auth", "can_moderate"]);
+
+Routes::add_web("GET", "/uploads/view/{id}", ["DocumentController", "view_upload"], ["Auth", "can_upload"]);
+
+
+
+Routes::add_web("GET", "/document/unfavourite/{doc}", ["FavouriteController", "user_unset_favourite"], ["Auth", "logged_in"]);
+Routes::add_web("GET", "/document/favourite/{doc}", ["FavouriteController", "user_set_favourite"], ["Auth", "logged_in"]);
+Routes::add_web("GET", "/favourites", ["FavouriteController", "favourites"], ["Auth", "logged_in"]);
+
+
+Routes::add_web("GET", "/systems", ["SystemController", "systems"], ["Auth", "logged_in"]);
+Routes::add_web("GET", "/system/add", ["SystemController", "systems_add"], ["Auth", "logged_in"]);
+Routes::add_web("POST", "/system/add", ["SystemController", "systems_do_add"], ["Auth", "logged_in"]);
+Routes::add_web("GET", "/system/{id}", ["SystemController", "system"], ["Auth", "logged_in"]);
+Routes::add_web("GET", "/system/{id}/add/{doc}", ["SystemController", "system_add_doc"], ["Auth", "logged_in"]);
+Routes::add_web("GET", "/system/{id}/del/{doc}", ["SystemController", "system_del_doc"], ["Auth", "logged_in"]);
+
+Routes::add_web("GET", "/extract/revision/{id}", ["RevisionController", "split_revision"], ["Auth", "can_moderate"]);
diff --git a/src/.vue b/src/.vue
new file mode 100755
index 0000000..52fe565
--- /dev/null
+++ b/src/.vue
@@ -0,0 +1,24 @@
+
+
+
diff --git a/src/DDDiv.vue b/src/DDDiv.vue
new file mode 100755
index 0000000..5430fa6
--- /dev/null
+++ b/src/DDDiv.vue
@@ -0,0 +1,90 @@
+
+
+
diff --git a/src/DocumentEdit.vue b/src/DocumentEdit.vue
new file mode 100755
index 0000000..2faf477
--- /dev/null
+++ b/src/DocumentEdit.vue
@@ -0,0 +1,195 @@
+
+
+
diff --git a/src/Download.vue b/src/Download.vue
new file mode 100755
index 0000000..fa102f7
--- /dev/null
+++ b/src/Download.vue
@@ -0,0 +1,127 @@
+
+
+
diff --git a/src/DownloadManager.vue b/src/DownloadManager.vue
new file mode 100755
index 0000000..681f6a3
--- /dev/null
+++ b/src/DownloadManager.vue
@@ -0,0 +1,76 @@
+
+
+
diff --git a/src/IDMatch.vue b/src/IDMatch.vue
new file mode 100755
index 0000000..20e1c34
--- /dev/null
+++ b/src/IDMatch.vue
@@ -0,0 +1,249 @@
+
+
+
diff --git a/src/Imports.vue b/src/Imports.vue
new file mode 100755
index 0000000..f0a410d
--- /dev/null
+++ b/src/Imports.vue
@@ -0,0 +1,550 @@
+
+
+
+
+
diff --git a/src/InlineEdit.vue b/src/InlineEdit.vue
new file mode 100755
index 0000000..c603f30
--- /dev/null
+++ b/src/InlineEdit.vue
@@ -0,0 +1,85 @@
+
+
+
diff --git a/src/InlineEditText.vue b/src/InlineEditText.vue
new file mode 100755
index 0000000..bd4f48d
--- /dev/null
+++ b/src/InlineEditText.vue
@@ -0,0 +1,114 @@
+
+
+
diff --git a/src/ItemAdder.vue b/src/ItemAdder.vue
new file mode 100644
index 0000000..66ee6c4
--- /dev/null
+++ b/src/ItemAdder.vue
@@ -0,0 +1,87 @@
+
+
+
diff --git a/src/JobDisplay.vue b/src/JobDisplay.vue
new file mode 100755
index 0000000..2d399c0
--- /dev/null
+++ b/src/JobDisplay.vue
@@ -0,0 +1,83 @@
+
+
+
diff --git a/src/KeyboardInteraction.vue b/src/KeyboardInteraction.vue
new file mode 100644
index 0000000..27bee83
--- /dev/null
+++ b/src/KeyboardInteraction.vue
@@ -0,0 +1,34 @@
+
+
+
diff --git a/src/PDF.vue b/src/PDF.vue
new file mode 100755
index 0000000..e5b09b6
--- /dev/null
+++ b/src/PDF.vue
@@ -0,0 +1,89 @@
+
+
+
diff --git a/src/PDFList.vue b/src/PDFList.vue
new file mode 100755
index 0000000..fa33593
--- /dev/null
+++ b/src/PDFList.vue
@@ -0,0 +1,123 @@
+
+
+
diff --git a/src/ProductControls.vue b/src/ProductControls.vue
new file mode 100755
index 0000000..61b7d51
--- /dev/null
+++ b/src/ProductControls.vue
@@ -0,0 +1,188 @@
+
+
+
diff --git a/src/ProductSelector.vue b/src/ProductSelector.vue
new file mode 100755
index 0000000..9d84358
--- /dev/null
+++ b/src/ProductSelector.vue
@@ -0,0 +1,172 @@
+
+
+
diff --git a/src/Search.vue b/src/Search.vue
new file mode 100755
index 0000000..7511aea
--- /dev/null
+++ b/src/Search.vue
@@ -0,0 +1,85 @@
+
+
+
diff --git a/src/SpiderPages.vue b/src/SpiderPages.vue
new file mode 100755
index 0000000..06a3f25
--- /dev/null
+++ b/src/SpiderPages.vue
@@ -0,0 +1,79 @@
+
+
+
diff --git a/src/StarRating.vue b/src/StarRating.vue
new file mode 100755
index 0000000..0cf1ced
--- /dev/null
+++ b/src/StarRating.vue
@@ -0,0 +1,73 @@
+
+
+
diff --git a/src/Template.vue b/src/Template.vue
new file mode 100755
index 0000000..52fe565
--- /dev/null
+++ b/src/Template.vue
@@ -0,0 +1,24 @@
+
+
+
diff --git a/src/app.css b/src/app.css
new file mode 100755
index 0000000..5f6356f
--- /dev/null
+++ b/src/app.css
@@ -0,0 +1,27 @@
+.table td.fit,
+.table th.fit {
+	white-space: nowrap;
+	width: 1%
+}
+
+a[title]:hover::after {
+	padding: 5px;
+}
+
+.ocrtext {
+	overflow: auto;
+	max-height: 400px;
+	
+}
+
+.page-right {
+	cursor: e-resize;
+}
+
+.page-left {
+	cursor: w-resize;
+}
+
+.page-end {
+	cursor: not-allowed;
+}
diff --git a/src/app.js b/src/app.js
new file mode 100755
index 0000000..bf0411f
--- /dev/null
+++ b/src/app.js
@@ -0,0 +1,54 @@
+
+import 'jquery';
+import 'jquery-simple-websocket';
+import 'bootstrap';
+import '@fortawesome/fontawesome-free/js/fontawesome';
+import '@fortawesome/fontawesome-free/js/solid';
+import '@fortawesome/fontawesome-free/js/regular';
+import '@fortawesome/fontawesome-free/js/brands';
+
+import 'bootstrap/dist/css/bootstrap.min.css';
+
+import { createApp } from 'vue/dist/vue.esm-bundler';
+
+import Download from './Download.vue';
+import DocumentEdit from './DocumentEdit.vue';
+import ProductSelector from './ProductSelector.vue';
+import ProductControls from './ProductControls.vue';
+import InlineEdit from './InlineEdit.vue';
+import InlineEditText from './InlineEditText.vue';
+import Search from './Search.vue';
+import PDFList from './PDFList.vue';
+import SpiderPages from './SpiderPages.vue';
+import DownloadManager from './DownloadManager.vue';
+import Imports from './Imports.vue';
+import IDMatch from './IDMatch.vue';
+import DDDiv from './DDDiv.vue';
+import PDF from './PDF.vue';
+import ItemAdder from './ItemAdder.vue';
+import KeyboardInteraction from './KeyboardInteraction.vue';
+import JobDisplay from './JobDisplay.vue';
+import StarRating from './StarRating.vue';
+
+const app = createApp({ });
+
+app.component("download", Download);
+app.component("documentedit", DocumentEdit);
+app.component("productselector", ProductSelector);
+app.component("productcontrols", ProductControls);
+app.component("inlineedit", InlineEdit);
+app.component("inlineedittext", InlineEditText);
+app.component("search", Search);
+app.component("pdflist", PDFList);
+app.component("spiderpages", SpiderPages);
+app.component("downloadmanager", DownloadManager);
+app.component("imports", Imports);
+app.component("idmatch", IDMatch);
+app.component("dddiv", DDDiv);
+app.component("pdf", PDF);
+app.component("itemadder", ItemAdder);
+app.component("keyboardinteraction", KeyboardInteraction);
+app.component("jobdisplay", JobDisplay);
+app.component("starrating", StarRating);
+
+$(window).on("load", function() { app.mount('#app') });
diff --git a/src/app.scss b/src/app.scss
new file mode 100755
index 0000000..e69de29
diff --git a/views/404.blade.php b/views/404.blade.php
new file mode 100644
index 0000000..b7edd70
--- /dev/null
+++ b/views/404.blade.php
@@ -0,0 +1,8 @@
+@extends("layout.main")
+@section("content")
+
+

Page not found

+ +

The page you were looking for could not be found. Maybe you should look for a different one instead.

+
+@endsection diff --git a/views/account.blade.php b/views/account.blade.php new file mode 100644 index 0000000..0dc207b --- /dev/null +++ b/views/account.blade.php @@ -0,0 +1,94 @@ +@extends("layout.main") +@section("content") + + + +@if ($tab == "account") +
+
+
+
Change Password
+
+
+ Current password + +
+
+ New password + +
+
+ Confirm password + + +
+
+
+
+
+@endif + + + +@if ($tab == "data") + +

Here is a list of all the data associated with your current session.

+ + + + + + + + + + + @foreach ($session as $k=>$v) + + + + + @endforeach + +
KeyData
{{ $k }}{{ json_encode($v) }}
+ +

And here is the contents of your user's database record.

+ + + + + + + + + + + @foreach ($user->_data as $k=>$v) + + + + + @endforeach + +
KeyData
{{ $k }}{{ json_encode($v) }}
+ +

And that's it. That's the sum total of the data we currently store.

+ +@endif + +@endsection diff --git a/views/document.blade.php b/views/document.blade.php new file mode 100644 index 0000000..2ddf95c --- /dev/null +++ b/views/document.blade.php @@ -0,0 +1,165 @@ +@extends("layout.main") +@section("content") +@if (Auth::can_moderate()) + +@endif + +@if (get_user()) +
+
+ +
+
+ +@endif + +

{{ $doc->title }}

+

{{ $doc->subtitle }}

+

{{ $doc->subsubtitle }}

+ +
+
+ @if (Auth::can_moderate()) +
+ + + + + + +
+ @endif +
Order Number: {{ $doc->internal_id }}
+ + @if (Auth::can_moderate()) + + + @foreach ($doc->metadata as $meta) + @php + $meta->load("metadata"); + @endphp + + + + + + @endforeach + +
{{ $meta->metadata->name }} +
+ + @else + + + @foreach ($doc->metadata as $meta) + @php + $meta->load("metadata"); + @endphp + + + + + @endforeach + +
{{ $meta->metadata->name }}{{ $meta->data }}
+ @endif + +
+ @if ($doc->attachments->count() > 0) +
Attachments
+ + + + + + + @foreach ($doc->attachments as $f) + + + + + @endforeach + +
FilenameSize
{{ $f->basename() }}{{ $f->size() }}
+ @endif + + + + @if ($doc->overview) +
+ {!! $doc->overview_md() !!} +
+ @endif +
+ +
+ +
+ @foreach ($doc->revisions as $rev) + @component("thumbnail-50", ["rev" => $rev]) + @endcomponent + @endforeach +
+
+ + + @if (count($doc->related) > 0) +
+
Related Documents
+ + @foreach ($doc->related as $r) + + + + + @endforeach +
+ + {{ $r->title }} + {{ $r->subtitle }} + {{ $r->subsubtitle }} + + + + {{ $r->internal_id }} + +
+
+ @endif +
+ +@endsection diff --git a/views/documents.blade.php b/views/documents.blade.php new file mode 100755 index 0000000..845101f --- /dev/null +++ b/views/documents.blade.php @@ -0,0 +1,140 @@ +@extends("layout.main") +@section("content") +
+ @foreach ($product->get_tree() as $p) + {{ $p->title }} + @endforeach +
+refurl) ?>
+ +@if (Auth::can_moderate()) +{{ $product->overview }} +@else +@if ($product->overview != "") +
+{!! $product->overview_md() !!} +
+@endif +@endif + + + +@if (Auth::can_moderate()) + + + +@endif + + + + + @foreach ($product->meta() as $m) + + @endforeach + + + + +@if ($product->parent != null) + + + @if (Auth::can_moderate()) + + @else + + @endif + +@endif + + +@foreach ($product->children as $child) + + + @if (Auth::can_moderate()) + + @else + + @endif + + +@endforeach + +@foreach ($product->documents_sorted_by_meta() as $doc) + + + + + @foreach ($product->meta() as $m) + + @endforeach + + +@endforeach +
+
+ +
+
Title{{ MetaType::name($m) }}Order No
+ + Parent + + Parent
+ @if ($child->title == "Trash") + + @else + + @endif + + + + {{$child->title}} + + + {{$child->title}}
+ @if ($doc->attachments->count() > 0) + + @else + + @endif + + @if (Auth::can_moderate()) + + + {{$doc->title}} + {{$doc->subtitle}} + {{$doc->subsubtitle}} + @if ($doc->oneliner) +
{{ $doc->oneliner }} + @endif +
+
+ @else + + {{$doc->title}} + {{$doc->subtitle}} + {{$doc->subsubtitle}} + @if ($doc->oneliner) +
{{ $doc->oneliner }} + @endif +
+ @endif + +
+ + {{ $doc->get_metadata_by_id($m) }} + + + + {{ $doc->internal_id }} + +
+ +
+ + + {{ count($product->documents) }} documents + + +
+ +@endsection diff --git a/views/downloads.blade.php b/views/downloads.blade.php new file mode 100644 index 0000000..c3f270f --- /dev/null +++ b/views/downloads.blade.php @@ -0,0 +1,4 @@ +@extends("layout.main") +@section("content") + +@endsection diff --git a/views/favourites.blade.php b/views/favourites.blade.php new file mode 100644 index 0000000..b2cc4c7 --- /dev/null +++ b/views/favourites.blade.php @@ -0,0 +1,16 @@ +@extends("layout.main") +@section("content") +
+ +

Your favourite documens

+
+ + + +@endsection diff --git a/views/forms/input.blade.php b/views/forms/input.blade.php new file mode 100644 index 0000000..c0667ea --- /dev/null +++ b/views/forms/input.blade.php @@ -0,0 +1,4 @@ +
+ + +
diff --git a/views/forms/password.blade.php b/views/forms/password.blade.php new file mode 100644 index 0000000..53ec3bd --- /dev/null +++ b/views/forms/password.blade.php @@ -0,0 +1,4 @@ +
+ + +
diff --git a/views/forms/submit.blade.php b/views/forms/submit.blade.php new file mode 100644 index 0000000..e26dbf5 --- /dev/null +++ b/views/forms/submit.blade.php @@ -0,0 +1,5 @@ +
+ +
+ + diff --git a/views/idmatch.blade.php b/views/idmatch.blade.php new file mode 100644 index 0000000..fd735e8 --- /dev/null +++ b/views/idmatch.blade.php @@ -0,0 +1,4 @@ +@extends("layout.main") +@section("content") + +@endsection diff --git a/views/imports.blade.php b/views/imports.blade.php new file mode 100644 index 0000000..169c3c3 --- /dev/null +++ b/views/imports.blade.php @@ -0,0 +1,4 @@ +@extends("layout.main") +@section("content") + +@endsection diff --git a/views/index.blade.php b/views/index.blade.php new file mode 100644 index 0000000..3cfd796 --- /dev/null +++ b/views/index.blade.php @@ -0,0 +1,46 @@ +@extends('layout.main') +@section('content') +

Welcome to DECPDF, the searchable and organised repository of +Digital Equipment Corporation (DEC) documentation, gathering together documents +and manuals from around the internet and from users' personal archives.

+ +

We have strived to arrange the content of this site in a logical order +so that it finally becomes easy to locate the documentation for your device +or system that you need - be it a PDP-11 computer, or an obscure feature +of VMS. It should all be there at your fingertips.

+ +

Do you have a document that we don't? Are we missing some vital information +that you could provide us with? Then register for an account and request +upload permissions, and you can help us to build the ultimate repository +of DEC documentation.

+ +

FAQ

+ +
Why do some documents have "Original" and "OCR" versions?
+

When a new document is uploaded it is tested to see if it has had OCR run on it +already. If it has, we use that OCR data for our indexing. If it hasn't, we automatically +run OCR on the document. This creates a new version of the document, the OCR version. +We keep the original alongside the new OCR version and give you the choice of which +version to download.

+ +
I have a different copy of a document you already have. Can I upload it?
+

Absolutely. Document revisions with the same main document ID code will be grouped together +within the document on the website. We actively seek out other versions of documents, or +even other scans of existing revisions of documents, to try and make the archive as complete +as possible.

+ +
What's the Order Number and Revision when I upload?
+

The order number is DEC's internal document reference ID. It is typically of the format +XX-YYYYY-ZZ-RRR, for example EK-3K370-TR-001. The first three sections are the document +ID and the last section is the document revision. When you upload please split the document +ID from the revision and enter them into the two separate boxes. That way the different +revisions of the same document are properly grouped together. Note that not all documents +(especially field maintenance print sets) use this format of document IDs, and not all documents +even have an ID. In this case, just enter what you can in the document ID and leave the +revision either empty or set to 000 (which indicates it's the original unrevised version). +If there is no document ID then leave the Order Number blank. A new document ID will be +generated as a placeholder for internal use.

+ +
Will you ever host binary files like disk images and software?
+

No. That opens up a whole other kettle of worms, alongside a can of fish as well.

+@endsection diff --git a/views/layout/main.blade.php b/views/layout/main.blade.php new file mode 100644 index 0000000..9ee3c07 --- /dev/null +++ b/views/layout/main.blade.php @@ -0,0 +1,123 @@ + + + + Digital PDFs + + + + + + + + + +
+ + +
+ @if($flash_error) +
{{ $flash_error }}
+ @endif + + @if($flash_warn) +
{{ $flash_warn }}
+ @endif + + @if($flash_info) +
{{ $flash_info }}
+ @endif + + @if($flash_success) +
{{ $flash_success }}
+ @endif +
+@yield('content') + +
+ +

Site structure and layout ©2026 Majenko Technologies

+ +
+ + + diff --git a/views/loggedin.blade.php b/views/loggedin.blade.php new file mode 100644 index 0000000..7f4ad5b --- /dev/null +++ b/views/loggedin.blade.php @@ -0,0 +1,4 @@ +@extends("layout.main") +@section("content") +

You are now logged in.

+@endsection diff --git a/views/login.blade.php b/views/login.blade.php new file mode 100755 index 0000000..09866fb --- /dev/null +++ b/views/login.blade.php @@ -0,0 +1,19 @@ +@extends("layout.main") +@section("content") +
+
+
+
+
Log In
+
+
+ {!!Form::input("Username or Email", "username", "")!!} + {!!Form::password("Password", "password", "")!!} + {!!Form::submit("Login")!!} +
+
+
+
+
+
+@endsection diff --git a/views/privacy.blade.php b/views/privacy.blade.php new file mode 100644 index 0000000..78c306c --- /dev/null +++ b/views/privacy.blade.php @@ -0,0 +1,23 @@ +@extends("layout.main") +@section("content") +

Privacy and Data

+ +

What cookies do you store?

+

One. Just one cookie. That is all we put on your computer. That cookie is a simple randomly generated session ID.

+ +

What is it used for?

+

It is used to associate temporary runtime information about how you are using the site. Such things as which user ID you are +currently logged into the site as, or what you are currently searching for.

+ +

How long do you keep that information?

+

All information connected to your session is stored in a memcached database. This is a temporary data store which is never +written to disk. The data expires after a month if not used, or if the storage system should get restarted. In other words, it's +ephemeral and we don't store it past holding it in memory.

+ +

What other data do you store about me?

+

Only what you provide when you register for an account - username, email and password (which is hashed so cannot be seen by anyone, ever).

+ +

Will you ever sell or otherwise pass on my data to someone else?

+

No. Absolutely not. Since we don't really store any data of note, there's not really anything to sell, and even if there was we +wouldn't sell it anyway. Selling people's data is just scummy and evil, and we don't want to be scummy and evil.

+@endsection diff --git a/views/register.blade.php b/views/register.blade.php new file mode 100644 index 0000000..2805356 --- /dev/null +++ b/views/register.blade.php @@ -0,0 +1,21 @@ +@extends("layout.main") +@section("content") +
+
+
+
+
Register
+
+
+ {!!Form::input("Username", "username", $username)!!} + {!!Form::input("Email", "email", $email)!!} + {!!Form::password("Password", "password", $password)!!} + {!!Form::password("Password Confirmation", "confirm", $confirm)!!} + {!!Form::submit("Register")!!} +
+
+
+
+
+
+@endsection diff --git a/views/revision.blade.php b/views/revision.blade.php new file mode 100644 index 0000000..98cd56a --- /dev/null +++ b/views/revision.blade.php @@ -0,0 +1,89 @@ +@extends("layout.main") +@section("content") + +@if (Auth::can_moderate()) + +@endif + + + > +
+ @component("thumbnail", ["rev" => $rev]) + @endcomponent + +
+ @if (Auth::can_moderate()) +
+ + + + + + +
+ @endif + + @if ($rev->document) + + + + + + + + + + @endif + + + + + + + + + + + + + + + + +
+ Document: + + + {{ $rev->document->title }} + {{ $rev->document->subtitle }} + {{ $rev->document->subsubtitle }} + +
+ Order Number: + + {{ $rev->document->internal_id }} +
+ Revision: + + @if (Auth::can_moderate()) + + @else + {{ $rev->revno }} + @endif +
+ Pages: + + @isset ($rev->info->Pages) + {{ $rev->info->Pages }} + @else + Unknown + @endisset +
+ Original Filename: + + {{ $rev->origtitle }} +
+ +
+
+@endsection diff --git a/views/search.blade.php b/views/search.blade.php new file mode 100644 index 0000000..065c31e --- /dev/null +++ b/views/search.blade.php @@ -0,0 +1,74 @@ +@extends("layout.main") +@section("content") + + + + + +
+@foreach ($results as $rev) +@if ($rev !== false) +@component("thumbnail_title", ["rev" => $rev]) +@endcomponent +@endif +@endforeach +
+ + + + +@endsection diff --git a/views/simple_table.blade.php b/views/simple_table.blade.php new file mode 100644 index 0000000..ee95e2d --- /dev/null +++ b/views/simple_table.blade.php @@ -0,0 +1,10 @@ + + + @foreach ($list as $k=>$v) + + + + + @endforeach + +
{{ $k }}{{ $v }}
diff --git a/views/sitemap.blade.php b/views/sitemap.blade.php new file mode 100644 index 0000000..d198df4 --- /dev/null +++ b/views/sitemap.blade.php @@ -0,0 +1,20 @@ +<{!! '?xml version="1.0" encoding="UTF-8"?' !!}> + +@foreach ($docs as $doc) + + {{ Config::get("URL") }}/document/{{ $doc->id }} + {{ $doc->last_modified() }} + +@endforeach + +@foreach ($revs as $rev) + + {{ Config::get("URL") }}/revision/{{ $rev->id }} + {{ $rev->last_modified() }} + + + {{ Config::get("URL") }}/pdf/{{ $rev->id }}/download/{{ $rev->filename() }} + {{ $rev->last_modified() }} + +@endforeach + diff --git a/views/spider_pages.blade.php b/views/spider_pages.blade.php new file mode 100644 index 0000000..1b3ae97 --- /dev/null +++ b/views/spider_pages.blade.php @@ -0,0 +1,4 @@ +@extends("layout.main") +@section("content") + +@endsection diff --git a/views/spider_pdfs.blade.php b/views/spider_pdfs.blade.php new file mode 100644 index 0000000..4ff0204 --- /dev/null +++ b/views/spider_pdfs.blade.php @@ -0,0 +1,4 @@ +@extends("layout.main") +@section("content") + +@endsection diff --git a/views/status.blade.php b/views/status.blade.php new file mode 100644 index 0000000..9dbd0bd --- /dev/null +++ b/views/status.blade.php @@ -0,0 +1,27 @@ +@extends("layout.main") +@section("content") + +
+
+

Spider

+ @component("simple_table", ["list" => $spider]); + @endcomponent +
+
+

PDF URLs

+ @component("simple_table", ["list" => $pdf]); + @endcomponent +
+
+

OCR

+ @component("simple_table", ["list" => $ocr]); + @endcomponent +
+
+

Index

+ @component("simple_table", ["list" => $idx]); + @endcomponent +
+
+ +@endsection diff --git a/views/system.blade.php b/views/system.blade.php new file mode 100644 index 0000000..ca49ce6 --- /dev/null +++ b/views/system.blade.php @@ -0,0 +1,32 @@ +@extends("layout.main") +@section("content") +
+ +

{{ $sys->name }}

+

{{ $sys->model }}

+
+

{{ $sys->notes }}

+
+
Documents
+ +@foreach ($sys->documents as $doc) + + + + + + +@endforeach +
+ + {{ $doc->title }} + {{ $doc->subtitle }} + {{ $doc->subsubtitle }} + + + {{ $doc->internal_id }} +
+
+

To add a document to this system first navigate to the document then +click the Add To System button and select this system from the dropdown.

+@endsection diff --git a/views/systems.blade.php b/views/systems.blade.php new file mode 100644 index 0000000..e099648 --- /dev/null +++ b/views/systems.blade.php @@ -0,0 +1,18 @@ +@extends("layout.main") +@section("content") +
+ +

Your systems

+
+ + + + Add a new system + +@endsection diff --git a/views/systems_add.blade.php b/views/systems_add.blade.php new file mode 100644 index 0000000..8cc6f41 --- /dev/null +++ b/views/systems_add.blade.php @@ -0,0 +1,34 @@ +@extends("layout.main") +@section("content") +
+ +

Add system

+

Adding a system allows you to gather together all the documents you need +for working with a specific computer you own, along with all the expansion +cards that are inside that computer.

+
+
+ + +

Give your system a friendly name. I'm sure you've already named all your computers.

+ + + +

What kind of computer is this system?

+ + + +

Add any notes about the system you like here.

+ +
+
+ Cancel + +
+
+ +
+ + +@endsection diff --git a/views/thumbnail-50.blade.php b/views/thumbnail-50.blade.php new file mode 100644 index 0000000..cc9ed57 --- /dev/null +++ b/views/thumbnail-50.blade.php @@ -0,0 +1,95 @@ +@php +$rev->load("document"); +@endphp +
+ + + +
+
+ @if (($rev->revno != "") && ($rev->revno != "0")) +
+ {{ $rev->document->internal_id }}-{{ $rev->revno }} +
+ @else +
+ {{ $rev->document->internal_id }} +
+ @endif +
+
+
+
+ + {{ fmt_date($rev->month, $rev->year) }} + +
+
+ @isset ($rev->info->Pages) + {{ $rev->info->Pages }} pages + @else + Number of pages unknown + @endisset +
+
+
+
+ Quality +
+
+ +
+
+ +
+
+
+
+
+ +
+
+ + Original + +
+
+ {{ format_size(stat($rev->path() . "/doc.pdf")["size"]) }} +
+
+ + @if (file_exists($rev->path() . "/ocr.pdf")) +
+
+
+
+
+
+
+ + OCR Version + +
+
+ {{ format_size(stat($rev->path() . "/ocr.pdf")["size"]) }} +
+
+ + @endif +
+
+
diff --git a/views/thumbnail.blade.php b/views/thumbnail.blade.php new file mode 100644 index 0000000..36c8308 --- /dev/null +++ b/views/thumbnail.blade.php @@ -0,0 +1,97 @@ +@php +$rev->load("document"); +@endphp +
+ + + +
+ @if ($rev->document) +
+ @if (($rev->revno != "") && ($rev->revno != "0")) +
+ {{ $rev->document->internal_id }}-{{ $rev->revno }} +
+ @else +
+ {{ $rev->document->internal_id }} +
+ @endif +
+ @endif +
+
+
+ + {{ fmt_date($rev->month, $rev->year) }} + +
+
+ @isset ($rev->info->Pages) + {{ $rev->info->Pages }} pages + @else + Number of pages unknown + @endisset +
+
+
+
+ Quality +
+
+ +
+
+
+
+
+
+
+
+
+ + Original + +
+
+ {{ format_size(stat($rev->path() . "/doc.pdf")["size"]) }} +
+
+ + @if (file_exists($rev->path() . "/ocr.pdf")) +
+
+
+
+
+
+
+ + OCR Version + +
+
+ {{ format_size(stat($rev->path() . "/ocr.pdf")["size"]) }} +
+
+ + @endif +
+
+ +
+
diff --git a/views/thumbnail_title.blade.php b/views/thumbnail_title.blade.php new file mode 100644 index 0000000..83ec3d2 --- /dev/null +++ b/views/thumbnail_title.blade.php @@ -0,0 +1,84 @@ +@php + $rev->load("document"); +@endphp +
+ + + +
+
+ + @if (($rev->revno != "") && ($rev->revno != "0")) +
+ {{ $rev->document->internal_id }}-{{ $rev->revno }} +
+ @else +
+ {{ $rev->document->internal_id }} +
+ @endif + +
{{ $rev->document->title }}
+ + @if ($rev->document->subtitle) +
{{ $rev->document->subtitle }}
+ @endif + + @if ($rev->document->subsubtitle) +
{{ $rev->document->subsubtitle }}
+ @endif +
+ + +
+
+
+ + {{ fmt_date($rev->month, $rev->year) }} + +
+
+ {{ $rev->info->Pages }} pages +
+
+
+
+ + Original + +
+
+ {{ format_size(stat($rev->path() . "/doc.pdf")["size"]) }} +
+
+ + @if (file_exists($rev->path() . "/ocr.pdf")) +
+
+ + OCR Version + +
+
+ {{ format_size(stat($rev->path() . "/ocr.pdf")["size"]) }} +
+
+ + @endif +
+
+
diff --git a/views/upload/attachment.blade.php b/views/upload/attachment.blade.php new file mode 100644 index 0000000..dbc6041 --- /dev/null +++ b/views/upload/attachment.blade.php @@ -0,0 +1,20 @@ +@extends("layout.main") +@section("content") + +
+
+
+ +
+ +
+ +
+
+
+
+ +@endsection diff --git a/views/upload/document.blade.php b/views/upload/document.blade.php new file mode 100644 index 0000000..c6b8c91 --- /dev/null +++ b/views/upload/document.blade.php @@ -0,0 +1,20 @@ +@extends("layout.main") +@section("content") + +
+
+
+ +
+ +
+ +
+
+
+
+ +@endsection diff --git a/views/upload/duplicate.blade.php b/views/upload/duplicate.blade.php new file mode 100644 index 0000000..865e63f --- /dev/null +++ b/views/upload/duplicate.blade.php @@ -0,0 +1,8 @@ +@extends("layout.main") +@section("content") + +

Duplicate file detected.

+ +Click here to view original + +@endsection diff --git a/views/upload/list.blade.php b/views/upload/list.blade.php new file mode 100644 index 0000000..acc559f --- /dev/null +++ b/views/upload/list.blade.php @@ -0,0 +1,78 @@ +@extends("layout.main") +@section("content") +
+ +@foreach ($uploads as $upload) +
+
+ {{ $upload->filename }} ({{ round($upload->getPDF()->size() / 1024 / 1024 * 100) / 100}} MB) +
+
+ AI: {{ $upload->ai }} + OCR: {{ $upload->ocr }} +
+
+
+
+@if ($upload->ai == 'Y') +

{{ $upload->ai_docid }} {{ $upload->title }} {{ $upload->subtitle }} {{ $upload->subsubtitle }}

+@endif +
+
+
+
+ +@if ($upload->ai == 'Y') +
+
+ {{ $upload->overview }} +
+
+@endif + +
+
+
+ @if (($upload->ai == 'Y') && ($upload->ocr == 'Y')) + @if ($upload->ocr_docid) + Import
{{ $upload->ocr_docid }}
+ @endif + @if ($upload->ai_docid) + Import
{{ $upload->ai_docid }}
+ @endif + + Import
No Code
+ @endif + @if (($upload->ai == "F") || ($upload->ocr == "F")) + Import
Regardless
+ Reprocess + @endif + Discard +
+
+
+ + + + +
+@endforeach + +
+
+
+ @if ($page > 0) + < Prev + @else + < Prev + @endif + Next > +
+
+
+ +
+
+ +
+@endsection diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..a7b1edf --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,57 @@ +const webpack = require('webpack'); +const path = require('path'); +const { VueLoaderPlugin } = require('vue-loader') + +module.exports = { + + module: { + rules: [ + { + test: /\.vue$/, + loader: 'vue-loader' + }, { + test: /\.css$/, + use: ['style-loader', 'css-loader'] + }, { + test: /\.(scss)$/, + use: [ + { + loader: 'style-loader', // inject CSS to page + }, { + loader: 'css-loader', // translates CSS into CommonJS modules + }, { + loader: 'postcss-loader', // Run post css actions + options: { + plugins: function () { // post css plugins, can be exported to postcss.config.js + return [ + require('precss'), + require('autoprefixer') + ]; + } + } + }, { + loader: 'sass-loader' // compiles Sass to CSS + } + ] + }, + ] + }, + mode: 'development', + entry: [ + './src/app.js', + './src/app.css', + ], + output: { + path: path.resolve(__dirname, 'public'), + filename: 'app.js', + }, + plugins: [ + // make sure to include the plugin! + new VueLoaderPlugin(), + new webpack.ProvidePlugin({ + $: 'jquery', + jQuery: 'jquery', + ImageZoom: 'js-image-zoom', + }), + ] +};