Initial import

This commit is contained in:
2026-01-18 00:53:18 +00:00
parent fb78291fb1
commit 940191502e
115 changed files with 15524 additions and 0 deletions

14
.env Normal file
View File

@@ -0,0 +1,14 @@
DEBUG=true
DB_HOST=db
DB_USER=something
DB_PASS=whatever
DB_DATABASE=whoever
GEMINI_KEY_0=blahblahblah
GEMINI_KEY_1=barbar
GEMINI_KEY_2=foofoo
MAGICK=convert

10
Makefile Normal file
View File

@@ -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/

169
app.php Executable file
View File

@@ -0,0 +1,169 @@
<?php
ini_set("display_errors", 1);
require_once(__DIR__ . "/lib/ErrorHandler.php");
if (!array_key_exists("SHELL", $_SERVER)) {
ErrorHandler::hook();
}
require_once(__DIR__ . "/lib/Session.php");
Session::init("memcache", 11211);
define('ROOT', __DIR__);
require_once(ROOT . "/vendor/autoload.php");
$args = array_merge($_GET, $_POST);
use \Michelf\Markdown;
use \Jenssegers\Blade\Blade;
_add_code_dir(ROOT . "/lib");
//$dir = opendir(ROOT . "/lib");
//while ($file = readdir($dir)) {
// if (str_ends_with($file, ".php")) {
// require_once(ROOT . "/lib/" . $file);
// }
//}
//closedir($dir);
DB::getInstance()->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 ($_SERVER['argc'] == 0) {
$blade = new Blade(ROOT . '/views', ROOT . '/cache');
$req = new Request;
App::dispatch($req);
}
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('<script>document.location="' . $url . '";</script>');
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);
}

View File

@@ -0,0 +1,66 @@
<?php
class CoverController {
static function get_cover($_request, $id, $filename, $size = false) {
$rev = new Revision($id);
if ($rev === false) return "";
$path = $rev->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;
}
}

View File

@@ -0,0 +1,379 @@
<?php
class DocumentController {
public static function browse($pid = 0) {
$product = new Product($pid);
return blade("documents", ["product" => $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("<pre>");
print_r($proc->stderr());
print("</pre>");
exit(0);
}
return redirect("/document/" . $doc->id);
}
public static function create_overview($id) {
$job = new GeminiJob($id, "document:$id");
$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);
}
}

View File

@@ -0,0 +1,31 @@
<?php
class DownloadController {
public static function get_download($id) {
return new DownloadJob($id);
}
public static function start_download() {
}
public static function api_downloads() {
$s = DownloadJob::find([["processed", "=", 0], ["owner", "=", get_user()->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();
}
}

View File

@@ -0,0 +1,7 @@
<?php
class HomeController {
static function index() {
return blade("index");
}
}

View File

@@ -0,0 +1,78 @@
<?php
class ImportController {
public static function downloads() {
return blade("downloads");
}
public static function imports() {
return blade("imports");
}
public static function api_imports() {
$is = ProcessJob::find([["imported", "=", 0]])->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();
}
}

View File

@@ -0,0 +1,23 @@
<?php
class JobController {
public static function api_get_jobs($source) {
$db = DB::getInstance();
$q = $db->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);
}
}

View File

@@ -0,0 +1,55 @@
<?php
class PDFController {
public static function download($id, $type, $filename) {
$rev = new Revision($id);
$path = $rev->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"];
}
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"];
}
}

View File

@@ -0,0 +1,161 @@
<?php
class ProductController {
public static function api_get_list($list) {
$db = DB::getInstance();
$pids = explode(",", $list);
$out = new Collection();
foreach ($pids as $pid) {
$q = $db->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) {
$prod = new Product($id);
$c = new Collection();
foreach ($prod->documents as $doc) {
$job = new GeminiJob($doc->id, "document:" . $doc->id);
$jobid = $job->queue();
$c->push(["document" => $doc->id, "job" => $jobid]);
}
return $c;
}
}

View File

@@ -0,0 +1,46 @@
<?php
class RevisionController {
public static function show($id) {
$rev = new Revision($id);
$rev->load("document");
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 DownloadJob($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);
}
}

View File

@@ -0,0 +1,76 @@
<?php
class SearchController {
static public function api_title_search() {
$out = new Collection;
$q = DB::getInstance()->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)]);
}
}

View File

@@ -0,0 +1,92 @@
<?php
class SpiderController {
public static function spider_pdfs() {
return blade("spider_pdfs");
}
public static function spider_pages() {
return blade("spider_pages");
}
public static function api_pdfs() {
$pdfs = Spider::find([["status", "=", "N"]])->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;
$job->queued = time();
$job->started = 0;
$job->finished = 0;
$job->processed = 0;
$job->url = $pdf->url;
$job->owner = get_user()->id;
$job->file = sprintf("download/file-%08X-%08X", rand(), time());
$job->save();
}
}
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();
}
}

View File

@@ -0,0 +1,79 @@
<?php
class SystemController {
public static function status() {
$status = [
"B" => "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();
}
}

57
app/jobs/DownloadJob.php Normal file
View File

@@ -0,0 +1,57 @@
<?php
class DownloadJob extends Job {
public $from = null;
public $to = null;
public $revid = null;
private $_pct = 0;
public function __construct($revid, $from, $to) {
$this->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");
print_r($this);
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");
}
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;
}
}
}

214
app/jobs/GeminiJob.php Normal file
View File

@@ -0,0 +1,214 @@
<?php
class GeminiJob extends Job {
public $docid;
public function __construct($docid, $source="unknown") {
parent::__construct($source);
$this->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();
return;
}
$this->status("Processing document with Gemini");
try {
$gemini = new Gemini();
$gemini->upload_callback([$this, "uploadcb"]);
$gemini->process_callback([$this, "processcb"]);
$lines = $gemini->geminiOverview($pdf);
} catch (Exception $e) {
$this->status($e->getMessage());
if ($e->getmessage() == "The request timed out. Please try again.") {
$this->retry();
} else if ($e->getmessage() == "HTTP Error 0 requesting AI assistance") {
$this->retry();
} else {
$this->fail();
}
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->internal_id = $iid[0];
}
array_shift($ld);
}
$doc->overview = implode("\n", $ld);
$doc->save();
/*
$db = DB::getInstance();
$q = $db->query("
select
product.id as id,
locate(product.title, document.title) as wordoffset
from
product,
document
where
locate(product.title, document.title) > 0 and
document.id = :id and
length(product.title) > 3
order by
length(product.title) desc
limit 0,1
", ["id" => $doc->id]);
if ($r = $db->nextRecord($q)) {
$d = new DocProduct();
$d->document = $doc->id;
$d->product = $r->id;
$d->save();
} else {
$q = $db->query("
select
product.id as id,
locate(product.title, document.overview) as wordoffset
from
product,
document
where
locate(product.title, document.overview) > 0 and
locate(product.title, document.overview) < 300 and
document.id = :id and
length(product.title) > 3
order by
length(product.title) desc
limit 0,1
", ["id" => $doc->id]);
if ($r = $db->nextRecord($q)) {
$d = new DocProduct();
$d->document = $doc->id;
$d->product = $r->id;
$d->save();
}
}
*/
$this->status("Finished");
}
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;
}
}

15
app/jobs/ProcessJob.php Normal file
View File

@@ -0,0 +1,15 @@
<?php
class ProcessJob extends Job {
public $docid = 0;
public function __construct($docid) {
parent::__construct("document:$docid");
$this->docid = $docid;
}
public function run() {
}
}

13
app/models/DocMeta.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
class DocMeta extends Model {
protected $_classes = [
"document" => "Document",
"metadata" => "MetaType"
];
}

17
app/models/DocProduct.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
class DocProduct extends Model {
protected $_classes = [
"document" => "Document",
"product" => "Product"
];
protected $_model = [
"id" => [MODEL_SERIAL],
"created" => [MODEL_BIGINT, 11],
"updated" => [MODEL_BIGINT, 11],
"document" => [MODEL_OBJECT, "Document"],
"product" => [MODEL_OBJECT, "Product"]
];
}

191
app/models/Document.php Normal file
View File

@@ -0,0 +1,191 @@
<?php
class Document extends Model {
protected $_computed = [
"related" => "get_related",
"revisions" => "get_revisions",
"products" => "get_products",
"metadata" => "get_metadata",
"attachments" => "get_attachments",
];
public function get_revisions() {
return Revision::find([["document", "=", $this->id]])->orderBy("revno")->all();
}
public function get_related() {
$revs = Revision::find([["document", "=", $this->id]])->all();
$related = new Collection;
foreach ($revs as $rev) {
$q = DB::getInstance()->query("
select
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) {
$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;
}
}

54
app/models/IDMatch.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
class IDMatch extends Model {
public static function find_docid($words) {
if (!is_array($words)) {
$words = IDMatch::get_words($words);
}
$matches = IDMatch::find()->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) {
print("<<$text>>\n");
$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);
print(">>$text<<\n");
//$text = preg_replace("/([A-Za-z])(- )/", '$1', $text);
$words = preg_split('/\s+/', $text);
return $words;
}
}

11
app/models/MetaType.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
class MetaType extends Model {
protected $table = "metatypes";
public static function name($id) {
$m = new MetaType($id);
return $m->name;
}
}

4
app/models/OCR.php Normal file
View File

@@ -0,0 +1,4 @@
<?php
class OCR extends Model {
}

163
app/models/Product.php Normal file
View File

@@ -0,0 +1,163 @@
<?php
class Product extends Model {
protected $_classes = [
"parent" => "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;
}
}

114
app/models/Revision.php Normal file
View File

@@ -0,0 +1,114 @@
<?php
class Revision extends Model {
protected $_classes = [
"document" => "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) {
$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 "";
}
$this->_body = $ocr->body;
}
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);
}
}

4
app/models/Spider.php Normal file
View File

@@ -0,0 +1,4 @@
<?php
class Spider extends Model {
}

View File

@@ -0,0 +1,4 @@
<?php
class SpiderBanned extends Model {
}

4
app/models/SpiderDom.php Normal file
View File

@@ -0,0 +1,4 @@
<?php
class SpiderDom extends Model {
}

View File

@@ -0,0 +1,5 @@
<?php
class SpiderPage extends Model {
protected $table="pages";
}

5
app/models/User.php Normal file
View File

@@ -0,0 +1,5 @@
<?php
class User extends Model {
public static $table = "users";
}

4583
backend/.done Executable file

File diff suppressed because it is too large Load Diff

39
backend/JobRunner Executable file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env php
<?php
$opts = getopt("c:j:");
$class = null;
$jobid = null;
if (array_key_exists("c", $opts)) {
$class = $opts['c'];
}
if (array_key_exists("j", $opts)) {
$jobid = $opts['j'];
}
require_once(__DIR__ . "/../app.php");
while (true) {
$job = Job::consumeNextJob($class);
if ($job) {
print("Executing job " . $job->jobID() . "\n");
try {
$job->run();
$job->finish();
} catch (PDOException $e) {
print_r($e);
$job->status($e->getMessage());
$job->fail();
} catch (Exception $e) {
print_r($e);
$job->status($e->get_message());
$job->fail();
}
print("Job finished\n");
} else {
sleep(1);
}
}

7
composer.json Executable file
View File

@@ -0,0 +1,7 @@
{
"require": {
"michelf/php-markdown": "^2.0",
"jenssegers/blade": "^2.0",
"illuminate/view": "11.7.0"
}
}

12
lib/AntiSpam.php Executable file
View File

@@ -0,0 +1,12 @@
<?php
class AntiSpam {
public static function tarpit() {
while (true) {
print(chr(rand() & 0xff));
ob_flush();
flush();
sleep(1);
}
}
}

77
lib/App.php Executable file
View File

