1<?php 2 3declare(strict_types=1); 4 5namespace OCA\Notes\Service; 6 7use OCA\Notes\Db\Meta; 8use OCA\Notes\Db\MetaMapper; 9 10/** MetaService. 11 * 12 * The MetaService maintains information about notes that cannot be gathered 13 * from Nextcloud middleware. 14 * 15 * Background: we want to minimize the transfered data size during 16 * synchronization with mobile clients. Therefore, the full note is only sent 17 * to the client if it was updated since last synchronization. For this 18 * purpose, we need to know at which time a file's content was changed. 19 * Unfortunately, Nextcloud does not save this information. Important: the 20 * filemtime is not sufficient for this, since a file's content can be changed 21 * without changing it's filemtime! 22 * 23 * Therefore, the Notes app maintains this information on its own. It is saved 24 * in the database table `notes_meta`. To be honest, we do not store the exact 25 * changed time, but a time `t` that is at some point between the real changed 26 * time and the next synchronization time. However, this is totally sufficient 27 * for this purpose. 28 * 29 * Therefore, on synchronization, the method `MetaService.getAll` is called. 30 * It generates an ETag for each note and compares it with the ETag from 31 * `notes_meta` database table in order to detect changes (or creates an entry 32 * if not existent). If there are changes, the ETag is updated and `LastUpdate` 33 * is set to the current time. The ETag is a hash over all note attributes 34 * (except content, see below). 35 * 36 * But in order to further speed up synchronization, the content is not 37 * compared every time (this would be very expensive!). Instead, a file hook 38 * (see `OCA\Notes\NotesHook`) deletes the meta entry on every file change. As 39 * a consequence, a new entry in `note_meta` is created on next 40 * synchronization. 41 * 42 * Hence, instead of using the real content for generating the note's ETag, it 43 * uses a "content ETag" which is a hash over the content. Additionaly to the 44 * file hooks, this "content ETag" is updated if Nextcloud's "file ETag" has 45 * changed (but again, the "file ETag" is just an indicator, since it is not a 46 * hash over the content). 47 * 48 * All in all, this has some complexity, but we can speed up synchronization 49 * with this approach! :-) 50 */ 51class MetaService { 52 private $metaMapper; 53 private $util; 54 55 public function __construct(MetaMapper $metaMapper, Util $util) { 56 $this->metaMapper = $metaMapper; 57 $this->util = $util; 58 } 59 60 public function deleteByNote(int $id) : void { 61 $this->metaMapper->deleteByNote($id); 62 } 63 64 public function getAll(string $userId, array $notes, bool $forceUpdate = false) : array { 65 // load data 66 $metas = $this->metaMapper->getAll($userId); 67 $metas = $this->getIndexedArray($metas, 'fileId'); 68 $result = []; 69 70 // delete obsolete notes 71 foreach ($metas as $id => $meta) { 72 if (!array_key_exists($id, $notes)) { 73 // DELETE obsolete notes 74 $this->metaMapper->delete($meta); 75 } 76 } 77 78 // insert/update changes 79 foreach ($notes as $id => $note) { 80 if (!array_key_exists($id, $metas)) { 81 // INSERT new notes 82 $meta = $this->createMeta($userId, $note); 83 } else { 84 // UPDATE changed notes 85 $meta = $metas[$id]; 86 if ($this->updateIfNeeded($meta, $note, $forceUpdate)) { 87 $this->metaMapper->update($meta); 88 } 89 } 90 $result[$id] = new MetaNote($note, $meta); 91 } 92 return $result; 93 } 94 95 public function update(string $userId, Note $note) : Meta { 96 $meta = null; 97 try { 98 $meta = $this->metaMapper->findById($userId, $note->getId()); 99 } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { 100 } 101 if ($meta === null) { 102 $meta = $this->createMeta($userId, $note); 103 } elseif ($this->updateIfNeeded($meta, $note, true)) { 104 $this->metaMapper->update($meta); 105 } 106 return $meta; 107 } 108 109 private function getIndexedArray(array $data, string $property) : array { 110 $property = ucfirst($property); 111 $getter = 'get'.$property; 112 $result = []; 113 foreach ($data as $entity) { 114 $result[$entity->$getter()] = $entity; 115 } 116 return $result; 117 } 118 119 private function createMeta(string $userId, Note $note) : Meta { 120 $meta = new Meta(); 121 $meta->setUserId($userId); 122 $meta->setFileId($note->getId()); 123 $meta->setLastUpdate(time()); 124 $this->updateIfNeeded($meta, $note, true); 125 try { 126 $this->metaMapper->insert($meta); 127 } catch (\Throwable $e) { 128 // It's likely that a concurrent request created this entry, too. 129 // We can ignore this, since the result should be the same. 130 // But we log it for being able to detect other problems. 131 // (If this happens often, this may cause performance problems.) 132 $this->util->logger->warning( 133 'Could not insert meta data for note '.$note->getId(), 134 [ 'exception' => $e ] 135 ); 136 } 137 return $meta; 138 } 139 140 private function updateIfNeeded(Meta &$meta, Note $note, bool $forceUpdate) : bool { 141 $generateContentEtag = $forceUpdate || !$meta->getContentEtag(); 142 $fileEtag = $note->getFileEtag(); 143 // a changed File-ETag is an indicator for changed content 144 if ($fileEtag !== $meta->getFileEtag()) { 145 $meta->setFileEtag($fileEtag); 146 $generateContentEtag = true; 147 } 148 // generate new Content-ETag 149 if ($generateContentEtag) { 150 $contentEtag = $this->generateContentEtag($note); // this is expensive 151 if ($contentEtag !== $meta->getContentEtag()) { 152 $meta->setContentEtag($contentEtag); 153 } 154 } 155 // always update ETag based on meta data (not content!) 156 $etag = $this->generateEtag($meta, $note); 157 if ($etag !== $meta->getEtag()) { 158 $meta->setEtag($etag); 159 $meta->setLastUpdate(time()); 160 } 161 return !empty($meta->getUpdatedFields()); 162 } 163 164 // warning: this is expensive 165 private function generateContentEtag(Note $note) : string { 166 try { 167 return Util::retryIfLocked(function () use ($note) { 168 return md5($note->getContent()); 169 }, 3); 170 } catch (\Throwable $t) { 171 $this->util->logger->error( 172 'Could not generate Content Etag for note '.$note->getId(), 173 [ 'exception' => $t ] 174 ); 175 return ''; 176 } 177 } 178 179 // this is not expensive, since we use the content ETag instead of the content itself 180 private function generateEtag(Meta &$meta, Note $note) : string { 181 $data = [ 182 $note->getId(), 183 $note->getTitle(), 184 $note->getModified(), 185 $note->getCategory(), 186 $note->getFavorite(), 187 $note->getReadOnly(), 188 $meta->getContentEtag(), 189 ]; 190 return md5(json_encode($data)); 191 } 192} 193