Completed
Push — master ( 1dfd21...36ade4 )
by Angus
03:51
created

Tracker_Model   F

Complexity

Total Complexity 118

Size/Duplication

Total Lines 823
Duplicated Lines 12.64 %

Coupling/Cohesion

Components 2
Dependencies 12

Test Coverage

Coverage 76.92%

Importance

Changes 0
Metric Value
dl 104
loc 823
ccs 10
cts 13
cp 0.7692
rs 3.4285
c 0
b 0
f 0
wmc 118
lcom 2
cbo 12

26 Methods

Rating   Name   Duplication   Size   Complexity  
B __construct() 0 23 4
D get_tracker_from_user_id() 42 125 20
A getSiteDataFromURL() 0 12 2
C updateTracker() 0 51 8
A updateTrackerByID() 0 11 2
A deleteTrackerByID() 11 11 1
A updateTitleById() 0 19 1
A updateTitleDataById() 0 7 1
A addTitle() 0 19 2
B updateLatestChapters() 9 53 6
C getTitleID() 7 36 11
D updateCustom() 11 36 9
B exportTrackerFromUserID() 0 26 3
C importTrackerFromJSON() 0 33 7
A deleteTrackerByIDList() 8 18 4
B setCategoryByIDList() 8 23 5
A setCategoryTrackerByID() 8 8 1
A updateTagsByID() 0 15 3
C favouriteChapter() 0 75 9
B getFavourites() 0 37 3
A getSites() 0 8 1
A getUsedCategories() 0 10 1
B getStats() 0 80 2
B getNextUpdateTime() 0 33 6
B reportBug() 0 19 5
A sites() 0 3 1

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complex Class

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like Tracker_Model 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 Tracker_Model, and based on these observations, apply Extract Interface, too.

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, bool $returnData = FALSE) {
172
		$query = $this->db->select('tracker_titles.id, tracker_titles.title, tracker_titles.title_url, tracker_titles.latest_chapter, 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(!isset($titleID) || !$titleID) $titleID = 0;
204
205
		return ($returnData && $titleID !== 0 ? $query->row_array() : $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($dbTitleData = $this->getTitleID($titleURL, (int) $site['id'], FALSE, TRUE)) {
395
							if($this->sites->{$site['site_class']}->doCustomCheck($dbTitleData['latest_chapter'], $titleData['latest_chapter'])) {
396
								$titleID = $dbTitleData['id'];
397 View Code Duplication
								if($this->updateTitleById((int) $titleID, $titleData['latest_chapter'])) {
398
									//Make sure last_checked is always updated on successful run.
399
									//CHECK: Is there a reason we aren't just doing this in updateTitleById?
400
									$this->db->set('last_checked', 'CURRENT_TIMESTAMP', FALSE)
401
									         ->where('id', $titleID)
402
									         ->update('tracker_titles');
403
404
									print " - ({$titleData['latest_chapter']})\n";
405
								} else {
406
									print " - Title doesn't exist? ($titleID)\n";
407
								}
408
							} else {
409
								print " - Failed Check.\n";
410
							}
411
						} else {
412
							log_message('error', "{$titleData['title']} || Title does not exist in DB??");
413
							print " - Possibly diff language than in DB? ($titleURL)\n";
414
						}
415
					} else {
416
						log_message('error', "{$titleData['title']} failed to custom update successfully");
417
						print " - FAILED TO PARSE\n";
418
					}
419
				}
420
			}
421
		}
422
	}