@@ -0,0 +1,77 @@
<?php
class App {
static function dispatch($req) {
$apiroute = Routes::find_api($req->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"));
}
}

174
lib/Auth.php Executable file
View File

@@ -0,0 +1,174 @@
<?php
class Auth {
static function routes() {
Routes::add_web("GET", "/register", ["Auth", "register"]);
Routes::add_web("POST", "/register", ["Auth", "do_register"]);
Routes::add_web("GET", "/login", ["Auth", "login"]);
Routes::add_web("POST", "/login", ["Auth", "do_login"]);
Routes::add_web("GET", "/logout", ["Auth", "logout"]);
Routes::add_web("GET", "/account", ["Auth", "account"], ["Auth", "logged_in"]);
Routes::add_web("POST", "/account/chpass", ["Auth", "chpass"], ["Auth", "logged_in"]);
Routes::add_web("GET", "/account/{tab}", ["Auth", "account"], ["Auth", "logged_in"]);
}
static function chpass($_request) {
$session = Session::all_data();
$user = get_user();
$current = $_request->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");
}
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 ($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']);
}
}

193
lib/Collection.php Executable file
View File

@@ -0,0 +1,193 @@
<?php
class Collection implements Countable,Iterator,ArrayAccess {
private $records;
private $position = 0;
public function __construct($data = null) {
if ($data !== null) {
$this->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;
}
}

21
lib/Config.php Executable file
View File

@@ -0,0 +1,21 @@
<?php
class Config {
private static $config = null;
private function __construct() {
}
public static function get(string $val, mixed $default = null) : mixed {
if (Config::$config == null) {
Config::$config = parse_ini_file(__DIR__ . "/../.env");
}
if (array_key_exists($val, Config::$config)) {
return Config::$config[$val];
} else {
return $default;
}
}
}

141
lib/DB.php Executable file
View File

@@ -0,0 +1,141 @@
<?php
class DB {
private static $instance = null;
private $db = null;
private function __construct() {
}
public static function getInstance() {
if (DB::$instance == null) {
DB::$instance = new DB;
}
return DB::$instance;
}
public function connect($dbuser, $dbpass, $dbhost, $dbname) {
$this->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 "<span class='error'>";
print $e[2];
print "</span>";
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;
}
}

103
lib/ErrorHandler.php Executable file
View File

@@ -0,0 +1,103 @@
<?php
require_once("Config.php");
class ErrorHandler {
public static function checkForFatalCrash() {
$error = error_get_last();
if ($error) {
if ($error['type'] == E_ERROR) {
ErrorHandler::handleException(new ErrorException($error['message'], 0, $error['type'], $error['file'], $error['line']));
}
}
}
public static function printErrorEntry($type, $message, $file, $line) {
?>
<?php
}
public static function getFileLines($file, $start, $end) {
$f = file_get_contents($file);
$l = explode("\n", $f);
if ($start < 0) $start = 0;
if ($end >= 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("<div style='background-color: #ffaaaa;'>");
print("<h3>" . $e->getMessage() . "</h3>");
print("At Line " . $e->getLine() . " of " . $e->getFile() . "<br>");
$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("<pre>");
foreach ($pre as $l) {
print($l . "\n");
}
print($line[0] . " <---\n");
foreach ($post as $l) {
print($l . "\n");
}
print("</pre>");
print("</div>");
$trace = $e->getTrace();
array_shift($trace);
foreach ($trace as $t) {
if (array_key_exists("class", $t) && array_key_exists("function", $t)) {
print("<div>");
print("<h5>From " . $t['class'] . "::" . $t['function'] . "</h5>");
print("At Line " . $t['line'] . " of " . $t['file'] . "<br>");
print("</div>");
}
}
}
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");
}
}

128
lib/File.php Executable file
View File

@@ -0,0 +1,128 @@
<?php
class File {
private $_path = null;
private $_headers = [];
public function __construct($path = null) {
if ($path instanceof File) {
$this->_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;
}
}

44
lib/Form.php Executable file
View File

@@ -0,0 +1,44 @@
<?php
class Form {
public static function input($title, $name, $value, $attrs = []) {
$at = [];
foreach ($attrs as $k=>$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)
]);
}
}

240
lib/Gemini.php Normal file
View File

@@ -0,0 +1,240 @@
<?php
require_once(__DIR__ . "/File.php");
require_once(__DIR__ . "/HTTPRequest.php");
class Gemini {
public $file_uri = "";
public $root = "https://generativelanguage.googleapis.com";
public $verbose = false;
private $_upload_callback = false;
private $_process_callback = false;
public static function get_key() {
$i = 0;
$keys = [];
while ($k = Config::get("GEMINI_KEY_" . $i)) {
$keys[] = $k;
$i++;
}
$keyid = rand(0, count($keys) - 1);
$key = $keys[$keyid];
return $key;
}
public function upload_file(File $file, $key = false) {
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': '" . $file->basename() . "'}}";
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(File $file) {
$cb = false;
if ($this->_process_callback) $cb = $this->_process_callback;
if ($cb) $cb("Uploading file");
if ($this->verbose) {
print("<pre>");
}
$key = Gemini::get_key();
$uri = $this->upload_file($file, $key);
$ob = [
"contents" => [
[
"parts" => [
[
"text" => "Summarize this document. In addition provide me the title of the document as the first line, surrounded by { and }. If it is all in capitals, re-capitalize it to make it easier to read. On the second line give the document order number surrounded by [ and ].",
],
[
"file_data" => [
"mime_type" => $file->mime(),
"file_uri" => $uri
]
]
]
]
]
];
$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/gemini-2.5-flash-lite: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;
}
}

112
lib/HTTPRequest.php Normal file
View File

@@ -0,0 +1,112 @@
<?php
class HTTPRequest {
public $body = "";
public $headers = [];
public $status = 0;
public $version = "1.0";
public $connect_timeout = 2;
public $transfer_timeout = 300;
public $ch = null;
public function __construct() {
$this->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;
}
}

124
lib/Image.php Executable file
View File

@@ -0,0 +1,124 @@
<?php
require_once(__DIR__ . "/File.php");
class Image extends File {
private $_img = null;
private $_type = null;
public function __construct($w, $h = null) {
if ($w instanceof File) {
$w = $w->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);
}
}

119
lib/Job.php Normal file
View File

@@ -0,0 +1,119 @@
<?php
abstract class Job {
private $_jobid = 0;
private $_source = "unknown";
public function __construct($source = "unknown") {
$this->_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() {
$class = get_called_class();
$data = serialize($this);
$db = DB::getInstance();
$this->_jobid = $db->insert("job", [
"class" => $class,
"data" => $data,
"queued" => time(),
"source" => $this->_source,
"status" => "Queued",
]);
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" => $txt
]);
}
// 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() {
$db = DB::getInstance();
$db->update("job", $this->_jobid, [
"failed" => time(),
]);
}
// Restart the job.
public function retry() {
$db = DB::getInstance();
$db->update("job", $this->_jobid, [
"failed" => 0,
"finished" => 0,
"started" => 0,
"status" => "Retrying"
]);
}
// 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 queued < unix_timestamp(now()) order by queued limit 1");
} else {
$q = $db->query("select * from job where started=0 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);
return $ob;
}
}

491
lib/Model.php Executable file
View File

@@ -0,0 +1,491 @@
<?php
// Special data types
define("MODEL_SERIAL", 0x1000); // auto increment id
define("MODEL_OBJECT", 0x1001); // Link to another object type by id
define("MODEL_CREATED", 0x1002); // CREATED timestamp
define("MODEL_UPDATED", 0x1003); // UPDATED timestamp
// Numeric data types
define("MODEL_BIT", 0x2000);
define("MODEL_TINYINT", 0x2001);
define("MODEL_BOOL", 0x2002);
define("MODEL_SMALLINT", 0x2003);
define("MODEL_MEDIUMINT", 0x2004);
define("MODEL_INT", 0x2005);
define("MODEL_BIGINT", 0x2006);
define("MODEL_FLOAT", 0x2007);
define("MODEL_DOUBLE", 0x2008);
define("MODEL_DECIMAL", 0x2009);
// String data types
define("MODEL_CHAR", 0x3000);
define("MODEL_VARCHAR", 0x3001);
define("MODEL_BINARY", 0x3002);
define("MODEL_VARBINARY", 0x3003);
define("MODEL_TINYBLOB", 0x3004);
define("MODEL_TINYTEXT", 0x3005);
define("MODEL_TEXT", 0x3006);
define("MODEL_BLOB", 0x3007);
define("MODEL_MEDIUMTEXT", 0x3008);
define("MODEL_MEDIUMBLOB", 0x3009);
define("MODEL_LONGTEXT", 0x300A);
define("MODEL_LONGBLOB", 0x300B);
define("MODEL_ENUM", 0x300C);
define("MODEL_SET", 0x300D);
// Date types
define("MODEL_DATE", 0x4000);
define("MODEL_DATETIME", 0x4001);
define("MODEL_TIMESTAMP", 0x4002);
define("MODEL_TIME", 0x4003);
define("MODEL_YEAR", 0x4004);
define("MODEL_INDEX", 12);
define("MODEL_FULLTEXT", 13);
define("MODEL_UNIQUE", 14);
class Model implements JsonSerializable {
public $_fields = []; // List of fields in the DB table
public $_virgin = []; // Virgin, untouched data loaded from the DB
public $_data = []; // Current (modified) data as stored in the DB
public $_objects = []; // Objects constructed from the values in _data
public $_timeout = 1;
public $_valid = false;
public $_loaded = false;
public static $_cache = null;
static protected $_raw = false;
public function __construct($id = null, $deep = true) {
$this->_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) {
$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));
}
}

145
lib/PDF.php Executable file
View File

@@ -0,0 +1,145 @@
<?php
require_once(__DIR__ . "/File.php");
define("PDF_SCREEN", 0);
define("PDF_EBOOK", 1);
define("PDF_PRINT", 2);
define("PDF_PREPRESS", 3);
class PDF extends File {
private $_force_download = false;
private $_fake_filename = null;
public function __construct($f) {
parent::__construct($f);
}
public function extract_page($page, $file, $dpi = 300) {
$m = Config::get("MAGICK");
$p = new Process($m);
$p->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($verbose=false) {
$g = new Gemini();
$g->verbose = $verbose;
try {
return $g->geminiOverview($this);
} 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;
}
}

82
lib/Process.php Executable file
View File

@@ -0,0 +1,82 @@
<?php
class Process {
private $_command = null;
private $_args = [];
private $_env = [];
private $_cwd = null;
private $_pipes = [];
private $_stdout = [];
private $_stderr = [];
private $_fd = null;
private $_status = null;
public function __construct($command) {
$this->_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));
}
}

112
lib/Query.php Executable file
View File

@@ -0,0 +1,112 @@
<?php
class Query {
private $class = "";
private $table = "";
private $where = [];
private $order = [];
private $limit = "";
private $query;
public function __construct($class, $table, $where = []) {
$this->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);
}
}

112
lib/Request.php Executable file
View File

@@ -0,0 +1,112 @@
<?php
class Request {
private $type = "";
private $headers = [];
private $referer = "";
private $get = [];
private $post = [];
private $put = [];
private $peer = "";
private $path = "";
private $route = null;
private $files = [];
public function __construct() {
$this->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;
}
}

98
lib/Route.php Executable file
View File

@@ -0,0 +1,98 @@
<?php
class Route {
public $method = "GET";
public $pattern = "";
public $function = null;
public $auth = false;
public $args = [];
function __construct($m, $p, $f, $a = false) {
$this->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) {
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"];
}
}

35
lib/Routes.php Executable file
View File

@@ -0,0 +1,35 @@
<?php
class Routes {
public static $api = [];
public static $web = [];
static function add_api($method, $path, $function, $auth = false) {
$r = new Route($method, $path, $function, $auth);
Routes::$api[] = $r;
}
static function add_web($method, $path, $function, $auth = false) {
$r = new Route($method, $path, $function, $auth);
Routes::$web[] = $r;
}
static function find_api($m, $path) {
foreach (Routes::$api as $route) {
if ($route->matches($m, $path)) {
return $route;
}
}
return false;
}
static function find_web($m, $path) {
foreach (Routes::$web as $route) {
if ($route->matches($m, $path)) {
return $route;
}
}
return false;
}
}

71
lib/Session.php Executable file
View File

@@ -0,0 +1,71 @@
<?php
class Session {
public static $cache = null;
public static $id = null;
public static function getID() {
if (array_key_exists("SESSION_ID", $_COOKIE)) {
Session::$id = $_COOKIE["SESSION_ID"];
}
if (Session::$id) return Session::$id;
Session::$id = uniqid("decpdf", true);
setcookie("SESSION_ID", Session::$id, time() + 60*60*24*30);
return Session::$id;
}
public static function init($host, $port, $weight = 50) {
if (Session::$cache == null) {
Session::$cache = new Memcached();
}
Session::$cache->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;
}
}

21
package.json Normal file
View File

@@ -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"
}
}

10
public/.htaccess Normal file
View File

@@ -0,0 +1,10 @@
RewriteEngine On
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]

2250
public/app.js Normal file

File diff suppressed because one or more lines are too long

BIN
public/d192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
public/d512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

3
public/index.php Normal file
View File

@@ -0,0 +1,3 @@
<?php
require_once(__DIR__ . "/../app.php");

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

View File

