Completed
Push — master ( da8f3c...d8ce3e )
by Angus
03:49
created

Tracker_Model::getSites()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 6
nc 1
nop 0
dl 0
loc 8
ccs 0
cts 0
cp 0
crap 2
rs 9.4285
c 0
b 0
f 0
1
<?php declare(strict_types=1); defined('BASEPATH') OR exit('No direct script access allowed');
2
3
class Tracker_Model extends CI_Model {
4
	public $sites;
5
	public $enabledCategories;
6
7 121
	public function __construct() {
8 121
		parent::__construct();
9
10 121
		$this->load->database();
11
12 121
		$this->enabledCategories = [
13
			'reading'      => 'Reading',
14
			'on-hold'      => 'On-Hold',
15
			'plan-to-read' => 'Plan to Read'
16
		];
17 121
		if($this->User_Options->get('category_custom_1') == 'enabled') {
18
			$this->enabledCategories['custom1'] = $this->User_Options->get('category_custom_1_text');
19
		}
20 121
		if($this->User_Options->get('category_custom_2') == 'enabled') {
21
			$this->enabledCategories['custom2'] = $this->User_Options->get('category_custom_2_text');
22
		}
23 121
		if($this->User_Options->get('category_custom_3') == 'enabled') {
24
			$this->enabledCategories['custom3'] = $this->User_Options->get('category_custom_3_text');
25
		}
26
27 121
		require_once(APPPATH.'models/Site_Model.php');
28 121
		$this->sites = new Sites_Model;
29 121
	}
30
31
	/****** GET TRACKER *******/
32
	public function get_tracker_from_user_id(int $userID) {
33
		$query = $this->db
34
			->select('tracker_chapters.*,
35
			          tracker_titles.site_id, tracker_titles.title, tracker_titles.title_url, tracker_titles.latest_chapter, tracker_titles.last_updated AS title_last_updated, tracker_titles.status AS title_status, tracker_titles.last_checked > DATE_SUB(NOW(), INTERVAL 1 WEEK) AS title_active,
36
			          tracker_sites.site, tracker_sites.site_class, tracker_sites.status AS site_status')
37
			->from('tracker_chapters')
38
			->join('tracker_titles', 'tracker_chapters.title_id = tracker_titles.id', 'left')
39
			->join('tracker_sites', 'tracker_sites.id = tracker_titles.site_id', 'left')
40
			->where('tracker_chapters.user_id', $userID)
41
			->where('tracker_chapters.active', 'Y')
42
			->get();
43
44
		$arr = ['series' => [], 'has_inactive' => FALSE];
45
		foreach($this->enabledCategories as $category => $name) {
46
			$arr['series'][$category] = [
47
				'name'         => $name,
48
				'manga'        => [],
49
				'unread_count' => 0
50
			];
51
		}
52
		if($query->num_rows() > 0) {
53
			foreach ($query->result() as $row) {
54
				$is_unread     = intval($row->latest_chapter == $row->current_chapter ? '1' : '0');
55
				$arr['series'][$row->category]['unread_count'] = (($arr['series'][$row->category]['unread_count'] ?? 0) + !$is_unread);
56
				$data = [
57
					'id' => $row->id,
58
					'generated_current_data' => $this->sites->{$row->site_class}->getChapterData($row->title_url, $row->current_chapter),
59
					'generated_latest_data'  => $this->sites->{$row->site_class}->getChapterData($row->title_url, $row->latest_chapter),
60
					'full_title_url'        =>  $this->sites->{$row->site_class}->getFullTitleURL($row->title_url),
61
62
					'new_chapter_exists'    => $is_unread,
63
					'tag_list'              => $row->tags,
64
					'has_tags'              => !empty($row->tags),
65
66
					'title_data' => [
67
						'id'              => $row->title_id,
68
						'title'           => $row->title,
69
						'title_url'       => $row->title_url,
70
						'latest_chapter'  => $row->latest_chapter,
71
						'current_chapter' => $row->current_chapter,
72
						'last_updated'    => $row->title_last_updated,
73
						//NOTE: active is used to warn the user if a title hasn't updated (Maybe due to nobody active tracking it or other reasons).
74
						//      This will ONLY be false when an actively updating series (site enabled & title status = 0) hasn't updated within the past week.
75
						'active'          => ($row->site_status == 'disabled' || in_array($row->title_status, [/*complete*/ 1, /* one-shot */ 2, /* ignored */ 255]) || $row->title_active == 1)
76
					],
77
					'site_data' => [
78
						'id'         => $row->site_id,
79
						'site'       => $row->site,
80
						'status'     => $row->site_status
81
					]
82
				];
83
				$arr['series'][$row->category]['manga'][] = $data;
84
85
				if(!$arr['has_inactive']) $arr['has_inactive'] = !$data['title_data']['active'];
86
			}
87
88
			//CHECK: Is this good for speed?
89
			//NOTE: This does not sort in the same way as tablesorter, but it works better.
90
			switch($this->User_Options->get('list_sort_type')) {
91
				case 'unread':
0 ignored issues
show
Coding Style introduced by
case statements should be defined using a colon.

As per the PSR-2 coding standard, case statements should not be wrapped in curly braces. There is no need for braces, since each case is terminated by the next break.

There is also the option to use a semicolon instead of a colon, this is discouraged because many programmers do not even know it works and the colon is universal between programming languages.

switch ($expr) {
    case "A": { //wrong
        doSomething();
        break;
    }
    case "B"; //wrong
        doSomething();
        break;
    case "C": //right
        doSomething();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
92
					foreach (array_keys($arr['series']) as $category) {
93
						usort($arr['series'][$category]['manga'], function ($a, $b) {
94
							$a_text = strtolower("{$a['new_chapter_exists']} - {$a['title_data']['title']}");
95
							$b_text = strtolower("{$b['new_chapter_exists']} - {$b['title_data']['title']}");
96
97
							if($this->User_Options->get('list_sort_order') == 'asc') {
98
								return $a_text <=> $b_text;
99
							} else {
100
								return $b_text <=> $a_text;
101
							}
102
						});
103
					}
104
					break;
105
106 View Code Duplication
				case 'alphabetical':
1 ignored issue
show
Coding Style introduced by
case statements should be defined using a colon.

As per the PSR-2 coding standard, case statements should not be wrapped in curly braces. There is no need for braces, since each case is terminated by the next break.

There is also the option to use a semicolon instead of a colon, this is discouraged because many programmers do not even know it works and the colon is universal between programming languages.

switch ($expr) {
    case "A": { //wrong
        doSomething();
        break;
    }
    case "B"; //wrong
        doSomething();
        break;
    case "C": //right
        doSomething();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
107
					foreach (array_keys($arr['series']) as $category) {
108
						usort($arr['series'][$category]['manga'], function ($a, $b) {
109
							$a_text = strtolower("{$a['title_data']['title']}");
110
							$b_text = strtolower("{$b['title_data']['title']}");
111
112
							if($this->User_Options->get('list_sort_order') == 'asc') {
113
								return $a_text <=> $b_text;
114
							} else {
115
								return $b_text <=> $a_text;
116
							}
117
						});
118
					}
119
					break;
120
121 View Code Duplication
				case 'my_status':
122
					foreach (array_keys($arr['series']) as $category) {
123
						usort($arr['series'][$category]['manga'], function ($a, $b) {
124
							$a_text = strtolower("{$a['generated_current_data']['number']}");
125
							$b_text = strtolower("{$b['generated_current_data']['number']}");
126
127
							if($this->User_Options->get('list_sort_order') == 'asc') {
128
								return $a_text <=> $b_text;
129
							} else {
130
								return $b_text <=> $a_text;
131
							}
132
						});
133
					}
134
					break;
135
136 View Code Duplication
				case 'latest':
137
					foreach (array_keys($arr['series']) as $category) {
138
						usort($arr['series'][$category]['manga'], function ($a, $b) {
139
							$a_text = strtolower("{$a['generated_latest_data']['number']}");
140
							$b_text = strtolower("{$b['generated_latest_data']['number']}");
141
142
							if($this->User_Options->get('list_sort_order') == 'asc') {
143
								return $a_text <=> $b_text;
144
							} else {
145
								return $b_text <=> $a_text;
146
							}
147
						});
148
					}
149
					break;
150
151
				default:
152
					break;
153
			}
154
		}
155
		return $arr;
156
	}
157
158
	public function getSiteDataFromURL(string $site_url) {
159
		$query = $this->db->select('id, site_class')
160
		                  ->from('tracker_sites')
161
		                  ->where('site', $site_url)
162
		                  ->get();
163
164
		if($query->num_rows() > 0) {
165
			$siteData = $query->row();
166
		}
167
168
		return $siteData ?? FALSE;
169
	}
170
171
	public function getTitleID(string $titleURL, int $siteID, bool $create = TRUE) {
172
		$query = $this->db->select('tracker_titles.id, tracker_titles.title, tracker_titles.title_url, tracker_titles.status, tracker_sites.site_class, (tracker_titles.last_checked > DATE_SUB(NOW(), INTERVAL 3 DAY)) AS active', FALSE)
173
		                  ->from('tracker_titles')
174
		                  ->join('tracker_sites', 'tracker_sites.id = tracker_titles.site_id', 'left')
175
		                  ->where('tracker_titles.title_url', $titleURL)
176
		                  ->where('tracker_titles.site_id', $siteID)
177
		                  ->get();
178
179
		if($query->num_rows() > 0) {
180
			$id = (int) $query->row('id');
181
182
			//This updates inactive series if they are newly added, as noted in https://github.com/DakuTree/manga-tracker/issues/5#issuecomment-247480804
183
			if(((int) $query->row('active')) === 0 && $query->row('status') === 0) {
184
				$titleData = $this->sites->{$query->row('site_class')}->getTitleData($query->row('title_url'));
185
				if(!is_null($titleData['latest_chapter'])) {
186 View Code Duplication
					if($this->updateTitleById((int) $id, $titleData['latest_chapter'])) {
187
						//Make sure last_checked is always updated on successful run.
188
						//CHECK: Is there a reason we aren't just doing this in updateTitleById?
189
						$this->db->set('last_checked', 'CURRENT_TIMESTAMP', FALSE)
190
						         ->where('id', $id)
191
						         ->update('tracker_titles');
192
					}
193
				} else {
194
					log_message('error', "{$query->row('title')} failed to update successfully");
195
				}
196
			}
197
198
			$titleID = $id;
199
		} else {
200
			//TODO: Check if title is valid URL!
201
			if($create) $titleID = $this->addTitle($titleURL, $siteID);
202
		}
203
		if(!$titleID) $titleID = 0;
0 ignored issues
show
Bug introduced by
The variable $titleID does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
204
205
		return $titleID;
206
	}
207
208
	public function updateTracker(int $userID, string $site, string $title, string $chapter) : bool {
209
		$success = FALSE;
210
		if($siteData = $this->Tracker->getSiteDataFromURL($site)) {
211
			//Validate user input
212
			if(!$this->sites->{$siteData->site_class}->isValidTitleURL($title)) {
213
				//Error is already logged via isValidTitleURL
214
				return FALSE;
215
			}
216
			if(!$this->sites->{$siteData->site_class}->isValidChapter($chapter)) {
217
				//Error is already logged via isValidChapter
218
				return FALSE;
219
			}
220
221
			//NOTE: If the title doesn't exist it will be created. This maybe isn't perfect, but it works for now.
222
			$titleID = $this->Tracker->getTitleID($title, (int) $siteData->id);
223
			if($titleID === 0) {
224
				//Something went wrong.
225
				log_message('error', "TitleID = 0 for {$title} @ {$siteData->id}");
226
				return FALSE;
227
			}
228
229
			$idQuery = $this->db->select('id')
230
			                    ->where('user_id', $userID)
231
			                    ->where('title_id', $titleID)
232
			                    ->get('tracker_chapters');
233
			if($idQuery->num_rows() > 0) {
234
				$success = (bool) $this->db->set(['current_chapter' => $chapter, 'active' => 'Y', 'last_updated' => NULL])
235
				                    ->where('user_id', $userID)
236
				                    ->where('title_id', $titleID)
237
				                    ->update('tracker_chapters');
238
239
				if($success) {
240
					$idQueryRow = $idQuery->row();
241
					$this->History->userUpdateTitle((int) $idQueryRow->id, $chapter);
242
				}
243
			} else {
244
				$category = $this->User_Options->get_by_userid('default_series_category', $userID);
245
				$success = (bool) $this->db->insert('tracker_chapters', [
246
					'user_id'         => $userID,
247
					'title_id'        => $titleID,
248
					'current_chapter' => $chapter,
249
					'category'        => $category
250
				]);
251
252
				if($success) {
253
					$this->History->userAddTitle((int) $this->db->insert_id(), $chapter, $category);
0 ignored issues
show
Bug introduced by
The method insert_id() does not exist on CI_DB_query_builder. Did you maybe mean insert()?

This check marks calls to methods that do not seem to exist on an object.

This is most likely the result of a method being renamed without all references to it being renamed likewise.

Loading history...
254
				}
255
			}
256
		}
257
		return $success;
258
	}
259
260
	public function updateTrackerByID(int $userID, int $chapterID, string $chapter) : bool {
261
		$success = (bool) $this->db->set(['current_chapter' => $chapter, 'active' => 'Y', 'last_updated' => NULL])
262
		                    ->where('user_id', $userID)
263
		                    ->where('id', $chapterID)
264
		                    ->update('tracker_chapters');
265
266
		if($success) {
267
			$this->History->userUpdateTitle($chapterID, $chapter);
268
		}
269
		return  $success;
270
	}
271
272 View Code Duplication
	public function deleteTrackerByID(int $userID, int $chapterID) {
273
		//Series are not fully deleted, they are just marked as inactive as to hide them from the user.
274
		//This is to allow user history to function properly.
275
276
		$success = $this->db->set(['active' => 'N', 'last_updated' => NULL])
277
		                    ->where('user_id', $userID)
278
		                    ->where('id', $chapterID)
279
		                    ->update('tracker_chapters');
280
281
		return (bool) $success;
282
	}
283
	private function updateTitleById(int $id, string $latestChapter) {
284
		//FIXME: Really not too happy with how we're doing history stuff here, it just feels messy.
285
		$query = $this->db->select('latest_chapter AS current_chapter')
286
		                  ->from('tracker_titles')
287
		                  ->where('id', $id)
288
		                  ->get();
289
		$row = $query->row();
290
291
		$success = $this->db->set(['latest_chapter' => $latestChapter]) //last_updated gets updated via a trigger if something changes
292
		                    ->where('id', $id)
293
		                    ->update('tracker_titles');
294
295
		//Update History
296
		//NOTE: To avoid doing another query to grab the last_updated time, we just use time() which <should> get the same thing.
297
		//FIXME: The <preferable> solution here is we'd just check against the last_updated time, but that can have a few issues.
298
		$this->History->updateTitleHistory($id, $row->current_chapter, $latestChapter, date('Y-m-d H:i:s'));
299
300
		return (bool) $success;
301
	}
302
	private function updateTitleDataById(int $id, array $titleData) {
0 ignored issues
show
Unused Code introduced by
This method is not used, and could be removed.
Loading history...
303
		$success = $this->db->set($titleData)
304
		                    ->where('id', $id)
305
		                    ->update('tracker_titles');
306
307
		return (bool) $success;
308
	}
309
	private function addTitle(string $titleURL, int $siteID) {
310
		$query = $this->db->select('site, site_class')
311
		                  ->from('tracker_sites')
312
		                  ->where('id', $siteID)
313
		                  ->get();
314
315
		$titleData = $this->sites->{$query->row()->site_class}->getTitleData($titleURL, TRUE);
316
317
		//FIXME: getTitleData can fail, which will in turn cause the below to fail aswell, we should try and account for that
318
		if($titleData) {
319
			$this->db->insert('tracker_titles', array_merge($titleData, ['title_url' => $titleURL, 'site_id' => $siteID]));
320
			$titleID = $this->db->insert_id();
0 ignored issues
show
Bug introduced by
The method insert_id() does not exist on CI_DB_query_builder. Did you maybe mean insert()?

This check marks calls to methods that do not seem to exist on an object.

This is most likely the result of a method being renamed without all references to it being renamed likewise.

Loading history...
321
322
			$this->History->updateTitleHistory((int) $titleID, NULL, $titleData['latest_chapter'], $titleData['last_updated']);
323
		} else {
324
			log_message('error', "getTitleData failed for: {$query->row()->site_class} | {$titleURL}");
325
		}
326
		return $titleID ?? 0;
327
	}
328
329
	/**
330
	 * Checks for any titles that haven't updated in 16 hours and updates them.
331
	 * This is ran every 6 hours via a cron job.
332
	 */
333
	public function updateLatestChapters() {
334
		$query = $this->db->select('
335
				tracker_titles.id,
336
				tracker_titles.title,
337
				tracker_titles.title_url,
338
				tracker_titles.status,
339
				tracker_sites.site,
340
				tracker_sites.site_class,
341
				tracker_sites.status,
342
				tracker_titles.latest_chapter,
343
				tracker_titles.last_updated,
344
				from_unixtime(MAX(auth_users.last_login)) AS timestamp
345
			')
346
			->from('tracker_titles')
347
			->join('tracker_sites', 'tracker_sites.id = tracker_titles.site_id', 'left')
348
			->join('tracker_chapters', 'tracker_titles.id = tracker_chapters.title_id', 'left')
349
			->join('auth_users', 'tracker_chapters.user_id = auth_users.id', 'left')
350
			->where('tracker_sites.status', 'enabled')
351
			->where('tracker_chapters.active', 'Y') //CHECK: Does this apply BEFORE the GROUP BY/HAVING is done?
352
			//Check if title is marked as on-going, and update if latest_chapter isn't set or hasn't updated within last 12 hours
353
			->where('(tracker_titles.status = 0 AND (`latest_chapter` = NULL OR `last_checked` < DATE_SUB(NOW(), INTERVAL 12 HOUR)))', NULL, FALSE) //TODO: Each title should have specific interval time?
354
			//Check if title is marked as complete, and update if it hasn't updated in the last week.
355
			->or_where('(tracker_titles.status = 1 AND `last_checked` < DATE_SUB(NOW(), INTERVAL 1 WEEK))', NULL, FALSE)
356
			//Status 2 (One-shot) & 255 (Ignore) are both not updated intentionally.
357
			->group_by('tracker_titles.id')
358
			->having('timestamp IS NOT NULL')
359
			->having('timestamp > DATE_SUB(NOW(), INTERVAL 120 HOUR)')
360
			->order_by('tracker_titles.title', 'ASC')
361
			->get();
362
363
		if($query->num_rows() > 0) {
364
			foreach ($query->result() as $row) {
365
				print "> {$row->title} <{$row->site_class}>"; //Print this prior to doing anything so we can more easily find out if something went wrong
366
				$titleData = $this->sites->{$row->site_class}->getTitleData($row->title_url);
367
				if(is_array($titleData) && !is_null($titleData['latest_chapter'])) {
368
					//FIXME: "At the moment" we don't seem to be doing anything with TitleData['last_updated'].
369
					//       Should we even use this? Y/N
370 View Code Duplication
					if($this->updateTitleById((int) $row->id, $titleData['latest_chapter'])) {
371
						//Make sure last_checked is always updated on successful run.
372
						//CHECK: Is there a reason we aren't just doing this in updateTitleById?
373
						$this->db->set('last_checked', 'CURRENT_TIMESTAMP', FALSE)
374
						         ->where('id', $row->id)
375
						         ->update('tracker_titles');
376
377
						print " - ({$titleData['latest_chapter']})\n";
378
					}
379
				} else {
380
					log_message('error', "{$row->title} failed to update successfully");
381
					print " - FAILED TO PARSE\n";
382
				}
383
			}
384
		}
385
	}
386
387
	public function updateCustom() {
388
		$sites = $this->getSites();
389
		foreach ($sites as $site) {
390
			if($titleDataList = $this->sites->{$site['site_class']}->doCustomUpdate()) {
391
				foreach ($titleDataList as $titleURL => $titleData) {
392
					print "> {$titleData['title']} <{$site['site_class']}>"; //Print this prior to doing anything so we can more easily find out if something went wrong
393
					if(is_array($titleData) && !is_null($titleData['latest_chapter'])) {
394
						if($titleID = $this->getTitleID($titleURL, (int) $site['id'], FALSE)) {
395 View Code Duplication
							if($this->updateTitleById((int) $titleID, $titleData['latest_chapter'])) {
396
								//Make sure last_checked is always updated on successful run.
397
								//CHECK: Is there a reason we aren't just doing this in updateTitleById?
398
								$this->db->set('last_checked', 'CURRENT_TIMESTAMP', FALSE)
399
								         ->where('id', $titleID)
400
								         ->update('tracker_titles');
401
402
								print " - ({$titleData['latest_chapter']})\n";
403
							}
404
						} else {
405
							log_message('error', "{$titleData['title']} || Title does not exist in DB??");
406
						}
407
					} else {
408
						log_message('error', "{$titleData['title']} failed to custom update successfully");
409
						print " - FAILED TO PARSE\n";
410
					}
411
				}
412
			}
413
		}
414
	}
415
416
	public function exportTrackerFromUserID(int $userID) {
417
		$query = $this->db
418
			->select('tracker_chapters.current_chapter,
419
			          tracker_chapters.category,
420
			          tracker_titles.title_url,
421
			          tracker_sites.site')
422
			->from('tracker_chapters')
423
			->join('tracker_titles', 'tracker_chapters.title_id = tracker_titles.`id', 'left')
424
			->join('tracker_sites', 'tracker_sites.id = tracker_titles.site_id', 'left')
425
			->where('tracker_chapters.user_id', $userID)
426
			->where('tracker_chapters.active', 'Y')
427
			->get();
428
429
		$arr = [];
430
		if($query->num_rows() > 0) {
431
			foreach ($query->result() as $row) {
432
				$arr[$row->category][] = [
433
					'site'            => $row->site,
434
					'title_url'       => $row->title_url,
435
					'current_chapter' => $row->current_chapter
436
				];
437
			}
438
439
			return $arr;
440
		}
441
	}
442
443
	public function importTrackerFromJSON(string $json_string) : array {
444
		//We already know the this is a valid JSON string as it was validated by form_validator.
445
		$json = json_decode($json_string, TRUE);
446
447
		/*
448
		 * 0 = Success
449
		 * 1 = Invalid keys.
450
		 * 2 = Has failed rows
451
		 */
452
		$status = ['code' => 0, 'failed_rows' => []];
453
454
		$categories = array_keys($json);
455
		if(count($categories) === count(array_intersect(['reading', 'on-hold', 'plan-to-read', 'custom1', 'custom2', 'custom3'], $categories))) {
456
			$json_keys = array_keys(call_user_func_array('array_merge', call_user_func_array('array_merge', $json)));
457
458
			if(count($json_keys) === 3 && !array_diff(array('site', 'title_url', 'current_chapter'), $json_keys)) {
459
				foreach($categories as $category) {
460
					foreach($json[$category] as $row) {
461
						$success = $this->updateTracker($this->User->id, $row['site'], $row['title_url'], $row['current_chapter']);
462
						if(!$success) {
463
							$status['code']          = 2;
464
							$status['failed_rows'][] = $row;
465
						}
466
					}
467
				}
468
			} else {
469
				$status['code'] = 1;
470
			}
471
		} else {
472
			$status['code'] = 1;
473
		}
474
		return $status;
475
	}
476
477
	public function deleteTrackerByIDList(array $idList) : array {
478
		/*
479
		 * 0 = Success
480
		 * 1 = Invalid IDs
481
		 */
482
		$status = ['code' => 0];
483
484 View Code Duplication
		foreach($idList as $id) {
485
			if(!(ctype_digit($id) && $this->deleteTrackerByID($this->User->id, (int) $id))) {
486
				$status['code'] = 1;
487
			} else {
488
				//Delete was successful, update history too.
489
				$this->History->userRemoveTitle((int) $id);
490
			}
491
		}
492
493
		return $status;
494
	}
495
496
	public function setCategoryByIDList(array $idList, string $category) : array {
497
		/*
498
		 * 0 = Success
499
		 * 1 = Invalid IDs
500
		 * 2 = Invalid category
501
		 */
502
		$status = ['code' => 0];
503
504
		if(in_array($category, array_keys($this->enabledCategories))) {
505 View Code Duplication
			foreach($idList as $id) {
506
				if(!(ctype_digit($id) && $this->setCategoryTrackerByID($this->User->id, (int) $id, $category))) {
507
					$status['code'] = 1;
508
				} else {
509
					//Category update was successful, update history too.
510
					$this->History->userUpdateCategory((int) $id, $category);
511
				}
512
			}
513
		} else {
514
			$status['code'] = 2;
515
		}
516
517
		return $status;
518
	}
519 View Code Duplication
	public function setCategoryTrackerByID(int $userID, int $chapterID, string $category) : bool {
520
		$success = $this->db->set(['category' => $category, 'active' => 'Y', 'last_updated' => NULL])
521
		                    ->where('user_id', $userID)
522
		                    ->where('id', $chapterID)
523
		                    ->update('tracker_chapters');
524
525
		return (bool) $success;
526
	}
527
528
	public function updateTagsByID(int $userID, int $chapterID, string $tag_string) : bool {
529
		$success = FALSE;
530
		if(preg_match("/^[a-z0-9\\-_,:]{0,255}$/", $tag_string)) {
531
			$success = (bool) $this->db->set(['tags' => $tag_string, 'active' => 'Y', 'last_updated' => NULL])
532
			                           ->where('user_id', $userID)
533
			                           ->where('id', $chapterID)
534
			                           ->update('tracker_chapters');
535
		}
536
537
		if($success) {
538
			//Tag update was successful, update history
539
			$this->History->userUpdateTags($chapterID, $tag_string);
540
		}
541
		return $success;
542
	}
543
544
	public function favouriteChapter(int $userID, string $site, string $title, string $chapter) : array {
545
		$success = array(
546
			'status' => 'Something went wrong',
547
			'bool'   => FALSE
548
		);
549
		if($siteData = $this->Tracker->getSiteDataFromURL($site)) {
550
			//Validate user input
551
			if(!$this->sites->{$siteData->site_class}->isValidTitleURL($title)) {
552
				//Error is already logged via isValidTitleURL
553
				$success['status'] = 'Title URL is not valid';
554
				return $success;
555
			}
556
			if(!$this->sites->{$siteData->site_class}->isValidChapter($chapter)) {
557
				//Error is already logged via isValidChapter
558
				$success['status'] = 'Chapter URL is not valid';
559
				return $success;
560
			}
561
562
			//NOTE: If the title doesn't exist it will be created. This maybe isn't perfect, but it works for now.
563
			$titleID = $this->Tracker->getTitleID($title, (int) $siteData->id);
564
			if($titleID === 0) {
565
				//Something went wrong.
566
				log_message('error', "TitleID = 0 for {$title} @ {$siteData->id}");
567
				return $success;
568
			}
569
570
			//We need the series to be tracked
571
			$idCQuery = $this->db->select('id')
572
			                    ->where('user_id', $userID)
573
			                    ->where('title_id', $titleID)
574
			                    ->get('tracker_chapters');
575
			if($idCQuery->num_rows() > 0) {
576
				$idCQueryRow = $idCQuery->row();
577
578
				//Check if it is already favourited
579
				$idFQuery = $this->db->select('id')
580
				                    ->where('chapter_id', $idCQueryRow->id)
581
				                    ->where('chapter', $chapter)
582
				                    ->get('tracker_favourites');
583
				if($idFQuery->num_rows() > 0) {
584
					//Chapter is already favourited, so remove it from DB
585
					$idFQueryRow = $idFQuery->row();
586
587
					$isSuccess = (bool) $this->db->where('id', $idFQueryRow->id)
588
					                           ->delete('tracker_favourites');
589
590
					if($isSuccess) {
591
						$success = array(
592
							'status' => 'Unfavourited',
593
							'bool'   => TRUE
594
						);
595
						$this->History->userRemoveFavourite((int) $idCQueryRow->id, $chapter);
596
					}
597
				} else {
598
					//Chapter is not favourited, so add to DB.
599
					$isSuccess = (bool) $this->db->insert('tracker_favourites', [
600
						'chapter_id'      => $idCQueryRow->id,
601
						'chapter'         => $chapter,
602
						'updated_at'      => date('Y-m-d H:i:s')
603
					]);
604
605
					if($isSuccess) {
606
						$success = array(
607
							'status' => 'Favourited',
608
							'bool'   => TRUE
609
						);
610
						$this->History->userAddFavourite((int) $idCQueryRow->id, $chapter);
611
					}
612
				}
613
			} else {
614
				$success['status'] = 'Series needs to be tracked before we can favourite chapters';
615
			}
616
		}
617
		return $success;
618
	}
619
	public function getFavourites(int $page) : array {
620
		$rowsPerPage = 50;
621
		$query = $this->db
622
			->select('SQL_CALC_FOUND_ROWS
623
			          tt.title, tt.title_url,
624
			          ts.site, ts.site_class,
625
			          tf.chapter, tf.updated_at', FALSE)
626
			->from('tracker_favourites AS tf')
627
			->join('tracker_chapters AS tc', 'tf.chapter_id = tc.id', 'left')
628
			->join('tracker_titles AS tt', 'tc.title_id = tt.id', 'left')
629
			->join('tracker_sites AS ts', 'tt.site_id = ts.id', 'left')
630
			->where('tc.user_id', $this->User->id) //CHECK: Is this inefficient? Would it be better to have a user_id column in tracker_favourites?
631
			->order_by('tf.id DESC')
632
			->limit($rowsPerPage, ($rowsPerPage * ($page - 1)))
633
			->get();
634
635
		$arr = ['rows' => [], 'totalPages' => 1];
636
		if($query->num_rows() > 0) {
637
			foreach($query->result() as $row) {
638
				$arrRow = [];
639
640
				$arrRow['updated_at'] = $row->updated_at;
641
				$arrRow['title']      = $row->title;
642
				$arrRow['title_url']  = $this->Tracker->sites->{$row->site_class}->getFullTitleURL($row->title_url);
643
644
				$arrRow['site'] = $row->site;
645
				$arrRow['site_sprite'] = str_replace('.', '-', $row->site);
646
647
				$chapterData = $this->Tracker->sites->{$row->site_class}->getChapterData($row->title_url, $row->chapter);
648
				$arrRow['chapter'] = "<a href=\"{$chapterData['url']}\">{$chapterData['number']}</a>";
649
				$arr['rows'][] = $arrRow;
650
			}
651
			$arr['totalPages'] = ceil($this->db->query('SELECT FOUND_ROWS() count;')->row()->count / $rowsPerPage);
652
		}
653
		return $arr;
654
655
	}
656
657
	public function getSites() : array {
658
		$query = $this->db->select('*')
659
		                  ->from('tracker_sites')
660
		                  ->where('status', 'enabled')
661
		                  ->get();
662
663
		return $query->result_array();
664
	}
665
666
	public function getUsedCategories(int $userID) : array {
667
		$query = $this->db->distinct()
668
		                  ->select('category')
669
		                  ->from('tracker_chapters')
670
		                  ->where('tracker_chapters.active', 'Y')
671
		                  ->where('user_id', $userID)
672
		                  ->get();
673
674
		return array_column($query->result_array(), 'category');
675
	}
676
677
	public function getStats() : array {
678
		if(!($stats = $this->cache->get('site_stats'))) {
679
			$stats = array();
680
681
			//CHECK: Is it possible to merge some of these queries?
682
			$queryUsers = $this->db->select([
0 ignored issues
show
Documentation introduced by
array('COUNT(*) AS total... END) AS active_users') is of type array<integer,string,{"0..."string","2":"string"}>, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
683
			                            'COUNT(*) AS total_users',
684
			                            'SUM(CASE WHEN api_key IS NOT NULL THEN 1 ELSE 0 END) AS validated_users',
685
			                            'SUM(CASE WHEN (api_key IS NOT NULL AND from_unixtime(last_login) > DATE_SUB(NOW(), INTERVAL 7 DAY)) THEN 1 ELSE 0 END) AS active_users'
686
			                       ], FALSE)
687
			                       ->from('auth_users')
688
			                       ->get();
689
			$stats = array_merge($stats, $queryUsers->result_array()[0]);
690
691
			$queryCounts = $this->db->select([
0 ignored issues
show
Documentation introduced by
array('tracker_titles.ti...rs.title_id) AS count') is of type array<integer,string,{"0":"string","1":"string"}>, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
692
			                             'tracker_titles.title',
693
			                             'COUNT(tracker_chapters.title_id) AS count'
694
			                        ], FALSE)
695
			                        ->from('tracker_chapters')
696
			                        ->join('tracker_titles', 'tracker_titles.id = tracker_chapters.title_id', 'left')
697
			                        ->group_by('tracker_chapters.title_id')
698
			                        ->having('count > 1')
699
			                        ->order_by('count DESC')
700
			                        ->get();
701
			$stats['titles_tracked_more'] = count($queryCounts->result_array());
702
			$stats['top_title_name']  = $queryCounts->result_array()[0]['title'] ?? 'N/A';
703
			$stats['top_title_count'] = $queryCounts->result_array()[0]['count'] ?? 'N/A';
704
705
			$queryTitles = $this->db->select([
0 ignored issues
show
Documentation introduced by
array('COUNT(DISTINCT tr...ND) AS updated_titles') is of type array<integer,string,{"0..."string","3":"string"}>, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
706
			                             'COUNT(DISTINCT tracker_titles.id) AS total_titles',
707
			                             'COUNT(DISTINCT tracker_titles.site_id) AS total_sites',
708
			                             'SUM(CASE WHEN from_unixtime(auth_users.last_login) > DATE_SUB(NOW(), INTERVAL 120 HOUR) IS NOT NULL THEN 0 ELSE 1 END) AS inactive_titles',
709
			                             'SUM(CASE WHEN (tracker_titles.last_updated > DATE_SUB(NOW(), INTERVAL 24 HOUR)) THEN 1 ELSE 0 END) AS updated_titles'
710
			                        ], FALSE)
711
			                        ->from('tracker_titles')
712
			                        ->join('tracker_sites', 'tracker_sites.id = tracker_titles.site_id', 'left')
713
			                        ->join('tracker_chapters', 'tracker_titles.id = tracker_chapters.title_id', 'left')
714
			                        ->join('auth_users', 'tracker_chapters.user_id = auth_users.id', 'left')
715
			                        ->get();
716
			$stats = array_merge($stats, $queryTitles->result_array()[0]);
717
718
			$querySites = $this->db->select([
0 ignored issues
show
Documentation introduced by
array('tracker_sites.site', 'COUNT(*) AS count') is of type array<integer,string,{"0":"string","1":"string"}>, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
719
			                           'tracker_sites.site',
720
			                           'COUNT(*) AS count'
721
			                       ], FALSE)
722
			                       ->from('tracker_titles')
723
			                       ->join('tracker_sites', 'tracker_sites.id = tracker_titles.site_id', 'left')
724
			                       ->group_by('tracker_titles.site_id')
725
			                       ->order_by('count DESC')
726
			                       ->limit(3)
727
			                       ->get();
728
			$querySitesResult = $querySites->result_array();
729
			$stats['rank1_site']       = $querySitesResult[0]['site'];
730
			$stats['rank1_site_count'] = $querySitesResult[0]['count'];
731
			$stats['rank2_site']       = $querySitesResult[1]['site'];
732
			$stats['rank2_site_count'] = $querySitesResult[1]['count'];
733
			$stats['rank3_site']       = $querySitesResult[2]['site'];
734
			$stats['rank3_site_count'] = $querySitesResult[2]['count'];
735
736
			$queryTitlesU = $this->db->select([
0 ignored issues
show
Documentation introduced by
array('COUNT(*) AS title_updated_count') is of type array<integer,string,{"0":"string"}>, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
737
			                             'COUNT(*) AS title_updated_count'
738
			                         ], FALSE)
739
			                         ->from('tracker_titles_history')
740
			                         ->get();
741
			$stats = array_merge($stats, $queryTitlesU->result_array()[0]);
742
743
			$queryUsersU = $this->db->select([
0 ignored issues
show
Documentation introduced by
array('COUNT(*) AS user_updated_count') is of type array<integer,string,{"0":"string"}>, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
744
			                            'COUNT(*) AS user_updated_count'
745
			                        ], FALSE)
746
			                        ->from('tracker_user_history')
747
			                        ->get();
748
			$stats = array_merge($stats, $queryUsersU->result_array()[0]);
749
750
			$stats['live_time'] = timespan(/*2016-09-10T03:17:19*/ 1473477439, time(), 2);
751
752
			$this->cache->save('site_stats', $stats, 3600); //Cache for an hour
753
		}
754
755
		return $stats;
756
	}
757
758
	//FIXME: Should this be moved elsewhere??
759
	public function getNextUpdateTime() : string {
760
		$temp_now = new DateTime();
761
		$temp_now->setTimezone(new DateTimeZone('America/New_York'));
762
		$temp_now_formatted = $temp_now->format('Y-m-d H:i:s');
763
764
		//NOTE: PHP Bug: DateTime:diff doesn't play nice with setTimezone, so we need to create another DT object
765
		$now         = new DateTime($temp_now_formatted);
766
		$future_date = new DateTime($temp_now_formatted);
767
		$now_hour    = (int) $now->format('H');
768
		if($now_hour < 4) {
769
			//Time until 4am
770
			$future_date->setTime(4, 00);
771
		} elseif($now_hour < 8) {
772
			//Time until 8am
773
			$future_date->setTime(8, 00);
774
		} elseif($now_hour < 12) {
775
			//Time until 12pm
776
			$future_date->setTime(12, 00);
777
		} elseif($now_hour < 16) {
778
			//Time until 4pm
779
			$future_date->setTime(16, 00);
780
		} elseif($now_hour < 20) {
781
			//Time until 8pm
782
			$future_date->setTime(20, 00);
783
		} else {
784
			//Time until 12am
785
			$future_date->setTime(00, 00);
786
			$future_date->add(new DateInterval('P1D'));
787
		}
788
789
		$interval = $future_date->diff($now);
790
		return $interval->format("%H:%I:%S");
791
	}
792
793
	public function reportBug(string $text, $userID = NULL, $url = NULL) : bool {
794
		$this->load->library('email');
795
796
		//This is pretty barebones bug reporting, and honestly not a great way to do it, but it works for now (until the Github is public).
797
		$body = "".
798
		(!is_null($url) && !empty($url) ? "URL: ".htmlspecialchars(substr($url, 0, 255))."<br>\n" : "").
799
		"Submitted by: ".$this->input->ip_address().(!is_null($userID) ? "| {$userID}" : "")."<br>\n".
800
		"<br>Bug report: ".htmlspecialchars(substr($text, 0, 1000));
801
802
		$success = TRUE;
803
		$this->email->from('[email protected]', $this->config->item('site_title', 'ion_auth'));
804
		$this->email->to($this->config->item('admin_email', 'ion_auth'));
805
		$this->email->subject($this->config->item('site_title', 'ion_auth')." - Bug Report");
806
		$this->email->message($body);
807
		if(!$this->email->send()) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->email->send() of type null|boolean is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
808
			$success = FALSE;
809
		}
810
		return $success;
811
	}
812
813
	/*************************************************/
814
	public function sites() {
815
		return $this;
816
	}
817
}
818