diff --git a/.env.example.ini b/.env.example.ini index a214a34..f166ada 100755 --- a/.env.example.ini +++ b/.env.example.ini @@ -1,20 +1,15 @@ -[client_api] -base_url = "" -api_key = "" -verify_peer = true - -[client_time_available] -time_zone = "Europe/Stockholm" -available_to_hour = 0; -reply_average_hours = 0; -available_from_hour = 0; - -[server_database] +[mariadb] host = "" user = "" pass = "" db = "" -[server_forgejo] -base_url = "" -scan_profiles = "" \ No newline at end of file +[config_time_available] +time_zone = "Europe/Stockholm" +available_to_hour = 0; +reply_average_hours = 0; +available_from_hour = 0; + +[service_forgejo] +url = "" +profiles = "" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 809e797..04b1c31 100755 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,2 @@ -# Public assets # -################# -public/.well-known -public/assets/js/modules/npm - -# Bootstrapping # -################# vendor -node_modules -.env.ini - -# OS generated files # -###################### -.DS_Store -.DS_Store? -._* -.Spotlight-V100 -.Trashes -Icon? -ehthumbs.db -Thumbs.db -.directory +.env.ini \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..1242f19 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "reflect"] + path = reflect + url = https://codeberg.org/reflect/reflect +[submodule "vegvisir"] + path = vegvisir + url = https://codeberg.org/vegvisir/vegvisir diff --git a/api/coffee/DELETE.php b/api/coffee/DELETE.php new file mode 100644 index 0000000..251ad37 --- /dev/null +++ b/api/coffee/DELETE.php @@ -0,0 +1,30 @@ +GET([ + new Rules(CoffeeTable::ID->value) + ->required() + ->type(Type::STRING) + ->min(UUID::LENGTH) + ->max(UUID::LENGTH) + ])); + } + + public function main(): Response { + return new Response(new Coffee($_GET[CoffeeTable::ID->value])->delete()); + } + } \ No newline at end of file diff --git a/api/coffee/GET.php b/api/coffee/GET.php new file mode 100644 index 0000000..b3387c7 --- /dev/null +++ b/api/coffee/GET.php @@ -0,0 +1,19 @@ +POST([ + new Rules(CoffeeTable::DATE_CREATED->value) + ->type(Type::STRING) + ->default(null) + ])); + } + + public function main(): Response { + $datetime = new DateTimeImmutable(); + + // Parse DateTime from POST string + if ($_POST[CoffeeTable::DATE_CREATED->value]) { + try { + $datetime = new DateTimeImmutable($_POST[CoffeeTable::DATE_CREATED->value]); + } catch (DateMalformedStringException $error) { + return new Response($error->getMessage(), 400); + } + } + + return new Response(Coffee::new($datetime)); + } + } \ No newline at end of file diff --git a/api/languages/GET.php b/api/languages/GET.php new file mode 100644 index 0000000..cc667a3 --- /dev/null +++ b/api/languages/GET.php @@ -0,0 +1,19 @@ +GET([ + new Rules(self::KEY_SERVICE) + ->type(Type::ENUM, ServiceEnum::values()) + ->default(ServiceEnum::ALL->value) + ])); + } + + public function main(): Response { + switch ($_GET[self::KEY_SERVICE]) { + case ServiceEnum::FORGEJO->value: + return new Response($this->update_forgejo()); + + case ServiceEnum::SEARCH->value: + return new Response($this->update_search()); + + case ServiceEnum::TIMELINE->value: + return new Response($this->update_timeline()); + + case ServiceEnum::ALL->value: + default: + return new Response( + $this->update_timeline() && + $this->update_search() && + $this->update_forgejo() + ); + } + } + + private function update_timeline(): bool { + return new GenerateTimeline()->generate(); + } + + private function update_search(): bool { + return new GenerateSearch()->generate(); + } + + private function update_forgejo(): bool { + return new Forgejo()->update(); + } + } \ No newline at end of file diff --git a/api/work/GET.php b/api/work/GET.php new file mode 100644 index 0000000..b001eea --- /dev/null +++ b/api/work/GET.php @@ -0,0 +1,28 @@ + array_map(fn(Tag $tag): string => $tag->label->name, Tag::from($work)), + "actions" => [], + "details" => $work + ]; + } + + public function __construct() { + parent::__construct(); + } + + public function main(): Response { + return new Response(array_map(fn(Work $work): object => self::entity($work), Work::all())); + } + } \ No newline at end of file diff --git a/composer.json b/composer.json index 07a4b64..9ca644e 100755 --- a/composer.json +++ b/composer.json @@ -1,6 +1,5 @@ { "require": { - "reflect/client": "dev-master", "vlw/mysql": "dev-master", "vlw/xenum": "dev-master" }, diff --git a/composer.lock b/composer.lock index a4dd9a1..e07d7a5 100755 --- a/composer.lock +++ b/composer.lock @@ -4,43 +4,15 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f74f68a452514a9d4dd011cef7648a7f", + "content-hash": "a7ce20d192550ef2d037220b593b5eb9", "packages": [ - { - "name": "reflect/client", - "version": "dev-master", - "source": { - "type": "git", - "url": "https://codeberg.org/reflect/client-php", - "reference": "89a8c041044c8c60cefafc4716d5d61b96c43e06" - }, - "default-branch": true, - "type": "library", - "autoload": { - "psr-4": { - "Reflect\\": "src/Reflect/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "GPL-2.0-only" - ], - "authors": [ - { - "name": "Victor Westerlund", - "email": "victor.vesterlund@gmail.com" - } - ], - "description": "Extendable PHP interface for communicating with Reflect API over HTTP or UNIX sockets", - "time": "2024-04-06T14:55:04+00:00" - }, { "name": "vlw/mysql", "version": "dev-master", "source": { "type": "git", "url": "https://codeberg.org/vlw/php-mysql", - "reference": "c64eb96049907da60dc9f237d26aef0e531b0015" + "reference": "0e367f797fa9348408881ed758976f21e8c667e4" }, "default-branch": true, "type": "library", @@ -60,7 +32,7 @@ } ], "description": "Abstraction library for common MySQL/MariaDB DML operations with php-mysqli", - "time": "2025-01-30T09:33:10+00:00" + "time": "2025-07-29T07:46:46+00:00" }, { "name": "vlw/xenum", @@ -68,7 +40,7 @@ "source": { "type": "git", "url": "https://codeberg.org/vlw/php-xenum", - "reference": "1c997a5574656b88a62f5ee160ee5a6439932a2f" + "reference": "ba3f43a9e2787bf938cfbfcb85ea87e5062df294" }, "default-branch": true, "type": "library", @@ -88,14 +60,13 @@ } ], "description": "PHP eXtended Enums. The missing quality-of-life features from PHP 8+ Enums", - "time": "2024-12-02T10:36:32+00:00" + "time": "2025-05-10T11:28:03+00:00" } ], "packages-dev": [], "aliases": [], "minimum-stability": "dev", "stability-flags": { - "reflect/client": 20, "vlw/mysql": 20, "vlw/xenum": 20 }, diff --git a/endpoints/about/languages/DELETE.php b/endpoints/about/languages/DELETE.php deleted file mode 100644 index 4da5b58..0000000 --- a/endpoints/about/languages/DELETE.php +++ /dev/null @@ -1,40 +0,0 @@ -ruleset = new Ruleset(strict: true); - $this->ruleset->validate_or_exit(); - - parent::__construct(); - } - - private function languages(): array { - $resp = (new Call(Endpoints::ABOUT_LANGUAGES->value))->get(); - - return array_column($resp->output(), LanguagesTable::ID->value); - } - - // Delete languages cache file if it exists - public function main(): Response { - $this->db->for(LanguagesTable::NAME); - - foreach ($this->languages() as $language){ - $this->db->delete([LanguagesTable::ID->value => $language]); - } - - return new Response(); - } - } \ No newline at end of file diff --git a/endpoints/about/languages/GET.php b/endpoints/about/languages/GET.php deleted file mode 100644 index b4b4ba0..0000000 --- a/endpoints/about/languages/GET.php +++ /dev/null @@ -1,43 +0,0 @@ -ruleset = new Ruleset(strict: true); - - $this->ruleset->GET([ - (new Rules(LanguagesTable::ID->value)) - ->type(Type::STRING) - ->min(1) - ->max(parent::SIZE_VARCHAR), - - (new Rules(LanguagesTable::BYTES->value)) - ->type(Type::NUMBER) - ->min(1) - ->max(parent::SIZE_UINT32) - ]); - - $this->ruleset->validate_or_exit(); - - parent::__construct(); - } - - public function main(): Response { - return $this->list(LanguagesTable::NAME, LanguagesTable::values(), [ - LanguagesTable::BYTES->value => Order::DESC - ]); - } - } \ No newline at end of file diff --git a/endpoints/about/languages/POST.php b/endpoints/about/languages/POST.php deleted file mode 100644 index cb95667..0000000 --- a/endpoints/about/languages/POST.php +++ /dev/null @@ -1,102 +0,0 @@ -ruleset = new Ruleset(strict: true); - $this->ruleset->validate_or_exit(); - - parent::__construct(); - } - - // Fetch JSON from URL - private static function fetch_json(string $url): array { - return json_decode(file_get_contents($url), true); - } - - // Fetch JSON from a Forgejo endpoint - private static function fetch_endpoint(string $endpoint): array { - $url = $_ENV["server_forgejo"]["base_url"] . $endpoint; - return self::fetch_json($url); - } - - // Write $this->languages to a JSON file - private function cache_languages(): void { - // Delete existing cache - (new Call(Endpoints::ABOUT_LANGUAGES->value))->delete(); - - $this->db->for(LanguagesTable::NAME); - - foreach ($this->languages as $language => $bytes) { - $this->db->insert([ - LanguagesTable::ID->value => $language, - LanguagesTable::BYTES->value => $bytes - ]); - } - } - - // Fetch and add languages to total from a fully-qualified Forgejo URL - private function add_repository_languages(string $url): void { - foreach(self::fetch_json($url) as $language => $bytes) { - // Create key for language if it doesn't exist - if (!array_key_exists($language, $this->languages)) { - $this->languages[$language] = 0; - } - - // Add bytes to language in total - $this->languages[$language] += $bytes; - } - } - - // Tally languages from public repositories for user id - private function add_public_repositores(int $uid): bool { - $resp = self::fetch_endpoint(sprintf(FORGEJO_ENDPOINT_SEARCH, $uid)); - - // Bail out if request failed or if response indicated a problem - if (!$resp or $resp["ok"] === false) { - return false; - } - - // Add langauges for each public repository - foreach ($resp["data"] as $repo) { - $this->add_repository_languages($repo["languages_url"]); - } - - return true; - } - - // Add languages from all public repositories for profiles in config - private function add_repositories_from_config_profiles(): void { - foreach(explode(",", $_ENV["server_forgejo"]["scan_profiles"]) as $profile) { - // Resolve user data from username - $user = self::fetch_endpoint(sprintf(FORGEJO_ENDPOINT_USER, $profile)); - $this->add_public_repositores($user["id"]); - } - } - - public function main(): Response { - $this->add_repositories_from_config_profiles(); - - // Sort langauges bytes tally by largest in descending order - arsort($this->languages); - - $this->cache_languages(); - return new Response($this->languages); - } - } \ No newline at end of file diff --git a/endpoints/coffee/GET.php b/endpoints/coffee/GET.php deleted file mode 100644 index 151b6dc..0000000 --- a/endpoints/coffee/GET.php +++ /dev/null @@ -1,38 +0,0 @@ -ruleset = new Ruleset(strict: true); - - $this->ruleset->GET([ - (new Rules(CoffeeTable::ID->value)) - ->type(Type::NUMBER) - ->min(1) - ->max(parent::SIZE_UINT32) - ]); - - $this->ruleset->validate_or_exit(); - - parent::__construct(); - } - - public function main(): Response { - return $this->list(CoffeeTable::NAME, CoffeeTable::values(), [ - CoffeeTable::ID->value => Order::DESC - ]); - } - } \ No newline at end of file diff --git a/endpoints/coffee/POST.php b/endpoints/coffee/POST.php deleted file mode 100644 index 7c96ad9..0000000 --- a/endpoints/coffee/POST.php +++ /dev/null @@ -1,38 +0,0 @@ -ruleset = new Ruleset(strict: true); - - $this->ruleset->POST([ - (new Rules(CoffeeTable::ID->value)) - ->type(Type::NUMBER) - ->min(1) - ->max(parent::SIZE_UINT32) - ->default(time()), - ]); - - $this->ruleset->validate_or_exit(); - - parent::__construct(); - } - - public function main(): Response { - return $this->db->for(CoffeeTable::NAME)->insert($_POST) === true - ? new Response(null, 201) - : new Response("Database error", 500); - } - } \ No newline at end of file diff --git a/endpoints/coffee/stats/GET.php b/endpoints/coffee/stats/GET.php deleted file mode 100644 index b3990ba..0000000 --- a/endpoints/coffee/stats/GET.php +++ /dev/null @@ -1,28 +0,0 @@ -ruleset = new Ruleset(strict: true); - $this->ruleset->validate_or_exit(); - - parent::__construct(); - } - - public function main(): Response { - return $this->list(StatsTable::NAME, StatsTable::values()); - } - } \ No newline at end of file diff --git a/endpoints/coffee/stats/POST.php b/endpoints/coffee/stats/POST.php deleted file mode 100644 index 6d05917..0000000 --- a/endpoints/coffee/stats/POST.php +++ /dev/null @@ -1,35 +0,0 @@ -ruleset = new Ruleset(strict: true); - $this->ruleset->validate_or_exit(); - - parent::__construct(); - } - - public function main(): Response { - $truncate = $this->db->execute_query("DELETE FROM `" . StatsTable::NAME . "`"); - - // Add a dummy row to run the MariaDB INSERT AFTER Trigger on the coffee database table - $insert = $this->db->for(CoffeeTable::NAME)->insert([CoffeeTable::ID->value => 0]); - // Remove the dummy row - $remove = $this->db->for(CoffeeTable::NAME)->where([CoffeeTable::ID->value => 0])->delete(); - - return $truncate && $insert && $remove ? new Response() : new Response("Error", 500); - } - } \ No newline at end of file diff --git a/endpoints/messages/POST.php b/endpoints/messages/POST.php deleted file mode 100644 index e6cbdd7..0000000 --- a/endpoints/messages/POST.php +++ /dev/null @@ -1,43 +0,0 @@ -ruleset = new Ruleset(strict: true); - - $this->ruleset->POST([ - (new Rules(MessagesTable::EMAIL->value)) - ->type(Type::STRING) - ->max(255) - ->default(null), - - (new Rules(MessagesTable::MESSAGE->value)) - ->required() - ->type(Type::STRING) - ->min(1) - ->max(parent::SIZE_TEXT) - ]); - - $this->ruleset->validate_or_exit(); - - parent::__construct(); - } - - public function main(): Response { - $_POST[MessagesTable::TIMESTAMP_CREATED->value] = time(); - - return $this->db->for(MessagesTable::NAME)->insert($_POST) === true - ? new Response(null, 201) - : new Response("Failed to send message", 500); - } - } \ No newline at end of file diff --git a/endpoints/search/DELETE.php b/endpoints/search/DELETE.php deleted file mode 100644 index 6d9e445..0000000 --- a/endpoints/search/DELETE.php +++ /dev/null @@ -1,35 +0,0 @@ -ruleset = new Ruleset(strict: true); - - $this->ruleset->POST([ - (new Rules(SearchTable::ID->value)) - ->required() - ->type(Type::STRING) - ->min(2) - ->max(parent::SIZE_VARCHAR) - ]); - - $this->ruleset->validate_or_exit(); - - parent::__construct(); - } - - public function main(): Response { - return $this->db->for(SearchTable::NAME)->delete($_POST) === true ? new Response() : new Response("", 500); - } - } \ No newline at end of file diff --git a/endpoints/search/GET.php b/endpoints/search/GET.php deleted file mode 100644 index 46c793a..0000000 --- a/endpoints/search/GET.php +++ /dev/null @@ -1,85 +0,0 @@ -ruleset = new Ruleset(strict: true); - - $this->ruleset->GET([ - (new Rules(SearchTable::QUERY->value)) - ->type(Type::STRING) - ->min(2) - ->max(parent::SIZE_VARCHAR) - ->default(null), - - (new Rules(SearchTable::ID->value)) - ->type(Type::STRING) - ->min(1) - ->max(10) - ->default(null), - - (new Rules(SearchTable::CATEGORY->value)) - ->type(Type::ENUM, SearchCategoryEnum::names()) - ->default(null) - ]); - - $this->ruleset->validate_or_exit(); - - parent::__construct(); - } - - private static function get_query(): string { - preg_match_all("/[a-zA-Z0-9]+/", $_GET[SearchTable::QUERY->value], $matches); - - return strtolower(implode("", $matches[0])); - } - - public function main(): Response { - $result = $this->db->for(SearchTable::NAME); - - if ($_GET[SearchTable::ID->value]) { - $result = $result->where([SearchTable::ID->value => $_GET[SearchTable::ID->value]]); - } else if ($_GET[SearchTable::QUERY->value]) { - $query = self::get_query(); - - $filter = [ - SearchTable::QUERY->value => [ - Operators::LIKE->value => "%{$query}%" - ] - ]; - - if ($_GET[SearchTable::CATEGORY->value]) { - $filter[SearchTable::CATEGORY->value] = $_GET[SearchTable::CATEGORY->value]; - } - - $result = $result->where($filter); - } else { - new Response([], 400); - } - - $result = $result->select([ - SearchTable::ID->value, - SearchTable::TITLE->value, - SearchTable::SUMMARY->value, - SearchTable::CATEGORY->value, - SearchTable::HREF->value - ]); - - return $result->num_rows > 0 - ? new Response($result->fetch_all(MYSQLI_ASSOC)) - : new Response([], 404); - } - } \ No newline at end of file diff --git a/endpoints/search/POST.php b/endpoints/search/POST.php deleted file mode 100644 index 95548e8..0000000 --- a/endpoints/search/POST.php +++ /dev/null @@ -1,67 +0,0 @@ -ruleset = new Ruleset(strict: true); - $this->ruleset->validate_or_exit(); - - parent::__construct(); - } - - private static function truncate_string(string $input): string { - return substr($input, 0, SEARCH_QUERY_MAX_LENGTH); - } - - private static function create_query(string $input): string { - preg_match_all("/[a-zA-Z0-9]+/", $input, $matches); - - return self::truncate_string(strtolower(implode("", $matches[0]))); - } - - private function index_work(): void { - foreach ((new Call(Endpoints::WORK->value))->get()->output() as $result) { - $query = self::create_query(implode("", array_values($result)), 0, SEARCH_QUERY_MAX_LENGTH); - - // Get actions related to current result - $actions = (new Call(Endpoints::WORK_ACTIONS->value))->params([ - ActionsTable::REF_WORK_ID->value => $result[WorkTable::ID->value] - ])->get()->output(); - - $this->db->for(SearchTable::NAME)->insert([ - SearchTable::QUERY->value => $query, - SearchTable::ID->value => crc32($query), - SearchTable::TITLE->value => self::truncate_string($result[WorkTable::TITLE->value]), - SearchTable::SUMMARY->value => self::truncate_string($result[WorkTable::SUMMARY->value]), - SearchTable::CATEGORY->value => SearchCategoryEnum::WORK->name, - // Use first action as link for search result - SearchTable::HREF->value => $actions - ? self::truncate_string($actions[0][ActionsTable::HREF->value]) - : null - ]); - } - } - - public function main(): Response { - $this->index_work(); - return new Response(); - } - } \ No newline at end of file diff --git a/endpoints/update/GET.php b/endpoints/update/GET.php deleted file mode 100644 index 6044689..0000000 --- a/endpoints/update/GET.php +++ /dev/null @@ -1,26 +0,0 @@ -ruleset = new Ruleset(strict: true); - $this->ruleset->validate_or_exit(); - } - - // Update all runtime database endpoints - public function main(): Response { - (new Call(Endpoints::SEARCH->value))->post(); - (new Call(Endpoints::COFFEE_STATS->value))->post(); - (new Call(Endpoints::ABOUT_LANGUAGES->value))->post(); - - return new Response(); - } - } \ No newline at end of file diff --git a/endpoints/work/GET.php b/endpoints/work/GET.php deleted file mode 100644 index 01ecdc4..0000000 --- a/endpoints/work/GET.php +++ /dev/null @@ -1,49 +0,0 @@ -ruleset = new Ruleset(strict: true); - - $this->ruleset->GET([ - (new Rules(WorkTable::ID->value)) - ->type(Type::STRING) - ->min(1) - ->max(parent::SIZE_VARCHAR), - - (new Rules(WorkTable::TITLE->value)) - ->type(Type::STRING) - ->max(parent::SIZE_VARCHAR), - - (new Rules(WorkTable::SUMMARY->value)) - ->type(Type::STRING) - ->max(parent::SIZE_TEXT), - - (new Rules(WorkTable::CREATED->value)) - ->type(Type::STRING) - ->min(1) - ->max(parent::SIZE_VARCHAR) - ]); - - $this->ruleset->validate_or_exit(); - - parent::__construct(); - } - - public function main(): Response { - return $this->list(WorkTable::NAME, WorkTable::values(), [ - WorkTable::CREATED->value => Order::DESC - ]); - } - } \ No newline at end of file diff --git a/endpoints/work/actions/GET.php b/endpoints/work/actions/GET.php deleted file mode 100644 index f92e0bb..0000000 --- a/endpoints/work/actions/GET.php +++ /dev/null @@ -1,36 +0,0 @@ -ruleset = new Ruleset(strict: true); - - $this->ruleset->GET([ - (new Rules(ActionsTable::REF_WORK_ID->value)) - ->type(Type::STRING) - ->min(1) - ->max(parent::SIZE_VARCHAR) - ]); - - $this->ruleset->validate_or_exit(); - - parent::__construct(); - } - - public function main(): Response { - return $this->list(ActionsTable::NAME, ActionsTable::values(), [ - ActionsTable::ORDER_IDX->value => Order::DESC - ]); - } - } \ No newline at end of file diff --git a/endpoints/work/tags/GET.php b/endpoints/work/tags/GET.php deleted file mode 100644 index a8a9f4c..0000000 --- a/endpoints/work/tags/GET.php +++ /dev/null @@ -1,35 +0,0 @@ -ruleset = new Ruleset(strict: true); - - $this->ruleset->GET([ - (new Rules(TagsTable::REF_WORK_ID->value)) - ->min(1) - ->max(parent::SIZE_VARCHAR), - - (new Rules(TagsTable::LABEL->value)) - ->type(Type::ENUM, TagsLabelEnum::names()) - ]); - - $this->ruleset->validate_or_exit(); - - parent::__construct(); - } - - public function main(): Response { - return $this->list(TagsTable::NAME, TagsTable::values()); - } - } \ No newline at end of file diff --git a/endpoints/work/timeline/GET.php b/endpoints/work/timeline/GET.php deleted file mode 100644 index b9b442d..0000000 --- a/endpoints/work/timeline/GET.php +++ /dev/null @@ -1,53 +0,0 @@ -ruleset = new Ruleset(strict: true); - - $this->ruleset->GET([ - (new Rules(TimelineTable::REF_WORK_ID->value)) - ->type(Type::STRING) - ->min(1) - ->max(parent::SIZE_VARCHAR), - - (new Rules(TimelineTable::YEAR->value)) - ->type(Type::NUMBER) - ->min(0) - ->max(parent::SIZE_UINT16), - - (new Rules(TimelineTable::MONTH->value)) - ->type(Type::NUMBER) - ->min(0) - ->max(parent::SIZE_UINT8), - - (new Rules(TimelineTable::DAY->value)) - ->type(Type::NUMBER) - ->min(0) - ->max(parent::SIZE_UINT8) - ]); - - $this->ruleset->validate_or_exit(); - - parent::__construct(); - } - - public function main(): Response { - return $this->list(TimelineTable::NAME, TimelineTable::values(), [ - TimelineTable::YEAR->value => Order::DESC, - TimelineTable::MONTH->value => Order::DESC, - TimelineTable::DAY->value => Order::DESC - ]); - } - } \ No newline at end of file diff --git a/install.sh b/install.sh deleted file mode 100644 index 7879587..0000000 --- a/install.sh +++ /dev/null @@ -1,10 +0,0 @@ -# Install dependencies -composer install --optimize-autoloader -npm install - -# (Re)create public NPM modules folder -rm -r public/assets/js/modules/npm -mkdir public/assets/js/modules/npm - -# Create link to Elevent MJS from public JS modules folder -ln -sr node_modules/elevent/src/Elevent.mjs public/assets/js/modules/npm/Elevent.mjs \ No newline at end of file diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 087a74c..0000000 --- a/package-lock.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "vlw.se", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "elevent": "^1.0.2" - } - }, - "node_modules/elevent": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/elevent/-/elevent-1.0.2.tgz", - "integrity": "sha512-ks5LBUBTg4Bpfmj99OcFAzuDGzBRDEZhTyxmq/Y3RbsdBQ4JCaIUYB0M15OBvBWgIn1BnCo4WCSmw0/YbCJliw==" - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 50060b0..0000000 --- a/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "dependencies": { - "elevent": "^1.0.2" - } -} diff --git a/public/about.php b/public/about.php index 90ee816..8394fbe 100644 --- a/public/about.php +++ b/public/about.php @@ -1,26 +1,23 @@ total_bytes = array_sum(array_map(fn(Language $language): int => $language->bytes(), parent::all())); + $this->total_bytes = array_sum(array_map(fn(Language $language): int => $language->bytes, parent::all())); } public function percent(Language $language, int $mode = PHP_ROUND_HALF_UP): int { - return round(($language->bytes() / $this->total_bytes) * 100, 0, $mode); + return round(($language->bytes / $this->total_bytes) * 100, 0, $mode); } public function percent_string(Language $language): string { @@ -29,17 +26,25 @@ public function bytes_si_string(Language $language): string { // Calculate factor for unit - $factor = floor((strlen($language->bytes()) - 1) / 3); + $factor = floor((strlen($language->bytes) - 1) / 3); // Divide by radix 10 - $format = $language->bytes() / pow(1000, $factor); + $format = $language->bytes / pow(1000, $factor); - return round($format) . " " . FORGEJO_SI_BYTE_MULTIPLE[$factor]; + return round($format) . " " . SI_BYTE_MULTIPLE[$factor]; } }; - $coffee = new class extends Stats { + $coffee = new class extends Coffee { + public readonly int $count_week; + public readonly int $count_week_average; + + public function __construct() { + $this->count_week = parent::count_week(); + $this->count_week_average = parent::count_week_average(); + } + public function week_average_string(): string { - $diff = $this->week() - $this->week_average(); + $diff = $this->count_week - $this->count_week_average; return match (true) { $diff < 0 => "less than", @@ -65,8 +70,8 @@ - - percent_string($language) ?> id ?>
(bytes() ?> bytes)
+
+ percent_string($language) ?> name ?>
(bytes ?> bytes)
@@ -74,11 +79,11 @@ - @@ -86,8 +91,8 @@ - - percent_string($language) ?> id ?>
(bytes() ?> bytes)
+
+ percent_string($language) ?> name ?>
(bytes ?> bytes)
@@ -101,7 +106,7 @@