@@ -0,0 +1,20 @@
{
"short_name": "DECPDF",
"name": "DECPDF",
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff",
"icons": [
{
"src": "/d192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/d512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

5
public/sys.css Normal file
View File

@@ -0,0 +1,5 @@
.table td.fit,
.table th.fit {
white-space: nowrap;
width: 1%
}

74
routes/api.php Normal file
View File

@@ -0,0 +1,74 @@
<?php
Routes::add_api("GET", "/api/download/{id}", ["DownloadController", "get_download"], ["Auth", "can_upload"]);
Routes::add_api("PUT", "/api/download", ["DownloadController", "start_download"], ["Auth", "can_upload"]);
Routes::add_api("GET", "/api/downloads", ["DownloadController", "api_downloads"], ["Auth", "can_upload"]);
Routes::add_api("PUT", "/api/downloads", ["DownloadController", "api_add_download"], ["Auth", "can_upload"]);
Routes::add_api("POST", "/api/document/dragdrop", ["DocumentController", "api_drag_drop"], ["Auth", "can_moderate"]);
Routes::add_api("GET", "/api/document/{id}", ["DocumentController", "api_get"]);
Routes::add_api("POST", "/api/document/{id}", ["DocumentController", "api_set"]);
Routes::add_api("GET", "/api/document/{id}/products", ["DocumentController", "api_get_products"]);
Routes::add_api("GET", "/api/document/{id}/available_metadata", ["DocumentController", "api_available_metadata"]);
Routes::add_api("GET", "/api/document/{id}/metadata", ["DocumentController", "api_get_metadata"]);
Routes::add_api("PUT", "/api/document/{id}/metadata", ["DocumentController", "api_new_metadata"], ["Auth", "can_moderate"]);
Routes::add_api("POST", "/api/document/{id}/metadata/{metadata}", ["DocumentController", "api_set_metadata"], ["Auth", "can_moderate"]);
Routes::add_api("DELETE", "/api/document/{id}/metadata/{metadata}", ["DocumentController", "api_delete_metadata"], ["Auth", "can_moderate"]);
Routes::add_api("DELETE", "/api/product/trash/{id}", ["ProductController", "api_empty_trash"], ["Auth", "can_moderate"]);
Routes::add_api("GET", "/api/product/{list}", ["ProductController", "api_get_list"]);
Routes::add_api("PUT", "/api/product/{id}", ["ProductController", "api_add_child"], ["Auth", "can_moderate"]);
Routes::add_api("POST", "/api/product/{id}", ["ProductController", "api_set"], ["Auth", "can_moderate"]);
routes::add_api("DELETE", "/api/product/{id}", ["ProductController", "api_delete"], ["Auth", "can_moderate"]);
Routes::add_api("GET", "/api/productmru", ["ProductController", "api_get_mru"]);
Routes::add_api("POST", "/api/productmru/{id}", ["ProductController", "api_add_mru"]);
Routes::add_api("POST", "/api/search/product", ["ProductController", "api_search"]);
Routes::add_api("POST", "/api/move/document/{id}", ["DocumentController", "api_move"], ["Auth", "can_moderate"]);
Routes::add_api("POST", "/api/move/product/{id}", ["ProductController", "api_move"], ["Auth", "can_moderate"]);
Routes::add_api("POST", "/api/merge/document/{id}", ["DocumentController", "api_merge"], ["Auth", "can_moderate"]);
Routes::add_api("POST", "/api/revision/{id}", ["RevisionController", "api_set"]);
Routes::add_api("POST", "/api/search/title", ["SearchController", "api_title_search"]);
Routes::add_api("GET", "/api/spider/pdfs", ["SpiderController", "api_pdfs"], ["Auth", "can_moderate"]);
Routes::add_api("DELETE", "/api/spider/pdf/{id}", ["SpiderController", "api_reject_pdf"], ["Auth", "can_moderate"]);
Routes::add_api("DELETE", "/api/spider/pdf", ["SpiderController", "api_reject_pdf"], ["Auth", "can_moderate"]);
Routes::add_api("PUT", "/api/spider/pdf/{id}", ["SpiderController", "api_accept_pdf"], ["Auth", "can_moderate"]);
Routes::add_api("PUT", "/api/spider/pdf", ["SpiderController", "api_accept_pdf"], ["Auth", "can_moderate"]);
Routes::add_api("GET", "/api/spider/pages", ["SpiderController", "api_pages"], ["Auth", "can_moderate"]);
Routes::add_api("DELETE", "/api/spider/page/{id}", ["SpiderController", "api_reject_page"], ["Auth", "can_moderate"]);
Routes::add_api("PUT", "/api/spider/page/{id}", ["SpiderController", "api_accept_page"], ["Auth", "can_moderate"]);
Routes::add_api("GET", "/api/imports", ["ImportController", "api_imports"], ["Auth", "can_upload"]);
Routes::add_api("DELETE", "/api/import/{id}", ["ImportController", "api_delete_import"], ["Auth", "can_upload"]);
Routes::add_api("POST", "/api/import/{id}", ["ImportController", "api_set_import"], ["Auth", "can_upload"]);
Routes::add_api("PUT", "/api/import/{id}", ["ImportController", "api_add_document"], ["Auth", "can_upload"]);
Routes::add_api("GET", "/api/documentbyid/{id}", ["DocumentController", "get_by_id"]);
Routes::add_api("GET", "/api/sys/idmatch", ["SystemController", "api_get_idmatches"], ["Auth", "can_moderate"]);
Routes::add_api("PUT", "/api/sys/idmatch", ["SystemController", "api_add_idmatch"], ["Auth", "can_moderate"]);
Routes::add_api("POST", "/api/sys/idmatch/{id}", ["SystemController", "api_set_idmatch"], ["Auth", "can_moderate"]);
Routes::add_api("DELETE", "/api/sys/idmatch/{id}", ["SystemController", "api_del_idmatch"], ["Auth", "can_moderate"]);
Routes::add_api("POST", "/api/search/prefix", ["DocumentController", "api_get_title_fragment"]);
Routes::add_api("GET", "/api/product/{id}/available_metadata", ["ProductController", "api_available_metadata"]);
Routes::add_api("PUT", "/api/product/{id}/metadata", ["ProductController", "api_add_metadata"]);
Routes::add_api("GET", "/api/jobs/{source}", ["JobController", "api_get_jobs"], ["Auth", "can_moderate"]);
Routes::add_api("DELETE", "/api/jobs/{id}", ["JobController", "api_delete_job"], ["Auth", "can_moderate"]);
Routes::add_api("GET", "/api/docid/{id}", ["DocumentController", "api_guess_docid"], ["Auth", "can_moderate"]);
Routes::add_api("GET", "/api/product/gemini_all/{id}", ["ProductController", "api_gemini_all"], ["Auth", "can_moderate"]);

73
routes/web.php Normal file
View File

@@ -0,0 +1,73 @@
<?php
Routes::add_web("GET", "/", ["Homecontroller", "index"]);
Routes::add_web("GET", "/cover/{id}/{size}/{filename}", ["CoverController", "get_cover"]);
Routes::add_web("GET", "/cover/{id}/{filename}", ["CoverController", "get_cover"]);
Routes::add_web("GET", "/documents/{pid}", ["DocumentController", "browse"]);
Routes::add_web("GET", "/documents", ["DocumentController", "browse"]);
Routes::add_web("GET", "/document/{id}", ["DocumentController", "show"]);
Routes::add_web("GET", "/overview/create/{id}", ["DocumentController", "create_overview"], ["Auth", "can_moderate"]);
Routes::add_web("GET", "/revision/{id}", ["RevisionController", "show"]);
Routes::add_web("GET", "/pdf/{id}/{type}/{filename}", ["PDFController", "download"]);
Routes::add_web("GET", "/page/{id}/{page}/page.jpg", ["PDFController", "get_page"]);
Auth::routes();
Routes::add_web("POST", "/search", ["SearchController", "search"]);
Routes::add_web("GET", "/search", ["SearchController", "search"]);
Routes::add_web("POST", "/search/{page}", ["SearchController", "search"]);
Routes::add_web("GET", "/search/{page}", ["SearchController", "search"]);
Routes::add_web("GET", "/spider_pdfs", ["SpiderController", "spider_pdfs"], ["Auth", "can_moderate"]);
Routes::add_web("GET", "/spider_pages", ["SpiderController", "spider_pages"], ["Auth", "can_moderate"]);
Routes::add_web("GET", "/status", ["SystemController", "status"], ["Auth", "can_moderate"]);
Routes::add_web("GET", "/downloads", ["ImportController", "downloads"], ["Auth", "can_upload"]);
Routes::add_web("GET", "/del_docproduct/{doc}/{prod}", ["DocumentController", "del_docproduct"], ["Auth", "can_moderate"]);
Routes::add_web("GET", "/imports", ["ImportController", "imports"], ["Auth", "can_upload"]);
Routes::add_web("GET", "/idmatch", function() { return blade("idmatch"); }, ["Auth", "can_moderate"]);
Routes::add_web("GET", "/merge/{id}", ["DocumentController", "merge"], ["Auth", "can_moderate"]);
Routes::add_web("GET", "/explode/{id}", ["DocumentController", "separate"], ["Auth", "can_moderate"]);
Routes::add_web("GET", "/test", function($_request) {
print("<pre>");
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/attachment/{id}", ["DocumentController", "upload_attachment"], ["Auth", "can_moderate"]);
Routes::add_web("POST", "/upload/attachment/{id}", ["DocumentController", "do_upload_attachment"], ["Auth", "can_moderate"]);

90
src/DDDiv.vue Executable file
View File

@@ -0,0 +1,90 @@
<template>
<div :draggable="draggable" :class="dropclass" @dragover="drag_over" @dragstart="drag_start" @drop="drop" @dragenter="drag_enter" @dragleave="drag_leave">
<slot></slot>
</div>
</template>
<script>
import { ref } from 'vue/dist/vue.esm-bundler';
export default {
props: [
"draggable",
"droppable",
"object_id",
"object_type",
"callback",
"extra_data",
],
setup(props, context) {
return {
dropclass: ref(""),
content: ref(""),
}
},
methods: {
init() {
},
drag_over(e) {
e.preventDefault();
},
drag_start(e) {
if (this.draggable == "true") {
e.dataTransfer.setData("object_id", this.object_id);
e.dataTransfer.setData("object_type", this.object_type);
e.dataTransfer.setData("extra_data", this.extra_data);
}
},
drop(e) {
if (this.droppable == "true") {
this.dropclass = "";
e.preventDefault();
console.log(e.dataTransfer.getData("object_id"));
console.log(e.dataTransfer.getData("object_type"));
console.log(e.dataTransfer.getData("extra_data"));
console.log(this);
$.ajax({
url: this.callback,
method: 'POST',
data: {
dst_id: this.object_id,
dst_type: this.object_type,
dst_extra: this.extra_data,
src_id: e.dataTransfer.getData("object_id"),
src_type: e.dataTransfer.getData("object_type"),
src_extra: e.dataTransfer.getData("extra_data"),
copy: e.ctrlKey,
}
}).done(this.drop_complete);
}
},
drop_complete(data) {
//console.log(data);
window.location.reload();
},
drag_enter(e) {
if (this.droppable == "true") {
this.dropclass = "bg-warning-subtle";
}
},
drag_leave(e) {
if (this.droppable == "true") {
this.dropclass = "";
}
},
},
mounted() {
this.init();
}
}
</script>

195
src/DocumentEdit.vue Executable file
View File

@@ -0,0 +1,195 @@
<template>
<div class="btn-group">
<button class="btn btn-primary" @click="edit" title="Edit Document"><i class='fa fa-pencil'></i></button>
<div id="docedit" class="modal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit Document</h5>
<button @click="close" type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div v-if="loading" class="text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div v-else>
<div class="row">
<div class="col-lg-6 col-12">
<label for="internal_id">Order Number</label>
<div class="input-group">
<input @keyup.enter="save" @keyup.esc="close" @keyup="id_caps" type="text" class="form-control" name="internal_id" v-model="internal_id">
<button @click="guess_docid" class="btn btn-secondary" title="Guess document ID"><i class="fa fa-wand-magic-sparkles"></i></button>
</div>
<label for="title">Title</label>
<div class="input-group">
<input ref="rtitle" @keyup.enter="save" @keyup.esc="close" type="text" class="form-control" name="title" v-model="title">
<button @click="split_title" class="btn btn-secondary" title="Split down on cursor or selection"><i class="fa fa-i-cursor"></i></button>
</div>
<label for="subtitle">Subtitle</label>
<div class="input-group">
<input ref="rsubtitle" @keyup.enter="save" @keyup.esc="close" type="text" class="form-control" name="subtitle" v-model="subtitle">
<button @click="split_subtitle" class="btn btn-secondary" title="Split down on cursor or selection"><i class="fa fa-i-cursor"></i></button>
</div>
<label for="subsubtitle">Sub-subtitle</label>
<input @keyup.enter="save" @keyup.esc="close" type="text" class="form-control" name="subsubtitle" v-model="subsubtitle">
</div>
<div class="col-lg-6 col-12">
<label for="overview">Overview <small>(Markdown supported)</small></label>
<textarea @keyup.esc="close" name="overview" v-model="overview" class="form-control w-100" rows="5"></textarea>
<label for="products">Products</label>
<productselector v-model="products"></productselector>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button @click="close" type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button @click="save" type="button" class="btn btn-primary">Save changes</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref } from 'vue/dist/vue.esm-bundler';
export default {
setup() {
return {
loading: ref(true),
internal_id: ref(""),
title: ref(""),
subtitle: ref(""),
subsubtitle: ref(""),
overview: ref(""),
products: ref(""),
}
},
mounted() {
},
methods: {
edit() {
this.loading = true;
$('#docedit').show();
$.ajax({
url: "/api/document/" + this.docid,
method: "GET"
}).done(this.get_products);
},
get_products(data) {
this.internal_id = data.internal_id;
this.title = data.title;
this.subtitle = data.subtitle;
this.subsubtitle = data.subsubtitle;
this.overview = data.overview;
$.ajax({
url: "/api/document/" + this.docid + "/products",
method: "GET"
}).done(this.fill_products);
},
fill_products(data) {
this.products = "";
for (var i = 0; i < data.length; i++) {
if (this.products != "") {
this.products += ",";
}
this.products += data[i].id;
}
this.loading = false;
},
close() {
$('#docedit').hide();
},
save() {
$.ajax({
url: "/api/document/" + this.docid,
method: "POST",
data: {
internal_id: this.internal_id,
title: this.title,
subtitle: this.subtitle,
subsubtitle: this.subsubtitle,
overview: this.overview,
products: this.products,
}
}).done(this.refresh);
},
refresh() {
window.location.reload();
},
id_caps() {
this.internal_id = this.internal_id.toUpperCase();
this.internal_id = this.internal_id.replace("", "-");
},
get_caret_position(oField) {
// Initialize
var iCaretPos = 0;
if (document.selection) {
// Set focus on the element
oField.focus();
// To get cursor position, get empty selection range
var oSel = document.selection.createRange();
// Move selection start to 0 position
oSel.moveStart('character', -oField.value.length);
// The caret position is selection length
iCaretPos = oSel.text.length;
} else if (oField.selectionStart || oField.selectionStart == '0') {
iCaretPos = oField.selectionDirection=='backward' ? oField.selectionStart : oField.selectionEnd;
}
return iCaretPos;
},
split_title() {
var pos = this.get_caret_position(this.$refs.rtitle);
var l = this.title.substring(0, pos);
var r = this.title.substring(pos);
this.title = l;
this.subtitle = r;
console.log(pos);
},
split_subtitle() {
var pos = this.get_caret_position(this.$refs.rsubtitle);
var l = this.subtitle.substring(0, pos);
var r = this.subtitle.substring(pos);
this.subtitle = l;
this.subsubtitle = r;
console.log(pos);
},
guess_docid() {
$.ajax({
url: "/api/docid/" + this.docid,
method: "GET"
}).done(this.guessed_docid);
},
guessed_docid(d) {
this.internal_id = d.docid;
},
},
props: [
"docid",
],
}
</script>

127
src/Download.vue Executable file
View File

@@ -0,0 +1,127 @@
<template>
<div>
<div class="progress {{pstyle}}" role="progressbar" aria-label="Download progress" style="--bs-progress-bar-transition: 0.01s;">
<div ref="pg" :class="pbstyle" :style="{'width': percent + '%'}">{{status}}</div>
</div>
<div class="text-truncate">
<small>{{srcurl}}</small>
</div>
<div v-if="failed" class="alert alert-danger">
The download has failed with error code <strong>{{errorcode}}</strong>. Please try again.
</div>
</div>
</template>
<script>
import { ref } from 'vue/dist/vue.esm-bundler'
export default {
emits: [
'complete',
'failure'
],
props: [
'jobid',
'url'
],
setup() {
return {
percent: ref(100),
pbstyle: ref(""),
pstyle: ref(""),
outfile: ref(""),
srcurl: ref(""),
failed: ref(false),
errorcode: ref(""),
status: ref(""),
}
},
methods: {
get_status() {
$.ajax({
url: "/api/download/" + this.jobid,
method: "get",
}) .fail(this.get_status_fail)
.done(this.got_status);
},
get_status_fail(data, status, xhr) {
this.errorcode = xhr;
this.failed = true;
},
got_status(data, status, xhr) {
this.update_bar(data);
if (data.done != data.size) {
setTimeout(this.get_status, 500);
} else {
if (data.size == 0) {
setTimeout(this.get_status, 2000);
}
}
},
submit(url) {
$.ajax({
url: "/api/download",
method: "put",
data: {
url: this.url
}
}) .fail(this.submit_fail)
.done(this.submitted);
},
submit_fail(data, status, xhr) {
this.errorcode = xhr;
this.failed = true;
this.$emit("failed");
},
submitted(data, status, xhr) {
this.update_bar(data);
setTimeout(this.get_status, 500);
},
update_bar(data) {
this.outfile = data.file;
this.srcurl = data.url;
if (data.started == 0) {
this.status = "Queued";
this.pbstyle = "progress-bar bg-secondary progress-bar-striped progress-bar-animated";
this.percent = 100;
return;
}
if (data.finished != 0) {
this.status = "Completed";
this.pbstyle = "progress-bar bg-success";
this.percent = 100;
this.$emit("complete");
let event = new CustomEvent("complete", { bubbles: true, detail: data });
document.dispatchEvent(event);
return;
}
if (data.size == 0) {
this.status = "Starting...";
} else {
this.percent = data.done / data.size * 100;
this.pbstyle = "progress-bar bg-primary";
this.status = Math.round(this.percent) + "%";
}
}
},
mounted() {
this.status = "Please wait...";
this.pbstyle = "progress-bar bg-secondary progress-bar-striped progress-bar-animated";
this.percent = 100;
if (this.jobid != "") {
this.get_status();
} else if (this.url != "") {
this.submit(url);
}
}
};
</script>

76
src/DownloadManager.vue Executable file
View File

@@ -0,0 +1,76 @@
<template>
<div>
<div class="input-group">
<span class="input-group-text">Download PDF URL:</span>
<input type="text" class="form-control" v-model="url">
<button class="btn btn-primary" @click="add_download">Download</button>
</div>
<ul v-for="download in downloads" class="list-group">
<li class="list-group-item">
<div class="row">
<div class="col-12 text-truncate">
{{ download.url }}
</div>
<div class="col-3">
<download :jobid="download.id"></download>
</div>
</div>
</li>
</ul>
</div>
</template>
<script>
import { ref } from 'vue/dist/vue.esm-bundler';
export default {
props: [
],
setup() {
return {
downloads: ref([]),
busy: ref(false),
url: ref(""),
}
},
methods: {
init() {
this.update();
},
update() {
this.busy = true;
$.ajax({
url: "/api/downloads",
method: "GET"
}).done(this.got_update);
},
add_download() {
this.busy = true;
$.ajax({
url: "/api/downloads",
method: "PUT",
data: {
url: this.url
}
}).done(this.got_update);
},
got_update(data) {
this.downloads = data;
this.busy = false;
setTimeout(this.update, 10000);
},
},
mounted() {
this.init();
}
}
</script>

249
src/IDMatch.vue Executable file
View File

@@ -0,0 +1,249 @@
<template>
<div>
<div class="input-group">
<span class="input-group-text">Test String:</span>
<input @keyup="refresh" class="form-control" type="text" v-model="teststring">
<span class="input-group-text"><i class="fa fa-arrow-left mx-1"></i> Type here to test matches</span>
<button class="btn btn-success" @click="addnew">Add New Match</button>
</div>
<table class="table table-hover w-100">
<thead>
<tr>
<th>Weight</th>
<th>Regular Expression</th>
<th>Example</th>
<th>Test Result</th>
</tr>
</thead>
<tbody>
<tr v-for="match in matches" :class="matchtest(match.regex)" @click="edit_reg(match)">
<td>{{ match.weight }}</td>
<td>/^{{ match.regex }}$/</td>
<td>{{ match.example }}</td>
<td>{{ teststring != "" ? matchresult(match.regex, teststring) : matchresult(match.regex, match.example)}}</td>
</tr>
</tbody>
</table>
<div id="newreg" class="modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add New ID Match</h5>
<button @click="addclose" type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" :disabled="saving"></button>
</div>
<div class="modal-body">
<label for="example">Example ID</label>
<input type="text" name="example" class="form-control" v-model="add_example" @keyup="do_add_test">
<label for="regex">Regular Expression</label>
<div class="input-group">
<span class="input-group-text">/^</span>
<input type="text" name="regex" class="form-control" v-model="add_regex" @keyup="do_add_test">
<span class="input-group-text">$/</span>
</div>
<label for="weight">Weight (lower is higher priority)</label>
<input type="text" name="weight" class="form-control" v-model="add_weight">
Test Result: {{ add_test }}
</div>
<div class="modal-footer">
<button @click="addclose" type="button" class="btn btn-secondary" data-bs-dismiss="modal" :disabled="saving">Close</button>
<button @click="addsave" type="button" class="btn btn-primary" :disabled="saving">
<span v-if="!saving">Save changes</span>
<span class="spinner-border spinner-border-sm" role="status" v-if="saving"></span>
<span v-if="saving" class="mx-1">Saving...</span>
</button>
</div>
</div>
</div>
</div>
<div id="editreg" class="modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit ID Match</h5>
<button @click="edit_close" type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" :disabled="saving"></button>
</div>
<div class="modal-body">
<label for="example">Example ID</label>
<input type="text" name="example" class="form-control" v-model="edit_example" @keyup="do_edit_test">
<label for="regex">Regular Expression</label>
<div class="input-group">
<span class="input-group-text">/^</span>
<input type="text" name="regex" class="form-control" v-model="edit_regex" @keyup="do_edit_test">
<span class="input-group-text">$/</span>
</div>
<label for="weight">Weight (lower is higher priority)</label>
<input type="text" name="weight" class="form-control" v-model="edit_weight">
Test Result: {{ edit_test }}
</div>
<div class="modal-footer">
<button @click="edit_delete" type="button" class="btn btn-danger" :disabled="saving">Delete</button>
<button @click="edit_close" type="button" class="btn btn-secondary" data-bs-dismiss="modal" :disabled="saving">Close</button>
<button @click="edit_save" type="button" class="btn btn-primary" :disabled="saving">
<span v-if="!saving">Save changes</span>
<span class="spinner-border spinner-border-sm" role="status" v-if="saving"></span>
<span v-if="saving" class="mx-1">Saving...</span>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref } from 'vue/dist/vue.esm-bundler';
export default {
props: [
],
setup() {
return {
matches: ref([]),
teststring: ref(""),
saving: ref(false),
add_example: ref(""),
add_regex: ref(""),
add_weight: ref(0),
add_test: ref(""),
edit_example: ref(""),
edit_regex: ref(""),
edit_weight: ref(0),
edit_test: ref(""),
edit_id: ref(0),
}
},
methods: {
init() {
this.update();
},
refresh() {
var m = this.matches;
this.matches = [];
this.matches = m;
},
update() {
$.ajax({
url: "/api/sys/idmatch",
method: "GET"
}).done(this.got_update);
},
got_update(data) {
this.matches = data;
},
matchresult(regex, example) {
try {
const re = new RegExp("^" + regex + "$");
var res = example.match(re);
if (res == null) {
return "FAILED";
}
return res[1];
} catch (E) {
return "Bad Syntax";
}
},
matchtest(regex) {
var teststring = this.teststring.trim();
if (teststring == "") return "";
if (this.matchresult(regex, teststring) != "FAILED") {
return "table-success";
}
return "table-danger";
},
addnew() {
this.add_example = "";
this.add_regex = "";
this.add_weight = 0;
$('#newreg').show();
},
do_add_test() {
this.add_test = this.matchresult(this.add_regex, this.add_example);
},
addclose() {
$('#newreg').hide();
},
addsave() {
$.ajax({
url: "/api/sys/idmatch",
method: "PUT",
data: {
example: this.add_example,
regex: this.add_regex,
weight: this.add_weight
}
}).done(this.got_update);
$('#newreg').hide();
},
edit_reg(m) {
this.edit_id = m.id;
this.edit_example = m.example;
this.edit_regex = m.regex;
this.edit_weight = m.weight;
$('#editreg').show();
},
edit_close() {
$('#editreg').hide();
},
do_edit_test() {
this.edit_test = this.matchresult(this.edit_regex, this.edit_example);
},
edit_save() {
$.ajax({
url: "/api/sys/idmatch/" + this.edit_id,
method: "POST",
data: {
example: this.edit_example,
regex: this.edit_regex,
weight: this.edit_weight
}
}).done(this.got_update);
$('#editreg').hide();
},
edit_delete() {
$.ajax({
url: "/api/sys/idmatch/" + this.edit_id,
method: "DELETE",
}).done(this.got_update);
$('#editreg').hide();
},
},
mounted() {
this.init();
}
}
</script>

550
src/Imports.vue Executable file
View File

@@ -0,0 +1,550 @@
<template>
<div>
<ul class="list-group">
<li v-for="i in imports" :class="fancyrow(i)">
<div class="row">
<div class="col-1">
<a v-if="i.revision != null" :href='"/cover/" + i.revision.id + "/cover.jpg"'><img class="w-100" :src='"/cover/" + i.revision.id + "/100/cover.jpg"'></a>
</div>
<div class="col-11">
<div class="row">
<div class="col-lg-2 col-6">
<strong v-if="i.imported > 0">Imported</strong>
<strong v-else-if="i.completed > 0 && i.revision">Completed</strong>
<strong v-else-if="i.completed > 0 && (!i.revision)">Failed</strong>
<strong v-else-if="i.started > 0">Processing</strong>
<strong v-else>Queued</strong>
</div>
<div class="col-lg-2 col-6">
<a v-if="i.revision != null" :href='"/revision/" + i.revision.id'>{{ i.revision.id }}</a>
</div>
<div class="col-lg-2 col-3">
{{ i.guessed_id }}
</div>
<div class="col-lg-2 col-3">
{{ i.guessed_revno }}
</div>
<div class="col-lg-2 col-6">
{{ i.guessed_month }}/{{ i.guessed_year }}
</div>
</div>
<div class="row">
<div class="col-9 text-truncate">
{{ i.guessed_title }}
</div>
<div class="col-lg-3 col-12 btn-group">
<button :disabled="busy" class="btn btn-danger" @click="del(i)"><i class="fa-solid fa-trash"></i></button>
<button :disabled="busy || (i.revision == null)" class="btn btn-secondary" @click="download(i)"><i class="fa-solid fa-download"></i></button>
<button :disabled="(i.started == 0) || busy" class="btn btn-primary" @click="restart(i)"><i class="fa-solid fa-rotate"></i></button>
<button class="btn btn-success" :disabled="(i.completed == 0 || (!i.revision)) || busy" @click="import_rev(i)"><i class="fa-solid fa-file-import"></i></button>
</div>
</div>
<div class="row">
<div class="col-12">
{{ i.origfile }}
</div>
</div>
</div>
</div>
</li>
</ul>
<div id="docedit" class="modal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Create Document</h5>
<button @click="close" type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" :disabled="saving"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-12 alert alert-info">
{{ i_origfile }}
</div>
</div>
<pdf :rev="i_revision"></pdf>
<div class="row">
<div class="col-lg-6 col-12">
<label for="i_id">Order Number</label>
<div class="input-group">
<input @blur="upper_id" @keyup.enter="save" ref="orderno" @keyup.esc="close" type="text" @keyup="find_doc" :class="'form-control' + ((i_id.length <= 25) ? ' is-valid' : ' is-invalid')" name="i_id" v-model="i_id" :disabled="saving">
<button class="btn btn-secondary" @click="paste_id"><i class="fa-solid fa-paste"></i></button>
</div>
<div v-if="newdoc" class="alert alert-warning">
A new document will be created with this order number.
</div>
<div v-else class="alert alert-success">
This PDF will be added to {{ existing_doc.title }} {{ existing_doc.subtitle }} {{ existing_doc.subsubtitle }} as a new revision.
</div>
<div v-if="newdoc">
<label for="i_title">Title</label>
<div class="input-group">
<input ref="rtitle" @keyup.enter="save" @keyup.esc="close" @keyup="try_title" type="text" :class="'form-control' + (i_title == '' ? ' is-invalid' : ' is-valid')" name="i_title" v-model="i_title" :disabled="saving">
<button class="btn btn-secondary" @click="split_title" title="Split down on cursor position"><i class='fa fa-i-cursor'></i></button>
</div>
<label for="i_subtitle">Subtitle</label>
<div class="input-group">
<input ref="rsubtitle" @keyup.enter="save" @keyup.esc="close" @keyup="try_subtitle" type="text" class="form-control" name="i_subtitle" v-model="i_subtitle" :disabled="saving">
<button class="btn btn-secondary" @click="split_subtitle" title="Split down on cursor position"><i class='fa fa-i-cursor'></i></button>
</div>
<label for="i_subsubtitle">Sub-subtitle</label>
<input @keyup.enter="save" @keyup.esc="close" @keyup="try_subsubtitle" type="text" class="form-control" name="i_subsubtitle" v-model="i_subsubtitle" :disabled="saving">
</div>
</div>
<div class="col-lg-6 col-12">
<label for="i_revno">Revision / Version Number</label>
<input @keyup.enter="save" @keyup.esc="close" type="text" class="form-control" name="i_revno" v-model="i_revno" :disabled="saving">
<div class="row">
<div class="col-6">
<label for="i_month">Month</label>
<input @keyup.enter="save" @keyup.esc="close" type="text" class="form-control" name="i_month" v-model="i_month" :disabled="saving">
</div>
<div class="col-6">
<label for="i_year">Year</label>
<input @keyup.enter="save" @keyup.esc="close" type="text" class="form-control" name="i_year" v-model="i_year" :disabled="saving">
</div>
</div>
<div v-if="newdoc">
<label for="i_overview">Overview <small>(Markdown supported)</small></label>
<textarea @keyup.esc="close" name="i_overview" v-model="i_overview" class="form-control w-100" rows="5" :disabled="saving"></textarea>
<label for="i_products">Products</label>
<productselector id="ps" v-model="i_products"></productselector>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<textarea editable="false" v-model="i_covertext" class="form-control"></textarea>
</div>
</div>
</div>
<div class="modal-footer">
<button @click="close" type="button" class="btn btn-secondary" data-bs-dismiss="modal" :disabled="saving">Close</button>
<button @click="save" type="button" class="btn btn-primary" :disabled="saving || (newdoc && ((i_products == '') || (i_title == '')))">
<span v-if="!saving">Save changes</span>
<span class="spinner-border spinner-border-sm" role="status" v-if="saving"></span>
<span v-if="saving" class="mx-1">Saving...</span>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref } from 'vue/dist/vue.esm-bundler';
export default {
props: [
],
setup() {
return {
imports: ref([]),
busy: ref(false),
i_products: ref(""),
i_id: ref(""),
i_title: ref(""),
i_subtitle: ref(""),
i_subsubtitle: ref(""),
i_revno: ref(""),
i_month: ref(0),
i_year: ref(0),
i_overview: ref(""),
i_cover: ref(""),
i_fullcover: ref(""),
i_origfile: ref(""),
i_covertext: ref(""),
i_revision: ref(0),
triglock: ref(false),
saving: ref(false),
importing: ref({
id: 0
}),
newdoc: ref(true),
existing_doc: ref({
title: '',
subtitle: '',
subsubtitle: '',
}),
timer: ref(null),
zoom: ref(null),
fsel_start: ref(0),
fsel_end: ref(0),
fsel_el: ref(null),
keytimer : ref(false),
}
},
methods: {
init() {
this.update();
var that = this;
},
focus() {
this.$refs.rtitle.focus();
this.zoom = new ImageZoom(document.getElementById("cover"), {
fillContainer: true,
offset: { vertical: 0, horizontal: 10 },
zoomStyle: "z-index: 10;",
});
},
update() {
this.timer = false;
$.ajax({
url: "/api/imports",
method: "GET"
}).done(this.got_update);
},
got_update(data) {
this.imports = data;
this.busy = false;
var r = 0;
for (var i = 0; i < this.imports.length; i++) {
if (this.imports[i].completed == 0) {
r = 1;
}
}
if (r) {
if (this.timer) {
clearTimeout(this.timer);
this.timer=null;
}
this.timer = setTimeout(this.update, 1000);
}
},
del(rev) {
this.busy = true;
$.ajax({
url: "/api/import/" + rev.id,
method: "DELETE"
}).done(this.got_update);
},
restart(rev) {
this.busy = true;
$.ajax({
url: "/api/import/" + rev.id,
method: "POST",
data: {
started: 0,
completed: 0,
imported: 0,
}
}).done(this.got_update);
},
import_rev(rev) {
this.importing = rev;
this.i_id = rev.guessed_id;
this.i_title = rev.guessed_title;
this.i_revno = rev.guessed_revno;
this.i_subtitle = "";
this.i_subsubtitle = "";
this.i_month = rev.guessed_month;
this.i_year = rev.guessed_year;
this.i_overview = rev.overview;
this.i_products = "";
this.i_cover = "/cover/" + rev.revision.id + "/cover.jpg";
this.i_fullcover = "/cover/" + rev.revision.id + "/cover.jpg";
this.i_origfile = rev.origfile;
this.i_covertext = rev.covertext;
this.i_revision = rev.revision.id;
this.find_doc();
$(document).on('hide.bs.modal', '#docedit', function(e) {alert("doc"); });
$('#docedit').on('hide.bs.modal', function(e) {alert("modal"); });
$('#docedit').show();
setTimeout(this.focus, 10);
},
test(e) {
console.log(e);
},
close() {
$('#docedit').hide();
},
save() {
if (this.busy) return;
if (this.saving) return;
if (this.newdoc && (this.i_products == '')) return;
if (this.newdoc && (this.i_title == '')) return;
this.busy = true;
this.saving = true;
$.ajax({
url: "/api/import/" + this.importing.id ,
method: "PUT",
data: {
internal_id: this.i_id,
title: this.i_title,
subtitle: this.i_subtitle,
subsubtitle: this.i_subsubtitle,
month: this.i_month,
year: this.i_year,
overview: this.i_overview,
products: this.i_products,
revno: this.i_revno,
}
}).done(this.saved);
},
saved(data) {
this.got_update(data);
this.saving = false;
$('#docedit').hide();
delete this.zoom;
},
find_doc() {
var i_id = this.i_id.toUpperCase();
$.ajax({
url: "/api/documentbyid/" + i_id,
method: "GET"
}).done(this.got_doc);
},
got_doc(data) {
if (data == false) {
this.newdoc = true;
} else {
this.newdoc = false;
this.existing_doc = data;
}
},
fancyrow(i) {
if (i.completed > 0) {
if (!i.revision) {
return "list-group-item list-group-item-danger";
} else {
return "list-group-item list-group-item-success";
}
}
if (i.started > 0) return "list-group-item list-group-item-warning";
if (i.queued > 0) return "list-group-item list-group-item-primary";
return "list-group-item";
},
get_caret_position(oField) {
// Initialize
var iCaretPos = 0;
if (document.selection) {
// Set focus on the element
oField.focus();
// To get cursor position, get empty selection range
var oSel = document.selection.createRange();
// Move selection start to 0 position
oSel.moveStart('character', -oField.value.length);
// The caret position is selection length
iCaretPos = oSel.text.length;
} else if (oField.selectionStart || oField.selectionStart == '0') {
iCaretPos = oField.selectionDirection=='backward' ? oField.selectionStart : oField.selectionEnd;
}
return iCaretPos;
},
select_to_end(oField, from) {
if (document.selection) {
oField.focus();
var oSel = document.selection.createRange();
oSel.moveStart('character', from);
oSel.moveEnd('character', oField.value.length);
} else {
oField.setSelectionRange(from, oField.value.length);
}
},
split_title() {
var pos = this.get_caret_position(this.$refs.rtitle);
var l = this.i_title.substring(0, pos);
var r = this.i_title.substring(pos);
this.i_title = l;
this.i_subtitle = r;
},
split_subtitle() {
var pos = this.get_caret_position(this.$refs.rsubtitle);
var l = this.i_subtitle.substring(0, pos);
var r = this.i_subtitle.substring(pos);
this.i_subtitle = l;
this.i_subsubtitle = r;
},
upper_id() {
this.i_id = this.i_id.toUpperCase();
},
paste_id() {
if (navigator.clipboard) {
navigator.clipboard.readText().then(this.do_set_id);
}
},
do_set_id(data) {
this.i_id = data;
this.find_doc();
},
download(data) {
document.location="/pdf/" + data.revision.id + "/download/doc.pdf";
},
try_title(e) {
if (e.key == "Shift") return;
if (e.key == "ArrowLeft") return;
if (e.key == "ArrowRight") return;
if (e.key == "ArrowUp") return;
if (e.key == "ArrowDown") return;
if (e.key == "Backspace") return;
if (this.triglock) return;
if (this.keytimer) {
clearTimeout(this.keytimer);
}
this.keytimer = setTimeout(this.try_title2, 250);
},
try_title2() {
this.keytimer = false
$.ajax({
url: "/api/search/prefix",
method: "POST",
data: {
title: this.i_title
}
}).done(this.fill_title);
},
fill_title(data, status) {
if (status == "success") {
this.triglock = true;
this.fsel_start = this.get_caret_position(this.$refs.rtitle);
this.fsel_el = this.$refs.rtitle;
this.i_title = data;
this.triglock = false;
setTimeout(this.fsel, 10);
}
},
try_subtitle(e) {
if (e.key == "Shift") return;
if (e.key == "ArrowLeft") return;
if (e.key == "ArrowRight") return;
if (e.key == "ArrowUp") return;
if (e.key == "ArrowDown") return;
if (e.key == "Backspace") return;
if (this.triglock) return;
if (this.keytimer) {
clearTimeout(this.keytimer);
}
this.keytimer = setTimeout(this.try_subtitle2, 250);
},
try_subtitle2() {
this.keytimer = false
$.ajax({
url: "/api/search/prefix",
method: "POST",
data: {
title: this.i_subtitle
}
}).done(this.fill_subtitle);
},
fill_subtitle(data, status) {
if (status == "success") {
this.triglock = true;
this.fsel_start = this.get_caret_position(this.$refs.rsubtitle);
this.fsel_el = this.$refs.rsubtitle;
this.i_subtitle = data;
this.triglock = false;
setTimeout(this.fsel, 10);
}
},
try_subsubtitle(e) {
if (e.key == "Shift") return;
if (e.key == "ArrowLeft") return;
if (e.key == "ArrowRight") return;
if (e.key == "ArrowUp") return;
if (e.key == "ArrowDown") return;
if (e.key == "Backspace") return;
if (this.triglock) return;
if (this.keytimer) {
clearTimeout(this.keytimer);
}
this.keytimer = setTimeout(this.try_subsubtitle2, 250);
},
try_subsubtitle2() {
this.keytimer = false
$.ajax({
url: "/api/search/prefix",
method: "POST",
data: {
title: this.i_subsubtitle
}
}).done(this.fill_subsubtitle);
},
fill_subsubtitle(data, status) {
if (status == "success") {
this.triglock = true;
this.fsel_start = this.get_caret_position(this.$refs.rsubsubtitle);
this.fsel_el = this.$refs.rsubsubtitle;
this.i_subsubtitle = data;
this.triglock = false;
setTimeout(this.fsel, 10);
}
},
fsel() {
this.select_to_end(this.fsel_el, this.fsel_start);
},
},
mounted() {
this.init();
}
}
</script>

85
src/InlineEdit.vue Executable file
View File

@@ -0,0 +1,85 @@
<template>
<div>
<div v-if="editing" class="input-group">
<input
ref="edval"
class="form-control"
type="text"
v-model="text"
@keyup.enter="submit_edit"
@keyup.esc="cancel_edit"
>
<button @click="cancel_edit" class="btn btn-danger px-1 py-0">
<i class="fa fa-close"></i>
</button>
<button @click="submit_edit" class="btn btn-success px-1 py-0">
<i class="fa fa-check"></i>
</button>
</div>
<div v-else class="d-flex justify-content-between align-middle">
<span class="align-middle my-auto">{{ text }}</span>
<button @click="enable_edit" class="btn btn-secondary px-1 py-0">
<i class="fa fa-pencil"></i>
</button>
</div>
</div>
</template>
<script>
import { ref } from 'vue/dist/vue.esm-bundler';
export default {
props: [
"value",
"url",
"field",
],
setup() {
return {
editing: ref(false),
text: ref(""),
saved: ref(""),
}
},
methods: {
enable_edit() {
this.editing = true;
this.saved = this.text;
var that = this;
setTimeout(function() { $(that.$refs.edval).focus() }, 100);
},
cancel_edit() {
this.text = this.saved;
this.editing = false;
},
submit_edit() {
var data = {};
data[this.field] = this.text;
console.log(data);
$.ajax({
url: this.url,
method: "POST",
data: data
});
this.editing = false;
},
},
mounted() {
this.text = this.value;
}
}
</script>

114
src/InlineEditText.vue Executable file
View File

@@ -0,0 +1,114 @@
<template>
<div>
<div v-if="editing">
<textarea
ref="edval"
class="form-control w-100"
rows="5"
type="text"
v-model="text"
@keyup.esc="cancel_edit"
></textarea>
<button @click="cancel_edit" class="btn btn-danger">
<i class="fa fa-close"></i>
</button>
<button @click="submit_edit" class="btn btn-success">
<i class="fa fa-check"></i>
</button>
</div>
<div v-else>
<div class="align-middle my-auto" v-html="md()"></div>
<button @click="enable_edit" class="btn btn-secondary">
<i class="fa fa-pencil"></i>
</button>
</div>
<div ref="slotData" class="visually-hidden">
<slot></slot>
</div>
</div>
</template>
<script>
import { useSlots, ref } from 'vue/dist/vue.esm-bundler';
import { marked } from 'marked';
export default {
props: [
"url",
"field",
],
setup() {
return {
editing: ref(false),
text: ref(""),
saved: ref(""),
}
},
methods: {
init() {
this.load();
},
enable_edit() {
this.editing = true;
this.saved = this.text;
var that = this;
setTimeout(function() { $(that.$refs.edval).focus() }, 100);
},
cancel_edit() {
this.text = this.saved;
this.editing = false;
},
submit_edit() {
var data = {};
data[this.field] = this.text;
$.ajax({
url: this.url,
method: "POST",
data: data
});
this.editing = false;
},
load() {
$.ajax({
url: this.url,
method: "GET"
}).done(this.loaded);
},
loaded(data) {
this.text = data[0][this.field];
},
md() {
var text = this.text;
if (text == null) {
text = "Loading...";
}
var txt = marked.parse(text);
return txt;
},
},
mounted() {
// var div = document.createElement("div");
// div.innerHTML = this.$slots.default()[0].children;
// this.text = div.textContent;
this.init();
}
}
</script>

87
src/ItemAdder.vue Normal file
View File

@@ -0,0 +1,87 @@
<template>
<div>
<div v-if="editing" class="input-group">
<select class="form-control" v-model="sel">
<option v-for='i in items' :value='i.key'>{{ i.value }}</option>
</select>
<button @click="cancel_edit" class="btn btn-danger">
<i class="fa fa-close"></i>
</button>
<button @click="submit_edit" class="btn btn-success">
<i class="fa fa-check"></i>
</button>
</div>
<div v-else>
<button @click="enable_edit" class="btn btn-secondary">
<i class="fa fa-plus"></i>
</button>
</div>
</div>
</template>
<script>
import { ref } from 'vue/dist/vue.esm-bundler';
export default {
props: [
"list",
"target",
"method",
],
setup() {
return {
editing: ref(false),
sel: ref(""),
items: ref([]),
}
},
methods: {
enable_edit() {
$.ajax({
url: this.list,
method: "GET"
}).done(this.start_edit);
},
start_edit(d) {
this.items = d;
this.editing = true;
var that = this;
},
cancel_edit() {
this.text = this.saved;
this.editing = false;
},
submit_edit() {
var data = {};
data["item_id"] = this.sel;
$.ajax({
url: this.target,
method: this.method,
data: data
}).done(this.fin);
this.editing = false;
},
fin() {
location.reload();
},
},
mounted() {
this.text = this.value;
}
}
</script>

78
src/JobDisplay.vue Executable file
View File

@@ -0,0 +1,78 @@
<template>
<div>
<ul class="list-group">
<li v-for="job in jobs" :class="'d-flex justify-content-between list-group-item ' + get_group_color(job)">
<span>
{{ job.id }} - {{ job.status }}
</span>
<small v-if='(job.finished + job.failed) > 0'><button @click='delete_job(job)' class='px-1 py-0 btn btn-danger'><i class='fa fa-close'></i></button></small>
</li>
</ul>
</div>
</template>
<script>
import { ref } from 'vue/dist/vue.esm-bundler';
export default {
props: [
"source"
],
setup() {
return {
jobs: ref([])
}
},
methods: {
update() {
$.ajax({
url: "/api/jobs/" + this.source,
method: "GET"
}).done(this.updated);
},
updated(d) {
this.jobs = d;
var shouldupdate = 0;
for (var i = 0; i < this.jobs.length; i++) {
console.log(this.jobs[i]);
if ((this.jobs[i].finished == 0) && (this.jobs[i].failed == 0)) {
shouldupdate = 1;
}
}
console.log(shouldupdate);
if (shouldupdate == 1) {
setTimeout(this.update, 1000);
}
},
get_group_color(j) {
if (j.failed > 0) {
return "list-group-item-danger";
}
if (j.finished > 0) {
return "list-group-item-success";
}
if (j.started > 0) {
return "list-group-item-warning";
}
return "list-group-item-info";
},
delete_job(j) {
$.ajax({
url: "/api/jobs/" + j.id,
method: "DELETE"
}).done(this.updated);
},
},
mounted() {
this.update();
}
}
</script>

View File

@@ -0,0 +1,34 @@
<template>
<div style="display: none;">
</div>
</template>
<script>
import { ref } from 'vue/dist/vue.esm-bundler';
export default {
props: [
"keyname",
"target",
],
setup() {
return {
}
},
methods: {
handler(e) {
console.log(e.key);
if (e.key == this.keyname) {
document.location=this.target;
}
},
},
mounted() {
window.addEventListener('keypress', this.handler);
}
}
</script>

67
src/PDF.vue Executable file
View File

@@ -0,0 +1,67 @@
<template>
<div>
<div class="row m-3 bg-white">
<div class="col-6 m-0 p-0">
<img @click="nav_left" class="w-100 m-0 p-0" :src="left">
</div>
<div class="col-6 m-0 p-0">
<img @click="nav_right" class="w-100 m-0 p-0" :src="right">
</div>
</div>
</div>
</template>
<script>
import { ref } from 'vue/dist/vue.esm-bundler';
export default {
props: {
rev: ref("")
},
setup(props, emit) {
return {
left: ref(""),
right: ref(""),
leftpage : ref(0),
}
},
methods: {
init() {
this.leftpage = 0;
this.update();
this.$watch('rev', this.show);
},
update() {
this.left = "/page/" + this.rev + "/" + this.leftpage + "/page.jpg";
this.right = "/page/" + this.rev + "/" + (this.leftpage + 1) + "/page.jpg";
},
show() {
this.left = '';
this.right = '';
this.leftpage = 0;
this.update();
},
nav_right() {
this.leftpage += 2;
this.update();
},
nav_left() {
this.leftpage -= 2;
if (this.leftpage < 0) {
this.leftpage = 0;
}
this.update();
},
},
mounted() {
this.init();
}
}
</script>

123
src/PDFList.vue Executable file
View File

@@ -0,0 +1,123 @@
<template>
<div>
<ul class="list-group">
<li v-for="pdf in pdfs" class="list-group-item">
<div class="row">
<div class="col-lg-11 col-12">
<div class="row">
<div class="col-12 text-center">
{{ pdf.title }}
</div>
<div class="col-12">
<a class="btn w-100 text-start" :href="pdf.url" target="_blank">{{ pdf.url }}</a>
</div>
</div>
</div>
<div class="btn-group col-lg-1 col-12 text-center">
<button :disabled="busy" @click="reject(pdf)" class="btn btn-danger"><i class="fa fa-close"></i></button>
<button :disabled="busy" @click="accept(pdf)" class="btn btn-success"><i class="fa fa-check"></i></button>
</div>
</div>
</li>
<li class="list-group-item">
<div class="row">
<div class="col-lg-11 col-12 text-center">
<a class="btn w-100" target="_blank">Entire List</a>
</div>
<div class="btn-group col-lg-1 col-12">
<button :disabled="busy" @click="reject_all" class="btn btn-danger"><i class="fa fa-close"></i></button>
<button :disabled="busy" @click="accept_all" class="btn btn-success"><i class="fa fa-check"></i></button>
</div>
</div>
</li>
</ul>
</div>
</template>
<script>
import { ref } from 'vue/dist/vue.esm-bundler';
export default {
props: [
],
setup() {
return {
pdfs: ref([]),
busy: ref(false),
}
},
methods: {
init() {
this.update();
},
update() {
this.busy = true;
$.ajax({
url: "/api/spider/pdfs",
method: "GET"
}).done(this.got_update);
},
got_update(data) {
this.pdfs = data;
this.busy = false;
},
accept(pdf) {
if (this.busy) return;
this.busy = true;
$.ajax({
url: '/api/spider/pdf/' + pdf.id,
method: "PUT"
}).done(this.got_update);
},
reject(pdf) {
if (this.busy) return;
this.busy = true;
$.ajax({
url: '/api/spider/pdf/' + pdf.id,
method: "DELETE"
}).done(this.got_update);
},
accept_all() {
if (this.busy) return;
this.busy = true;
var l = [];
for (var i = 0; i < this.pdfs.length; i++) {
l.push(this.pdfs[i].id);
}
$.ajax({
url: '/api/spider/pdf/' + l.join(","),
method: "PUT"
}).done(this.got_update);
},
reject_all() {
if (this.busy) return;
this.busy = true;
var l = [];
for (var i = 0; i < this.pdfs.length; i++) {
l.push(this.pdfs[i].id);
}
$.ajax({
url: '/api/spider/pdf/' + l.join(","),
method: "DELETE"
}).done(this.got_update);
},
},
mounted() {
this.init();
}
}
</script>

180
src/ProductControls.vue Executable file
View File

@@ -0,0 +1,180 @@
<template>
<nav class="d-flex justify-content-end">
<itemadder :list="'/api/product/' + pid + '/available_metadata'" :target="'/api/product/' + pid + '/metadata'" method="PUT"></itemadder>
<div ref="ddcreate_child" class="dropdown ms-1">
<button class="btn btn-success dropdown-toggle" title="Add" data-bs-toggle="dropdown"><i class="fa fa-plus-circle"></i></button>
<ul class="dropdown-menu" style="min-width: 300px;">
<li class="p-2">
<strong>Create new child product</strong>
<div class="input-group">
<span class="input-group-text">Name</span>
<input @keyup.enter="create_child" ref="increate_child" class="form-control" name="title" v-model="newtitle">
<button class="btn btn-primary" @click="create_child">Create</button>
</div>
</li>
</ul>
</div>
<div ref="ddrename" class="dropdown ms-1">
<button class="btn btn-success dropdown-toggle" title="Rename" data-bs-toggle="dropdown" @click="set_edittitle"><i class="fa fa-pencil"></i></button>
<ul class="dropdown-menu" style="min-width: 300px;">
<li class="p-2">
<strong>Rename product</strong>
<div class="input-group">
<span class="input-group-text">Name</span>
<input ref="inrename" class="form-control" name="title" v-model="edittitle">
<button class="btn btn-primary" @click="rename">Rename</button>
</div>
</li>
</ul>
</div>
<button class="btn btn-success ms-1" @click="gemini_all"><i class="fa fa-magic-wand-sparkles"></i></button>
<button :class="'btn ms-1 ' + dp_class" @click="delete_product"><i class="fa fa-trash"></i></button>
<button @click="empty_trash" class="ms-1 btn btn-danger" v-if="prod.title=='Trash'"><i class="fa fa-recycle"></i></button>
</nav>
</template>
<script>
import { ref } from 'vue/dist/vue.esm-bundler';
export default {
props: [
"pid",
],
setup() {
return {
newtitle: ref(""),
edittitle: ref(""),
dp_class: ref("btn-danger"),
dp_clicks: ref(0),
prod: ref({}),
}
},
methods: {
init() {
$(this.$refs.ddcreate_child).on("show.bs.dropdown", this.add_child_show);
$(this.$refs.ddcreate_child).on("shown.bs.dropdown", this.add_child_shown);
$(this.$refs.ddrename).on("show.bs.dropdown", this.rename_show);
$(this.$refs.ddrename).on("shown.bs.dropdown", this.rename_shown);
this.get_product();
},
get_product() {
$.ajax({
url: "/api/product/" + this.pid,
method: "GET"
}).done(this.got_product);
},
add_child_show(e) {
this.newtitle = "";
},
add_child_shown(e) {
$(this.$refs.increate_child).focus();
},
rename_show(e) {
this.edittitle = this.prod.title;
},
set_edittitle() {
this.edittitle = this.prod.title;
},
got_product(data) {
this.prod = data[0];
},
rename_shown(e) {
},
create_child() {
console.log("Adding child");
$.ajax({
url: "/api/product/" + this.pid,
method: "PUT",
data: {
title: this.newtitle
}
}).done(this.created_child);
},
rename() {
$.ajax({
url: "/api/product/" + this.pid,
method: "POST",
data: {
title: this.edittitle
}
}).done(this.renamed);
},
renamed() {
window.location.reload();
},
created_child(data) {
window.location.reload();
},
delete_product() {
this.dp_clicks++;
console.log("clicked " + this.dp_clicks);
if (this.dp_clicks >= 2) {
$.ajax({
url: "/api/product/" + this.pid,
method: "DELETE",
}).done(this.done_delete);
return;
}
this.dp_class = "btn-warning";
setTimeout(this.undo_delete, 1000);
},
undo_delete() {
console.log("Undo delete");
this.dp_class = "btn-danger";
this.dp_clicks = 0;
},
done_delete(data) {
document.location="/documents/" + data.id;
},
empty_trash() {
$.ajax({
url: "/api/product/trash/" + this.prod.id,
method: "DELETE"
}).done(this.trash_emptied);
},
trash_emptied() {
window.location.reload();
},
gemini_all() {
$.ajax({
url: "/api/product/gemini_all/" + this.prod.id,
method: "GET"
}).done(this.gemini_all_queued);
},
gemini_all_queued(d) {
},
},
mounted() {
this.init();
},
};
</script>

172
src/ProductSelector.vue Executable file
View File

@@ -0,0 +1,172 @@
<template>
<div>
<div v-for="p in products" class="btn-group m-1">
<button class="btn btn-secondary p-1" :title="p.full_path"><small>{{ p.title }}</small></button>
<button :pid="p.id" @click.stop="remove(p.id)" class="btn btn-secondary p-1" title="Remove"><small><i class="fa fa-close"></i></small></button>
</div>
<div ref="dropdown" class="dropdown" @show.bs.dropdown="add_show">
<button class="btn btn-success p-1 dropdown-toggle" title="Add" data-bs-toggle="dropdown" @click="add_show"><small><i class="fa fa-plus"></i></small></button>
<ul class="dropdown-menu" @show.bs.dropdown="add_show">
<li v-for="p in mru"><a href='#' @click="add_pid(p)" class="dropdown-item">{{ p.full_path }}</a></li>
<hr>
<li><input ref="searchinput" @input="do_search" class='form-control' name='search' v-model="search" autocomplete="off"></li>
<li v-for="p in searchres"><a href='#' @click="add_pid(p)" class="dropdown-item">{{ p.full_path }}</a></li>
</ul>
</div>
<button @click="set_default_pids" class="btn btn-primary">Set Default</button>
<button @click="clear_default_pids" class="btn btn-primary">Clear Default</button>
</div>
</template>
<script>
import { ref, computed } from 'vue/dist/vue.esm-bundler';
export default {
props: {
modelValue: ref(""),
},
setup(props, {emit}) {
return {
products: ref([]),
mru: ref([]),
search: ref(""),
searchres: ref([]),
pids: computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
}),
was_mod: ref(false),
req: ref(null),
}
},
methods: {
init() {
$(document).on("show.bs.dropdown", '.dropdown', this.add_show);
$(document).on("shown.bs.dropdown", $(this.$refs.dropdown), this.add_shown);
$(this.$refs.dropdown).on("show.bs.dropdown", this.add_show);
$(this.$refs.dropdown).on("shown.bs.dropdown", this.add_shown);
this.get_products();
this.$watch('modelValue', this.get_products);
},
get_products() {
if (this.pids!= "") {
$.ajax({
url: "/api/product/" + this.pids,
method: "GET"
}).done(this.got_products);
} else {
this.products = [];
}
var m = this.was_mod;
this.was_mod = false;
if (!m) {
if (this.pids == "") {
// Get default if available
var p = localStorage.getItem("default_pids");
if ((p != null) && (p != "")) {
this.was_mod = true;
this.pids = p;
}
}
}
},
got_products(data) {
this.products = data;
},
remove(e) {
this.was_mod = true;
var tmp = [];
var pidarray = [];
for (var i = 0; i < this.products.length; i++) {
if (this.products[i].id != e) {
tmp.push(this.products[i]);
pidarray.push(this.products[i].id);
}
}
this.products = tmp;
this.pids = pidarray.join(",");
},
add_show(e) {
this.search = "";
this.searchres = []
$.ajax({
url: "/api/productmru",
method: "GET"
}).done(this.fill_mru);
},
add_shown(e) {
this.$refs.searchinput.focus();
},
fill_mru(data) {
this.mru = data;
},
do_search(e) {
if (this.search.length >= 3) {
if (this.req != null) {
this.req.abort();
this.req = null;
}
this.req=$.ajax({
url: "/api/search/product",
method: "POST",
data: {
search: this.search
}
}).done(this.search_done);
}
},
search_done(data) {
this.searchres = data;
},
add_pid(prod) {
this.was_mod = true;
var pidarray;
if (this.pids == "") {
pidarray = [];
} else {
pidarray = this.pids.split(",");
}
if (pidarray.includes(prod.id)) {
return;
}
pidarray.push(prod.id);
this.pids = pidarray.join(",");
this.products.push(prod);
$.ajax({
url: "/api/productmru/" + prod.id,
method: "POST",
data: {
}
});
},
set_default_pids() {
localStorage.setItem("default_pids", this.pids);
},
clear_default_pids() {
localStorage.setItem("default_pids", "");
},
},
mounted() {
this.init();
},
};
</script>

