Item::getChangeSets()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 9
Code Lines 7

Duplication

Lines 9
Ratio 100 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 0
Metric Value
dl 9
loc 9
ccs 3
cts 3
cp 1
rs 9.6666
c 0
b 0
f 0
cc 1
eloc 7
nc 1
nop 0
crap 1
1
<?php
2
3
namespace App;
4
5
use Exception;
6
use Intervention\Image\Constraint;
7
use Intervention\Image\ImageManager;
8
9
class Item
10
{
11
12
    /** The default date granularity is 'exact' (ID 1). */
13
    const DATE_GRANULARITY_DEFAULT = 1;
14
15
    /** @var \App\Db */
16
    private $db;
17
18
    /** @var \StdClass */
19
    private $data;
20
21
    /** @var User */
22
    protected $user;
23
24 11
    public function __construct($id = null, User $user = null)
25
    {
26 11
        $this->db = new Db();
27 11
        if ($id !== null) {
28
            $this->load($id);
29
        }
30 11
        $this->user = $user;
31 11
    }
32
33
    /**
34
     * When Items are loaded as results of a PDO::query(), the values are set as public class
35
     * attributes. We want to do more than just set the value, though, so we don't set any column
36
     * names as attributes of this class, and instead handle them here.
37
     * @param string $varName The name of the class attribute.
38
     * @param mixed $value The value to set the class attribute to.
39
     */
40 1
    public function __set($varName, $value)
41
    {
42 1
        if ($varName === 'id') {
43 1
            $this->load($value);
44
        }
45 1
    }
46
47
    /**
48
     * Set the current user.
49
     * @param User $user
50
     */
51
    public function setUser(User $user)
52
    {
53
        $this->user = $user;
54
    }
55
56
    /**
57
     * @param $id
58
     * @throws Exception
59
     */
60 11
    public function load($id)
61
    {
62 11
        if (!empty($id) && !is_numeric($id)) {
63
            throw new Exception("Not an Item ID: " . print_r($id, true));
64
        }
65
        $sql = 'SELECT items.id, items.title, items.description, items.date, '
66
            . '    items.date_granularity, dg.php_format AS date_granularity_format, '
67
            . '    items.read_group, items.edit_group '
68
            . ' FROM items JOIN date_granularities dg ON dg.id=items.date_granularity '
69 11
            . ' WHERE items.id=:id ';
70 11
        $params = ['id' => $id];
71 11
        $this->data = $this->db->query($sql, $params)->fetch();
72 11
    }
73
74
    /**
75
     * Is this item editable by any of the current user's groups?
76
     *
77
     * @return bool
78
     */
79 11
    public function editable()
80
    {
81 11
        if (!$this->user || $this->user->getId() === false) {
82
            return false;
83
        }
84 11
        if ($this->getId() === false) {
85 11
            return true;
86
        }
87 5
        $editGroupId = $this->getEditGroup()->id;
88 5
        foreach ($this->user->getGroups() as $group) {
89 5
            if ($editGroupId == $group['id']) {
90 5
                return true;
91
            }
92
        }
93
        return false;
94
    }
95
96
    /**
97
     * Save an item's data.
98
     *
99
     * @param string[] $metadata Array of metadata pairs.
100
     * @param string $tagsString CSV string of tags.
101
     * @param string $filename The full filesystem path to a file to attach to this Item. Don't use with $fileContents.
102
     * @param string $fileContents A string to treat as the contents of a file. Don't use with $filename.
103
     * @throws Exception If the item is not editable by the current user.
104
     */
105 11
    public function save($metadata, $tagsString = null, $filename = null, $fileContents = null)
106
    {
107 11
        if (isset($metadata['id'])) {
108 2
            $this->load($metadata['id']);
109
        }
110 11
        if (!$this->editable()) {
111
            throw new Exception("You are not allowed to edit this item.");
112
        }
113 11
        if (empty($metadata['title'])) {
114 6
            $metadata['title'] = 'Untitled';
115
        }
116 11
        if (empty($metadata['description'])) {
117 11
            $metadata['description'] = null;
118
        }
119 11
        if (empty($metadata['date'])) {
120 10
            $metadata['date'] = null;
121
        }
122 11
        if (empty($metadata['date_granularity'])) {
123 11
            $metadata['date_granularity'] = self::DATE_GRANULARITY_DEFAULT;
124
        }
125 11
        if (empty($metadata['edit_group'])) {
126 11
            $metadata['edit_group'] = $this->getEditGroup()->id;
127
        }
128 11
        if (empty($metadata['read_group'])) {
129 11
            $metadata['read_group'] = $this->getReadGroup()->id;
130
        }
131
        $setClause = 'SET title=:title, description=:description, date=:date, '
132 11
            . ' date_granularity=:date_granularity, edit_group=:edit_group, read_group=:read_group ';
133
134
        // Start a transaction. End after the key words and files have been written.
135 11
        $this->db->query('BEGIN');
136
137 11
        if ($this->isLoaded()) {
138
            // Update?
139 5
            $metadata['id'] = $this->getId();
140 5
            $sql = "UPDATE items $setClause WHERE id=:id";
141 5
            $this->db->query($sql, $metadata);
142 5
            $id = $metadata['id'];
143
        } else {
144
            // Or insert?
145 11
            unset($metadata['id']);
146 11
            $sql = "INSERT INTO items $setClause";
147 11
            $this->db->query($sql, $metadata);
148 11
            $id = $this->db->lastInsertId();
149
        }
150 11
        $this->load($id);
151
152
        // Save tags.
153 11
        if (!empty($tagsString)) {
154 2
            $this->db->query("DELETE FROM item_tags WHERE item=:id", ['id' => $id]);
155 2
            $tags = array_map('trim', array_unique(str_getcsv($tagsString)));
156 2
            foreach ($tags as $tag) {
157 2
                $this->db->query("INSERT IGNORE INTO tags SET title=:title", ['title' => $tag]);
158 2
                $selectTagId = "SELECT id FROM tags WHERE title LIKE :title";
159 2
                $tagId = $this->db->query($selectTagId, ['title' => $tag])->fetchColumn();
160 2
                $insertJoin = "INSERT IGNORE INTO item_tags SET item=:item, tag=:tag";
161 2
                $this->db->query($insertJoin, ['item' => $id, 'tag' => $tagId]);
162
            }
163
        }
164
165 11
        $newVer = $this->getVersionCount() + 1;
166
        // Save file contents. If no file contents OR filename are provided, save an empty file.
167 11
        if (!empty($fileContents) || (empty($fileContents) && empty($filename))) {
168 10
            $filesystem = App::getFilesystem();
169 10
            $filesystem->put("storage://" . $this->getFilePath($newVer), $fileContents);
170
        }
171
172
        // Save uploaded file.
173 11
        if (!empty($filename)) {
174 2
            $filesystem = App::getFilesystem();
175 2
            $stream = fopen($filename, 'r+');
176 2
            $filesystem->putStream("storage://" . $this->getFilePath($newVer), $stream);
177 2
            fclose($stream);
178
        }
179
180
        // End the transaction and reload the data from the DB.
181 11
        $this->db->query('COMMIT');
182 11
    }
183
184
    /**
185
     * Whether this file is a text file.
186
     */
187 1
    public function isText($version = null)
188
    {
189 1
        return $this->getMimeType($version) === 'text/plain';
190
    }
191
192
    public function isImage($version = null)
193
    {
194
        return 0 === strpos($this->getMimeType($version), 'image');
195
    }
196
197
    /**
198
     * Get the file's mime type, or false if there's no file.
199
     * @param integer $version
200
     * @return integer|false
201
     * @throws Exception
202
     */
203 5
    public function getMimeType($version = null)
204
    {
205 5
        return $this->performFileSystemOperation('getMimetype', $version);
206
    }
207
208
    /**
209
     * Get the contents of the file.
210
     *
211
     * @param integer $version Which file version to get.
212
     * @return false|string
213
     * @throws Exception
214
     */
215 3
    public function getFileContents($version = null)
216
    {
217 3
        return $this->performFileSystemOperation('read', $version);
218
    }
219
220
    /**
221
     * Perform an operation on the filesystem file.
222
     * @param integer|null $version
223
     * @param string $method The method name, from the MountManager class (one of: 'read' or 'getMimetype').
224
     * @return bool|string
225
     * @throws Exception If the method can't be executed.
226
     */
227 5
    protected function performFileSystemOperation($method, $version)
228
    {
229 5
        if (!$this->isLoaded()) {
230
            return false;
231
        }
232 5
        if (is_null($version)) {
233 5
            $version = $this->getVersionCount();
234
        }
235 5
        $filesystem = App::getFilesystem();
236 5
        $path = "storage://" . $this->getFilePath($version);
237 5
        if ($filesystem->has($path)) {
238 5
            if (!is_callable([$filesystem, $method])) {
239
                throw new Exception("Unable to execute $method on the filesystem");
240
            }
241 5
            return $filesystem->$method($path);
242
        }
243
        return false;
244
    }
245
246
    /**
247
     * Get a local filesystem path to the requested file, creating it if required.
248
     * @param string $format The file format, one of 'o', 'd', or 't'.
249
     * @param int $version The version of the file to fetch.
250
     * @return string The fully-qualified path to the file.
251
     * @throws Exception
252
     */
253 3
    public function getCachePath($format = 'o', $version = null)
254
    {
255 3
        if (is_null($version)) {
256 3
            $version = $this->getVersionCount();
257
        }
258 3
        if ($version > $this->getVersionCount()) {
259
            throw new Exception("Version $version does not exist for Item ".$this->getId());
260
        }
261 3
        $filesystem = App::getFilesystem();
262 3
        $path = $this->getFilePath($version);
263
264
        // Get local filesystem root.
265 3
        $config = new Config();
266 3
        $filesystems = $config->filesystems();
267 3
        $root = realpath($filesystems['cache']['root']);
268
269
        // First of all copy the original file to the cache.
270 3
        $filenameOrig = $this->getId() . '_v' . $version . '_o';
271 3
        if (!$filesystem->has("cache://" . $filenameOrig)) {
272 3
            $filesystem->copy("storage://$path", "cache://" . $filenameOrig);
273
        }
274 3
        $pathnameOrig = $root . DIRECTORY_SEPARATOR . $filenameOrig;
275 3
        if ($format === 'o') {
276 3
            return $pathnameOrig;
277
        }
278
279
        // Then create smaller version if required.
280 1
        $filenameDisplay = $this->getId() . '_v' . $version . '_t';
281 1
        $pathnameDisplay = $root . DIRECTORY_SEPARATOR . $filenameDisplay;
282 1
        $manager = new ImageManager();
283 1
        $image = $manager->make($pathnameOrig);
284 1
        if ($format === 'd') {
285
            // 'Display' size.
286 1
            $width = $image->getWidth();
287 1
            $height = $image->getHeight();
288 1
            $newWidth = ($width > $height) ? 700 : null;
289 1
            $newHeight = ($width > $height) ? null : 700;
290 1
            $image->resize($newWidth, $newHeight, function (Constraint $constraint) {
291 1
                $constraint->aspectRatio();
292 1
                $constraint->upsize();
293 1
            });
294
        } else {
295
            // Thumbnail.
296 1
            $image->fit(200);
297
        }
298 1
        $image->save($pathnameDisplay);
299
300 1
        clearstatcache(false, $pathnameDisplay);
301
302 1
        return $pathnameDisplay;
303
    }
304
305
    public function getFileStream($version = null)
306
    {
307
        if (is_null($version)) {
308
            $version = $this->getVersionCount();
309
        }
310
        $filesystem = App::getFilesystem();
311
        $path = "storage://" . $this->getFilePath($version);
312
        if ($filesystem->has($path)) {
313
            return $filesystem->readStream($path);
314
        }
315
    }
316
317 11
    public function getVersionCount()
318
    {
319 11
        $filesystem = App::getFilesystem();
320 11
        $out = $filesystem->getFilesystem('storage')->listContents($this->getHashedPath());
321 11
        return count($out);
322
    }
323
324 11
    public function getHashedPath()
325
    {
326 11
        $hash = md5($this->getId());
327 11
        return $hash[0] . $hash[1] . '/' . $hash[2] . $hash[3] . '/' . $this->getId();
328
    }
329
330
    /**
331
     * Get the path to a version of the attached file.
332
     * Never has a leading slash, and the last component is the filename.
333
     * The returned filepath may not actually exist.
334
     * @return string The file path, e.g. 'c4/ca/1/v1'.
335
     * @throws Exception
336
     */
337 11
    public function getFilePath($version = null)
338
    {
339 11
        if (is_null($version)) {
340 2
            $version = $this->getVersionCount();
341
        }
342 11
        if (!is_int($version)) {
343
            throw new Exception("Version must be an integer ('$version' was given)");
344
        }
345 11
        return $this->getHashedPath() . '/v' . $version;
346
    }
347
348 1 View Code Duplication
    public function getTags()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
349
    {
350
        $tagsSql = 'SELECT t.id, t.title '
351
            . ' FROM item_tags it JOIN tags t ON (it.tag=t.id) '
352
            . ' WHERE it.item=:id '
353 1
            . ' ORDER BY t.title ASC ';
354 1
        $params = ['id' => $this->getId()];
355 1
        return $this->db->query($tagsSql, $params)->fetchAll();
356
    }
357
358
    public function getTagsString()
359
    {
360
        $out = [];
361
        foreach ($this->getTags() as $tag) {
362
            $out[] = $tag->title;
363
        }
364
        return join(', ', $out);
365
    }
366
367
    /**
368
     * Find out whether an Item is loaded.
369
     * @uses \App\Item::getId() If an Item has an ID, it's considered loaded.
370
     * @return boolean
371
     */
372 11
    public function isLoaded()
373
    {
374 11
        return ($this->getId() !== false);
375
    }
376
377 11
    public function getId()
378
    {
379 11
        return isset($this->data->id) ? (int) $this->data->id : false;
380
    }
381
382 2
    public function getTitle()
383
    {
384 2
        return isset($this->data->title) ? $this->data->title : false;
385
    }
386
387
    public function getDescription()
388
    {
389
        return isset($this->data->description) ? $this->data->description : false;
390
    }
391
392 11
    public function getEditGroup()
393
    {
394 11
        $defaultGroup = ($this->user instanceof User) ? $this->user->getDefaultGroup()->id : User::GROUP_ADMIN;
395 11
        $groupId = isset($this->data->edit_group) ? $this->data->edit_group : $defaultGroup;
396 11
        $editGroup = $this->db->query("SELECT * FROM groups WHERE id=:id", ['id'=>$groupId])->fetch();
397 11
        return $editGroup;
398
    }
399
400 11 View Code Duplication
    public function getReadGroup()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
401
    {
402 11
        $groupId = isset($this->data->read_group) ? $this->data->read_group : User::GROUP_PUBLIC;
403 11
        $readGroup = $this->db->query("SELECT * FROM groups WHERE id=:id", ['id'=>$groupId])->fetch();
404
        //dump($readGroup);exit();
0 ignored issues
show
Unused Code Comprehensibility introduced by
89% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
405 11
        return $readGroup;
406
    }
407
408
    /**
409
     * Get the raw unformatted date string, including the time component. This may have lots of
410
     * trailing zeros, depending on the granularity of this item.
411
     * @return string|boolean The date, or false if there isn't one.
412
     */
413 1
    public function getDate()
414
    {
415
        // For new items, default to the current time.
416 1
        if (!$this->isLoaded()) {
417
            return date('Y-m-d H:i:s');
418
        }
419
        // Otherwise, use what's in the database or false when there's no date.
420 1
        return isset($this->data->date) ? $this->data->date : false;
421
    }
422
423
    /**
424
     * @return string
425
     */
426 1
    public function getDateFormatted()
427
    {
428 1
        if (empty($this->data->date)) {
429
            return '';
430
        }
431 1
        $format = $this->data->date_granularity_format;
432 1
        $date = new \DateTime($this->data->date);
433 1
        return $date->format($format);
434
    }
435
436 1
    public function getDateGranularity()
437
    {
438 1
        return isset($this->data->date_granularity) ? $this->data->date_granularity : self::DATE_GRANULARITY_DEFAULT;
439
    }
440
441 1 View Code Duplication
    public function getChangeSets()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
442
    {
443
        $sql = 'SELECT * FROM changes c'
444
            .' JOIN changesets cs ON c.changeset = cs.id'
445
            .' JOIN users u ON u.id = cs.user'
446
            .' WHERE item = :id'
447 1
            .' ORDER BY datetime DESC, cs.id DESC';
448 1
        return $this->db->query($sql, ['id'=>$this->getId()])->fetchAll();
449
    }
450
}
451