Personal

-

One thing that most people know about me is that I like coffee.. lots of coffee. In fact, I've had week() ?> cupweek() === 1 ? "" : "s" ?> of coffee in the last 7 days! That's week_average_string() ?> my average of week_average() ?> per week, impressive! Even though you just read that.. I don't consider myself too much of a coffee snob! As long as it's dark roast and warm, I'm probably happy to have it.

+

One thing that most people know about me is that I like coffee.. lots of coffee. In fact, I've had count_week ?> cupcount_week === 1 ? "" : "s" ?> of coffee in the last 7 days! That's week_average_string() ?> my average of count_week_average ?> per week, impressive! Even though you just read that.. I don't consider myself too much of a coffee snob! As long as it's dark roast and warm, I'm probably happy to have it.

At times, I become a true, amateur, armchair detective for a variety of your typical-nerdy topics that I find interesting and you can bet I spend way more time reading about those things than I will ever have use for in life.

Another silent passion of mine that comes out every few years is building computers and fiddling with networking stuff.

@@ -132,4 +137,4 @@

ISO 8601

digital archiving

- + \ No newline at end of file diff --git a/public/assets/css/pages/work.css b/public/assets/css/pages/work/index.css similarity index 100% rename from public/assets/css/pages/work.css rename to public/assets/css/pages/work/index.css diff --git a/public/assets/js/pages/about.js b/public/assets/js/pages/about.js index c774d7d..4c6e9e5 100644 --- a/public/assets/js/pages/about.js +++ b/public/assets/js/pages/about.js @@ -1,5 +1,3 @@ -import { Hoverpop } from "/assets/js/modules/Hoverpop.mjs"; - const randomIntFromInterval = (min, max) => { return Math.floor(Math.random() * (max - min + 1) + min); } @@ -64,5 +62,17 @@ const implodeInterests = () => { interestsElement.addEventListener(canHover ? "mouseleave" : "touchend", () => implodeInterests()); } -// Languages stacking bar chart hoverpop -new Hoverpop(document.querySelectorAll("stacked-bar-chart chart-segment")); \ No newline at end of file +// Language bar chart hover tooltip +document.querySelectorAll("stacked-bar-chart chart-segment").forEach(element => { + const tooltipElement = element.querySelector("[data-hover]"); + + element.addEventListener("mouseenter", () => tooltipElement.classList.add("hovering")); + element.addEventListener("mouseleave", () => tooltipElement.classList.remove("hovering")); + + element.addEventListener("mousemove", (event) => { + const x = event.layerX - (tooltipElement.clientWidth / 2); + const y = event.layerY + (tooltipElement.clientHeight - 30); + + tooltipElement.style.setProperty("transform", `translate(${x}px, ${y}px)`); + }); +}); \ No newline at end of file diff --git a/public/assets/js/pages/contact.js b/public/assets/js/pages/contact.js index 45f8a85..286805f 100644 --- a/public/assets/js/pages/contact.js +++ b/public/assets/js/pages/contact.js @@ -1,5 +1,3 @@ -import { Hoverpop } from "/assets/js/modules/Hoverpop.mjs"; - class ContactForm { static STORAGE_KEY = "contact_form_message"; @@ -62,7 +60,16 @@ class ContactForm { form ? (new ContactForm(form)) : ContactForm.removeSavedMessage(); } -// Social links hoverpop -{ - new Hoverpop(document.querySelectorAll("social")); -} \ No newline at end of file +document.querySelectorAll("social").forEach(element => { + const tooltipElement = element.querySelector("[data-hover]"); + + element.addEventListener("mouseenter", () => tooltipElement.classList.add("hovering")); + element.addEventListener("mouseleave", () => tooltipElement.classList.remove("hovering")); + + element.addEventListener("mousemove", (event) => { + const x = event.layerX - (tooltipElement.clientWidth / 2); + const y = event.layerY + tooltipElement.clientHeight; + + tooltipElement.style.setProperty("transform", `translate(${x}px, ${y}px)`); + }); +}); \ No newline at end of file diff --git a/public/assets/js/pages/index.js b/public/assets/js/pages/index.js index 45a55ba..56c54bc 100644 --- a/public/assets/js/pages/index.js +++ b/public/assets/js/pages/index.js @@ -1,5 +1,3 @@ -import { Elevent } from "/assets/js/modules/npm/Elevent.mjs"; - // Click to copy email button { const EMAIL_CPY_ANIM_DUR_MSECONDS = 1000; @@ -55,7 +53,7 @@ import { Elevent } from "/assets/js/modules/npm/Elevent.mjs"; }, EMAIL_CPY_ANIM_DUR_MSECONDS + 100); } - new Elevent("click", document.querySelector(".email"), async () => { + document.querySelector(".email").addEventListener("click", async () => { try { await navigator.clipboard.writeText("victor@vlw.se"); diff --git a/public/assets/js/shell.js b/public/assets/js/shell.js index cbda87f..d37d5ce 100644 --- a/public/assets/js/shell.js +++ b/public/assets/js/shell.js @@ -1,7 +1,7 @@ -import { Elevent } from "/assets/js/modules/npm/Elevent.mjs"; - +const DEBOUNCE_TIMEOUT_MS = 100; const CLASSNAME_SEARCHBOX_ACTIVE = "searchboxActive"; +<<<<<<< HEAD // Set global Vegvisir naviation delay for page transition effect VV.delay = 100; @@ -51,9 +51,51 @@ VV.delay = 100; }, 100); }); } +======= +// Navigate to the start page if the logo in the header is clicked +document.querySelector("header .logo").addEventListener("click", () => new VV().navigate("/")); +>>>>>>> chore/v3.2 // Navigate to the start page if the logo in the header is clicked document.querySelector("header .logo").addEventListener("click", () => new VV().navigate("/")); // Scroll page to top on navigation -VV.shell.addEventListener(VV.EVENT.FINISH, () => window.scrollTo({ top: 0 })); \ No newline at end of file +<<<<<<< HEAD +VV.shell.addEventListener(VV.EVENT.FINISH, () => window.scrollTo({ top: 0 })); +======= +VV.shell.addEventListener(VV.EVENT.FINISH, () => window.scrollTo({ top: 0 })); + +// Open search box +document.querySelector(".searchbox-open").addEventListener("click", () => { + document.querySelector("header").classList.add(CLASSNAME_SEARCHBOX_ACTIVE); + // Select searchbox inner input element + document.querySelector("searchbox input").focus(); +}); + +// Close searchbox +document.querySelector(".searchbox-close").addEventListener("click", () => { + // Disable search button interaction while animation is running + // This is required to prevent conflicts with the :hover "peak" transformation + const searchButtonElement = document.querySelector("header button.search"); + const transformDuration = parseInt(window.getComputedStyle(searchButtonElement).getPropertyValue("--transform-duration")); + searchButtonElement.style.setProperty("pointer-events", "none"); + + document.querySelector("header").classList.remove(CLASSNAME_SEARCHBOX_ACTIVE); + + // Wait for the transform animation to finish + setTimeout(() => searchButtonElement.style.removeProperty("pointer-events"), transformDuration); +}); + +// Close searchbox on top shell navigations +VV.shell.addEventListener(VV.EVENT.START, () => document.querySelector("header").classList.remove(CLASSNAME_SEARCHBOX_ACTIVE)); + +// Handle search logic +document.querySelector("header input[type='search']").addEventListener("input", (event) => { + // Debounce user input + clearTimeout(event.target._throttle); + event.target._throttle = setTimeout(() => { + // Navigate search-results element on user input + new VV(document.querySelector("search-results")).navigate(`/search?q=${event.target.value}`); + }, DEBOUNCE_TIMEOUT_MS); +}); +>>>>>>> chore/v3.2 diff --git a/public/contact.php b/public/contact.php index 8217831..7a559b4 100644 --- a/public/contact.php +++ b/public/contact.php @@ -1,18 +1,15 @@ hour() >= $_ENV["client_time_available"]["available_from_hour"] && $this->hour() < $_ENV["client_time_available"]["available_to_hour"]; + return $this->hour() >= $_ENV["config_time_available"]["available_from_hour"] && $this->hour() < $_ENV["config_time_available"]["available_to_hour"]; } public function get_estimated_reply_hours(): int { // I'm available! Return the estimated reply time for that if ($this->is_available()) { - return $_ENV["client_time_available"]["reply_average_hours"]; + return $_ENV["config_time_available"]["reply_average_hours"]; } - return $this->hour() < $_ENV["client_time_available"]["available_from_hour"] + return $this->hour() < $_ENV["config_time_available"]["available_from_hour"] // Return hours past midnight until I become available (clamped to estimated reply hours) - ? max($_ENV["client_time_available"]["available_from_hour"] - $this->hour(), $_ENV["client_time_available"]["reply_average_hours"]) + ? max($_ENV["config_time_available"]["available_from_hour"] - $this->hour(), $_ENV["config_time_available"]["reply_average_hours"]) // Return hours before midnight until I become available (clamped to estimated reply hours) - : max($_ENV["client_time_available"]["available_from_hour"] + (24 - $this->hour()), $_ENV["client_time_available"]["reply_average_hours"]); + : max($_ENV["config_time_available"]["available_from_hour"] + (24 - $this->hour()), $_ENV["config_time_available"]["reply_average_hours"]); } } @@ -84,18 +81,9 @@ + name] ?? "", $_POST[Messages::EMAIL->name] ?? null); ?> - call(Endpoints::MESSAGES->value)->post([ - MessagesTable::EMAIL->value => $_POST[MessagesTable::EMAIL->value], - MessagesTable::MESSAGE->value => $_POST[MessagesTable::MESSAGE->value] - ]); - - ?> - - ok): ?> + date_created): ?>

