nextcloud-notes/lib/Service/MetaService.php

174 lines
5.7 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\Notes\Service;
use OCA\Notes\Db\Meta;
use OCA\Notes\Db\MetaMapper;
/** MetaService.
*
* The MetaService maintains information about notes that cannot be gathered
* from Nextcloud middleware.
*
* Background: we want to minimize the transfered data size during
* synchronization with mobile clients. Therefore, the full note is only sent
* to the client if it was updated since last synchronization. For this
* purpose, we need to know at which time a file's content was changed.
* Unfortunately, Nextcloud does not save this information. Important: the
* filemtime is not sufficient for this, since a file's content can be changed
* without changing it's filemtime!
*
* Therefore, the Notes app maintains this information on its own. It is saved
* in the database table `notes_meta`. To be honest, we do not store the exact
* changed time, but a time `t` that is at some point between the real changed
* time and the next synchronization time. However, this is totally sufficient
* for this purpose.
*
* Therefore, on synchronization, the method `MetaService.updateAll` is called.
* It generates an ETag for each note and compares it with the ETag from
* `notes_meta` database table in order to detect changes (or creates an entry
* if not existent). If there are changes, the ETag is updated and `LastUpdate`
* is set to the current time. The ETag is a hash over all note attributes
* (except content, see below).
*
* But in order to further speed up synchronization, the content is not
* compared every time (this would be very expensive!). Instead, a file hook
* (see `OCA\Notes\NotesHook`) deletes the meta entry on every file change. As
* a consequence, a new entry in `note_meta` is created on next
* synchronization.
*
* Hence, instead of using the real content for generating the note's ETag, it
* uses a "content ETag" which is a hash over the content. Additionaly to the
* file hooks, this "content ETag" is updated if Nextcloud's "file ETag" has
* changed (but again, the "file ETag" is just an indicator, since it is not a
* hash over the content).
*
* All in all, this has some complexity, but we can speed up synchronization
* with this approach! :-)
*/
class MetaService {
private $metaMapper;
public function __construct(MetaMapper $metaMapper) {
$this->metaMapper = $metaMapper;
}
public function deleteByNote(int $id) : void {
$this->metaMapper->deleteByNote($id);
}
public function updateAll(string $userId, array $notes, bool $forceUpdate = false) : array {
// load data
$metas = $this->metaMapper->getAll($userId);
$metas = $this->getIndexedArray($metas, 'fileId');
$notes = $this->getIndexedArray($notes, 'id');
// delete obsolete notes
foreach ($metas as $id => $meta) {
if (!array_key_exists($id, $notes)) {
// DELETE obsolete notes
$this->metaMapper->delete($meta);
unset($metas[$id]);
}
}
// insert/update changes
foreach ($notes as $id => $note) {
if (!array_key_exists($id, $metas)) {
// INSERT new notes
$metas[$note->getId()] = $this->createMeta($userId, $note);
} else {
// UPDATE changed notes
$meta = $metas[$id];
if ($this->updateIfNeeded($meta, $note, $forceUpdate)) {
$this->metaMapper->update($meta);
}
}
}
return $metas;
}
public function update(string $userId, Note $note) : void {
$meta = null;
try {
$meta = $this->metaMapper->findById($userId, $note->getId());
} catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
}
if ($meta === null) {
$this->createMeta($userId, $note);
} elseif ($this->updateIfNeeded($meta, $note, true)) {
$this->metaMapper->update($meta);
}
}
private function getIndexedArray(array $data, string $property) : array {
$property = ucfirst($property);
$getter = 'get'.$property;
$result = [];
foreach ($data as $entity) {
$result[$entity->$getter()] = $entity;
}
return $result;
}
private function createMeta(string $userId, Note $note) : Meta {
$meta = new Meta();
$meta->setUserId($userId);
$meta->setFileId($note->getId());
$meta->setLastUpdate(time());
$this->updateIfNeeded($meta, $note, true);
try {
$this->metaMapper->insert($meta);
/* @phan-suppress-next-line PhanUndeclaredClassCatch */
} catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException $e) {
// It's likely that a concurrent request created this entry, too.
// We can ignore this, since the result should be the same.
}
return $meta;
}
private function updateIfNeeded(Meta &$meta, Note $note, bool $forceUpdate) : bool {
$generateContentEtag = $forceUpdate;
$fileEtag = $note->getFileEtag();
// a changed File-ETag is an indicator for changed content
if ($fileEtag !== $meta->getFileEtag()) {
$meta->setFileEtag($fileEtag);
$generateContentEtag = true;
}
// generate new Content-ETag
if ($generateContentEtag) {
$contentEtag = $this->generateContentEtag($note); // this is expensive
if ($contentEtag !== $meta->getContentEtag()) {
$meta->setContentEtag($contentEtag);
}
}
// always update ETag based on meta data (not content!)
$etag = $this->generateEtag($meta, $note);
if ($etag !== $meta->getEtag()) {
$meta->setEtag($etag);
$meta->setLastUpdate(time());
}
return !empty($meta->getUpdatedFields());
}
// warning: this is expensive
private function generateContentEtag(Note $note) : string {
return md5($note->getContent());
}
// this is not expensive, since we use the content ETag instead of the content itself
private function generateEtag(Meta &$meta, Note $note) : string {
$data = [
$note->getId(),
$note->getTitle(),
$note->getModified(),
$note->getCategory(),
$note->getFavorite(),
$meta->getContentEtag(),
];
return md5(json_encode($data));
}
}