85
src/Search.vue Executable file
View File

@@ -0,0 +1,85 @@
<template>
<div tabindex=0 @blur.capture="click_close">
<form action="/search" method="POST">
<div class="input-group">
<input name="search" ref="sb" @focus="key_search" @keyup="key_search" class="form-control" v-model="search">
<input type="submit" class="btn btn-success" value="Search">
</div>
<div v-if="results.length > 0" class="z-3 position-absolute bg-light">
<ul class="list-group">
<li class="list-group-item" v-for="r in results"><a :href="'/document/' + r.id"><strong>{{ r.title }}</strong> {{ r.subtitle }} <small>{{ r.subsubtitle }}</small></a></li>
</ul>
</div>
</form>
</div>
</template>
<script>
import { ref } from 'vue/dist/vue.esm-bundler';
export default {
props: [
],
setup() {
return {
search: ref(""),
searchTimer: ref(null),
results: ref([]),
req: ref(null),
}
},
methods: {
click_close(event) {
if (!event.currentTarget.contains(event.relatedTarget)) {
this.results = [];
}
},
key_search() {
if (this.searchTimer != null) {
window.clearTimeout(this.searchTimer);
this.searchTimer = null;
}
this.searchTimer = setTimeout(this.do_key_search, 100);
},
do_key_search() {
if (this.search.length < 3) {
this.results = [];
} else {
this.searchTimer = null;
if (this.req != null) {
this.req.abort();
this.req = null;
}
this.req = $.ajax({
url: "/api/search/title",
method: "POST",
data: {
search: this.search
}
}).done(this.got_results);
}
},
got_results(data) {
this.results = data;
},
shortcut(e) {
if (e.target.tagName == "INPUT") return;
if (e.target.tagName == "TEXTAREA") return;
if (e.key == "/") {
e.preventDefault();
$(this.$refs.sb).focus();
}
},
},
mounted() {
$(document).on("keydown", this.shortcut);
}
}
</script>

