v3 init push

This commit is contained in:
2026-09-03 18:28:33 +02:00
parent f719cb2989
commit 361cf93562
112 changed files with 8760 additions and 4873 deletions
+201
View File
@@ -0,0 +1,201 @@
<?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 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', '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',
'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,
'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);
}
}