Files
daydream/lib/Journal.php
T
2026-09-04 01:21:19 +02:00

389 lines
14 KiB
PHP

<?php
namespace Franzz\MyThoughts;
use Franzz\Objects\Db;
use Franzz\Objects\PhpObject;
/**
* The book itself: one continuous, ordered stream of entries per user.
*
* Entries are never split into pages here. Where a page break falls depends on
* the reader's viewport and font size, so pagination is a client-side layout
* concern and the server only ever stores flat text plus the moment it was
* started. Ordering is by id_entry: ids are handed out in writing order, which
* makes them a stable cursor even when two entries share a timestamp.
*/
class Journal extends PhpObject {
public const ENTRY_TABLE = 'entries';
public const STATUS_OPEN = 'open';
public const STATUS_CLOSED = 'closed';
//Entries loaded per request. The book flows, so the client keeps a window
//of entries in memory and extends it when the reader turns past its edge.
public const CHUNK_SIZE = 40;
//An entry left open by a browser that never got to send its close (crash,
//killed tab, lost network) is sealed on the writer's next visit.
private const OPEN_ENTRY_TTL = 60 * 60 * 12;
private const MAX_CONTENT_LENGTH = 262144; //256 KiB of plain text
private Db $oDb;
private User $oUser;
public function __construct(Db &$oDb, User &$oUser) {
parent::__construct(__CLASS__);
$this->oDb = &$oDb;
$this->oUser = &$oUser;
}
/* Reading */
/**
* Everything the book needs to render on load: the tail of the stream (what
* you were last writing), plus every entry's date for the bookmark rail and
* the calendar. Bookmarks stay cheap - id and timestamp only, no content.
*/
public function getBook(): string {
if(!$this->oUser->isLoggedIn()) return MyThoughts::getJsonResult(false, MyThoughts::UNAUTHORIZED);
$this->sealStaleEntries();
$asEntries = $this->getEntryWindow();
return MyThoughts::getJsonResult(true, '', [
'entries' => $asEntries,
'bookmarks' => $this->getBookmarks(),
'has_older' => $this->hasOlderThan($asEntries[0]['id'] ?? 0),
'has_newer' => false
]);
}
public function getEntries(string $sDirection, int $iCursorId): string {
if(!$this->oUser->isLoggedIn()) return MyThoughts::getJsonResult(false, MyThoughts::UNAUTHORIZED);
$asEntries = match($sDirection) {
'before' => $this->getEntryWindow($iCursorId, 'before'),
'after' => $this->getEntryWindow($iCursorId, 'after'),
'around' => $this->getEntryWindow($iCursorId, 'around'),
default => null
};
if($asEntries === null) return MyThoughts::getJsonResult(false, MyThoughts::NOT_FOUND);
return MyThoughts::getJsonResult(true, '', [
'entries' => $asEntries,
'has_older' => $this->hasOlderThan($asEntries[0]['id'] ?? 0),
'has_newer' => $this->hasNewerThan(end($asEntries)['id'] ?? 0)
]);
}
/**
* Resolve a calendar click to a cursor: the first entry written on or after
* the given day, falling back to the last entry before it when that day and
* everything after it is blank.
*/
public function getEntryIdAtDate(string $sDate): string {
if(!$this->oUser->isLoggedIn()) return MyThoughts::getJsonResult(false, MyThoughts::UNAUTHORIZED);
$oDate = \DateTime::createFromFormat('Y-m-d', $sDate);
if(!$oDate) return MyThoughts::getJsonResult(false, MyThoughts::NOT_FOUND);
$sDate = $oDate->format('Y-m-d');
$sMidnight = $sDate.' 00:00:00';
$iEntryId = $this->selectFirstId(['started_on' => $sMidnight], ['started_on' => ' >= '], 'ASC');
if(!$iEntryId) $iEntryId = $this->selectFirstId(['started_on' => $sMidnight], ['started_on' => ' < '], 'DESC');
if(!$iEntryId) return MyThoughts::getJsonResult(false, 'book.no_entry_yet');
return MyThoughts::getJsonResult(true, '', ['id' => (int) $iEntryId]);
}
/* Writing */
/**
* Hand back the entry this session should be writing into: the one still
* open from a reload a minute ago, or a fresh one stamped with now.
*/
public function openEntry(): string {
if(!$this->oUser->isLoggedIn()) return MyThoughts::getJsonResult(false, MyThoughts::UNAUTHORIZED);
$this->sealStaleEntries();
$iEntryId = (int) $this->selectFirstId(['status' => self::STATUS_OPEN], [], 'DESC');
if($iEntryId <= 0) {
$iEntryId = $this->oDb->insertRow(self::ENTRY_TABLE, [
Db::getId(User::USER_TABLE) => $this->oUser->getUserId(),
'content' => '',
'status' => self::STATUS_OPEN,
'started_on' => date(Db::TIMESTAMP_FORMAT),
'closed_on' => MyThoughts::ZERO_TIMESTAMP,
'timezone' => date_default_timezone_get()
]);
if($iEntryId <= 0) return MyThoughts::getJsonResult(false, 'error.commit_db');
}
return MyThoughts::getJsonResult(true, '', ['entry' => $this->getEntryById($iEntryId)]);
}
public function saveEntry(int $iEntryId, string $sContent): string {
if(!$this->oUser->isLoggedIn()) return MyThoughts::getJsonResult(false, MyThoughts::UNAUTHORIZED);
if(!$this->ownsEntry($iEntryId)) return MyThoughts::getJsonResult(false, MyThoughts::NOT_FOUND);
$sContent = self::normaliseContent($sContent);
if($this->oDb->updateRow(self::ENTRY_TABLE, $iEntryId, ['content' => $sContent]) === false) {
return MyThoughts::getJsonResult(false, 'error.commit_db');
}
return MyThoughts::getJsonResult(true, '', ['id' => $iEntryId, 'saved_at' => time()]);
}
/**
* Seal an entry: what was written stays as a journal entry, and an entry
* nobody actually wrote in is dropped rather than left as a blank page.
*/
public function closeEntry(int $iEntryId, string $sContent = '', bool $bHasContent = false): string {
if(!$this->oUser->isLoggedIn()) return MyThoughts::getJsonResult(false, MyThoughts::UNAUTHORIZED);
if(!$this->ownsEntry($iEntryId)) return MyThoughts::getJsonResult(false, MyThoughts::NOT_FOUND);
$asResult = $this->sealEntry($iEntryId, $bHasContent ? $sContent : null);
return MyThoughts::getJsonResult($asResult['result'], $asResult['desc_lang_id'], $asResult['data']);
}
/**
* Shared by the explicit close and by the stale-entry sweep, which runs
* inside other endpoints and so must not emit a response of its own.
*/
private function sealEntry(int $iEntryId, ?string $sContent = null): array {
$asData = ['status' => self::STATUS_CLOSED, 'closed_on' => date(Db::TIMESTAMP_FORMAT)];
//The unload beacon carries the last keystrokes the debounced autosave
//never got to send, so trust it over what is already stored.
if($sContent !== null) $asData['content'] = self::normaliseContent($sContent);
$sStored = $asData['content'] ?? (string) $this->oDb->selectValue(self::ENTRY_TABLE, 'content', $iEntryId);
if(trim($sStored) === '') {
$this->oDb->deleteRow(self::ENTRY_TABLE, $iEntryId);
return MyThoughts::getResult(true, '', ['id' => $iEntryId, 'discarded' => true]);
}
if($this->oDb->updateRow(self::ENTRY_TABLE, $iEntryId, $asData) === false) {
return MyThoughts::getResult(false, 'error.commit_db');
}
return MyThoughts::getResult(true, '', ['entry' => $this->getEntryById($iEntryId), 'discarded' => false]);
}
public function deleteEntry(int $iEntryId): string {
if(!$this->oUser->isLoggedIn()) return MyThoughts::getJsonResult(false, MyThoughts::UNAUTHORIZED);
if(!$this->ownsEntry($iEntryId)) return MyThoughts::getJsonResult(false, MyThoughts::NOT_FOUND);
if(!$this->oDb->deleteRow(self::ENTRY_TABLE, $iEntryId)) return MyThoughts::getJsonResult(false, 'error.commit_db');
return MyThoughts::getJsonResult(true, 'book.entry_deleted', ['id' => $iEntryId]);
}
/* Internals */
/**
* @param int $iCursorId 0 for the newest window
* @param string $sDirection before|after|around, relative to the cursor
*/
private function getEntryWindow(int $iCursorId = 0, string $sDirection = 'latest'): array {
$sIdColumn = Db::getId(self::ENTRY_TABLE);
//"around" is two half-windows so the target entry lands mid-book with
//something to read on either side of it.
if($sDirection == 'around' && $iCursorId > 0) {
$asBefore = $this->selectEntries([$sIdColumn => $iCursorId], [$sIdColumn => ' < '], 'DESC', (int) (self::CHUNK_SIZE / 2));
$asFrom = $this->selectEntries([$sIdColumn => $iCursorId], [$sIdColumn => ' >= '], 'ASC', (int) (self::CHUNK_SIZE / 2));
return array_merge(array_reverse($asBefore), $asFrom);
}
//selectEntries() scopes every read to the logged-in user, so the window
//itself only has to say where in the stream it starts.
$asConstraints = [];
$asOperators = [];
if($iCursorId > 0 && $sDirection == 'before') {
$asConstraints[$sIdColumn] = $iCursorId;
$asOperators[$sIdColumn] = ' < ';
}
elseif($iCursorId > 0 && $sDirection == 'after') {
$asConstraints[$sIdColumn] = $iCursorId;
$asOperators[$sIdColumn] = ' > ';
}
//"after" reads forward; everything else reads backward from the cursor
//and is flipped, so the caller always gets chronological order.
$bForward = ($sDirection == 'after');
$asEntries = $this->selectEntries($asConstraints, $asOperators, $bForward ? 'ASC' : 'DESC', self::CHUNK_SIZE);
return $bForward ? $asEntries : array_reverse($asEntries);
}
private function selectEntries(array $asConstraints, array $asOperators, string $sOrder, int $iLimit): array {
$sIdColumn = Db::getId(self::ENTRY_TABLE);
$asConstraints[Db::getId(User::USER_TABLE)] = $this->oUser->getUserId();
$asRows = $this->oDb->selectRows([
'select' => [$sIdColumn.' AS id', 'content', 'status', 'timezone', 'UNIX_TIMESTAMP(started_on) AS time', 'UNIX_TIMESTAMP(closed_on) AS closed'],
'from' => self::ENTRY_TABLE,
'constraint'=> $asConstraints,
'constOpe' => $asOperators,
'orderBy' => [$sIdColumn => $sOrder],
'limit' => $iLimit
]);
return array_map([self::class, 'castEntry'], $asRows);
}
private function getEntryById(int $iEntryId): array {
$asEntries = $this->selectEntries([Db::getId(self::ENTRY_TABLE) => $iEntryId], [], 'ASC', 1);
return $asEntries[0] ?? [];
}
/**
* The bookmark rail and the calendar both need every entry's date, and
* nothing else - so this deliberately never selects content.
*/
private function getBookmarks(): array {
$sIdColumn = Db::getId(self::ENTRY_TABLE);
$asRows = $this->oDb->selectRows([
'select' => [
$sIdColumn.' AS id',
'UNIX_TIMESTAMP(started_on) AS time',
'timezone',
'status',
'LEFT(content, 60) AS preview'
],
'from' => self::ENTRY_TABLE,
'constraint'=> [Db::getId(User::USER_TABLE) => $this->oUser->getUserId()],
//By when it was written, not by when it was inserted. The id is only
//the tiebreaker for two entries begun in the same second.
'orderBy' => ['started_on' => 'ASC', $sIdColumn => 'ASC']
]);
return array_map(static function(array $asRow): array {
return [
'id' => (int) $asRow['id'],
'time' => (int) $asRow['time'],
'timezone' => $asRow['timezone'],
'status' => $asRow['status'],
'preview' => trim(preg_replace('/\s+/u', ' ', $asRow['preview'] ?? ''))
];
}, $asRows);
}
private function hasOlderThan(int $iEntryId): bool {
$sIdColumn = Db::getId(self::ENTRY_TABLE);
return ($iEntryId > 0) && ($this->selectFirstId([$sIdColumn => $iEntryId], [$sIdColumn => ' < '], 'DESC') !== false);
}
private function hasNewerThan(int $iEntryId): bool {
$sIdColumn = Db::getId(self::ENTRY_TABLE);
return ($iEntryId > 0) && ($this->selectFirstId([$sIdColumn => $iEntryId], [$sIdColumn => ' > '], 'ASC') !== false);
}
/**
* @return int|false Id of the first matching entry in the given order
*/
private function selectFirstId(array $asConstraints, array $asOperators, string $sOrder) {
$sIdColumn = Db::getId(self::ENTRY_TABLE);
$asConstraints[Db::getId(User::USER_TABLE)] = $this->oUser->getUserId();
$asRows = $this->oDb->selectRows([
'select' => [$sIdColumn],
'from' => self::ENTRY_TABLE,
'constraint'=> $asConstraints,
'constOpe' => $asOperators,
'orderBy' => [$sIdColumn => $sOrder],
'limit' => 1
]);
return empty($asRows) ? false : (int) $asRows[0];
}
private function ownsEntry(int $iEntryId): bool {
if($iEntryId <= 0) return false;
$iOwnerId = $this->oDb->selectValue(
self::ENTRY_TABLE,
Db::getId(User::USER_TABLE),
[Db::getId(self::ENTRY_TABLE) => $iEntryId]
);
return ($iOwnerId !== false) && ((int) $iOwnerId === $this->oUser->getUserId());
}
/**
* Close anything the browser abandoned. Without this a stale open entry
* would silently swallow tomorrow's writing into yesterday's timestamp.
*/
private function sealStaleEntries(): void {
$sIdColumn = Db::getId(self::ENTRY_TABLE);
//Db::updateRows only builds equality constraints, so the cutoff has to
//be resolved to a list of ids first.
$asStale = $this->oDb->selectRows([
'select' => [$sIdColumn],
'from' => self::ENTRY_TABLE,
'constraint'=> [
Db::getId(User::USER_TABLE) => $this->oUser->getUserId(),
'status' => self::STATUS_OPEN,
'led' => date(Db::TIMESTAMP_FORMAT, time() - self::OPEN_ENTRY_TTL)
],
'constOpe' => ['led' => ' < ']
]);
foreach($asStale as $iStaleId) $this->sealEntry((int) $iStaleId);
//Blank pages left behind by earlier sessions - nothing was written, so
//they are not entries and should not show up as bookmarks.
$asBlank = $this->oDb->selectRows([
'select' => [$sIdColumn],
'from' => self::ENTRY_TABLE,
'constraint'=> [
Db::getId(User::USER_TABLE) => $this->oUser->getUserId(),
'status' => self::STATUS_CLOSED,
'TRIM(content)' => ''
]
]);
foreach($asBlank as $iBlankId) $this->oDb->deleteRow(self::ENTRY_TABLE, (int) $iBlankId);
}
private static function castEntry(array $asRow): array {
return [
'id' => (int) $asRow['id'],
'content' => (string) $asRow['content'],
'status' => $asRow['status'],
'timezone' => $asRow['timezone'],
'time' => (int) $asRow['time'],
'closed' => (int) $asRow['closed']
];
}
/**
* Plain text in, plain text out: normalise line endings, strip control
* characters the book can never render, and cap the size.
*
* Tab and newline are the two the page does render - the text column sets a
* tab-size and the paginator breaks paragraphs on newlines - so they are
* held back from the sweep. Everything else in \p{C} (other controls, zero
* width joiners, bidi overrides) would either draw nothing or quietly
* corrupt the character offsets the caret is placed with.
*/
private static function normaliseContent(string $sContent): string {
$sContent = str_replace(["\r\n", "\r"], "\n", $sContent);
$sContent = preg_replace('/[^\P{C}\n\t]+/u', '', $sContent) ?? '';
return mb_substr($sContent, 0, self::MAX_CONTENT_LENGTH);
}
}