79
src/SpiderPages.vue Executable file
View File

@@ -0,0 +1,79 @@
<template>
<div>
<ul class="list-group">
<li v-for="page in pages" class="list-group-item">
<div class="row">
<div class="col-lg-11 col-12 text-center">
<a class="btn text-left w-100" :href="page.url" target="_blank">
<h6>{{ page.title }}</h6>
{{ page.url }}
</a>
</div>
<div class="btn-group col-lg-1 col-12">
<button :disabled="busy" @click="reject(page)" class="btn btn-danger"><i class="fa fa-close"></i></button>
<button :disabled="busy" @click="accept(page)" class="btn btn-success"><i class="fa fa-check"></i></button>
</div>
</div>
</li>
</ul>
</div>
</template>
<script>
import { ref } from 'vue/dist/vue.esm-bundler';
export default {
props: [
],
setup() {
return {
pages: ref([]),
busy: ref(false),
}
},
methods: {
init() {
this.update();
},
update() {
this.busy = true;
$.ajax({
url: "/api/spider/pages",
method: "GET"
}).done(this.got_update);
},
got_update(data) {
this.pages = data;
this.busy = false;
},
accept(page) {
if (this.busy) return;
this.busy = true;
$.ajax({
url: "/api/spider/page/" + page.id,
method: "PUT"
}).done(this.got_update)
},
reject(page) {
if (this.busy) return;
this.busy = true;
$.ajax({
url: "/api/spider/page/" + page.id,
method: "DELETE"
}).done(this.got_update)
},
},
mounted() {
this.init();
}
}
</script>