423
424
	public function exportTrackerFromUserID(int $userID) {
425
		$query = $this->db
426
			->select('tracker_chapters.current_chapter,
427
			          tracker_chapters.category,
428
			          tracker_titles.title_url,
429
			          tracker_sites.site')
430
			->from('tracker_chapters')
431
			->join('tracker_titles', 'tracker_chapters.title_id = tracker_titles.`id', 'left')
432
			->join('tracker_sites', 'tracker_sites.id = tracker_titles.site_id', 'left')
433
			->where('tracker_chapters.user_id', $userID)
434
			->where('tracker_chapters.active', 'Y')
435
			->get();
436
437
		$arr = [];
438
		if($query->num_rows() > 0) {
439
			foreach ($query->result() as $row) {
440
				$arr[$row->category][] = [
441
					'site'            => $row->site,
442
					'title_url'       => $row->title_url,
443
					'current_chapter' => $row->current_chapter
444
				];
445
			}
446
447
			return $arr;
448
		}
449
	}
450
451
	public function importTrackerFromJSON(string $json_string) : array {
452
		//We already know the this is a valid JSON string as it was validated by form_validator.
453
		$json = json_decode($json_string, TRUE);
454
455
		/*
456
		 * 0 = Success
457
		 * 1 = Invalid keys.
458
		 * 2 = Has failed rows
459
		 */
460
		$status = ['code' => 0, 'failed_rows' => []];
461
462
		$categories = array_keys($json);
463
		if(count($categories) === count(array_intersect(['reading', 'on-hold', 'plan-to-read', 'custom1', 'custom2', 'custom3'], $categories))) {
464
			$json_keys = array_keys(call_user_func_array('array_merge', call_user_func_array('array_merge', $json)));
465
466
			if(count($json_keys) === 3 && !array_diff(array('site', 'title_url', 'current_chapter'), $json_keys)) {
467
				foreach($categories as $category) {
468
					foreach($json[$category] as $row) {
469
						$success = $this->updateTracker($this->User->id, $row['site'], $row['title_url'], $row['current_chapter']);
470
						if(!$success) {
471
							$status['code']          = 2;
472
							$status['failed_rows'][] = $row;
473
						}
474
					}
475
				}
476
			} else {
477
				$status['code'] = 1;
478
			}
479
		} else {
480
			$status['code'] = 1;
481
		}
482
		return $status;
483
	}
484
485
	public function deleteTrackerByIDList(array $idList) : array {
486
		/*
487
		 * 0 = Success
488
		 * 1 = Invalid IDs
489
		 */
490
		$status = ['code' => 0];
491
492 View Code Duplication
		foreach($idList as $id) {
493
			if(!(ctype_digit($id) && $this->deleteTrackerByID($this->User->id, (int) $id))) {
494
				$status['code'] = 1;
495
			} else {
496
				//Delete was successful, update history too.
497
				$this->History->userRemoveTitle((int) $id);
498
			}
499
		}
500
501
		return $status;
502
	}
503
504
	public function setCategoryByIDList(array $idList, string $category) : array {
505
		/*
506
		 * 0 = Success
507
		 * 1 = Invalid IDs
508
		 * 2 = Invalid category
509
		 */
510
		$status = ['code' => 0];
511
512
		if(in_array($category, array_keys($this->enabledCategories))) {
513 View Code Duplication
			foreach($idList as $id) {
514
				if(!(ctype_digit($id) && $this->setCategoryTrackerByID($this->User->id, (int) $id, $category))) {
515
					$status['code'] = 1;
516
				} else {
517
					//Category update was successful, update history too.
518
					$this->History->userUpdateCategory((int) $id, $category);
519
				}
520
			}
521
		} else {
522
			$status['code'] = 2;
523
		}
524
525
		return $status;
526
	}
527 View Code Duplication
	public function setCategoryTrackerByID(int $userID, int $chapterID, string $category) : bool {
528
		$success = $this->db->set(['category' => $category, 'active' => 'Y', 'last_updated' => NULL])
529
		                    ->where('user_id', $userID)
530
		                    ->where('id', $chapterID)
531
		                    ->update('tracker_chapters');
532
533
		return (bool) $success;
534
	}
535
536
	public function updateTagsByID(int $userID, int $chapterID, string $tag_string) : bool {
537
		$success = FALSE;
538
		if(preg_match("/^[a-z0-9\\-_,:]{0,255}$/", $tag_string)) {
539
			$success = (bool) $this->db->set(['tags' => $tag_string, 'active' => 'Y', 'last_updated' => NULL])
540
			                           ->where('user_id', $userID)
541
			                           ->where('id', $chapterID)
542
			                           ->update('tracker_chapters');
543
		}
544
545
		if($success) {
546
			//Tag update was successful, update history
547
			$this->History->userUpdateTags($chapterID, $tag_string);
548
		}
549
		return $success;
550
	}
551
552
	public function favouriteChapter(int $userID, string $site, string $title, string $chapter) : array {
553
		$success = array(
554
			'status' => 'Something went wrong',
555
			'bool'   => FALSE
556
		);
557
		if($siteData = $this->Tracker->getSiteDataFromURL($site)) {
558
			//Validate user input
559
			if(!$this->sites->{$siteData->site_class}->isValidTitleURL($title)) {
560
				//Error is already logged via isValidTitleURL
561
				$success['status'] = 'Title URL is not valid';
562
				return $success;
563
			}
564
			if(!$this->sites->{$siteData->site_class}->isValidChapter($chapter)) {
565
				//Error is already logged via isValidChapter
566
				$success['status'] = 'Chapter URL is not valid';
567
				return $success;
568
			}
569
570
			//NOTE: If the title doesn't exist it will be created. This maybe isn't perfect, but it works for now.
571
			$titleID = $this->Tracker->getTitleID($title, (int) $siteData->id);
572
			if($titleID === 0) {
573
				//Something went wrong.
574
				log_message('error', "TitleID = 0 for {$title} @ {$siteData->id}");
575
				return $success;
576
			}
577
578
			//We need the series to be tracked
579
			$idCQuery = $this->db->select('id')
580
			                    ->where('user_id', $userID)
581
			                    ->where('title_id', $titleID)
582
			                    ->get('tracker_chapters');
583
			if($idCQuery->num_rows() > 0) {
584
				$idCQueryRow = $idCQuery->row();
585
586
				//Check if it is already favourited
587
				$idFQuery = $this->db->select('id')
588
				                    ->where('chapter_id', $idCQueryRow->id)
589
				                    ->where('chapter', $chapter)
590
				                    ->get('tracker_favourites');
591
				if($idFQuery->num_rows() > 0) {
592
					//Chapter is already favourited, so remove it from DB
593
					$idFQueryRow = $idFQuery->row();
594
595
					$isSuccess = (bool) $this->db->where('id', $idFQueryRow->id)
596
					                           ->delete('tracker_favourites');
597
598
					if($isSuccess) {
599
						$success = array(
600
							'status' => 'Unfavourited',
601
							'bool'   => TRUE
602
						);
603
						$this->History->userRemoveFavourite((int) $idCQueryRow->id, $chapter);
604
					}
605
				} else {
606
					//Chapter is not favourited, so add to DB.
607
					$isSuccess = (bool) $this->db->insert('tracker_favourites', [
608
						'chapter_id'      => $idCQueryRow->id,
609
						'chapter'         => $chapter,
610
						'updated_at'      => date('Y-m-d H:i:s')
611
					]);
612
613
					if($isSuccess) {
614
						$success = array(
615
							'status' => 'Favourited',
616
							'bool'   => TRUE
617
						);
618
						$this->History->userAddFavourite((int) $idCQueryRow->id, $chapter);
619
					}
620
				}
621
			} else {
622
				$success['status'] = 'Series needs to be tracked before we can favourite chapters';
623
			}
624
		}
625
		return $success;
626
	}
627
	public function getFavourites(int $page) : array {
628
		$rowsPerPage = 50;
629
		$query = $this->db
630
			->select('SQL_CALC_FOUND_ROWS
631
			          tt.title, tt.title_url,
632
			          ts.site, ts.site_class,
633
			          tf.chapter, tf.updated_at', FALSE)
634
			->from('tracker_favourites AS tf')
635
			->join('tracker_chapters AS tc', 'tf.chapter_id = tc.id', 'left')
636
			->join('tracker_titles AS tt', 'tc.title_id = tt.id', 'left')
637
			->join('tracker_sites AS ts', 'tt.site_id = ts.id', 'left')
638
			->where('tc.user_id', $this->User->id) //CHECK: Is this inefficient? Would it be better to have a user_id column in tracker_favourites?
639
			->order_by('tf.id DESC')
640
			->limit($rowsPerPage, ($rowsPerPage * ($page - 1)))
641
			->get();
642
643
		$arr = ['rows' => [], 'totalPages' => 1];
644
		if($query->num_rows() > 0) {
645
			foreach($query->result() as $row) {
646
				$arrRow = [];
647
648
				$arrRow['updated_at'] = $row->updated_at;
649
				$arrRow['title']      = $row->title;
650
				$arrRow['title_url']  = $this->Tracker->sites->{$row->site_class}->getFullTitleURL($row->title_url);
651
652
				$arrRow['site'] = $row->site;
653
				$arrRow['site_sprite'] = str_replace('.', '-', $row->site);
654
655
				$chapterData = $this->Tracker->sites->{$row->site_class}->getChapterData($row->title_url, $row->chapter);
656
				$arrRow['chapter'] = "<a href=\"{$chapterData['url']}\">{$chapterData['number']}</a>";
657
				$arr['rows'][] = $arrRow;
658
			}
659
			$arr['totalPages'] = ceil($this->db->query('SELECT FOUND_ROWS() count;')->row()->count / $rowsPerPage);
660
		}
661
		return $arr;
662
663
	}
664
665
	public function getSites() : array {
666
		$query = $this->db->select('*')
667
		                  ->from('tracker_sites')
668
		                  ->where('status', 'enabled')
669
		                  ->get();
670
671
		return $query->result_array();
672
	}
673
674
	public function getUsedCategories(int $userID) : array {
675
		$query = $this->db->distinct()
676
		                  ->select('category')
677
		                  ->from('tracker_chapters')
678
		                  ->where('tracker_chapters.active', 'Y')
679
		                  ->where('user_id', $userID)
680
		                  ->get();
681
682
		return array_column($query->result_array(), 'category');
683
	}
684
685
	public function getStats() : array {
686
		if(!($stats = $this->cache->get('site_stats'))) {
687
			$stats = array();
688
689
			//CHECK: Is it possible to merge some of these queries?
690
			$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...
691
			                            'COUNT(*) AS total_users',
692
			                            'SUM(CASE WHEN api_key IS NOT NULL THEN 1 ELSE 0 END) AS validated_users',
693
			                            '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'
694
			                       ], FALSE)
695
			                       ->from('auth_users')
696
			                       ->get();
697
			$stats = array_merge($stats, $queryUsers->result_array()[0]);
698
699
			$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...
700
			                             'tracker_titles.title',
701
			                             'COUNT(tracker_chapters.title_id) AS count'
702
			                        ], FALSE)
