Completed
Pull Request — master (#188)
by korelstar
30:32
created

NotesService   B

Complexity

Total Complexity 51

Size/Duplication

Total Lines 364
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 2

Importance

Changes 10
Bugs 2 Features 0
Metric Value
wmc 51
lcom 1
cbo 2
dl 0
loc 364
rs 8.3206
c 10
b 2
f 0

17 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 6 1
B getAll() 0 21 5
A get() 0 4 1
A getTags() 0 9 3
B getNote() 0 15 7
A create() 0 12 1
C update() 0 49 7
A favorite() 0 15 4
A delete() 0 5 1
A sanitisePath() 0 20 2
B getSafeTitleFromContent() 0 24 2
A getFileById() 0 8 3
A getFolderForUser() 0 4 1
A getOrCreateFolder() 0 8 2
A generateFileName() 0 20 4
A gatherNoteFiles() 0 14 4
A isNote() 0 11 3

How to fix   Complexity   

Complex Class

Complex classes like NotesService often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use NotesService, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * Nextcloud - Notes
4
 *
5
 * This file is licensed under the Affero General Public License version 3 or
6
 * later. See the COPYING file.
7
 *
8
 * @author Bernhard Posselt <[email protected]>
9
 * @copyright Bernhard Posselt 2012, 2014
10
 */
11
12
namespace OCA\Notes\Service;
13
14
use OCP\Files\FileInfo;
15
use OCP\IL10N;
16
use OCP\Files\IRootFolder;
17
use OCP\Files\Folder;
18
use OCP\ILogger;
19
use OC\Encryption\Exceptions\DecryptionFailedException;
20
use League\Flysystem\FileNotFoundException;
21
use OCA\Notes\Db\Note;
22
23
/**
24
 * Class NotesService
25
 *
26
 * @package OCA\Notes\Service
27
 */
28
class NotesService {
29
30
    private $l10n;
31
    private $root;
32
    private $logger;
33
    private $appName;
34
35
    /**
36
     * @param IRootFolder $root
37
     * @param IL10N $l10n
38
     * @param ILogger $logger
39
     * @param String $appName
40
     */
41
    public function __construct (IRootFolder $root, IL10N $l10n, ILogger $logger, $appName) {
42
        $this->root = $root;
43
        $this->l10n = $l10n;
44
        $this->logger = $logger;
45
        $this->appName = $appName;
46
    }
47
48
49
    /**
50
     * @param string $userId
51
     * @return array with all notes in the current directory
52
     */
53
    public function getAll ($userId){
54
        $notesFolder = $this->getFolderForUser($userId);
55
        $notes = $this->gatherNoteFiles($notesFolder);
56
        $filesById = [];
57
        foreach($notes as $note) {
58
            $filesById[$note->getId()] = $note;
59
        }
60
        $tagger = \OC::$server->getTagManager()->load('files');
61
        if($tagger===null) {
62
            $tags = [];
63
        } else {
64
            $tags = $tagger->getTagsForObjects(array_keys($filesById));
65
        }
66
67
        $notes = [];
68
        foreach($filesById as $id=>$file) {
69
            $notes[] = $this->getNote($file, $notesFolder, array_key_exists($id, $tags) ? $tags[$id] : []);
70
        }
71
72
        return $notes;
73
    }
74
75
76
    /**
77
     * Used to get a single note by id
78
     * @param int $id the id of the note to get
79
     * @param string $userId
80
     * @throws NoteDoesNotExistException if note does not exist
81
     * @return Note
82
     */
83
    public function get ($id, $userId) {
84
        $folder = $this->getFolderForUser($userId);
85
        return $this->getNote($this->getFileById($folder, $id), $folder, $this->getTags($id));
86
    }
87
88
    private function getTags ($id) {
89
        $tagger = \OC::$server->getTagManager()->load('files');
90
        if($tagger===null) {
91
            $tags = [];
92
        } else {
93
            $tags = $tagger->getTagsForObjects([$id]);
94
        }
95
        return array_key_exists($id, $tags) ? $tags[$id] : [];
96
    }
97
    private function getNote($file,$notesFolder,$tags=[]){
98
99
        $id=$file->getId();
100
101
        try{
102
            $note=Note::fromFile($file, $notesFolder, $tags);
103
        }catch(FileNotFoundException $e){
0 ignored issues
show
Bug introduced by
The class League\Flysystem\FileNotFoundException does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
104
            $note = Note::fromException($this->l10n->t('File error').': ('.$file->getName().') '.$e->getMessage(), $file, $notesFolder, array_key_exists($id, $tags) ? $tags[$id] : []);
105
        }catch(DecryptionFailedException $e){
0 ignored issues
show
Bug introduced by
The class OC\Encryption\Exceptions\DecryptionFailedException does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
106
            $note = Note::fromException($this->l10n->t('Encryption Error').': ('.$file->getName().') '.$e->getMessage(), $file, $notesFolder, array_key_exists($id, $tags) ? $tags[$id] : []);
107
        }catch(\Exception $e){
108
            $note = Note::fromException($this->l10n->t('Error').': ('.$file->getName().') '.$e->getMessage(), $file, $notesFolder, array_key_exists($id, $tags) ? $tags[$id] : []);
109
        }
110
        return $note;
111
    }
112
    /**
113
     * Creates a note and returns the empty note
114
     * @param string $userId
115
     * @see update for setting note content
116
     * @return Note the newly created note
117
     */
118
    public function create ($userId) {
119
        $title = $this->l10n->t('New note');
120
        $folder = $this->getFolderForUser($userId);
121
122
        // check new note exists already and we need to number it
123
        // pass -1 because no file has id -1 and that will ensure
124
        // to only return filenames that dont yet exist
125
        $path = $this->generateFileName($folder, $title, "txt", -1);
126
        $file = $folder->newFile($path);
127
128
        return $this->getNote($file, $folder);
129
    }
130
131
132
    /**
133
     * Updates a note. Be sure to check the returned note since the title is
134
     * dynamically generated and filename conflicts are resolved
135
     * @param int $id the id of the note used to update
136
     * @param string $content the content which will be written into the note
137
     * the title is generated from the first line of the content
138
     * @param int $mtime time of the note modification (optional)
139
     * @throws NoteDoesNotExistException if note does not exist
140
     * @return \OCA\Notes\Db\Note the updated note
141
     */
142
    public function update ($id, $content, $userId, $category=null, $mtime=0) {
143
        $notesFolder = $this->getFolderForUser($userId);
144
        $file = $this->getFileById($notesFolder, $id);
145
        $folder = $file->getParent();
146
        $title = $this->getSafeTitleFromContent($content);
147
148
149
        // rename/move file with respect to title/category
150
        // this can fail if access rights are not sufficient or category name is illegal
151
        try {
152
            $currentFilePath = $this->root->getFullPath($file->getPath());
153
            $fileExtension = pathinfo($file->getName(), PATHINFO_EXTENSION);
154
155
            // detect (new) folder path based on category name
156
            if($category===null) {
157
                $basePath = pathinfo($currentFilePath, PATHINFO_DIRNAME);
158
            } else {
159
                $basePath = $notesFolder->getPath();
160
                if(!empty($category)) {
161
                    // sanitise path
162
                    $cats = explode('/', $category);
163
                    $cats = array_map([$this, 'sanitisePath'], $cats);
164
                    $cats = array_filter($cats, function($str) { return !empty($str); });
165
                    $basePath .= '/'.implode('/', $cats);
166
                }
167
                $this->getOrCreateFolder($basePath);
168
            }
169
170
            // assemble new file path
171
            $newFilePath = $basePath . '/' . $this->generateFileName($folder, $title, $fileExtension, $id);
172
173
            // if the current path is not the new path, the file has to be renamed
174
            if($currentFilePath !== $newFilePath) {
175
                $file->move($newFilePath);
176
            }
177
        } catch(\OCP\Files\NotPermittedException $e) {
0 ignored issues
show
Bug introduced by
The class OCP\Files\NotPermittedException does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
178
            $this->logger->error('Moving note '.$id.' ('.$title.') to the desired target is not allowed. Please check the note\'s target category ('.$category.').', ['app' => $this->appName]);
179
        } catch(\Exception $e) {
180
            $this->logger->error('Moving note '.$id.' ('.$title.') to the desired target has failed with a '.get_class($e).': '.$e->getMessage(), ['app' => $this->appName]);
181
        }
182
183
        $file->putContent($content);
184
185
        if($mtime) {
186
            $file->touch($mtime);
187
        }
188
189
        return $this->getNote($file, $notesFolder, $this->getTags($id));
190
    }
191
192
193
    /**
194
     * Set or unset a note as favorite.
195
     * @param int $id the id of the note used to update
196
     * @param boolean $favorite whether the note should be a favorite or not
197
     * @throws NoteDoesNotExistException if note does not exist
198
     * @return boolean the new favorite state of the note
199
     */
200
    public function favorite ($id, $favorite, $userId){
201
        $folder = $this->getFolderForUser($userId);
202
        $file = $this->getFileById($folder, $id);
203
        if(!$this->isNote($file)) {
204
            throw new NoteDoesNotExistException();
205
        }
206
        $tagger = \OC::$server->getTagManager()->load('files');
207
        if($favorite)
208
            $tagger->addToFavorites($id);
209
        else
210
            $tagger->removeFromFavorites($id);
211
212
        $tags = $tagger->getTagsForObjects([$id]);
213
        return array_key_exists($id, $tags) && in_array(\OC\Tags::TAG_FAVORITE, $tags[$id]);
214
    }
215
216
217
    /**
218
     * Deletes a note
219
     * @param int $id the id of the note which should be deleted
220
     * @param string $userId
221
     * @throws NoteDoesNotExistException if note does not
222
     * exist
223
     */
224
    public function delete ($id, $userId) {
225
        $folder = $this->getFolderForUser($userId);
226
        $file = $this->getFileById($folder, $id);
227
        $file->delete();
228
    }
229
230
    // removes characters that are illegal in a file or folder name on some operating systems
231
    private function sanitisePath($str) {
232
        // remove characters which are illegal on Windows (includes illegal characters on Unix/Linux)
233
        // prevents also directory traversal by eliminiating slashes
234
        // see also \OC\Files\Storage\Common::verifyPosixPath(...)
235
        $str = str_replace(['*', '|', '/', '\\', ':', '"', '<', '>', '?'], '', $str);
236
237
        // if mysql doesn't support 4byte UTF-8, then remove those characters
238
        // see \OC\Files\Storage\Common::verifyPath(...)
239
        if (!\OC::$server->getDatabaseConnection()->supports4ByteText()) {
240
            $str = preg_replace('%(?:
241
                \xF0[\x90-\xBF][\x80-\xBF]{2}      # planes 1-3
242
              | [\xF1-\xF3][\x80-\xBF]{3}          # planes 4-15
243
              | \xF4[\x80-\x8F][\x80-\xBF]{2}      # plane 16
244
              )%xs', '', $str);
245
        }
246
247
        // prevent file to be hidden
248
        $str = preg_replace("/^[\. ]+/mu", "", $str);
249
        return trim($str);
250
    }
251
252
    private function getSafeTitleFromContent($content) {
253
        // prepare content: remove markdown characters and empty spaces
254
        $content = preg_replace("/^\s*[*+-]\s+/mu", "", $content); // list item
255
        $content = preg_replace("/^#+\s+(.*?)\s*#*$/mu", "$1", $content); // headline
256
        $content = preg_replace("/^(=+|-+)$/mu", "", $content); // separate line for headline
257
        $content = preg_replace("/(\*+|_+)(.*?)\\1/mu", "$2", $content); // emphasis
258
259
        // sanitize: prevent directory traversal, illegal characters and unintended file names
260
        $content = $this->sanitisePath($content);
261
262
        // generate title from the first line of the content
263
        $splitContent = preg_split("/\R/u", $content, 2);
264
        $title = trim($splitContent[0]);
265
266
        // ensure that title is not empty
267
        if(empty($title)) {
268
            $title = $this->l10n->t('New note');
269
        }
270
271
        // using a maximum of 100 chars should be enough
272
        $title = mb_substr($title, 0, 100, "UTF-8");
273
274
        return $title;
275
    }
276
277
    /**
278
     * @param Folder $folder
279
     * @param int $id
280
     * @throws NoteDoesNotExistException
281
     * @return \OCP\Files\File
282
     */
283
    private function getFileById ($folder, $id) {
284
        $file = $folder->getById($id);
285
286
        if(count($file) <= 0 || !$this->isNote($file[0])) {
287
            throw new NoteDoesNotExistException();
288
        }
289
        return $file[0];
290
    }
291
292
293
    /**
294
     * @param string $userId the user id
295
     * @return Folder
296
     */
297
    private function getFolderForUser ($userId) {
298
        $path = '/' . $userId . '/files/Notes';
299
        return $this->getOrCreateFolder($path);
300
    }
301
302
303
    /**
304
     * Finds a folder and creates it if non-existent
305
     * @param string $path path to the folder
306
     * @return Folder
307
     */
308
    private function getOrCreateFolder($path) {
309
        if ($this->root->nodeExists($path)) {
310
            $folder = $this->root->get($path);
311
        } else {
312
            $folder = $this->root->newFolder($path);
313
        }
314
        return $folder;
315
    }
316
317
318
    /**
319
     * get path of file and the title.txt and check if they are the same
320
     * file. If not the title needs to be renamed
321
     *
322
     * @param Folder $folder a folder to the notes directory
323
     * @param string $title the filename which should be used
324
     * @param string $extension the extension which should be used
325
     * @param int $id the id of the note for which the title should be generated
326
     * used to see if the file itself has the title and not a different file for
327
     * checking for filename collisions
328
     * @return string the resolved filename to prevent overwriting different
329
     * files with the same title
330
     */
331
    private function generateFileName (Folder $folder, $title, $extension, $id) {
332
        $path = $title . '.' . $extension;
333
334
        // if file does not exist, that name has not been taken. Similar we don't
335
        // need to handle file collisions if it is the filename did not change
336
        if (!$folder->nodeExists($path) || $folder->get($path)->getId() === $id) {
337
            return $path;
338
        } else {
339
            // increments name (2) to name (3)
340
            $match = preg_match('/\((?P<id>\d+)\)$/u', $title, $matches);
341
            if($match) {
342
                $newId = ((int) $matches['id']) + 1;
343
                $newTitle = preg_replace('/(.*)\s\((\d+)\)$/u',
344
                    '$1 (' . $newId . ')', $title);
345
            } else {
346
                $newTitle = $title . ' (2)';
347
            }
348
            return $this->generateFileName($folder, $newTitle, $extension, $id);
349
        }
350
    }
351
352
	/**
353
	 * gather note files in given directory and all subdirectories
354
	 * @param Folder $folder
355
	 * @return array
356
	 */
357
	private function gatherNoteFiles ($folder) {
358
		$notes = [];
359
		$nodes = $folder->getDirectoryListing();
360
		foreach($nodes as $node) {
361
			if($node->getType() === FileInfo::TYPE_FOLDER) {
362
				$notes = array_merge($notes, $this->gatherNoteFiles($node));
363
				continue;
364
			}
365
			if($this->isNote($node)) {
366
				$notes[] = $node;
367
			}
368
		}
369
		return $notes;
370
	}
371
372
373
    /**
374
     * test if file is a note
375
     *
376
     * @param \OCP\Files\File $file
377
     * @return bool
378
     */
379
    private function isNote($file) {
380
        $allowedExtensions = ['txt', 'org', 'markdown', 'md', 'note'];
381
382
        if($file->getType() !== 'file') return false;
383
        if(!in_array(
384
            pathinfo($file->getName(), PATHINFO_EXTENSION),
385
            $allowedExtensions
386
        )) return false;
387
388
        return true;
389
    }
390
391
}
392