🙏 Message sent!

@@ -103,8 +91,6 @@

😟 Oh no, something went wrong

-

Response from API:

-
output() ?>
@@ -113,11 +99,11 @@
- + - +
- - \ No newline at end of file + \ No newline at end of file diff --git a/public/search.php b/public/search.php index 5e3bb1b..4f4f7f6 100644 --- a/public/search.php +++ b/public/search.php @@ -1,26 +1,23 @@ value] ?? null; - } - - public static function get_category(): ?SearchCategoryEnum { - return SearchCategoryEnum::tryFromName($_GET[SearchTable::CATEGORY->value] ?? ""); - } - - public function search(): array { - return parent::all([SearchTable::QUERY->value => self::get_query()]); + public readonly string $query; + public readonly array $results; + + public function __construct() { + $this->query = $_GET[GET_KEY_QUERY] ?? ""; + $this->results = strlen($this->query) > MIN_QUERY_LENGTH ? parent::query($this->query, limit: LIMIT_RESULTS) : []; } } @@ -28,54 +25,54 @@ -value, $_GET)): ?> + - search()): ?> + results): ?>
-

search()) ?> result(s)

-
- search() as $result): ?> + results as $result): ?>
-
- + query)): default: ?>

0 result(s)

-
@@ -86,15 +83,15 @@
- +