703
			                        ->from('tracker_chapters')
704
			                        ->join('tracker_titles', 'tracker_titles.id = tracker_chapters.title_id', 'left')
705
			                        ->group_by('tracker_chapters.title_id')
706
			                        ->having('count > 1')
707
			                        ->order_by('count DESC')
708
			                        ->get();
709
			$stats['titles_tracked_more'] = count($queryCounts->result_array());
710
			$stats['top_title_name']  = $queryCounts->result_array()[0]['title'] ?? 'N/A';
711
			$stats['top_title_count'] = $queryCounts->result_array()[0]['count'] ?? 'N/A';
712
713
			$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...
714
			                             'COUNT(DISTINCT tracker_titles.id) AS total_titles',
715
			                             'COUNT(DISTINCT tracker_titles.site_id) AS total_sites',
716
			                             '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',
717
			                             'SUM(CASE WHEN (tracker_titles.last_updated > DATE_SUB(NOW(), INTERVAL 24 HOUR)) THEN 1 ELSE 0 END) AS updated_titles'
718
			                        ], FALSE)
719
			                        ->from('tracker_titles')
720
			                        ->join('tracker_sites', 'tracker_sites.id = tracker_titles.site_id', 'left')
721
			                        ->join('tracker_chapters', 'tracker_titles.id = tracker_chapters.title_id', 'left')