24
src/Template.vue Executable file
View File

@@ -0,0 +1,24 @@
<template>
<div>
</div>
</template>
<script>
import { ref } from 'vue/dist/vue.esm-bundler';
export default {
props: [
],
setup() {
return {
}
},
methods: {
},
mounted() {
}
}
</script>

9
src/app.css Executable file
View File

@@ -0,0 +1,9 @@
.table td.fit,
.table th.fit {
white-space: nowrap;
width: 1%
}
a[title]:hover::after {
padding: 5px;
}

52
src/app.js Executable file
View File

@@ -0,0 +1,52 @@
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';
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);
$(window).on("load", function() { app.mount('#app') });

0
src/app.scss Executable file
View File

8
views/404.blade.php Normal file
View File

@@ -0,0 +1,8 @@
@extends("layout.main")
@section("content")
<div class="text-center">
<h1>Page not found</h1>
<img src='/logo.png'>
<p>The page you were looking for could not be found. Maybe you should look for a different one instead.</p>
</div>
@endsection

94
views/account.blade.php Normal file
View File

@@ -0,0 +1,94 @@
@extends("layout.main")
@section("content")
<ul class="nav nav-tabs mt-3 mb-2">
<li class="nav-item">
@if ($tab == "account")
<a class="nav-link active" aria-current="page" href="#">Account</a>
@else
<a class="nav-link" aria-current="page" href="/account">Account</a>
@endif
</li>
<li class="nav-item">
@if ($tab == "data")
<a class="nav-link active" aria-current="page" href="#">Data</a>
@else
<a class="nav-link" aria-current="page" href="/account/data">Data</a>
@endif
</li>
</ul>
@if ($tab == "account")
<div class="row">
<div class="col-12 col-lg-3"></div>
<div class="col-12 col-lg-6">
<h5>Change Password</h5>
<form action='/account/chpass' method="POST">
<div class="input-group">
<span class="input-group-text w-25">Current password</span>
<input class="form-control" name="current" type="password">
</div>
<div class="input-group">
<span class="input-group-text w-25">New password</span>
<input class="form-control" name="pass1" type="password">
</div>
<div class="input-group">
<span class="input-group-text w-25">Confirm password</span>
<input class="form-control w-50" name="pass2" type="password">
<input class="form-control w-25 btn btn-primary" type="submit" value="Change">
</div>
</form>
</div>
<div class="col-12 col-lg-3"></div>
</div>
@endif
@if ($tab == "data")
<p>Here is a list of all the data associated with your current session.</p>
<table class="table">
<thead>
<tr>
<th>Key</th>
<th>Data</th>
</tr>
</thead>
<tbody>
@foreach ($session as $k=>$v)
<tr>
<td>{{ $k }}</td>
<td>{{ json_encode($v) }}</td>
</tr>
@endforeach
</tbody>
</table>
<p>And here is the contents of your user's database record.</p>
<table class="table">
<thead>
<tr>
<th>Key</th>
<th>Data</th>
</tr>
</thead>
<tbody>
@foreach ($user->_data as $k=>$v)
<tr>
<td>{{ $k }}</td>
<td>{{ json_encode($v) }}</td>
</tr>
@endforeach
</tbody>
</table>
<p>And that's it. That's the sum total of the data we currently store.</p>
@endif
@endsection

