233 lines
8.9 KiB
PHP
233 lines
8.9 KiB
PHP
<?php
|
|
|
|
namespace Franzz\MyThoughts;
|
|
|
|
use Franzz\Objects\Db;
|
|
use Franzz\Objects\Main;
|
|
use Franzz\Objects\Translator;
|
|
use Settings;
|
|
|
|
/* Timezones
|
|
* ---------
|
|
* Every request carries the browser's IANA timezone in `t`, which Main applies
|
|
* to PHP and to the MySQL session. Timestamps therefore go in and come out in
|
|
* the reader's own time. On top of that each entry stores the timezone it was
|
|
* written in, so a journal written across a move or a trip still shows each
|
|
* page stamped with the local time of the moment it was written - and the
|
|
* client formats from a UNIX timestamp, never from a preformatted string.
|
|
*/
|
|
class MyThoughts extends Main {
|
|
public const PROJECT_NAME = 'MyThoughts';
|
|
public const DEFAULT_LANG = 'en';
|
|
|
|
/**
|
|
* The hands the book can be written in.
|
|
*
|
|
* One list, here, because two things need it and they must not drift: the
|
|
* settings picker offers it, and updateSettings() validates against it.
|
|
*
|
|
* `scale` is the type size as a fraction of one ruled line. It is not
|
|
* cosmetic - every one of these hands has a different x-height for the same
|
|
* em, so a single size would leave one sitting on the rule and the next
|
|
* floating above it. The values are measured, not chosen.
|
|
*
|
|
* Adding one here also needs its webfont imported in src/scripts/fonts.js.
|
|
*/
|
|
public const HANDS = [
|
|
['id' => '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);
|
|
}
|
|
}
|