'caveat', 'name' => 'Caveat', 'family' => '"Caveat Variable", "Caveat"', 'scale' => 0.82], ['id' => 'kalam', 'name' => 'Kalam', 'family' => '"Kalam"', 'scale' => 0.81], ['id' => 'patrick', 'name' => 'Patrick Hand', 'family' => '"Patrick Hand"', 'scale' => 0.8], ['id' => 'shadows', 'name' => 'Shadows Into Light', 'family' => '"Shadows Into Light"', 'scale' => 0.7], ['id' => 'architect', 'name' => "Architect's Daughter",'family' => '"Architects Daughter"', 'scale' => 0.68], ['id' => 'indie', 'name' => 'Indie Flower', 'family' => '"Indie Flower"', 'scale' => 0.67] ]; public const DEFAULT_HAND = 'caveat'; /** @return string[] ids of every hand on offer */ public static function getHandIds(): array { return array_column(self::HANDS, 'id'); } //The dictionaries shipped in resources/lang. Listed rather than globbed: //Translator resolves its folder relative to the calling script, and the //settings panel needs the list on a page load either way. public const LANGUAGES = ['en', 'fr']; /* "Never happened", for the TIMESTAMP columns declared DEFAULT 0. * * Not date(TIMESTAMP_FORMAT, 0): that formats the epoch in the session's * timezone, which east of Greenwich lands before 1970-01-01 00:00:01 UTC - * below what a MySQL TIMESTAMP can hold - and is rejected outright under * STRICT_TRANS_TABLES. This is the same literal the columns default to. */ public const ZERO_TIMESTAMP = '0000-00-00 00:00:00'; private const MAIN_PAGE = 'index'; private const VITE_APP = 'src/app.js'; private User $oUser; private Journal $oJournal; public function __construct($sProcessPage, $sTimezone) { parent::__construct($sProcessPage, true, $sTimezone); $this->oUser = new User($this->oDb); $this->oLang = new Translator($this->oUser->getLang(), self::DEFAULT_LANG); $this->oJournal = new Journal($this->oDb, $this->oUser); } public function getUser(): User { return $this->oUser; } public function getJournal(): Journal { return $this->oJournal; } protected function install() { $this->oDb->install(); } protected function getSqlOptions() { return [ 'tables' => [ User::USER_TABLE => ['name', 'email', 'password', 'token', 'token_exp', 'language', 'timezone', 'hand', 'clearance'], Journal::ENTRY_TABLE=> [Db::getId(User::USER_TABLE), 'content', 'status', 'started_on', 'closed_on', 'timezone'] ], 'types' => [ 'name' => 'VARCHAR(100) NOT NULL', 'email' => 'VARCHAR(320) NOT NULL', 'password' => "VARCHAR(255) NOT NULL DEFAULT ''", 'token' => "VARCHAR(64) NOT NULL DEFAULT ''", 'token_exp' => 'TIMESTAMP DEFAULT 0', 'hand' => "VARCHAR(20) NOT NULL DEFAULT '".self::DEFAULT_HAND."'", 'language' => 'VARCHAR(2)', 'timezone' => 'CHAR(64) NOT NULL', //see mysql.time_zone_name 'clearance' => 'TINYINT(1) DEFAULT '.User::CLEARANCE_USER, 'content' => 'LONGTEXT', 'status' => 'VARCHAR(10)', 'started_on'=> 'TIMESTAMP DEFAULT 0', //DEFAULT 0 removes auto-set to current time 'closed_on' => 'TIMESTAMP DEFAULT 0' ], 'constraints' => [ User::USER_TABLE => 'UNIQUE KEY `uni_email` (`email`)', //The book is always read as "this user's entries, in order", //so the reading order is indexed rather than the id alone. Journal::ENTRY_TABLE=> ['INDEX `idx_user_entry` (`id_user`, `id_entry`)', 'INDEX `idx_user_date` (`id_user`, `started_on`)'] ], //Deliberately no 'cascading_delete': Db cascades by reusing the same //id in the linked table, which would delete unrelated rows here. //The generated foreign keys already guard referential integrity. ]; } /* Pages & API */ public function getAppMainPage(string $sCsrfToken = ''): string { $asViteAssets = $this->getViteAssets(); return parent::getMainPage( [ 'user' => $this->oUser->getUserInfo(), 'consts' => [ 'title' => self::PROJECT_NAME, 'languages' => self::LANGUAGES, 'hands' => self::HANDS, 'chunk_size' => Journal::CHUNK_SIZE, 'default_timezone' => Settings::TIMEZONE, 'autosave_delay' => 1200, //ms of stillness before a save 'autosave_max_wait' => 8000, //ms of continuous typing before a forced save 'csrf_token' => $sCsrfToken ] ], self::MAIN_PAGE, [ 'tags' => [ 'language' => $this->oLang->getLanguage(), 'title' => self::PROJECT_NAME, 'app_entry' => $asViteAssets['app'] ], 'instances' => [ 'css' => $asViteAssets['css'], 'module' => $asViteAssets['module'] ] ] ); } public function signup(string $sName, string $sEmail, string $sPassword, string $sTimezone): string { $asResult = $this->oUser->signup($sName, $sEmail, $sPassword, $this->oLang->getLanguage(), $sTimezone); return self::getJsonResult($asResult['result'], $asResult['desc_lang_id'], $asResult['data'], $asResult['desc_lang_params']); } public function login(string $sEmail, string $sPassword, string $sTimezone, bool $bRemember): string { $asResult = $this->oUser->login($sEmail, $sPassword, $sTimezone, $bRemember); return self::getJsonResult($asResult['result'], $asResult['desc_lang_id'], $asResult['data'], $asResult['desc_lang_params']); } public function logout(): string { $asResult = $this->oUser->logout(); return self::getJsonResult($asResult['result'], $asResult['desc_lang_id'], $asResult['data']); } public function updateAccount(string $sField, string $sValue): string { $asResult = $this->oUser->updateSettings($sField, $sValue); //A language change has to reach the next page load's translations too. if($asResult['result'] && $sField == 'language') $this->oLang->setLanguage($this->oUser->getLang(), self::DEFAULT_LANG); return self::getJsonResult($asResult['result'], $asResult['desc_lang_id'], $asResult['data'], $asResult['desc_lang_params']); } /* Vite assets */ private function getViteAssets(): array { $sManifestPath = __DIR__.'/../public/.vite/manifest.json'; if(!file_exists($sManifestPath)) { $this->addError('Vite manifest not found - run "npm run dev" or "npm run prod" to build the frontend.'); return ['app' => '', 'css' => [], 'module' => []]; } $asManifest = json_decode(file_get_contents($sManifestPath), true); $asAppImport = $asManifest[self::VITE_APP] ?? []; //Recursive search for chunk imports $asImports = []; $asSeenImports = [self::VITE_APP => true]; $this->appendViteImportedChunks($asManifest, $asAppImport, $asSeenImports, $asImports); //CSS $asCssFiles = []; foreach(array_merge([$asAppImport], $asImports) as $asChunk) { foreach($asChunk['css'] ?? [] as $sCssFile) $asCssFiles[] = $sCssFile; } //Modules $asModuleFiles = []; foreach($asImports as $asImport) { if(str_ends_with($asImport['file'] ?? '', '.js')) $asModuleFiles[] = $asImport['file']; } return [ 'app' => $asAppImport['file'] ?? '', 'css' => self::getViteAssetInstances($asCssFiles), 'module' => self::getViteAssetInstances($asModuleFiles) ]; } private function appendViteImportedChunks($asManifest, $asChunk, &$asSeenImports, &$asImports): void { foreach($asChunk['imports'] ?? [] as $sImport) { if(isset($asSeenImports[$sImport]) || !isset($asManifest[$sImport])) continue; $asSeenImports[$sImport] = true; $this->appendViteImportedChunks($asManifest, $asManifest[$sImport], $asSeenImports, $asImports); $asImports[] = $asManifest[$sImport]; } } private static function getViteAssetInstances(array $asFilePaths): array { return array_map(static function($sFilePath) { return ['filename' => $sFilePath]; }, $asFilePaths); } }