137
views/document.blade.php Normal file
View File

@@ -0,0 +1,137 @@
@extends("layout.main")
@section("content")
@if (Auth::can_moderate())
<jobdisplay source="document:{{ $doc->id }}"></jobdisplay>
@endif
<h1 class="text-center">{{ $doc->title }}</h1>
<h2 class="text-center">{{ $doc->subtitle }}</h2>
<h3 class="text-center">{{ $doc->subsubtitle }}</h3>
<div class="row" id="droppable">
<div class="col-lg-6 col-12">
@if (Auth::can_moderate())
<div class="btn-group">
<documentedit docid="{{ $doc->id }}"></documentedit>
<a href="/upload/attachment/{{ $doc->id }}" class="btn btn-primary" title="Upload attachment"><i class="fa-solid fa-file-arrow-up"></i></a>
<a href="/overview/create/{{ $doc->id }}" class="btn btn-primary" title="Create Overview with AI"><i class="fa-solid fa-wand-magic-sparkles"></i></a>
<a href="/merge/{{ $doc->id }}" class="btn btn-primary" title="Merge Revisions"><i class="fa fa-link"></i></a>
<a href="/explode/{{ $doc->id }}" class="btn btn-primary" title="Separate Revisions"><i class="fa fa-unlink"></i></a>
</div>
@endif
<h5 class="text-center">Order Number: {{ $doc->internal_id }}</h5>
@if (Auth::can_moderate())
<table class="table">
<tbody>
@foreach ($doc->metadata as $meta)
@php
$meta->load("metadata");
@endphp
<tr>
<td class="w-25">{{ $meta->metadata->name }}</td>
<td class="w-75"><inlineedit value="{{ $meta->data }}" url="/api/document/{{ $doc->id }}/metadata/{{ $meta->metadata->id }}" field="data"/></td>
<td style="width: 0"><a class="btn btn-danger px-1 py-0" href="/document/{{ $doc->id }}/metadata/{{ $meta->metadata->id }}/delete">
<i class="fa fa-trash"></i></a></td>
</tr>
@endforeach
</tbody>
</table>
<itemadder list="/api/document/{{ $doc->id }}/available_metadata" target="/api/document/{{ $doc->id }}/metadata" method="PUT"></itemadder>
@else
<table class="table">
<tbody>
@foreach ($doc->metadata as $meta)
@php
$meta->load("metadata");
@endphp
<tr>
<td class="w-25">{{ $meta->metadata->name }}</td>
<td class="w-75">{{ $meta->data }}</td>
</tr>
@endforeach
</tbody>
</table>
@endif
<hr/>
@if ($doc->attachments->count() > 0)
<h5>Attachments</h5>
<table class="table w-100">
<thead>
<th>Filename</th>
<th>Size</th>
</thead>
<tbody>
@foreach ($doc->attachments as $f)
<tr>
<td><a href='/attachment/{{$doc->id}}/{{$f->basename()}}'>{{ $f->basename() }}</a></td>
<td>{{ $f->size() }}</td>
</tr>
@endforeach
</tbody>
</table>
@endif
<ul class="list-group">
@foreach ($doc->products as $prod)
<li class="list-group-item">
@if (Auth::can_moderate())
<a class="btn btn-danger px-1 py-0" href='/del_docproduct/{{$doc->id}}/{{$prod->id}}'>
<i class="fa fa-close"></i>
</a>
<a class="btn w-90 text-start" href='/documents/{{ $prod->id }}'>
{{ $prod->full_path }}
</a>
@else
<a class="btn w-100 text-start" href='/documents/{{ $prod->id }}'>
{{ $prod->full_path }}
</a>
@endif
</li>
@endforeach
</ul>
@if ($doc->overview)
<div class="overflow-y-auto p-3">
{!! $doc->overview_md() !!}
</div>
@endif
</div>
<div class="col-lg-6 col-12">
<div class="row">
@foreach ($doc->revisions as $rev)
@component("thumbnail-50", ["rev" => $rev])
@endcomponent
@endforeach
</div>
</div>
@if (count($doc->related) > 0)
<div class="col-12">
<h5>Related Documents</h5>
<table class="table">
@foreach ($doc->related as $r)
<tr>
<td>
<a href='/document/{{ $r->id }}'>
<strong>{{ $r->title }}</strong>
{{ $r->subtitle }}
<small>{{ $r->subsubtitle }}</small>
</a>
</td>
<td>
<a href='/document/{{ $r->id }}'>
{{ $r->internal_id }}
</a>
</td>
</tr>
@endforeach
</table>
</div>
@endif
</div>
@endsection