722
			                        ->join('auth_users', 'tracker_chapters.user_id = auth_users.id', 'left')
723
			                        ->get();
724
			$stats = array_merge($stats, $queryTitles->result_array()[0]);
725
726
			$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...
727
			                           'tracker_sites.site',
728
			                           'COUNT(*) AS count'
729
			                       ], FALSE)
730
			                       ->from('tracker_titles')
731
			                       ->join('tracker_sites', 'tracker_sites.id = tracker_titles.site_id', 'left')
732
			                       ->group_by('tracker_titles.site_id')
733
			                       ->order_by('count DESC')
734
			                       ->limit(3)
735
			                       ->get();
736
			$querySitesResult = $querySites->result_array();
737
			$stats['rank1_site']       = $querySitesResult[0]['site'];
738
			$stats['rank1_site_count'] = $querySitesResult[0]['count'];
739
			$stats['rank2_site']       = $querySitesResult[1]['site'];
740
			$stats['rank2_site_count'] = $querySitesResult[1]['count'];
741
			$stats['rank3_site']       = $querySitesResult[2]['site'];
742
			$stats['rank3_site_count'] = $querySitesResult[2]['count'];
743
744
			$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...
745
			                             'COUNT(*) AS title_updated_count'
746
			                         ], FALSE)
