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