Completed
Pull Request — master (#233)
by
unknown
10:59
created

VirtualLib::getVL()   B

Complexity

Conditions 7
Paths 6

Size

Total Lines 10
Code Lines 8

Duplication

Lines 0
Ratio 0 %
Metric Value
dl 0
loc 10
rs 8.2222
cc 7
eloc 8
nc 6
nop 2
1
<?php
2
/**
3
 * COPS (Calibre OPDS PHP Server) class file
4
 *
5
 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
6
 * @author     Klaus Broelemann <[email protected]>
7
 */
8
9
require_once('base.php');
10
11
/**
12
 * Read and filter virtual libraries
13
 */
14
class VirtualLib {
15
	const SQL_VL_KEY = "virtual_libraries";  // Key for the virtual library entries.
16
	
17
	private $db_id = null;   // Database Index for the virtual lib
18
	private $vl_id = null;   // Library Index
19
	private $filter = null;  // structured representation of the current filter 
20
	
21
	private static $currentVL = null;  // Singleton: current virtual lib
22
	
23
	/**
24
	 * The constructor parses the calibre search string and creates a array-based representation out of it.
25
	 * 
26
	 * @param int $database The database id of the new object.
27
	 * @param int $virtualLib The virtual library id of the new object.
28
	 */
29
	private function __construct($database, $virtualLib) {
30
		$this->db_id  = $database;
31
		$this->vl_id  = $virtualLib;
32
		
33
		// Get the current search string
34
		$vlList = self::getVLList($database);
35
		$vlList = array_values($vlList);
36
		$searchStr = $vlList[$virtualLib];
37
		
38
		$this->filter = self::includeBookFilter(
39
						Filter::parseFilter($searchStr)
40
					);
41
	}
42
	
43
	/**
44
	 * Includes the booke filter (see $config['cops_books_filter'])
45
	 * @param Filter $filter
46
	 * @return string
47
	 */
48
	private static function includeBookFilter($filter) {
49
		$bookFilter = getURLParam ("tag", NULL);
50
		if (empty ($bookFilter)) return $filter;
51
		
52
		$negated = false;
53
		if (preg_match ("/^!(.*)$/", $bookFilter, $matches)) {
54
			$negated = true;
55
			$bookFilter = $matches[1];
56
		}
57
		$bookFilter = new ComparingFilter("tags", $bookFilter, "=");
58
		if ($negated)
59
			$bookFilter->negate();
60
		
61
		$result = new CombinationFilter(array($filter, $bookFilter));
62
		return $result->simplify();
63
	}
64
	
65
	/**
66
	 * Returns a SQL query that finds the IDs of all books accepted by the filter.
67
	 *
68
	 * The sql statement return only one column with the name 'id'.
69
	 * This statement can be included into other sql statements in order to apply the filter, e.g. by using inner joins
70
	 * like "select books.* from books inner join ({0}) as filter on books.id = filter.id"
71
	 * @see Filter
72
	 * 
73
	 * @return string an sql query
74
	 */
75
	public function getFilterQuery() {
76
		return $this->filter->toSQLQuery();
77
	}
78
	
79
	/**
80
	 * Get the name of the virtual library.
81
	 * 
82
	 * @return string Name of the virtual library.
83
	 */
84
	public function getName() {
85
		$names = self::getVLNameList($this->db_id);
86
		return $names[$this->vl_id];
87
	}
88
	
89
	/**
90
	 * Get the current VirtualLib object. 
91
	 *  
92
	 * @param int $database The current database id.
93
	 * @param int $virtualLib The current virtual library id.
94
	 * @return VirtualLib The corresponding VirtualLib object.
95
	 */
96
	public static function getVL($database = null, $virtualLib = null) {
97
		if (is_null($database))
98
			$database = getURLParam(DB, 0);
99
		if ( is_null(self::$currentVL) || self::$currentVL->db_id != $database || (self::$currentVL->vl_id != $virtualLib && !is_null($virtualLib))) {
100
			if (is_null($virtualLib))
101
				$virtualLib = GetUrlParam (VL, 0);
102
			self::$currentVL = new VirtualLib($database, $virtualLib);
103
		}
104
		return self::$currentVL;
105
	}
106
	
107
	/**
108
	 * Checks if the support for virtual libraries is enabled in the settings.
109
	 * 
110
	 * @return boolean true, if virtual libraries are enabled.
111
	 */
112
	public static function isVLEnabled() {
113
		global $config;
114
		return ($config['enable_virtual_libraries'] == 1);
115
	}
116
	
117
	/**
118
	 * Gets a list of all virtual libraries in a database.
119
	 * If virtual libraries are disabled, only an empty entry is returned.
120
	 * 
121
	 * @param int $database id of the database
122
	 * @return array An array of virtual libraries with the names as keys and the filter strings as values.  
123
	 */
124
	public static function getVLList($database = NULL) {
125
		// Standard return if virtual libraries are not enabled
126
		if (!self::isVLEnabled())
127
			return array("" => "");
128
		// Load list from Database
129
		$vLibs = json_decode(Base::getCalibreSetting(self::SQL_VL_KEY, $database), true);
130
		// Add "All Books" at the beginning
131
		if (is_null($vLibs))
132
			return array(localize ("allbooks.title") => "");
133
		else
134
			return array_merge(array(localize ("allbooks.title") => ""), $vLibs);
135
	}
136
	
137
	/**
138
	 * Gets a list of all virtual libraries in a database.
139
	 *
140
	 * @param int $database id of the database
141
	 * @return array An array of virtual libraries with the names as keys and the filter strings as values.
142
	 */
143
	public static function getVLNameList($database = NULL) {
144
		return array_keys(self::getVLList($database));
145
	}
146
	
147
	/**
148
	 * Combines the database and virtual lib names into a merged name.
149
	 * 
150
	 * The resulting name has the form "{$dbName} - {$vlName}". If one of these parameters is empty, the dash will also be removed.
151
	 * If the support for virtual libraries is not enabled, this function simply returns the database name.
152
	 * 
153
	 * @param string $dbName The database name. If NULL, the name of the current db is taken.
154
	 * @param string $vlName The name of the virtual library. If NULL, the name of the current vl is taken.
155
	 */
156
	public static function getDisplayName($dbName = NULL, $vlName = NULL) {
157
		if (is_null($dbName))
158
			$dbName = Base::getDbName();
159
		if (is_null($vlName))
160
			$vlName = self::getVL()->getName();
161
		if (self::isVLEnabled())
162
			return trim(str_format('{0} - {1}', $dbName, $vlName), ' -');
163
		else
164
			return $dbName;
165
	}
166
}
167
168
/**
169
 * Abstract classe to store filters internally. It's derived classes represent the different filter types.
170
 *
171
 */
172
abstract class Filter {
173
	// Special settings for known attributes
174
	private static $KNOWN_ATTRIBUTES = array(
175
		"authors"    => array(),
176
		"series"     => array("link_join_on" => "series"),
177
		"publishers" => array(),
178
		"tags"       => array(),
179
		"ratings"    => array("filterColumn" => "rating"),
180
		"languages"  => array("filterColumn" => "lang_code", "link_join_on" => "lang_code"),
181
		"formats"    => array("table" => "data", "filterColumn" => "format", "link_table" => "data", "link_join_on" => "id"),
182
	);
183
	
184
	private $isNegated = false;
185
	
186
	/**
187
	 * Creates the attribute settings
188
	 * @param string $attr the name of the attribute, e.g. "tags"
189
	 * @return array an assotiative array with the keys "table", "filterColumn", "link_table", "link_join_on", "bookID".
190
	 */
191
	public static function getAttributeSettings($attr) {
192
		$attr = self::normalizeAttribute($attr);
193
		if (!array_key_exists($attr, self::$KNOWN_ATTRIBUTES)) {
194
			if (substr($attr, 0, 1) == "#") {
195
				// search for a custom column
196
				$lookup = substr($attr, 1);
197
				$id = CustomColumn::getCustomId($lookup);
198
				if (is_null($id))
199
					// custom column unknown
200
					return NULL;
201
				// Read and add custom column information
202
				self::$KNOWN_ATTRIBUTES[$attr] = array(
203
					"table"        => CustomColumn::getTableName($id),
204
					"filterColumn" => "value",
205
					"link_table"   => CustomColumn::getTableLinkName($id),
206
					"link_join_on" => CustomColumn::getTableLinkColumn($id),
207
					"bookID"       => "book"
208
				);
209
			} else
210
				// invalid attribute name
211
				return NULL;
212
		}
213
		return self::$KNOWN_ATTRIBUTES[$attr] + array(
214
			"table"        => $attr,
215
			"filterColumn" => "name",
216
			"link_table"   => "books_" . $attr . "_link",
217
			"link_join_on" => substr($attr, 0, strlen($attr) - 1),
218
			"bookID"       => "book"
219
		);
220
	}
221
	
222
	/**
223
	 * Normalizes the attribute. 
224
	 * 
225
	 * Some attributes can be used in plural (e.g. languages) and singular (e.g. language). This function appends a missing s and puts everything to lower case
226
	 * @param string $attr the attribute, like it was used in calibre
227
	 * @return the normalized attribute name
228
	 */
229
	public static function normalizeAttribute($attr) {
230
		$attr = strtolower($attr);
231
		if (substr($attr, -1) != 's' && substr($attr, 0, 1) != '#')
232
			$attr .= 's';
233
		return $attr;
234
	}
235
	
236
	/**
237
	 * Gets the from - part of a table, its link-table and a placeholder for the filter
238
	 * 
239
	 * @param string $table a table, e.g. "authors"
240
	 * @return string a from string with a placeholder for the filter query
241
	 */
242
	public static function getLinkedTable($table) {
243
		foreach (array_keys(self::$KNOWN_ATTRIBUTES) as $attr) {
244
			$tabInfo = self::getAttributeSettings($attr);
245
			if ($tabInfo["table"] == $table) {
246
				return str_format_n(
247
						"{table} inner join {link_table} as link on {table}.id = link.{link_join_on} 
248
							inner join ({placeholder}) as filter on filter.id = link.{bookID}", $tabInfo + array("placeholder" => "{0}"));
249
			}
250
		}
251
		return $table;
252
	}
253
	/**
254
	 * Converts the calibre search string into afilter object
255
	 *
256
	 * @param string $searchStr The calibre string
257
	 * @return Filter The internal, array-based representation
258
	 */
259
	public static function parseFilter($searchStr) {
260
		// deal with empty input strings
261
		if (strlen($searchStr) == 0)
262
			return new EmptyFilter();
263
	
264
		// Simple search string pattern. It recognizes search string of the form
265
		//     [attr]:[value]
266
		// and their negation
267
		//     not [attr]:[value]
268
		// where value is either a number, a boolean or a string in double quote.
269
		// In the latter case, the string starts with an operator (= or ~), followed by the search text.
270
		// TODO: deal with more complex search terms that can contain "and", "or" and brackets
271
		$pattern = '%(?P<neg>not)?\s*(?P<attr>#?\w+):(?P<value>"(?P<op>=|~|\>|<|>=|<=)(?P<text>([^"]|\\\\")*)(?<!\\\\)"|true|false|\d+)%i';
272
		if (!preg_match($pattern, $searchStr, $match)) {
273
			trigger_error("Virtual Library Filter is not supported.", E_USER_WARNING);
274
			return new EmptyFilter();
275
		}
276
		
277
		// Postprocess escaped quotes
278
		if (array_key_exists("text", $match))
279
			$match["text"] = str_replace("\\\"", "\"", $match["text"]);
280
	
281
		// Create the actual filter object
282
		$value = $match["value"];
283
		$filter   = null;
0 ignored issues
show
Unused Code introduced by
$filter is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
284
		if (substr($value, 0, 1) == '"') {
285
			$filter = new ComparingFilter($match["attr"], $match["text"], $match["op"]);
286
		} elseif (preg_match("#\d+#", $value)) {
287
			$filter = new ComparingFilter($match["attr"], $value);
288
		} else {
289
			$value = (strcasecmp($value, "true") == 0);
290
			$filter = new ExistenceFilter($match["attr"], $value);
291
		}
292
	
293
		// Negate if a leading "not" is given
294
		if (strlen($match["neg"]) > 0)
295
			$filter->negate();
296
	
297
		return $filter;
298
	}
299
	
300
	/**
301
	 * Returns a SQL query that finds the IDs of all books accepted by the filter. The single columns name is id.
302
	 */
303
	public abstract function toSQLQuery();
304
	
305
	/**
306
	 * Negates the current filter. A second call will undo it.
307
	 */
308
	public function negate() {
309
		$this->isNegated = !$this->isNegated;
310
	}
311
	
312
	public function isNegated() {
313
		return $this->isNegated;
314
	}
315
}
316
317
/**
318
 * Class that represents an empty filter
319
 *
320
 */
321
class EmptyFilter extends Filter {
322
	public function __construct() {
323
		// Do Nothing
324
	}
325
	
326
	// Return all books (or no book if the filter is negated)
327
	public function toSQLQuery() {
328
		if ($this->isNegated())
329
			return "select id from books where 1 = 0";
330
		return "select id from books";
331
	}
332
}
333
334
/**
335
 * Class that represents a filter, that compares an attribute with a given value, e.g. tags with "Fiction" 
336
 *
337
 * This class allows for other comparation operators beside "="
338
 */
339
class ComparingFilter extends Filter {
340
	private $attr = null;   // The attribute that is filtered
341
	private $value = null;  // The value with which to compare
342
	private $op = null;     // The operator that is used for comparing
343
	
344
	/**
345
	 * Creates a comparing filter
346
	 * 
347
	 * @param string $attr The attribute that is filtered.
348
	 * @param mixed $value The value with which to compare.
349
	 * @param string $op The operator that is used for comparing, optional.
350
	 */
351
	public function __construct($attr, $value, $op = "=") {
352
		$this->attr = self::normalizeAttribute($attr);
353
		$this->value = $value;
354
		if ($op == "~")
355
			$op = "like";
356
		$this->op = $op;
357
		
358
		// Specialty of ratings
359
		if ($this->attr == "ratings") 
360
			$this->value *= 2;
361
		
362
		// Specialty of languages
363
		// This only works if calibre and cops use the same language!!!
364
		if ($this->attr == "languages")
365
			$this->value = Language::getLanguageCode($this->value);
366
	}
367
	
368
	public function toSQLQuery() {
369
		$queryParams = self::getAttributeSettings($this->attr);
370
		// Do not filter if attribute is not valid
371
		if (is_null($queryParams))
372
			return "select id from books";
373
		
374
		// Include parameters into the sql query 
375
		$queryParams["value"] = $this->value;
376
		$queryParams["op"] = $this->op;
377
		$queryParams["neg"] = $this->isNegated() ? "not" : "";
378
		if ($this->attr == "formats")
379
			$sql = str_format_n(
380
					"select distinct {table}.{bookID} as id ".
381
					"from {table} ".
382
					"where {neg} ({table}.{filterColumn} {op} '{value}')",
383
					$queryParams);
384
		else
385
			$sql = str_format_n(
386
					"select distinct {link_table}.{bookID} as id ".
387
					"from {table} inner join {link_table} on {table}.id = {link_table}.{link_join_on} ".
388
					"where {neg} ({table}.{filterColumn} {op} '{value}')",
389
					$queryParams);
390
		return $sql;
391
	}
392
}
393
394
/**
395
 * Class that represents a filter, that checks if a given attribute exists for a book.
396
 */
397
class ExistenceFilter extends Filter {
398
	private $attr = null;   // The attribute that is filtered
399
400
	/**
401
	 * Creates an existence filter
402
	 *
403
	 * @param string $attr The attribute that is filtered.
404
	 * @param boolean $value True, if objects with that attribute are accepted by the filter, false if not.
405
	 */
406
	public function __construct($attr, $value = true) {
407
		$this->attr = $attr;
408
		
409
		// $value == false is the negation of $value == true 
410
		if (!$value)
411
			$this->negate();
412
	}
413
	
414
	public function toSQLQuery() {
415
		$queryParams = self::getAttributeSettings($this->attr);
416
		// Do not filter if attribute is not valid
417
		if (is_null($queryParams))
418
			return "select id from books";
419
	
420
		// Include parameters into the sql query
421
		$queryParams["op"] = $this->isNegated() ? "==" : ">";
422
		$sql = str_format_n(
423
				"select books.id as id from books left join {link_table} as link on link.{bookID} = books.id group by books.id having count(link.{link_join_on}) {op} 0",
424
				$queryParams);
425
		return $sql;
426
	}
427
}
428
429
/**
430
 * Filter class that represents the combination of two or more filter using and / or
431
 *
432
 */
433
class CombinationFilter extends Filter {
434
	private $op = null;
435
	private $parts = array();
436
437
	/**
438
	 * Constructor that combines multiple filters to one filter
439
	 * @param array $parts An array of Filter objects
440
	 * @param string $op The operator for combining. Either "and" or "or" (case insensitive).
441
	 */
442
	public function __construct($parts, $op = "and") {
443
		$this->parts = $parts;
444
		$this->op = strtolower($op);
445
	}
446
	
447
	/**
448
	 * Checks if the combination is a conjunction (=and)
449
	 * @return boolean true, iff the filter is a conjunction
450
	 */
451
	public function isConjunction() {
452
		return ($this->op == "and");
453
	}
454
	
455
	/**
456
	 * Simplifys the filter, by merging parts that are CombinationFilter objects into this filter, if they have the same operator.
457
	 * This means "(x and y) and z" becomes "x and y and z". 
458
	 * Furthermore, empty filters are removed and if only one part exists, this part is returned. 
459
	 * 
460
	 * @return the simplified filter. The result might not be a CombinationFilter 
461
	 */
462
	public function simplify() {
463
		$newParts = array();
464
		foreach ($this->parts as $part) {
465
			// Simplify inner combination filters
466
			if ($part instanceof CombinationFilter)
467
				$part = $part->simplify();
468
				
469
			if ($part instanceof CombinationFilter && $part->isConjunction() == $this->isConjunction()) {
470
				// Part is a CombinationFilter with the same operator --> merge it's parts into this filter 
471
				foreach ($part->parts as $innerPart)
472
					array_push($newParts, $innerPart);
473
			} elseif ($part instanceof EmptyFilter) {
474
				// A positiv empty filter can be ignored in a conjunction and makes a disjunction always positiv.
475
				// A negative empty filter can be ignored in a disjunction and makes a conjunction always negative.
476
				if ($part->isNegated() == $this->isConjunction())
477
					return $part;
478
			} else 
479
				array_push($newParts, $part);
480
		}
481
		$this->parts = $newParts;
482
		return $this;
483
	}
484
	
485
	public function negate() {
486
		// Use De Morgan's laws to avoid direct negation
487
		
488
		// 1. Negate the operator
489
		if ($this->isConjunction())
490
			$this->op = "or";
491
		else
492
			$this->op = "and";
493
		
494
		// 2. negate all parts
495
		foreach ($this->parts as $filter)
496
			$filer->negate();
0 ignored issues
show
Bug introduced by
The variable $filer does not exist. Did you mean $filter?

This check looks for variables that are accessed but have not been defined. It raises an issue if it finds another variable that has a similar name.

The variable may have been renamed without also renaming all references.

Loading history...
497
	}
498
	
499
	public function toSQLQuery() {
500
		$sql = "";
0 ignored issues
show
Unused Code introduced by
$sql is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
501
		if ($this->isConjunction()) {
502
			// combining all parts by inner joins implements the "and" logic
503
			$sql = str_format("({0}) as f0", $this->parts[0]->toSQLQuery());
504
			for ($i = 1; $i < count($this->parts); $i++)
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
505
				$sql .= str_format(" inner join ({0}) as f{1} on f0.id = f{1}.id", $this->parts[$i]->toSQLQuery(), $i);
506
		} else {
507
			// combining all parts by union implements the "or" logic
508
			$sql = str_format("{0}", $this->parts[0]->toSQLQuery());
509
			for ($i = 1; $i < count($this->parts); $i++)
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
510
				$sql .= str_format(" union {0}", $this->parts[$i]->toSQLQuery());
511
		}
512
		return $sql;
513
	}
514
}