747
			                         ->from('tracker_titles_history')
748
			                         ->get();
749
			$stats = array_merge($stats, $queryTitlesU->result_array()[0]);
750
751
			$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...
752
			                            'COUNT(*) AS user_updated_count'
753
			                        ], FALSE)
754
			                        ->from('tracker_user_history')
755
			                        ->get();
756
			$stats = array_merge($stats, $queryUsersU->result_array()[0]);
757
758
			$stats['live_time'] = timespan(/*2016-09-10T03:17:19*/ 1473477439, time(), 2);
759
760
			$this->cache->save('site_stats', $stats, 3600); //Cache for an hour
761
		}
762
763
		return $stats;
764
	}
765
766
	//FIXME: Should this be moved elsewhere??
767
	public function getNextUpdateTime() : string {
768
		$temp_now = new DateTime();
769
		$temp_now->setTimezone(new DateTimeZone('America/New_York'));
770
		$temp_now_formatted = $temp_now->format('Y-m-d H:i:s');
771
772
		//NOTE: PHP Bug: DateTime:diff doesn't play nice with setTimezone, so we need to create another DT object
773
		$now         = new DateTime($temp_now_formatted);
774
		$future_date = new DateTime($temp_now_formatted);
775
		$now_hour    = (int) $now->format('H');
776
		if($now_hour < 4) {
777
			//Time until 4am
778
			$future_date->setTime(4, 00);
779
		} elseif($now_hour < 8) {
780
			//Time until 8am
781
			$future_date->setTime(8, 00);
782
		} elseif($now_hour < 12) {
783
			//Time until 12pm
784
			$future_date->setTime(12, 00);
785
		} elseif($now_hour < 16) {
786
			//Time until 4pm
787
			$future_date->setTime(16, 00);
788
		} elseif($now_hour < 20) {
789
			//Time until 8pm
790
			$future_date->setTime(20, 00);
791
		} else {
792
			//Time until 12am
793
			$future_date->setTime(00, 00);
794
			$future_date->add(new DateInterval('P1D'));
795
		}
796
797
		$interval = $future_date->diff($now);
798
		return $interval->format("%H:%I:%S");
799
	}
800
801
	public function reportBug(string $text, $userID = NULL, $url = NULL) : bool {
802
		$this->load->library('email');
803
804
		//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).
805
		$body = "".
806
		(!is_null($url) && !empty($url) ? "URL: ".htmlspecialchars(substr($url, 0, 255))."<br>\n" : "").
807
		"Submitted by: ".$this->input->ip_address().(!is_null($userID) ? "| {$userID}" : "")."<br>\n".
808
		"<br>Bug report: ".htmlspecialchars(substr($text, 0, 1000));
809
810
		$success = TRUE;
811
		$this->email->from('[email protected]', $this->config->item('site_title', 'ion_auth'));
812
		$this->email->to($this->config->item('admin_email', 'ion_auth'));
813
		$this->email->subject($this->config->item('site_title', 'ion_auth')." - Bug Report");
814
		$this->email->message($body);
815
		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...
816
			$success = FALSE;
817
		}
818
		return $success;
819
	}
820
821
	/*************************************************/
822
	public function sites() {
823
		return $this;
824
	}
825
}
826