v3 init push
This commit is contained in:
+386
@@ -0,0 +1,386 @@
|
||||
<?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()],
|
||||
'orderBy' => [$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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user