Start typing to search

- -

Almost, type at least two letters to search

+ +

Almost there, type at least two letters to search

@@ -104,7 +101,7 @@
- +

Start typing to search

diff --git a/public/shell.php b/public/shell.php index 68c8644..2fd9cbc 100644 --- a/public/shell.php +++ b/public/shell.php @@ -37,7 +37,6 @@ //--> - @@ -69,8 +68,7 @@ - - + \ No newline at end of file diff --git a/public/work.php b/public/work/index.php similarity index 87% rename from public/work.php rename to public/work/index.php index e4b83e5..786e347 100644 --- a/public/work.php +++ b/public/work/index.php @@ -1,13 +1,11 @@ - +
@@ -15,7 +13,7 @@

vegvisir

-

summary() ?>

+

summary ?>

-

summary() ?>

+

summary ?>

vlw/php-mysql

-

summary() ?>

+

summary ?>

Website for iCellate Medical

-

summary() ?>

+

summary ?>

Website for GeneMate by iCellate

-

summary() ?>

+

summary ?>

Campaign pages for Deltaco AB

-

summary() ?>

+

summary ?>

@@ -149,5 +142,4 @@
-
- \ No newline at end of file +
\ No newline at end of file diff --git a/public/work/wip.php b/public/work/wip.php index c2e4ec6..3bddf13 100644 --- a/public/work/wip.php +++ b/public/work/wip.php @@ -1,10 +1,3 @@ -

Soon, very soon!

