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); } }