140
views/documents.blade.php Executable file
View File

@@ -0,0 +1,140 @@
@extends("layout.main")
@section("content")
<div class="my-4 btn-group">
@foreach ($product->get_tree() as $p)
<a href='/documents/{{ $p->id }}' class="btn btn-secondary"><strong>{{ $p->title }}</strong></a>
@endforeach
</div>
<a href='<?php print($product->refurl) ?>'><?php print($product->refurl) ?></a><br/>
@if (Auth::can_moderate())
<inlineedittext url='/api/product/{{$product->id}}' field='overview'>{{ $product->overview }}</inlineedittext>
@else
@if ($product->overview != "")
<div class="blockquote">
{!! $product->overview_md() !!}
</div>
@endif
@endif
<table class="table table-hover">
@if (Auth::can_moderate())
<tr>
<td colspan="{{ 3 + count($product->meta()) }}">
<div class="w-100 text-end">
<productcontrols pid="{{ $product->id }}"></productcontrols>
</div>
</td>
</tr>
@endif
<tr>
<th></th>
<th>Title</th>
@foreach ($product->meta() as $m)
<th class="fit">{{ MetaType::name($m) }}</th>
@endforeach
<th>Order No</th>
</tr>
@if ($product->parent != null)
<tr>
<td class="fit"><a class="btn" href='/documents/{{ $product->parent->id }}'><i class="fa fa-level-up" aria-hidden="true"></a></td>
@if (Auth::can_moderate())
<td class="w-100" colspan="{{ 2 + count($product->meta()) }}">
<dddiv droppable="true" object_id="{{$product->parent->id}}" object_type="product" callback="/api/document/dragdrop">
<a class="btn w-100 text-start" href='/documents/{{ $product->parent->id }}'>Parent</a>
</dddiv>
</td>
@else
<td class="w-100" colspan="{{ 2 + count($product->meta()) }}"><a class="btn w-100 text-start" href='/documents/{{ $product->parent->id }}'>Parent</a></td>
@endif
</tr>
@endif
@foreach ($product->children as $child)
<tr>
<td class="fit"><a class="btn" href='/documents/{{ $child->id }}'>
@if ($child->title == "Trash")
<i class="fa fa-trash" aria-hidden="true"></i>
@else
<i class="fa fa-folder" aria-hidden="true"></i>
@endif
</a></td>
@if (Auth::can_moderate())
<td class="w-100">
<dddiv draggable="true" droppable="true" object_id="{{ $child->id }}" object_type="product" callback="/api/document/dragdrop">
<a class="btn w-100 text-start" href='/documents/{{$child->id}}' >
{{$child->title}}
</a>
</dddiv>
</td>
@else
<td class="w-100"><a class="btn w-100 text-start" href='/documents/{{$child->id}}'>{{$child->title}}</a></td>
@endif
<td colspan="{{ 1 + count($product->meta()) }}" class="fit"></td>
</tr>
@endforeach
@foreach ($product->documents_sorted_by_meta() as $doc)
<tr>
<td class="fit">
@if ($doc->attachments->count() > 0)
<a class="btn" href='/document/{{$doc->id}}'><i class="fa fa-file-arrow-down text-success" aria-hidden="true"></i></a>
@else
<a class="btn" href='/document/{{$doc->id}}'><i class="fa fa-file-text" aria-hidden="true"></i></a>
@endif
</td>
<td class="w-100">
@if (Auth::can_moderate())
<dddiv draggable="true" droppable="true" object_id="{{$doc->id}}" object_type="document" extra_data="{{$product->id}}" callback="/api/document/dragdrop">
<a class="btn w-100 text-start" href='/document/{{$doc->id}}'>
<strong>{{$doc->title}}</strong>
{{$doc->subtitle}}
<small>{{$doc->subsubtitle}}</small>
@if ($doc->overview)
<small><i title="{{ $doc->overview }}" class="ms-1 fa-solid fa-paragraph"></i></small>
@endif
</a>
</dddiv>
@else
<a class="btn w-100 text-start" href='/document/{{$doc->id}}'>
<strong>{{$doc->title}}</strong>
{{$doc->subtitle}}
<small>{{$doc->subsubtitle}}</small>
@if ($doc->overview)
<small><i title="{{ $doc->overview }}" class="ms-1 fa-solid fa-paragraph"></i></small>
@endif
</a>
@endif
</td>
@foreach ($product->meta() as $m)
<td class="fit">
<a draggable="true" object_id="{{$doc->id}}" pid_from="{{$product->id}}" class="btn" href='/document/{{$doc->id}}'>
{{ $doc->get_metadata_by_id($m) }}
</a>
</td>
@endforeach
<td class="fit">
<a draggable="true" object_id="{{$doc->id}}" pid_from="{{$product->id}}" class="btn" href='/document/{{$doc->id}}'>
{{ $doc->internal_id }}
</a>
</td>
</tr>
@endforeach
</table>
<div class="text-center">
<small>
<i>
{{ count($product->documents) }} documents
</i>
</small>
</div>
@endsection

View File

@@ -0,0 +1,4 @@
@extends("layout.main")
@section("content")
<downloadmanager></downloadmanager>
@endsection

View File

@@ -0,0 +1,4 @@
<div class="mb-3">
<label for="{{$name}}" class="form-label">{{$title}}</label>
<input class="form-control" id="{{$name}}" type="text" name="{{$name}}" value="{{$value}}" {{$attrs}}>
</div>

View File

@@ -0,0 +1,4 @@
<div class="mb-3">
<label for="{{$name}}" class="form-label">{{$title}}</label>
<input class="form-control" id="{{$name}}" type="password" name="{{$name}}" value="{{$value}}" {{$attrs}}>
</div>

View File

@@ -0,0 +1,5 @@
<div class="mb-3 text-end">
<input class="btn btn-primary" type="submit" value="{{$title}}" {{$attrs}}>
</div>

4
views/idmatch.blade.php Normal file
View File

@@ -0,0 +1,4 @@
@extends("layout.main")
@section("content")
<idmatch></idmatch>
@endsection

4
views/imports.blade.php Normal file
View File

@@ -0,0 +1,4 @@
@extends("layout.main")
@section("content")
<imports></imports>
@endsection

46
views/index.blade.php Normal file
View File

@@ -0,0 +1,46 @@
@extends('layout.main')
@section('content')
<p class="mt-3">Welcome to <i><b>DECPDF</b></i>, 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.</p>
<p>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.</p>
<p>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.</p>
<h3>FAQ</h3>
<h5>Why do some documents have &quot;Original&quot; and &quot;OCR&quot; versions?</h5>
<p>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.</p>
<h5>I have a different copy of a document you already have. Can I upload it?</h5>
<p>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.</p>
<h5>What's the Order Number and Revision when I upload?</h5>
<p>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.</p>
<h5>Will you ever host binary files like disk images and software?</h5>
<p>No. That opens up a whole other kettle of worms, alongside a can of fish as well.</p>
@endsection

120
views/layout/main.blade.php Normal file
View File

@@ -0,0 +1,120 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<title>Digital PDFs</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="manifest" href="/manifest.webmanifest">
<script src="/app.js"></script>
</head>
<body>
<div id="app" class="container">
<nav class="navbar navbar-expand-lg bg-body-tertiary w-100">
<div class="container">
<div class="d-flex flex-row justify-content-between w-100">
<a class="d-none d-lg-inline navbar-brand" href='/'>Digital PDFs</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-1g-0">
<li class="nav-item">
<a class="nav-link" href='/documents'>Documents</a>
</li>
@if (Auth::can_moderate())
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Spider
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="/spider_pdfs">Spidered PDFs</a></li>
<li><a class="dropdown-item" href="/spider_pages">Potential Sites</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
System
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="/status">System Status</a></li>
<li><a class="dropdown-item" href="/idmatch">ID Matches</a></li>
</ul>
</li>
@endif
@if (Auth::logged_in())
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ get_user()->username }}
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="/account">My Account</a></li>
<?php if (Auth::can_upload()) { ?>
<li><a class="dropdown-item" href="/upload">Upload PDF</a></li>
<li><a class="dropdown-item" href="/downloads">My Downloads</a></li>
<li><a class="dropdown-item" href="/imports">My Imports</a></li>
<?php } ?>
<li><hr></li>
<li><a class="dropdown-item text-danger" href="/logout">Log Out</a></li>
</ul>
</li>
@else
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Guest
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="/register">Register</a></li>
<li><hr></li>
<li><a class="dropdown-item" href="/login">Log In</a></li>
</ul>
</li>
@endif
</ul>
</div>
<div>
<search/>
</div>
</div>
</div>
</nav>
<div class="container">
@if($flash_error)
<div class="my-2 alert alert-danger">{{ $flash_error }}</div>
@endif
@if($flash_warn)
<div class="my-2 alert alert-warning">{{ $flash_warn }}</div>
@endif
@if($flash_info)
<div class="my-2 alert alert-info">{{ $flash_info }}</div>
@endif
@if($flash_success)
<div class="my-2 alert alert-success">{{ $flash_success }}</div>
@endif
</div>
@yield('content')
<hr>
<nav class="navbar">
<ul class="navbar-nav">
<li class="nav-item"><a href='/' class="nav-link small p-0">Home</a></li>
</ul>
<ul class="navbar-nav">
<li class="nav-item"><a href='/privacy' class="nav-link small p-0">Privacy and Data</a></li>
</ul>
</nav>
<p class="text-center small text-secondary">Site structure and layout &copy;2025 Majenko Technologies</p>
</div>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More