@@ -13,6 +6,6 @@
\ No newline at end of file diff --git a/reflect b/reflect new file mode 160000 index 0000000..f8d4595 --- /dev/null +++ b/reflect @@ -0,0 +1 @@ +Subproject commit f8d45950d78a16adc64db920b5dbf59dabce5bca diff --git a/src/API/API.php b/src/API/API.php index f3c10f1..d0bbeb6 100644 --- a/src/API/API.php +++ b/src/API/API.php @@ -1,18 +1,15 @@ validate_or_exit(); } } \ No newline at end of file diff --git a/src/API/Endpoints.php b/src/API/Endpoints.php deleted file mode 100644 index efd1012..0000000 --- a/src/API/Endpoints.php +++ /dev/null @@ -1,20 +0,0 @@ -db = new MySQL( - $_ENV["server_database"]["host"], - $_ENV["server_database"]["user"], - $_ENV["server_database"]["pass"], - $_ENV["server_database"]["db"], + parent::__construct( + $_ENV["mariadb"]["host"], + $_ENV["mariadb"]["user"], + $_ENV["mariadb"]["pass"], + $_ENV["mariadb"]["db"], ); } - - // Return all rows from a table using $_GET paramters as the WHERE clause as a Reflect\Response - public function list(string $table, array $columns, array $order = null): Response { - $resp = $this->db->for($table)->where(array_intersect_key($_GET, array_flip($columns))); - - // Optionally order rows by columns - if ($order) { - $resp = $resp->order($order); - } - - // Return all matched rows or a 404 with an empty array if there were not results - $resp = $resp->select($columns); - return $resp && $resp->num_rows > 0 - ? new Response($resp->fetch_all(MYSQLI_ASSOC)) - : new Response([], 404); - } } \ No newline at end of file diff --git a/src/Database/Models/About/Language.php b/src/Database/Models/About/Language.php deleted file mode 100644 index f264b54..0000000 --- a/src/Database/Models/About/Language.php +++ /dev/null @@ -1,30 +0,0 @@ -value => $this->id - ]); - } - - public static function all(array $params = []): array { - return array_map(fn(array $item): Language => new Language($item[LanguagesTable::ID->value]), parent::list(Endpoints::ABOUT_LANGUAGES, $params)); - } - - public function bytes(): int { - return $this->get(LanguagesTable::BYTES->value); - } - } \ No newline at end of file diff --git a/src/Database/Models/Coffee/Coffee.php b/src/Database/Models/Coffee/Coffee.php index e1adb6a..2f3eafa 100644 --- a/src/Database/Models/Coffee/Coffee.php +++ b/src/Database/Models/Coffee/Coffee.php @@ -3,33 +3,69 @@ namespace VLW\Database\Models\Coffee; use \VV; + use \vlw\MySQL\Order; use \DateTimeImmutable; - use VLW\API\Endpoints; + use VLW\Helpers\UUID; + use VLW\Database\Database; use VLW\Database\Models\Model; - use VLW\Database\Tables\Coffee\CoffeeTable; + use VLW\Database\Tables\Coffee\{Stats, Coffee as CoffeeTable}; - require_once VV::root("src/API/Endpoints.php"); + require_once VV::root("src/Helpers/UUID.php"); + require_once VV::root("src/Database/Database.php"); require_once VV::root("src/Database/Models/Model.php"); + require_once VV::root("src/Database/Tables/Coffee/Stats.php"); require_once VV::root("src/Database/Tables/Coffee/Coffee.php"); - require_once VV::root("src/Database/Models/Coffee/Coffee.php"); class Coffee extends Model { + final public static function new(?DateTimeImmutable $datetime = null): self { + $id = UUID::v4(); + + if (!parent::create(CoffeeTable::TABLE, [ + CoffeeTable::ID->value => $id, + CoffeeTable::DATE_CREATED->value => $datetime ? $datetime->format(parent::DATE_FORMAT) : date(parent::DATE_FORMAT) + ])) { throw new Exception("Failed to create Work entity"); } + + return new Coffee($id); + } + + final public static function all(): array { + return array_map(fn(array $work): Coffee => new Coffee($work[CoffeeTable::ID->value]), new Database() + ->from(CoffeeTable::TABLE) + ->order([CoffeeTable::DATE_CREATED->value => Order::DESC]) + ->select(CoffeeTable::ID->value) + ->fetch_all(MYSQLI_ASSOC) + ); + } + + final public static function count_week(): int { + return new Database() + ->from(Stats::TABLE) + ->limit(1) + ->select(Stats::COUNT_WEEK->value) + ->fetch_assoc()[Stats::COUNT_WEEK->value] ?? 0; + } + + final public static function count_week_average(): int { + return new Database() + ->from(Stats::TABLE) + ->limit(1) + ->select(Stats::COUNT_WEEK_AVERAGE->value) + ->fetch_assoc()[Stats::COUNT_WEEK_AVERAGE->value] ?? 0; + } + public function __construct(public readonly string $id) { - parent::__construct(Endpoints::COFFEE, [ + parent::__construct(CoffeeTable::TABLE, CoffeeTable::values(), [ CoffeeTable::ID->value => $this->id ]); } - public static function all(array $params = []): array { - return array_map(fn(array $item): Coffee => new Coffee($item[CoffeeTable::ID->value]), parent::list(Endpoints::COFFEE, $params)); + public function delete(): bool { + return $this->db->delete([CoffeeTable::ID->value => $this->id]); } - public function timestamp(): int { - return $this->get(CoffeeTable::ID->value); - } - - public function datetime(): DateTimeImmutable { - return DateTimeImmutable::createFromFormat("U", $this->timestamp()); + final public DateTimeImmutable $date_created { + get => new DateTimeImmutable($this->get(CoffeeTable::DATE_CREATED->value)); + set (DateTimeImmutable $date_created) => $this->set(CoffeeTable::DATE_CREATED->value, $date_created->format(parent::DATE_FORMAT)); } } \ No newline at end of file diff --git a/src/Database/Models/Coffee/Stats.php b/src/Database/Models/Coffee/Stats.php deleted file mode 100644 index 8e777a7..0000000 --- a/src/Database/Models/Coffee/Stats.php +++ /dev/null @@ -1,32 +0,0 @@ -get(StatsTable::COUNT_WEEK->value) ?? 0; - } - - public function week_average(): int { - return $this->get(StatsTable::COUNT_WEEK_AVERAGE->value) ?? 0; - } - } \ No newline at end of file diff --git a/src/Database/Models/Languages/Language.php b/src/Database/Models/Languages/Language.php new file mode 100644 index 0000000..4376db3 --- /dev/null +++ b/src/Database/Models/Languages/Language.php @@ -0,0 +1,65 @@ +value => $id, + Languages::NAME->value => $name, + Languages::BYTES->value => $bytes + ])) { throw new Exception("Failed to create Language entity"); } + + return new Language($id); + } + + final public static function all(): array { + return array_map(fn(array $language): Language => new Language($language[Languages::ID->value]), new Database() + ->from(Languages::TABLE) + ->order([Languages::BYTES->value => Order::DESC]) + ->select(Languages::ID->value) + ->fetch_all(MYSQLI_ASSOC) + ); + } + + final public static function from(string $name): ?self { + return array_map(fn(array $language): Language => new Language($language[Languages::ID->value]), new Database() + ->from(Languages::TABLE) + ->where([Languages::NAME->value => $name]) + ->limit(1) + ->select(Languages::ID->value) + ->fetch_all(MYSQLI_ASSOC) + ); + } + + public function __construct(public readonly string $id) { + parent::__construct(Languages::TABLE, Languages::values(), [ + Languages::ID->value => $this->id + ]); + } + + final public string $name { + get => $this->get(Languages::NAME->value); + set (string $name) => $this->set(Languages::NAME->value, $name); + } + + final public int $bytes { + get => $this->get(Languages::BYTES->value); + set (int $bytes) => $this->set(Languages::BYTES->value, $bytes); + } + } \ No newline at end of file diff --git a/src/Database/Models/Messages/Message.php b/src/Database/Models/Messages/Message.php new file mode 100644 index 0000000..b587933 --- /dev/null +++ b/src/Database/Models/Messages/Message.php @@ -0,0 +1,62 @@ +value => $id, + Messages::EMAIL->value => $email, + Messages::MESSAGE->value => $message, + Messages::DATE_CREATED->value => date(parent::DATE_FORMAT) + ])) { throw new Exception("Failed to create Message entity"); } + + return new Message($id); + } + + final public static function all(): array { + return array_map(fn(array $Message): Message => new Message($Message[Messages::ID->value]), new Database() + ->from(Messages::TABLE) + ->order([Messages::DATE_CREATED->value => Order::DESC]) + ->select(Messages::ID->value) + ->fetch_all(MYSQLI_ASSOC) + ); + } + + public function __construct(public readonly string $id) { + parent::__construct(Messages::TABLE, Messages::values(), [ + Messages::ID->value => $this->id + ]); + } + + final public ?string $email { + get => $this->get(Messages::EMAIL->value); + set (?string $email) => $this->set(Messages::EMAIL->value, $email); + } + + final public string $message { + get => $this->get(Messages::MESSAGE->value); + set (string $message) => $this->set(Messages::MESSAGE->value, $message); + } + + final public DateTimeImmutable $date_created { + get => new DateTimeImmutable($this->get(Messages::DATE_CREATED->value)); + set (DateTimeImmutable $date_created) => $this->set(Messages::DATE_CREATED->value, $date_created->format(parent::DATE_FORMAT)); + } + } \ No newline at end of file diff --git a/src/Database/Models/Model.php b/src/Database/Models/Model.php index 5bcc1a7..2031e9a 100644 --- a/src/Database/Models/Model.php +++ b/src/Database/Models/Model.php @@ -4,40 +4,59 @@ use \VV; - use VLW\API\{Client, Endpoints}; + use VLW\Database\Database; - require_once VV::root("src/API/API.php"); - require_once VV::root("src/API/Endpoints.php"); + require_once VV::root("src/Database/Database.php"); abstract class Model { - abstract public static function all(array $params = []): array; - - private array $row; + const DATE_FORMAT = Database::DATE_FORMAT; + + abstract public string $id { get; } + + private static Database $_db; + + protected readonly Database $db; + private bool $_resolved = false; + private array $_row; public function __construct( - public readonly ?Endpoints $endpoint = null, - private readonly array $params = [] + private readonly string $table, + private readonly array $columns, + private readonly array $where ) { - if ($this->endpoint) { - $this->assign(self::first(self::list($endpoint, $params))); + // Establish once and reuse Database connection + $this->db = self::$_db ??= new Database(); + } + + private ?array $row { + get { + // Return existing row data + if ($this->_resolved) { return $this->_row; } + + $this->_resolved = true; + return $this->_row = $this->db + ->from($this->table) + ->where($this->where) + ->limit(1) + ->select($this->columns) + ->fetch_assoc() ?? []; } } - public static function first(array $array): array { - return $array && is_array(array_values($array)[0]) ? $array[0] : $array; - } - - public static function list(Endpoints $endpoint, array $params = []): array { - $resp = (new Client())->call($endpoint->value)->params($params)->get(); - return $resp->ok ? $resp->json() : []; - } - - public function assign(array $row): self { - $this->row = $row; - return $this; + protected static function create(string $table, array $values): bool { + return new Database()->from($table)->insert($values); } public function get(string $key): mixed { return $this->row[$key] ?? null; } + + public function set(string $key, mixed $value): bool { + $this->_row[$key] = $value; + + return $this->db + ->from($this->table) + ->where($this->where) + ->update([$key => $value]); + } } \ No newline at end of file diff --git a/src/Database/Models/Search/Search.php b/src/Database/Models/Search/Search.php index 61e9578..7e8a8de 100644 --- a/src/Database/Models/Search/Search.php +++ b/src/Database/Models/Search/Search.php @@ -3,40 +3,69 @@ namespace VLW\Database\Models\Search; use \VV; + use \vlw\MySQL\Operators; - use VLW\API\Endpoints; + use VLW\Helpers\UUID; + use VLW\Database\Database; use VLW\Database\Models\Model; - use VLW\Database\Tables\Search\{SearchTable, SearchCategoryEnum}; + use VLW\Database\Tables\Search\{Search as SearchTable, SearchTypeEnum}; - require_once VV::root("src/Consts.php"); - require_once VV::root("src/API/Endpoints.php"); + require_once VV::root("src/Helpers/UUID.php"); + require_once VV::root("src/Database/Database.php"); require_once VV::root("src/Database/Models/Model.php"); require_once VV::root("src/Database/Tables/Search/Search.php"); class Search extends Model { + final public static function new(string $query, SearchTypeEnum $type, string $title): self { + $id = UUID::v4(); + + if (!parent::create(SearchTable::TABLE, [ + SearchTable::ID->value => $id, + SearchTable::QUERY->value => $query, + SearchTable::TYPE->value => $type->name, + SearchTable::TITLE->value => $title, + SearchTable::TEXT->value => null, + SearchTable::HREF->value => null + ])) { throw new Exception("Failed to create Search entity"); } + + return new Search($id); + } + + final public static function query(string $query, ?int $limit = null): array { + return array_map(fn(array $search): Search => new Search($search[SearchTable::ID->value]), new Database() + ->from(SearchTable::TABLE) + ->where([SearchTable::QUERY->value => [ + Operators::LIKE->value => "%{$query}%" + ]]) + ->limit($limit) + ->select(SearchTable::ID->value) + ->fetch_all(MYSQLI_ASSOC) + ); + } + public function __construct(public readonly string $id) { - parent::__construct(Endpoints::SEARCH, [ + parent::__construct(SearchTable::TABLE, SearchTable::values(), [ SearchTable::ID->value => $this->id ]); } - public static function all(array $params = []): array { - return array_map(fn(array $item): Search => new Search($item[SearchTable::ID->value]), parent::list(Endpoints::SEARCH, $params)); + final public string $title { + get => $this->get(SearchTable::TITLE->value); + set (string $title) => $this->set(SearchTable::TITLE->value, $title); } - public function title(): ?string { - return $this->get(SearchTable::TITLE->value); + final public ?string $text { + get => $this->get(SearchTable::TEXT->value); + set (?string $text) => $this->set(SearchTable::TEXT->value, $text); } - public function summary(): ?string { - return $this->get(SearchTable::SUMMARY->value); + final public SearchTypeEnum $type { + get => SearchTypeEnum::fromName($this->get(SearchTable::TYPE->value)); + set (SearchTypeEnum $type) => $this->set(SearchTable::TYPE->value, $type->name); } - public function category(): ?SearchCategoryEnum { - return SearchCategoryEnum::tryFromName($this->get(SearchTable::CATEGORY->value)); - } - - public function href(): ?string { - return $this->get(SearchTable::HREF->value); + final public string $href { + get => $this->get(SearchTable::HREF->value); + set (string $href) => $this->set(SearchTable::HREF->value, $href); } } \ No newline at end of file diff --git a/src/Database/Models/Work/Action.php b/src/Database/Models/Work/Action.php index fa1afb2..8e3d52e 100644 --- a/src/Database/Models/Work/Action.php +++ b/src/Database/Models/Work/Action.php @@ -3,49 +3,84 @@ namespace VLW\Database\Models\Work; use \VV; + use \vlw\MySQL\Order; - use VLW\API\Endpoints; + use VLW\Helpers\UUID; + use VLW\Database\Database; use VLW\Database\Models\Model; use VLW\Database\Models\Work\Work; - use VLW\Database\Tables\Work\ActionsTable; + use VLW\Database\Tables\Work\Actions; - require_once VV::root("src/API/Endpoints.php"); + require_once VV::root("src/Helpers/UUID.php"); + require_once VV::root("src/Database/Database.php"); require_once VV::root("src/Database/Models/Model.php"); require_once VV::root("src/Database/Models/Work/Work.php"); require_once VV::root("src/Database/Tables/Work/Actions.php"); class Action extends Model { - public function __construct() { - parent::__construct(); + final public static function new(Work $work): self { + $id = UUID::v4(); + + if (!parent::create(Actions::TABLE, [ + Actions::ID->value => $id, + Actions::REF_WORK_ID->value => $work->id, + Actions::HREF->value => null, + Actions::CLASSLIST->value => null, + Actions::ICON_PREPEND->value => null, + Actions::ICON_APPEND->value => null + ])) { throw new Exception("Failed to create Work Action entity"); } + + return new Action($id); } - public static function all(array $params = []): array { - return array_map(fn(array $item): Action => (new Action())->assign($item), parent::list(Endpoints::WORK_ACTIONS, $params)); + final public static function from(Work $work): array { + return array_map(fn(array $tag): Action => new Action($tag[Actions::ID->value]), new Database() + ->from(Actions::TABLE) + ->where([Actions::REF_WORK_ID->value => $work->id]) + ->order([Actions::ORDER_IDX->value => Order::DESC]) + ->select(Actions::ID->value) + ->fetch_all(MYSQLI_ASSOC) + ); } - public static function from(Work $work): array { - return self::all([ - ActionsTable::REF_WORK_ID->value => $work->id + public function __construct(public readonly string $id) { + parent::__construct(Actions::TABLE, Actions::values(), [ + Actions::ID->value => $this->id ]); } - public function icon_prepended(): ?string { - return $this->get(ActionsTable::ICON_PREPENDED->value); + final public Work $work { + get => $this->get(Actions::REF_WORK_ID->value); + set (Work $work) => $this->set(Actions::REF_WORK_ID->value, $work); } - public function icon_appended(): ?string { - return $this->get(ActionsTable::ICON_APPENDED->value); + final public int $order_idx { + get => $this->get(TimelineTable::ORDER_IDX->value); + set (int $order_idx) => $this->set(TimelineTable::ORDER_IDX->value, $order_idx); } - public function display_text(): string { - return $this->get(ActionsTable::DISPLAY_TEXT->value); + final public ?string $href { + get => $this->get(Actions::HREF->value); + set (?string $href) => $this->set(Actions::HREF->value, $href); } - public function href(): ?string { - return $this->get(ActionsTable::HREF->value); + final public string $text { + get => $this->get(Actions::TEXT->value); + set (string $text) => $this->set(Actions::TEXT->value, $text); } - public function classes(): array { - return $this->get(ActionsTable::CLASS_LIST->value) ? explode(",", $this->get(ActionsTable::CLASS_LIST->value)) : []; + final public ?string $classlist { + get => $this->get(Actions::CLASSLIST->value); + set (?string $classlist) => $this->set(Actions::CLASSLIST->value, $classlist); + } + + final public ?string $icon_prepend { + get => $this->get(Actions::ICON_PREPEND->value); + set (?string $icon_prepend) => $this->set(Actions::ICON_PREPEND->value, $icon_prepend); + } + + final public ?string $icon_append { + get => $this->get(Actions::ICON_APPEND->value); + set (?string $icon_append) => $this->set(Actions::ICON_APPEND->value, $icon_append); } } \ No newline at end of file diff --git a/src/Database/Models/Work/Tag.php b/src/Database/Models/Work/Tag.php index fa6dc2f..d204ab0 100644 --- a/src/Database/Models/Work/Tag.php +++ b/src/Database/Models/Work/Tag.php @@ -3,33 +3,56 @@ namespace VLW\Database\Models\Work; use \VV; + use \vlw\MySQL\Order; - use VLW\API\Endpoints; + use VLW\Helpers\UUID; + use VLW\Database\Database; use VLW\Database\Models\Model; use VLW\Database\Models\Work\Work; - use VLW\Database\Tables\Work\{TagsTable, TagsLabelEnum}; + use VLW\Database\Tables\Work\{Tags, TagsLabelEnum}; - require_once VV::root("src/API/Endpoints.php"); + require_once VV::root("src/Helpers/UUID.php"); + require_once VV::root("src/Database/Database.php"); require_once VV::root("src/Database/Models/Model.php"); require_once VV::root("src/Database/Models/Work/Work.php"); require_once VV::root("src/Database/Tables/Work/Tags.php"); class Tag extends Model { - public function __construct() { - parent::__construct(); + final public static function new(Work $work, TagsLabelEnum $label): self { + $id = UUID::v4(); + + if (!parent::create(TimelineTable::TABLE, [ + TimelineTable::ID->value => $id, + TimelineTable::REF_WORK_ID->value => $work->id, + TimelineTable::LABEL->value => $label->name + ])) { throw new Exception("Failed to create Work Tag entity"); } + + return new Tag($id); } - public static function all(array $params = []): array { - return array_map(fn(array $item): Tag => (new Tag())->assign($item), parent::list(Endpoints::WORK_TAGS, $params)); + final public static function from(Work $work): array { + return array_map(fn(array $tag): Tag => new Tag($tag[Tags::ID->value]), new Database() + ->from(Tags::TABLE) + ->where([Tags::REF_WORK_ID->value => $work->id]) + ->order([Tags::LABEL->value => Order::DESC]) + ->select(Tags::ID->value) + ->fetch_all(MYSQLI_ASSOC) + ); } - public static function from(Work $work): array { - return self::all([ - TagsTable::REF_WORK_ID->value => $work->id + public function __construct(public readonly string $id) { + parent::__construct(Tags::TABLE, Tags::values(), [ + Tags::ID->value => $this->id ]); } - public function label(): TagsLabelEnum { - return TagsLabelEnum::fromName($this->get(TagsTable::LABEL->value)); + final public Work $work { + get => new Work($this->get(Tags::REF_WORK_ID->value)); + set (Work $work) => $this->set(Tags::REF_WORK_ID->value, $work); + } + + final public TagsLabelEnum $label { + get => TagsLabelEnum::fromName($this->get(Tags::LABEL->value)); + set (TagsLabelEnum $pathname) => $this->set(Tags::LABEL->value, $pathname->name); } } \ No newline at end of file diff --git a/src/Database/Models/Work/Timeline.php b/src/Database/Models/Work/Timeline.php index 08c5613..a6833ae 100644 --- a/src/Database/Models/Work/Timeline.php +++ b/src/Database/Models/Work/Timeline.php @@ -3,41 +3,71 @@ namespace VLW\Database\Models\Work; use \VV; + use \vlw\MySQL\Order; - use VLW\API\Endpoints; + use VLW\Helpers\UUID; + use VLW\Database\Database; use VLW\Database\Models\Model; use VLW\Database\Models\Work\Work; - use VLW\Database\Tables\Work\TimelineTable; + use VLW\Database\Tables\Work\Timeline as TimelineTable; - require_once VV::root("src/API/Endpoints.php"); + require_once VV::root("src/Helpers/UUID.php"); + require_once VV::root("src/Database/Database.php"); require_once VV::root("src/Database/Models/Model.php"); require_once VV::root("src/Database/Models/Work/Work.php"); require_once VV::root("src/Database/Tables/Work/Timeline.php"); class Timeline extends Model { + final public static function new(Work $work): self { + $id = UUID::v4(); + + if (!parent::create(TimelineTable::TABLE, [ + TimelineTable::ID->value => $id, + TimelineTable::REF_WORK_ID->value => $work->id, + TimelineTable::YEAR->value => (int) $work->date_created->format("Y"), + TimelineTable::MONTH->value => (int) $work->date_created->format("n"), + TimelineTable::DAY->value => (int) $work->date_created->format("j"), + ])) { throw new Exception("Failed to create Work Timeline entity"); } + + return new Timeline($id); + } + + final public static function all(): array { + return array_map(fn(array $work): Timeline => new Timeline($work[TimelineTable::ID->value]), new Database() + ->from(TimelineTable::TABLE) + ->order([ + TimelineTable::YEAR->value => Order::DESC, + TimelineTable::MONTH->value => Order::DESC, + TimelineTable::DAY->value => Order::DESC + ]) + ->select(TimelineTable::ID->value) + ->fetch_all(MYSQLI_ASSOC) + ); + } + public function __construct(public readonly string $id) { - parent::__construct(Endpoints::WORK_TIMELINE, [ - TimelineTable::REF_WORK_ID->value => $this->id + parent::__construct(TimelineTable::TABLE, TimelineTable::values(), [ + TimelineTable::ID->value => $this->id ]); } - public static function all(array $params = []): array { - return array_map(fn(array $item): Timeline => new Timeline($item[TimelineTable::REF_WORK_ID->value]), parent::list(Endpoints::WORK_TIMELINE, $params)); + final public Work $work { + get => new Work($this->get(TimelineTable::REF_WORK_ID->value)); + set (Work $work) => $this->set(TimelineTable::REF_WORK_ID->value, $work->id); } - public function work(): Work { - return new Work($this->get(TimelineTable::REF_WORK_ID->value)); + final public int $year { + get => $this->get(TimelineTable::YEAR->value); + set (int $year) => $this->set(TimelineTable::YEAR->value, $year); } - public function year(): int { - return $this->get(TimelineTable::YEAR->value); + final public int $month { + get => $this->get(TimelineTable::MONTH->value); + set (int $month) => $this->set(TimelineTable::MONTH->value, $month); } - public function month(): int { - return $this->get(TimelineTable::MONTH->value); - } - - public function day(): int { - return $this->get(TimelineTable::DAY->value); + final public int $day { + get => $this->get(TimelineTable::DAY->value); + set (int $day) => $this->set(TimelineTable::DAY->value, $day); } } \ No newline at end of file diff --git a/src/Database/Models/Work/Work.php b/src/Database/Models/Work/Work.php index f591789..063c9a7 100644 --- a/src/Database/Models/Work/Work.php +++ b/src/Database/Models/Work/Work.php @@ -3,48 +3,88 @@ namespace VLW\Database\Models\Work; use \VV; + use \vlw\MySQL\Order; + use \DateTimeImmutable; - use VLW\API\Endpoints; + use VLW\Helpers\UUID; + use VLW\Database\Database; use VLW\Database\Models\Model; - use VLW\Database\Tables\Work\WorkTable; use VLW\Database\Models\Work\{Tag, Action}; + use VLW\Database\Tables\Work\Work as WorkTable; - require_once VV::root("src/API/Endpoints.php"); + require_once VV::root("src/Helpers/UUID.php"); + require_once VV::root("src/Database/Database.php"); require_once VV::root("src/Database/Models/Model.php"); require_once VV::root("src/Database/Models/Work/Tag.php"); require_once VV::root("src/Database/Tables/Work/Work.php"); require_once VV::root("src/Database/Models/Work/Action.php"); class Work extends Model { - public const DATE_FORMAT = "Y-m-d"; + final public static function new(string $namespace): self { + $id = UUID::v4(); + + if (!parent::create(WorkTable::TABLE, [ + WorkTable::ID->value => $id, + WorkTable::NAMESPACE->value => $namespace, + WorkTable::TITLE->value => $pathname, + WorkTable::SUMMARY->value => null, + WorkTable::DATE_CREATED->value => date(parent::DATE_FORMAT) + ])) { throw new Exception("Failed to create Work entity"); } + + return new Work($id); + } + + final public static function all(): array { + return array_map(fn(array $work): Work => new Work($work[WorkTable::ID->value]), new Database() + ->from(WorkTable::TABLE) + ->order([WorkTable::DATE_CREATED->value => Order::DESC]) + ->select(WorkTable::ID->value) + ->fetch_all(MYSQLI_ASSOC) + ); + } + + final public static function from(string $namespace): ?self { + $work = new Database() + ->from(WorkTable::TABLE) + ->where([WorkTable::NAMESPACE->value => $namespace]) + ->limit(1) + ->select(WorkTable::ID->value) + ->fetch_assoc(); + + return $work ? new Work($work[WorkTable::ID->value]) : null; + } public function __construct(public readonly string $id) { - parent::__construct(Endpoints::WORK, [ + parent::__construct(WorkTable::TABLE, WorkTable::values(), [ WorkTable::ID->value => $this->id ]); } - public static function all(array $params = []): array { - return array_map(fn(array $item): Work => new Work($item[WorkTable::ID->value]), parent::list(Endpoints::WORK, $params)); - } - - public function title(): ?string { - return $this->get(WorkTable::TITLE->value); - } - - public function summary(): string { - return $this->get(WorkTable::SUMMARY->value); - } - - public function created(): \DateTimeImmutable { - return new \DateTimeImmutable($this->get(WorkTable::CREATED->value)); - } - - public function tags(): array { + final public function tags(): array { return Tag::from($this); } - public function actions(): array { + final public function actions(): array { return Action::from($this); } + + final public string $namespace { + get => $this->get(WorkTable::NAMESPACE->value); + set (string $namespace) => $this->set(WorkTable::NAMESPACE->value, $namespace); + } + + final public string $title { + get => $this->get(WorkTable::TITLE->value); + set (string $title) => $this->set(WorkTable::TITLE->value, $title); + } + + final public ?string $summary { + get => $this->get(WorkTable::SUMMARY->value); + set (?string $summary) => $this->set(WorkTable::SUMMARY->value, $summary); + } + + final public DateTimeImmutable $date_created { + get => new DateTimeImmutable($this->get(WorkTable::DATE_CREATED->value)); + set (DateTimeImmutable $date_created) => $this->set(WorkTable::DATE_CREATED->value, $date_created->format(parent::DATE_FORMAT)); + } } \ No newline at end of file diff --git a/src/Database/Seeds/vlw_reflect.sql b/src/Database/Seeds/api.sql similarity index 93% rename from src/Database/Seeds/vlw_reflect.sql rename to src/Database/Seeds/api.sql index 70d08aa..31b6ede 100644 --- a/src/Database/Seeds/vlw_reflect.sql +++ b/src/Database/Seeds/api.sql @@ -14,24 +14,22 @@ CREATE TABLE `acl` ( `method` enum('GET','POST','PUT','PATCH','DELETE') NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +INSERT INTO `acl` (`ref_group`, `ref_endpoint`, `method`) VALUES +(NULL, 'coffee', 'GET'), +(NULL, 'languages', 'GET'), +(NULL, 'update', 'GET'), +(NULL, 'work', 'GET'); + CREATE TABLE `endpoints` ( `id` varchar(255) NOT NULL, `active` tinyint(1) UNSIGNED NOT NULL DEFAULT 1 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; INSERT INTO `endpoints` (`id`, `active`) VALUES -('about/languages', 1), ('coffee', 1), -('coffee/stats', 1), -('messages', 1), -('notes', 1), -('playground/coffee', 1), -('search', 1), +('languages', 1), ('update', 1), -('work', 1), -('work/actions', 1), -('work/tags', 1), -('work/timeline', 1); +('work', 1); CREATE TABLE `groups` ( `id` varchar(255) NOT NULL, diff --git a/src/Database/Seeds/vlw.sql b/src/Database/Seeds/vlw.sql index 14ee184..058351a 100644 --- a/src/Database/Seeds/vlw.sql +++ b/src/Database/Seeds/vlw.sql @@ -8,33 +8,29 @@ SET time_zone = "+00:00"; /*!40101 SET NAMES utf8mb4 */; -CREATE TABLE `about_languages` ( - `id` varchar(255) NOT NULL, - `bytes` int(10) UNSIGNED NOT NULL -) ENGINE=MEMORY DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; - CREATE TABLE `coffee` ( - `id` int(32) NOT NULL + `id` char(36) NOT NULL, + `date_created` datetime NOT NULL DEFAULT current_timestamp() ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; DELIMITER $$ -CREATE TRIGGER `after_coffee_insert` AFTER INSERT ON `coffee` FOR EACH ROW BEGIN +CREATE TRIGGER `coffee_stats_update` AFTER INSERT ON `coffee` FOR EACH ROW BEGIN DECLARE count_recent INT; DECLARE count_average INT; - -- Clear the stats table -- + DELETE FROM coffee_stats; - -- Count the number of rows with timestamp less than 7 days ago + SELECT COUNT(*) INTO count_recent FROM coffee - WHERE id > UNIX_TIMESTAMP(NOW() - INTERVAL 7 DAY); + WHERE date_created > NOW() - INTERVAL 7 DAY; - -- Calculate the average count of rows for each week - SELECT COUNT(*) / COUNT(DISTINCT YEAR(FROM_UNIXTIME(id)), WEEK(FROM_UNIXTIME(id))) + + SELECT COUNT(*) / COUNT(DISTINCT YEAR(date_created), WEEK(date_created)) INTO count_average FROM coffee; - -- Insert the count into the stats table + INSERT INTO coffee_stats (count_week, count_week_average) VALUES (count_recent, count_average); END $$ @@ -43,241 +39,93 @@ DELIMITER ; CREATE TABLE `coffee_stats` ( `count_week` smallint(5) UNSIGNED NOT NULL DEFAULT 0, `count_week_average` smallint(5) UNSIGNED NOT NULL DEFAULT 0 -) ENGINE=MEMORY DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +CREATE TABLE `languages` ( + `id` char(36) NOT NULL, + `name` varchar(255) NOT NULL, + `bytes` int(10) UNSIGNED NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; CREATE TABLE `messages` ( + `id` char(36) NOT NULL, `email` varchar(255) DEFAULT NULL, `message` text NOT NULL, - `timestamp_created` int(32) NOT NULL + `date_created` datetime NOT NULL DEFAULT current_timestamp() ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; CREATE TABLE `search` ( - `query` varchar(2048) NOT NULL, - `id` char(10) NOT NULL, - `title` varchar(2048) DEFAULT NULL, - `summary` varchar(2048) NOT NULL, - `category` enum('WORK') DEFAULT NULL, - `href` varchar(2048) DEFAULT NULL -) ENGINE=MEMORY DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + `id` char(36) NOT NULL, + `query` text NOT NULL, + `type` enum('WORK') NOT NULL, + `title` varchar(255) NOT NULL, + `text` text DEFAULT NULL, + `href` varchar(255) DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; CREATE TABLE `work` ( - `id` varchar(255) NOT NULL, - `title` varchar(255) DEFAULT NULL, - `summary` text NOT NULL, - `created` date NOT NULL + `id` char(36) NOT NULL, + `namespace` varchar(255) NOT NULL, + `title` varchar(255) NOT NULL, + `summary` text DEFAULT NULL, + `date_created` datetime NOT NULL DEFAULT current_timestamp() ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; -INSERT INTO `work` (`id`, `title`, `summary`, `created`) VALUES -('deltaco/asyncapp', 'Campaign pages for Deltaco', 'From design mock-ups created by the SweDeltaco marketing team, I built various web pages for campagins and special events for the nordic IT-distributor\'s website using a custom content injection framework for SharePoint that would later inspire my other project, Vegvisir.', '2020-10-18'), -('deltaco/distit', 'WordPress modules for DistIT', 'Maintenance of a web server for DistIT\'s WordPress website. DistIT is the parent company of SweDeltaco where I was employed for a few years. In addition to server maintenance, I also wrote a few custom WordPress modules for the site, and helped update DistIT\'s custom WordPress theme with new content when required.', '2021-01-21'), -('deltaco/e-charge', 'Product guide for Deltaco E-Charge', 'Front- and back-end for a product configurator from a design mock-up created by one of SweDeltaco\'s graphics designers. The configurator was for Deltaco\'s \"E-charge\"-line of EV-charger products.', '2021-04-06'), -('deltaco/office', 'Product guide for Deltaco Office', 'Product configurator of my own design for Deltaco\'s \"Deltaco Office\"-line of products. The configurator is open source and was implemented by various big-name brands of resellers across the nordics.', '2020-09-04'), -('deltaco/pdf-generator', 'PDF datasheet generator for Deltaco', 'Custom PDF generator for SharePoint 2013', '2020-09-04'), -('deltaco/reseller-form', 'Customer registration form for Deltaco', 'Custom web form which integrated with existing back-end infrastructure to handle new authorized resellers of Deltaco\'s assortment.', '2020-08-04'), -('icellate/genemate', 'Website for GeneMate by iCellate Medical', 'Together with copy written by the marketing team at iCellate, and a new brand new appearance for the company, I helped design a new website and underlying systems for their GeneMate product.', '2022-11-07'), -('icellate/website', 'Website for iCellate Medical', 'Together with the iCellate team, I created a new front-end for the biopharma startup using my Vegvisir framework as the foundation.', '2023-04-19'), -('itg/lan', 'Reservation website for ITG-Sundbyberg', 'Redesign of IT-Gymnasiet Sundbyberg\'s seat reservation system, tournament registration, and information website for their yearly LAN events.', '2014-09-02'), -('itg/upload', 'Web project upload for ITG-Sundbyberg', 'Special school assignment for my Web programming course at IT-Gymnasiet Sundbyberg', '2014-06-11'), -('vlw/bbcb', 'Big Black Coffee Button', 'A very simple PWA for updating the \"coffe tally\" on my about page from anywhere in the world whenever I have a cup of coffee!', '2025-03-13'), -('vlw/camera-obscura', 'cameraobscura.gr', 'Portable front-end website for Camera Obscura GR', '2018-04-25'), -('vlw/collage', 'vlw/collage', 'Create an image where each \"pixel\" is a smaller image of similar color to the original image.', '2021-03-21'), -('vlw/curl', 'cURL wrapper Bash script', 'Public domain shell script optimized to be run in a Visual Studio Code-like interface that wraps cURL on any system with Bash installed. I created it to make manual requests for Reflect endpoints with API Bearer token keys.', '2025-02-09'), -('vlw/dediprison', 'DediPrison', 'Public Minecraft server project together with a friend that had around 20-30 active monthly players.', '2015-10-13'), -('vlw/disneyplus-pip', 'vlw/disneyplus-pip', 'Enable (or rather disable Disney\'s block of) picture-in-picture on disneyplus.com for Chrome.', '2021-01-31'), -('vlw/edkb', 'vlw/edkb', 'Printable keyboard overlay for some controls in Elite Dangerous.', '2021-03-18'), -('vlw/elevent', 'vlw/elevent', 'A small npm module that is intended to add more control over event listeners on HTMLElements with JavaScript. Kind of a superset of addEventListener.', '2024-11-11'), -('vlw/eyeart', 'eyeart.me', 'Website designed by me for the Greek/Swedish photographer, eyeart. The website features albums, a blog, and news pages.', '2014-03-02'), -('vlw/href', 'API-managed permalink redirector/URL shortener', 'This is a simple API-managed permalink generator/URL shortener that I created to hotlink resources for my projects. Permalink destinations can be altered if the target resource needs to be moved. Permalinks can also replace other permalinks with native inheritance at the database-level', '2025-02-09'), -('vlw/ion-musik', 'Website for ION Musik', 'Portable front-end website for Greek musican, ION Musik.', '2015-06-11'), -('vlw/labylib', 'LabyLib', 'Library for controlling LabyMod cosmetics programmatically in Python.', '2020-11-11'), -('vlw/labylib-animated-cape', 'vlw/labylib-animated-cape', 'Minecraft cosmetics scripts for my labylib library that cycles between a set of Labymod capes, creating a (slow) animation.', '2020-11-15'), -('vlw/labylib-chattycape', 'vlw/labylib-chattycape', 'Minecraft cosmetics update script for my labylib library that drew a picture of the last person who sent something in chat.', '2020-11-15'), -('vlw/misskey-microblogger', 'vlw/misskey-microblogger', 'Bot program for Misskey (and compatible forks) that simulates a whole community of independent microbloggers with posts, reactions, and replies. Users have unique personalities, friend groups, partners, and even enemies to whom they will act and respond differently to, sometimes not at all.', '2024-11-09'), -('vlw/monkeydo', 'MonkeyDo', 'A multi-threaded keyframe animation library for JavaScript.', '2021-10-08'), -('vlw/php-age', 'vlw/php-age', 'Asymmetric encryption and decryption of files from PHP with this wrapper for the age command line tool.', '2023-08-22'), -('vlw/php-functionflags', 'vlw/php-functionflags', '', '2023-03-16'), -('vlw/php-globalsnapshot', 'vlw/php-globalsnapshot', '\"Proxy\" PHP superglobals by taking snapshots of current values. The snapshotted state can then be restored at any point.', '2024-04-18'), -('vlw/php-mime-types', 'vlw/php-mime-types', 'Library for resolving a RFC 4288-compatible MIME-type list. After loading a list, files on disk can be queried for types and extensions.', '2024-10-27'), -('vlw/php-mysql', 'vlw/php-mysql', 'Yet another abstraction library for the php-mysql extension. For this library, I was willing to sacrifice most of MySQL\'s flexibility that comes with string interpolation in favor of method chaining that adheres to an SQL-like syntax. For simple DML operations I think it\'s pretty intuitive.', '2023-04-08'), -('vlw/php-sqlite', 'vlw/php-sqlite', 'Abstraction library for common DML queries on an SQLite database with php-sqlite3.', '2023-04-18'), -('vlw/php-xenum', 'vlw/php-xenum', 'Adds a variety of missing quality-of-life methods to PHP 8.0 Enums.', '2023-06-12'), -('vlw/pysheeter', 'Pysheeter', 'Sprite sheet generator for PNGs in Python.', '2020-11-20'), -('vlw/reflect', 'Reflect', 'A weird framework for building REST APIs in PHP with focus on native internal request routing and proxying.', '2022-11-18'), -('vlw/stadia-avatar', 'vlw/stadia-avatar', '', '2021-02-03'), -('vlw/still-alive', 'vlw/still-alive', '', '2021-10-12'), -('vlw/vegvisir', 'Vegvisir', 'Web navigation framework for PHP websites that does on the fly MPA-to-SPA routing between pages on the [open] web seas.', '2022-11-26'), -('vlw/vlw.se', 'vlw.se', 'Can I put my own website here, is that cheating? Maybe, but I think this site counts as the most important thing I\'ve personally created. I\'ve only used my own libraries and frameworks to create this website, so it kind of works as a live demonstration of many of my web projects bundled together.', '2023-06-13'); - CREATE TABLE `work_actions` ( - `ref_work_id` varchar(255) NOT NULL, - `icon_prepended` varchar(255) DEFAULT NULL, - `icon_appended` varchar(255) DEFAULT NULL, - `order_idx` tinyint(1) UNSIGNED NOT NULL DEFAULT 0, - `display_text` varchar(255) NOT NULL, + `id` char(36) NOT NULL, + `ref_work_id` char(36) NOT NULL, + `order_idx` tinyint(3) UNSIGNED NOT NULL DEFAULT 0, `href` varchar(255) DEFAULT NULL, - `class_list` varchar(255) DEFAULT NULL + `text` varchar(255) NOT NULL, + `classlist` varchar(255) DEFAULT NULL, + `icon_prepend` varchar(255) DEFAULT NULL, + `icon_append` varchar(255) DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; -INSERT INTO `work_actions` (`ref_work_id`, `icon_prepended`, `icon_appended`, `order_idx`, `display_text`, `href`, `class_list`) VALUES -('vlw/collage', 'codeberg.svg', NULL, 0, 'view source', 'https://codeberg.org/vlw/collage', NULL), -('vlw/disneyplus-pip', 'codeberg.svg', NULL, 0, 'view source', 'https://codeberg.org/vlw/disneyplus-pip', NULL), -('vlw/edkb', 'codeberg.svg', NULL, 0, 'view source', 'https://codeberg.org/vlw/edkb', NULL), -('vlw/labylib', 'codeberg.svg', NULL, 0, 'view source', 'https://codeberg.org/vlw/labylib', NULL), -('vlw/monkeydo', 'codeberg.svg', NULL, 0, 'view source', 'https://codeberg.org/vlw/monkeydo', NULL), -('vlw/php-age', 'codeberg.svg', NULL, 0, 'view source', 'https://codeberg.org/vlw/php-age', NULL), -('vlw/php-mysql', 'codeberg.svg', NULL, 0, 'view source', 'https://codeberg.org/vlw/php-mysql', NULL), -('vlw/php-xenum', 'codeberg.svg', NULL, 0, 'view source', 'https://codeberg.org/vlw/php-xenum', NULL), -('vlw/pysheeter', 'codeberg.svg', NULL, 0, 'view source', 'https://codeberg.org/vlw/pysheeter', NULL), -('vlw/vlw.se', 'codeberg.svg', NULL, 0, 'view source', 'https://codeberg.org/vlw/vlw.se', NULL), -('vlw/elevent', 'codeberg.svg', NULL, 0, 'view source', 'https://codeberg.org/vlw/elevent', NULL), -('vlw/misskey-microblogger', 'codeberg.svg', NULL, 0, 'view source', 'https://codeberg.org/vlw/misskey-microblogger', ''), -('vlw/php-mime-types', 'codeberg.svg', NULL, 0, 'view source', 'https://codeberg.org/vlw/php-mime-types', NULL), -('vlw/php-globalsnapshot', 'codeberg.svg', NULL, 0, 'view source', 'https://codeberg.org/vlw/php-globalsnapshot', NULL), -('vlw/php-sqlite', 'codeberg.svg', NULL, 0, 'view source', 'https://codeberg.org/vlw/php-sqlite', NULL), -('vlw/php-functionflags', 'codeberg.svg', NULL, 0, 'view source', 'https://codeberg.org/vlw/functionflags', NULL), -('vlw/still-alive', 'codeberg.svg', NULL, 0, 'view source', 'https://codeberg.org/vlw/still-alive', NULL), -('vlw/still-alive', 'star.svg', NULL, 0, 'open demo', 'https://victorwesterlund.github.io/still-alive/', NULL), -('vlw/bbcb', 'codeberg.svg', NULL, 0, 'view source', 'https://codeberg.org/vlw/big-black-coffee-button', NULL), -('vlw/href', 'codeberg.svg', NULL, 0, 'view source', 'https://codeberg.org/vlw/href', NULL), -('vlw/curl', 'codeberg.svg', NULL, 0, 'view source', 'https://codeberg.org/vlw/curl', NULL); - CREATE TABLE `work_tags` ( - `ref_work_id` varchar(255) NOT NULL, - `label` enum('VLW','RELEASE','WEBSITE','REPO') NOT NULL + `id` char(36) NOT NULL, + `ref_work_id` char(36) NOT NULL, + `label` enum('VLW','WEBSITE','REPO') NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; -INSERT INTO `work_tags` (`ref_work_id`, `label`) VALUES -('vlw/eyeart', 'WEBSITE'), -('itg/upload', 'WEBSITE'), -('itg/lan', 'WEBSITE'), -('vlw/dediprison', 'VLW'), -('vlw/dediprison', 'WEBSITE'), -('vlw/ion-musik', 'WEBSITE'), -('vlw/camera-obscura', 'WEBSITE'), -('deltaco/asyncapp', 'REPO'), -('deltaco/reseller-form', 'WEBSITE'), -('deltaco/pdf-generator', 'REPO'), -('deltaco/office', 'WEBSITE'), -('deltaco/distit', 'WEBSITE'), -('deltaco/e-charge', 'WEBSITE'), -('vlw/labylib', 'VLW'), -('vlw/labylib', 'REPO'), -('vlw/edkb', 'VLW'), -('vlw/collage', 'VLW'), -('vlw/collage', 'REPO'), -('vlw/monkeydo', 'VLW'), -('vlw/monkeydo', 'REPO'), -('vlw/disneyplus-pip', 'VLW'), -('vlw/disneyplus-pip', 'REPO'), -('vlw/php-mysql', 'VLW'), -('vlw/php-mysql', 'REPO'), -('vlw/pysheeter', 'VLW'), -('vlw/pysheeter', 'REPO'), -('vlw/php-age', 'VLW'), -('vlw/php-age', 'REPO'), -('vlw/php-xenum', 'VLW'), -('vlw/php-xenum', 'REPO'), -('vlw/reflect', 'VLW'), -('vlw/reflect', 'REPO'), -('vlw/vegvisir', 'VLW'), -('vlw/vegvisir', 'REPO'), -('icellate/website', 'WEBSITE'), -('icellate/genemate', 'WEBSITE'), -('vlw/elevent', 'VLW'), -('vlw/elevent', 'REPO'), -('vlw/misskey-microblogger', 'VLW'), -('vlw/misskey-microblogger', 'REPO'), -('vlw/php-mime-types', 'VLW'), -('vlw/php-mime-types', 'REPO'), -('vlw/php-globalsnapshot', 'VLW'), -('vlw/php-globalsnapshot', 'REPO'), -('vlw/php-sqlite', 'VLW'), -('vlw/php-sqlite', 'REPO'), -('vlw/php-functionflags', 'VLW'), -('vlw/php-functionflags', 'REPO'), -('vlw/still-alive', 'VLW'), -('vlw/still-alive', 'WEBSITE'), -('vlw/stadia-avatar', 'VLW'), -('vlw/stadia-avatar', 'REPO'), -('vlw/labylib-chattycape', 'VLW'), -('vlw/labylib-chattycape', 'REPO'), -('vlw/labylib-animated-cape', 'VLW'), -('vlw/labylib-animated-cape', 'REPO'), -('vlw/bbcb', 'VLW'), -('vlw/bbcb', 'WEBSITE'), -('vlw/href', 'VLW'), -('vlw/curl', 'VLW'); - CREATE TABLE `work_timeline` ( - `ref_work_id` varchar(255) NOT NULL, + `id` char(36) NOT NULL, + `ref_work_id` char(36) NOT NULL, `year` smallint(5) UNSIGNED NOT NULL, `month` tinyint(3) UNSIGNED NOT NULL, `day` tinyint(3) UNSIGNED NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; -INSERT INTO `work_timeline` (`ref_work_id`, `year`, `month`, `day`) VALUES -('deltaco/asyncapp', 2020, 10, 18), -('deltaco/distit', 2021, 1, 21), -('deltaco/e-charge', 2021, 4, 6), -('deltaco/office', 2020, 9, 4), -('deltaco/pdf-generator', 2020, 9, 4), -('deltaco/reseller-form', 2020, 8, 4), -('icellate/genemate', 2022, 11, 7), -('icellate/website', 2023, 4, 19), -('itg/lan', 2014, 9, 2), -('itg/upload', 2014, 6, 11), -('vlw/bbcb', 2025, 3, 13), -('vlw/camera-obscura', 2018, 4, 25), -('vlw/collage', 2021, 3, 21), -('vlw/curl', 2025, 2, 9), -('vlw/dediprison', 2015, 10, 13), -('vlw/disneyplus-pip', 2021, 1, 31), -('vlw/edkb', 2021, 3, 18), -('vlw/elevent', 2024, 11, 11), -('vlw/eyeart', 2014, 3, 2), -('vlw/href', 2025, 2, 9), -('vlw/ion-musik', 2015, 6, 11), -('vlw/labylib', 2020, 11, 11), -('vlw/labylib-animated-cape', 2020, 11, 15), -('vlw/labylib-chattycape', 2020, 11, 15), -('vlw/misskey-microblogger', 2024, 11, 9), -('vlw/monkeydo', 2021, 10, 8), -('vlw/php-age', 2023, 8, 22), -('vlw/php-functionflags', 2023, 3, 16), -('vlw/php-globalsnapshot', 2024, 4, 18), -('vlw/php-mime-types', 2024, 10, 27), -('vlw/php-mysql', 2023, 4, 8), -('vlw/php-sqlite', 2023, 4, 18), -('vlw/php-xenum', 2023, 6, 12), -('vlw/pysheeter', 2020, 11, 20), -('vlw/reflect', 2022, 11, 18), -('vlw/stadia-avatar', 2021, 2, 3), -('vlw/still-alive', 2021, 10, 12), -('vlw/vegvisir', 2022, 11, 26), -('vlw/vlw.se', 2023, 6, 13); - - -ALTER TABLE `about_languages` - ADD PRIMARY KEY (`id`); ALTER TABLE `coffee` ADD PRIMARY KEY (`id`); -ALTER TABLE `search` +ALTER TABLE `languages` ADD PRIMARY KEY (`id`), - ADD KEY `keywords` (`query`(768)); + ADD UNIQUE KEY `name` (`name`); -ALTER TABLE `work` +ALTER TABLE `messages` ADD PRIMARY KEY (`id`); +ALTER TABLE `search` + ADD PRIMARY KEY (`id`); +ALTER TABLE `search` ADD FULLTEXT KEY `query` (`query`); + +ALTER TABLE `work` + ADD PRIMARY KEY (`id`), + ADD UNIQUE KEY `pathname` (`namespace`); + ALTER TABLE `work_actions` + ADD PRIMARY KEY (`id`), ADD KEY `ref_work_id` (`ref_work_id`); ALTER TABLE `work_tags` - ADD KEY `anchor` (`ref_work_id`); + ADD PRIMARY KEY (`id`), + ADD KEY `ref_work_id` (`ref_work_id`); ALTER TABLE `work_timeline` - ADD PRIMARY KEY (`ref_work_id`); + ADD PRIMARY KEY (`id`), + ADD UNIQUE KEY `ref_work_id` (`ref_work_id`); ALTER TABLE `work_actions` diff --git a/src/Database/Tables/About/Languages.php b/src/Database/Tables/About/Languages.php deleted file mode 100644 index 54f4566..0000000 --- a/src/Database/Tables/About/Languages.php +++ /dev/null @@ -1,14 +0,0 @@ -db = new Database(); + $this->languages = []; + } + + // Add languages from all public repositories for profiles in config + public function update(): bool { + foreach(explode(",", $_ENV["service_forgejo"]["profiles"]) as $profile) { + // Resolve user data from username + $user = self::fetch_endpoint(sprintf(self::FORGEJO_ENDPOINT_USER, $profile)); + if (!$this->add_public_repositores($user["id"])) { + return false; + } + } + + $this->save_languages(); + return true; + } + + private function truncate(): bool { + return $this->db->from(Languages::TABLE)->delete(); + } + + // Save languages from $this->languages to the database + private function save_languages(): void { + $this->truncate(); + + foreach ($this->languages as $language => $bytes) { + Language::new($language, $bytes); + } + } + + // Fetch and add languages to total from a fully-qualified Forgejo URL + private function add_repository_languages(string $url): void { + foreach(self::fetch_json($url) as $language => $bytes) { + // Create key for language if it doesn't exist + if (!array_key_exists($language, $this->languages)) { + $this->languages[$language] = 0; + } + + // Add bytes to language in total + $this->languages[$language] += $bytes; + } + } + + // Tally languages from public repositories for user id + private function add_public_repositores(int $uid): bool { + $resp = self::fetch_endpoint(sprintf(self::FORGEJO_ENDPOINT_SEARCH, $uid)); + + // Bail out if request failed or if response indicated a problem + if (!$resp or $resp["ok"] === false) { + return false; + } + + // Add langauges for each public repository + foreach ($resp["data"] as $repo) { + $this->add_repository_languages($repo["languages_url"]); + } + + return true; + } + } \ No newline at end of file diff --git a/src/Helpers/GenerateSearch.php b/src/Helpers/GenerateSearch.php new file mode 100644 index 0000000..3b05c63 --- /dev/null +++ b/src/Helpers/GenerateSearch.php @@ -0,0 +1,56 @@ +db = new Database(); + } + + public function generate(): bool { + $this->truncate(); + + return $this->index_work(); + } + + private function truncate(): bool { + return $this->db->from(SearchTable::TABLE)->delete(); + } + + private function index_work(): bool { + foreach (Work::all() as $work) { + // Construct a space separated fulltext query string from work entity data + $query = strtolower(implode(" ", [ + $work->title, + $work->summary ?? "", + $work->date_created->format("Y"), + $work->date_created->format("n"), + $work->date_created->format("j"), + SearchTypeEnum::WORK->name + ])); + + $search = Search::new($query, SearchTypeEnum::WORK, $work->title); + if (!$search) { return false; } + + $search->text = $work->summary; + // Use href from first Work Action if set or default to "about" page from namespace + $search->href = $work->actions() ? $work->actions()[0]->href : "/work/{$work->namespace}"; + } + + return true; + } + } \ No newline at end of file diff --git a/src/Helpers/GenerateTimeline.php b/src/Helpers/GenerateTimeline.php new file mode 100644 index 0000000..54dfc37 --- /dev/null +++ b/src/Helpers/GenerateTimeline.php @@ -0,0 +1,36 @@ +db = new Database(); + } + + public function generate(): bool { + $this->truncate(); + + foreach (Work::all() as $work) { + if (!Timeline::new($work)) { return false; }; + } + + return true; + } + + private function truncate(): bool { + return $this->db->from(TimelineTable::TABLE)->delete(); + } + } \ No newline at end of file diff --git a/src/Helpers/UUID.php b/src/Helpers/UUID.php new file mode 100644 index 0000000..1b3b46e --- /dev/null +++ b/src/Helpers/UUID.php @@ -0,0 +1,25 @@ +