1 package org.coolreader.db;
2 
3 import android.database.Cursor;
4 import android.database.DatabaseUtils;
5 import android.database.SQLException;
6 import android.database.sqlite.SQLiteException;
7 import android.database.sqlite.SQLiteStatement;
8 import android.util.Log;
9 
10 import org.coolreader.crengine.BookInfo;
11 import org.coolreader.crengine.Bookmark;
12 import org.coolreader.crengine.DocumentFormat;
13 import org.coolreader.crengine.FileInfo;
14 import org.coolreader.crengine.L;
15 import org.coolreader.crengine.Logger;
16 import org.coolreader.crengine.MountPathCorrector;
17 import org.coolreader.crengine.OPDSConst;
18 import org.coolreader.crengine.Services;
19 import org.coolreader.crengine.Utils;
20 import org.coolreader.genrescollection.GenresCollection;
21 
22 import java.util.ArrayList;
23 import java.util.Collection;
24 import java.util.Collections;
25 import java.util.HashMap;
26 import java.util.Iterator;
27 import java.util.Map;
28 
29 public class MainDB extends BaseDB {
30 	public static final Logger log = L.create("mdb");
31 	public static final Logger vlog = L.create("mdb", Log.VERBOSE);
32 
33 	private boolean pathCorrectionRequired = false;
34 	public final int DB_VERSION = 34;
35 	@Override
upgradeSchema()36 	protected boolean upgradeSchema() {
37 		// When the database is just created, its version is 0.
38 		int currentVersion = mDB.getVersion();
39 		//int currentVersion = 32;
40 		// TODO: check database structure consistency regardless of its version.
41 		if (currentVersion > DB_VERSION) {
42 			// trying to update the structure of a database that has been modified by some kind of inconsistent fork of the program.
43 			log.v("MainDB: incompatible database version found (" + currentVersion + "), forced setting to 26.");
44 			currentVersion = 26;
45 		}
46 		if (mDB.needUpgrade(DB_VERSION) || currentVersion < DB_VERSION) {
47 			execSQL("CREATE TABLE IF NOT EXISTS author (" +
48 					"id INTEGER PRIMARY KEY AUTOINCREMENT," +
49 					"name VARCHAR NOT NULL COLLATE NOCASE" +
50 					")");
51 			execSQL("CREATE INDEX IF NOT EXISTS " +
52 	                "author_name_index ON author (name) ");
53 			execSQL("CREATE TABLE IF NOT EXISTS series (" +
54 					"id INTEGER PRIMARY KEY AUTOINCREMENT," +
55 					"name VARCHAR NOT NULL COLLATE NOCASE" +
56 					")");
57 			execSQL("CREATE INDEX IF NOT EXISTS " +
58 			        "series_name_index ON series (name) ");
59 			execSQL("CREATE TABLE IF NOT EXISTS folder (" +
60 					"id INTEGER PRIMARY KEY AUTOINCREMENT," +
61 					"name VARCHAR NOT NULL" +
62 					")");
63 			execSQL("CREATE INDEX IF NOT EXISTS " +
64 					"folder_name_index ON folder (name) ");
65 			execSQL("CREATE TABLE IF NOT EXISTS book (" +
66 					"id INTEGER PRIMARY KEY AUTOINCREMENT," +
67 					"pathname VARCHAR NOT NULL," +
68 					"folder_fk INTEGER REFERENCES folder (id)," +
69 					"filename VARCHAR NOT NULL," +
70 					"arcname VARCHAR," +
71 					"title VARCHAR COLLATE NOCASE," +
72 					"series_fk INTEGER REFERENCES series (id)," +
73 					"series_number INTEGER," +
74 					"format INTEGER," +
75 					"filesize INTEGER," +
76 					"arcsize INTEGER," +
77 					"create_time INTEGER," +
78 					"last_access_time INTEGER, " +
79 					"flags INTEGER DEFAULT 0, " +
80 					"language VARCHAR DEFAULT NULL, " +
81 					"description TEXT DEFAULT NULL, " +
82 					"crc32 INTEGER DEFAULT NULL, " +
83 					"domVersion INTEGER DEFAULT 0, " +
84 					"rendFlags INTEGER DEFAULT 0" +
85 					")");
86 			execSQL("CREATE INDEX IF NOT EXISTS " +
87 					"book_folder_index ON book (folder_fk) ");
88 			execSQL("CREATE UNIQUE INDEX IF NOT EXISTS " +
89 					"book_pathname_index ON book (pathname) ");
90 			execSQL("CREATE INDEX IF NOT EXISTS " +
91 					"book_filename_index ON book (filename) ");
92 			execSQL("CREATE INDEX IF NOT EXISTS " +
93 					"book_title_index ON book (title) ");
94 			execSQL("CREATE INDEX IF NOT EXISTS " +
95 					"book_last_access_time_index ON book (last_access_time) ");
96 			execSQL("CREATE INDEX IF NOT EXISTS " +
97 					"book_title_index ON book (title) ");
98 			execSQL("CREATE TABLE IF NOT EXISTS book_author (" +
99 					"book_fk INTEGER NOT NULL REFERENCES book (id)," +
100 					"author_fk INTEGER NOT NULL REFERENCES author (id)," +
101 					"PRIMARY KEY (book_fk, author_fk)" +
102 					")");
103 			execSQL("CREATE UNIQUE INDEX IF NOT EXISTS " +
104 					"author_book_index ON book_author (author_fk, book_fk) ");
105 			execSQL("CREATE TABLE IF NOT EXISTS bookmark (" +
106 					"id INTEGER PRIMARY KEY AUTOINCREMENT," +
107 					"book_fk INTEGER NOT NULL REFERENCES book (id)," +
108 					"type INTEGER NOT NULL DEFAULT 0," +
109 					"percent INTEGER DEFAULT 0," +
110 					"shortcut INTEGER DEFAULT 0," +
111 					"time_stamp INTEGER DEFAULT 0," +
112 					"start_pos VARCHAR NOT NULL," +
113 					"end_pos VARCHAR," +
114 					"title_text VARCHAR," +
115 					"pos_text VARCHAR," +
116 					"comment_text VARCHAR, " +
117 					"time_elapsed INTEGER DEFAULT 0" +
118 					")");
119 			execSQL("CREATE INDEX IF NOT EXISTS " +
120 			"bookmark_book_index ON bookmark (book_fk) ");
121 			execSQL("CREATE TABLE IF NOT EXISTS metadata (" +
122 					"param VARCHAR NOT NULL PRIMARY KEY, " +
123 					"value VARCHAR NOT NULL)");
124 			execSQL("CREATE TABLE IF NOT EXISTS genre_group (" +
125 					"id INTEGER NOT NULL PRIMARY KEY, " +
126 					"code VARCHAR NOT NULL)");
127 			execSQL("CREATE INDEX IF NOT EXISTS " +
128 					"genre_group_code_index ON genre_group (code) ");
129 			execSQL("CREATE TABLE IF NOT EXISTS genre (" +
130 					"id INTEGER NOT NULL PRIMARY KEY, " +
131 					"code VARCHAR NOT NULL)");
132 			execSQL("CREATE INDEX IF NOT EXISTS " +
133 					"genre_code_index ON genre (code) ");
134 			execSQL("CREATE TABLE IF NOT EXISTS genre_hier (" +
135 					"group_fk INTEGER NOT NULL REFERENCES genre_group(id), " +
136 					"genre_fk INTEGER NOT NULL REFERENCES genre(id), " +
137 					"UNIQUE (group_fk, genre_fk))");
138 			execSQL("CREATE TABLE IF NOT EXISTS book_genre (" +
139 					"book_fk INTEGER NOT NULL REFERENCES book(id), " +
140 					"genre_fk INTEGER NOT NULL REFERENCES genre(id), " +
141 					"UNIQUE (book_fk, genre_fk))");
142 			execSQL("CREATE UNIQUE INDEX IF NOT EXISTS " +
143 					"book_genre_index ON book_genre (book_fk, genre_fk) ");
144 			// ====================================================================
145 			if ( currentVersion<1 )
146 				execSQLIgnoreErrors("ALTER TABLE bookmark ADD COLUMN shortcut INTEGER DEFAULT 0");
147 			if ( currentVersion<4 )
148 				execSQLIgnoreErrors("ALTER TABLE book ADD COLUMN flags INTEGER DEFAULT 0");
149 			if ( currentVersion<6 )
150 				execSQL("CREATE TABLE IF NOT EXISTS opds_catalog (" +
151                         "id INTEGER PRIMARY KEY AUTOINCREMENT, " +
152                         "name VARCHAR NOT NULL COLLATE NOCASE, " +
153                         "url VARCHAR NOT NULL COLLATE NOCASE, " +
154                         "last_usage INTEGER DEFAULT 0," +
155                         "username VARCHAR DEFAULT NULL, " +
156                         "password VARCHAR DEFAULT NULL" +
157                         ")");
158 			if (currentVersion < 7) {
159 				addOPDSCatalogs(DEF_OPDS_URLS1);
160 			}
161 			if (currentVersion < 13)
162 			    execSQLIgnoreErrors("ALTER TABLE book ADD COLUMN language VARCHAR DEFAULT NULL");
163 			if (currentVersion < 14)
164 				pathCorrectionRequired = true;
165 			if (currentVersion < 15)
166 			    execSQLIgnoreErrors("ALTER TABLE opds_catalog ADD COLUMN last_usage INTEGER DEFAULT 0");
167 			if (currentVersion < 16)
168 				execSQLIgnoreErrors("ALTER TABLE bookmark ADD COLUMN time_elapsed INTEGER DEFAULT 0");
169 			if (currentVersion < 17)
170 				pathCorrectionRequired = true; // chance to correct paths under Android 4.2
171 			if (currentVersion < 20)
172 				removeOPDSCatalogsFromBlackList(); // BLACK LIST enforcement, by LitRes request
173             if (currentVersion < 21)
174                 execSQL("CREATE TABLE IF NOT EXISTS favorite_folders (" +
175                         "id INTEGER PRIMARY KEY AUTOINCREMENT, " +
176                         "path VARCHAR NOT NULL, " +
177                         "position INTEGER NOT NULL default 0" +
178                         ")");
179 			if (currentVersion < 23) {
180 			    execSQLIgnoreErrors("ALTER TABLE opds_catalog ADD COLUMN username VARCHAR DEFAULT NULL");
181 			    execSQLIgnoreErrors("ALTER TABLE opds_catalog ADD COLUMN password VARCHAR DEFAULT NULL");
182 			}
183 			if (currentVersion < 26) {
184 				execSQLIgnoreErrors("CREATE TABLE IF NOT EXISTS search_history (" +
185 						"id INTEGER PRIMARY KEY AUTOINCREMENT, " +
186 						"book_fk INTEGER NOT NULL REFERENCES book (id), " +
187 						"search_text VARCHAR " +
188 						")");
189 				execSQLIgnoreErrors("CREATE INDEX IF NOT EXISTS " +
190 						"search_history_index ON search_history (book_fk) ");
191 			}
192 			if (currentVersion < 27) {
193 				removeOPDSCatalogsByURLs(OBSOLETE_OPDS_URLS);
194 				addOPDSCatalogs(DEF_OPDS_URLS3);
195 			}
196 			if (currentVersion < 28) {
197 				execSQLIgnoreErrors("ALTER TABLE book ADD COLUMN crc32 INTEGER DEFAULT NULL");
198 				execSQLIgnoreErrors("ALTER TABLE book ADD COLUMN domVersion INTEGER DEFAULT 0");
199 				execSQLIgnoreErrors("ALTER TABLE book ADD COLUMN rendFlags INTEGER DEFAULT 0");
200 			}
201 			if (currentVersion < 29) {
202 				// After adding support for the 'fb3' and 'docx' formats in version 3.2.33,
203 				// the 'format' field in the 'book' table becomes invalid because the enum DocumentFormat has been changed.
204 				// So, after reading this field from the database, we must recheck the format by pathname.
205 				// TODO: check format by mime-type or file contents...
206 				log.i("Update 'format' field in table 'book'...");
207 				String sql = "SELECT id, pathname, format FROM book";
208 				HashMap<Long, Long> formatsMap = new HashMap<>();
209 				try (Cursor rs = mDB.rawQuery(sql, null)) {
210 					if (rs.moveToFirst()) {
211 						do {
212 							Long id = rs.getLong(0);
213 							String pathname = rs.getString(1);
214 							long old_format = rs.getLong(2);
215 							if (old_format > 1) {        // skip 'none', 'fb2' - ordinal is not changed
216 								DocumentFormat new_format = DocumentFormat.byExtension(pathname);
217 								if (null != new_format && old_format != new_format.ordinal())
218 									formatsMap.put(id, (long) new_format.ordinal());
219 							}
220 						} while (rs.moveToNext());
221 					}
222 				} catch (Exception e) {
223 					Log.e("cr3", "exception while reading format", e);
224 				}
225 				// Save new format in table 'book'...
226 				if (!formatsMap.isEmpty()) {
227 					int updatedCount = 0;
228 					mDB.beginTransaction();
229 					try (SQLiteStatement stmt = mDB.compileStatement("UPDATE book SET format = ? WHERE id = ?")) {
230 						for (Map.Entry<Long, Long> record : formatsMap.entrySet()) {
231 							stmt.clearBindings();
232 							stmt.bindLong(1, record.getValue());
233 							stmt.bindLong(2, record.getKey());
234 							stmt.execute();
235 							updatedCount++;
236 						}
237 						mDB.setTransactionSuccessful();
238 						vlog.i("Updated " + updatedCount + " records with invalid format.");
239 					} catch (Exception e) {
240 						Log.e("cr3", "exception while reading format", e);
241 					} finally {
242 						mDB.endTransaction();
243 					}
244 				}
245 			}
246 			if (currentVersion < 30) {
247 				// Forced update DOM version from previous latest (20200223) to current (20200824).
248 				execSQLIgnoreErrors("UPDATE book SET domVersion=20200824 WHERE domVersion=20200223");
249 			}
250 			if (currentVersion < 31) {
251 				execSQLIgnoreErrors("ALTER TABLE book ADD COLUMN description TEXT DEFAULT NULL");
252 			}
253 			if (currentVersion < 33) {
254 				execSQLIgnoreErrors("CREATE TABLE IF NOT EXISTS metadata (" +
255 						"param VARCHAR NOT NULL PRIMARY KEY, " +
256 						"value VARCHAR NOT NULL)");
257 				execSQLIgnoreErrors("CREATE TABLE IF NOT EXISTS genre_group (" +
258 						"id INTEGER NOT NULL PRIMARY KEY, " +
259 						"code VARCHAR NOT NULL");
260 				execSQLIgnoreErrors("CREATE TABLE IF NOT EXISTS genre (" +
261 						"id INTEGER NOT NULL, " +
262 						"parent INTEGER NOT NULL REFERENCES genre_group(id), " +
263 						"code VARCHAR NOT NULL, " +
264 						"PRIMARY KEY (id, parent))");
265 				execSQLIgnoreErrors("CREATE TABLE IF NOT EXISTS book_genre (" +
266 						"book_fk INTEGER NOT NULL REFERENCES book(id), " +
267 						"genre_fk INTEGER NOT NULL REFERENCES genre(id), " +
268 						"UNIQUE (book_fk, genre_fk))");
269 				execSQLIgnoreErrors("CREATE INDEX IF NOT EXISTS " +
270 						"genre_group_code_index ON genre_group (code) ");
271 				execSQLIgnoreErrors("CREATE INDEX IF NOT EXISTS " +
272 						"genre_code_index ON genre (code) ");
273 				execSQLIgnoreErrors("CREATE UNIQUE INDEX IF NOT EXISTS " +
274 						"book_genre_index ON book_genre (book_fk, genre_fk) ");
275 			}
276 			if (currentVersion < 34) {
277 				execSQLIgnoreErrors("CREATE TABLE IF NOT EXISTS genre_hier (" +
278 						"group_fk INTEGER NOT NULL REFERENCES genre_group(id), " +
279 						"genre_fk INTEGER NOT NULL REFERENCES genre(id) )");
280 				execSQLIgnoreErrors("INSERT INTO genre_hier (group_fk, genre_fk) SELECT parent as group_fk, id as genre_fk FROM genre ORDER BY parent, id");
281 				execSQLIgnoreErrors("CREATE TABLE IF NOT EXISTS genre_new (" +
282 						"id INTEGER NOT NULL PRIMARY KEY," +
283 						"code VARCHAR NOT NULL UNIQUE)");
284 				execSQLIgnoreErrors("INSERT INTO genre_new (id, code) SELECT id, code FROM genre GROUP BY id");
285 				Long pragma_foreign_keys = longQuery("PRAGMA foreign_keys");
286 				if (null == pragma_foreign_keys)
287 					pragma_foreign_keys = 0L;
288 				if (pragma_foreign_keys != 0L)
289 					execSQLIgnoreErrors("PRAGMA foreign_keys=OFF");
290 				execSQLIgnoreErrors("DROP TABLE genre");
291 				execSQLIgnoreErrors("ALTER TABLE genre_new RENAME TO genre");
292 				if (pragma_foreign_keys != 0L)
293 					execSQLIgnoreErrors("PRAGMA foreign_keys=ON");
294 			}
295 
296 			//==============================================================
297 			// add more updates above this line
298 
299 			// set current version
300 			mDB.setVersion(DB_VERSION);
301 		}
302 
303 		checkOrUpgradeGenresHandbook();
304 
305 		dumpStatistics();
306 
307 		return true;
308 	}
309 
dumpStatistics()310 	private void dumpStatistics() {
311 		log.i("mainDB: " + longQuery("SELECT count(*) FROM author") + " authors, "
312 				 + longQuery("SELECT count(*) FROM series") + " series, "
313 				 + longQuery("SELECT count(*) FROM book") + " books, "
314 				 + longQuery("SELECT count(*) FROM bookmark") + " bookmarks, "
315 				 + longQuery("SELECT count(*) FROM folder") + " folders"
316 		);
317 	}
318 
checkOrUpgradeGenresHandbook()319 	private boolean checkOrUpgradeGenresHandbook() {
320 		boolean res = true;
321 		boolean needUpgrade = false;
322 		Long version = longQuery("SELECT value FROM metadata WHERE param='genre_version'");
323 		if (null == version || version != Services.getGenresCollection().getVersion())
324 			needUpgrade = true;
325 		if (needUpgrade) {
326 			mDB.beginTransaction();
327 			try {
328 				// fill/append table "genre_group"
329 				SQLiteStatement stmt = mDB.compileStatement("INSERT OR IGNORE INTO genre_group (id, code) VALUES (?,?)");
330 				Map<String, GenresCollection.GenreRecord> collection = Services.getGenresCollection().getCollection();
331 				for (Map.Entry<String, GenresCollection.GenreRecord> entry : collection.entrySet()) {
332 					GenresCollection.GenreRecord group = entry.getValue();
333 					if (group.getLevel() == 0) {
334 						stmt.bindLong(1, group.getId());
335 						stmt.bindString(2, group.getCode());
336 						stmt.executeInsert();
337 					}
338 				}
339 				// fill/append table "genre"
340 				stmt = mDB.compileStatement("INSERT OR IGNORE INTO genre (id, code) VALUES (?,?)");
341 				for (Map.Entry<String, GenresCollection.GenreRecord> entry : collection.entrySet()) {
342 					GenresCollection.GenreRecord group = entry.getValue();
343 					if (group.hasChilds()) {
344 						for (GenresCollection.GenreRecord genre : group.getChilds()) {
345 							stmt.bindLong(1, genre.getId());
346 							stmt.bindString(2, genre.getCode());
347 							stmt.executeInsert();
348 						}
349 					}
350 				}
351 				// fill/append table "genre_hier"
352 				stmt = mDB.compileStatement("INSERT OR IGNORE INTO genre_hier (group_fk, genre_fk) VALUES (?,?)");
353 				for (Map.Entry<String, GenresCollection.GenreRecord> entry : collection.entrySet()) {
354 					GenresCollection.GenreRecord group = entry.getValue();
355 					if (group.hasChilds()) {
356 						for (GenresCollection.GenreRecord genre : group.getChilds()) {
357 							stmt.bindLong(1, group.getId());
358 							stmt.bindLong(2, genre.getId());
359 							stmt.executeInsert();
360 						}
361 					}
362 				}
363 				// Update genres data version in metadata
364 				stmt = mDB.compileStatement("INSERT OR REPLACE INTO metadata (param, value) VALUES ('genre_version', ?)");
365 				stmt.bindLong(1, Services.getGenresCollection().getVersion());
366 				stmt.executeInsert();
367 				mDB.setTransactionSuccessful();
368 			} catch (SQLException e) {
369 				res = false;
370 				e.printStackTrace();
371 			}
372 			mDB.endTransaction();
373 		}
374 		return res;
375 	}
376 
377 	@Override
dbFileName()378 	protected String dbFileName() {
379 		return "cr3db.sqlite";
380 	}
381 
clearCaches()382 	public void clearCaches() {
383 		seriesCache.clear();
384 		authorCache.clear();
385 		folderCache.clear();
386 		fileInfoCache.clear();
387 	}
388 
flush()389 	public void flush() {
390         super.flush();
391         if (seriesStmt != null) {
392             seriesStmt.close();
393             seriesStmt = null;
394         }
395         if (folderStmt != null) {
396         	folderStmt.close();
397         	folderStmt = null;
398         }
399         if (authorStmt != null) {
400             authorStmt.close();
401             authorStmt = null;
402         }
403         if (seriesSelectStmt != null) {
404             seriesSelectStmt.close();
405             seriesSelectStmt = null;
406         }
407         if (folderSelectStmt != null) {
408         	folderSelectStmt.close();
409         	folderSelectStmt = null;
410         }
411         if (authorSelectStmt != null) {
412             authorSelectStmt.close();
413             authorSelectStmt = null;
414         }
415 	}
416 
417 	//=======================================================================================
418     // OPDS access code
419     //=======================================================================================
420 	private final static String[] DEF_OPDS_URLS1 = {
421 			// feedbooks.com tested 2020.01
422 			// offers preview or requires registration
423 			//"http://www.feedbooks.com/catalog.atom", "Feedbooks",
424 			// tested 2020.01 - error 500
425 			"http://bookserver.archive.org/catalog/", "Internet Archive",
426 			// obsolete link
427 			//		"http://m.gutenberg.org/", "Project Gutenberg",
428 			//		"http://ebooksearch.webfactional.com/catalog.atom", "eBookSearch",
429 			//"http://bookserver.revues.org/", "Revues.org",
430 			//"http://www.legimi.com/opds/root.atom", "Legimi",
431 			//https://www.ebooksgratuits.com/opds/index.php
432 			// tested 2020.01
433 			"http://www.ebooksgratuits.com/opds/", "Ebooks libres et gratuits (fr)",
434 	};
435 
436 	private final static String[] OBSOLETE_OPDS_URLS = {
437 			"http://m.gutenberg.org/", // "Project Gutenberg" old URL
438 			"http://www.shucang.org/s/index.php", //"ShuCang.org"
439 			"http://www.legimi.com/opds/root.atom", //"Legimi",
440 			"http://bookserver.revues.org/", //"Revues.org",
441 			"http://ebooksearch.webfactional.com/catalog.atom", //
442 	};
443 
444 	private final static String[] DEF_OPDS_URLS3 = {
445 			// o'reilly
446 			//"http://opds.oreilly.com/opds/", "O'Reilly",
447 			"http://m.gutenberg.org/ebooks.opds/", "Project Gutenberg",
448 			//"https://api.gitbook.com/opds/catalog.atom", "GitBook",
449 			"http://srv.manybooks.net/opds/index.php", "ManyBooks",
450 			//"http://opds.openedition.org/", "OpenEdition (fr)",
451 			"https://gallica.bnf.fr/opds", "Gallica (fr)",
452 			"https://www.textos.info/catalogo.atom", "textos.info (es)",
453 			"https://wolnelektury.pl/opds/", "Wolne Lektury (pl)",
454 			"http://www.bokselskap.no/wp-content/themes/bokselskap/tekster/opds/root.xml", "Bokselskap (no)",
455 	};
456 
addOPDSCatalogs(String[] catalogs)457 	private void addOPDSCatalogs(String[] catalogs) {
458 		for (int i=0; i<catalogs.length-1; i+=2) {
459 			String url = catalogs[i];
460 			String name = catalogs[i+1];
461 			saveOPDSCatalog(null, url, name, null, null);
462 		}
463 	}
464 
removeOPDSCatalogsByURLs(String .... urls)465 	public void removeOPDSCatalogsByURLs(String ... urls) {
466 		for (String url : urls) {
467 			execSQLIgnoreErrors("DELETE FROM opds_catalog WHERE url=" + quoteSqlString(url));
468 		}
469 	}
470 
removeOPDSCatalogsFromBlackList()471 	public void removeOPDSCatalogsFromBlackList() {
472 		if (OPDSConst.BLACK_LIST_MODE != OPDSConst.BLACK_LIST_MODE_FORCE) {
473 			removeOPDSCatalogsByURLs("http://flibusta.net/opds/");
474 		} else {
475 			removeOPDSCatalogsByURLs(OPDSConst.BLACK_LIST);
476 		}
477 	}
478 
updateOPDSCatalogLastUsage(String url)479 	public void updateOPDSCatalogLastUsage(String url) {
480 		try {
481 			Long existingIdByUrl = longQuery("SELECT id FROM opds_catalog WHERE url=" + quoteSqlString(url));
482 			if (existingIdByUrl == null)
483 				return;
484 			// update existing
485 			Long lastUsage = longQuery("SELECT max(last_usage) FROM opds_catalog");
486 			if (lastUsage == null)
487 				lastUsage = 1L;
488 			else
489 				lastUsage = lastUsage + 1;
490 			execSQL("UPDATE opds_catalog SET last_usage="+ lastUsage +" WHERE id=" + existingIdByUrl);
491 		} catch (Exception e) {
492 			log.e("exception while updating OPDS catalog item", e);
493 		}
494 	}
495 
496 
saveOPDSCatalog(Long id, String url, String name, String username, String password)497 	public boolean saveOPDSCatalog(Long id, String url, String name, String username, String password) {
498 		if (!isOpened())
499 			return false;
500 		if (url==null || name==null)
501 			return false;
502 		url = url.trim();
503 		name = name.trim();
504 		if (url.length()==0 || name.length()==0)
505 			return false;
506 		try {
507 			Long existingIdByUrl = longQuery("SELECT id FROM opds_catalog WHERE url=" + quoteSqlString(url));
508 			Long existingIdByName = longQuery("SELECT id FROM opds_catalog WHERE name=" + quoteSqlString(name));
509 			if (existingIdByUrl!=null && existingIdByName!=null && !existingIdByName.equals(existingIdByUrl))
510 				return false; // duplicates detected
511 			if (id==null) {
512 				id = existingIdByUrl;
513 				if (id==null)
514 					id = existingIdByName;
515 			}
516 			if (id==null) {
517 				// insert new
518 				execSQL("INSERT INTO opds_catalog (name, url, username, password) VALUES ("+quoteSqlString(name)+", "+quoteSqlString(url)+", "+quoteSqlString(username)+", "+quoteSqlString(password)+")");
519 			} else {
520 				// update existing
521 				execSQL("UPDATE opds_catalog SET name="+quoteSqlString(name)+", url="+quoteSqlString(url)+", username="+quoteSqlString(username)+", password="+quoteSqlString(password)+" WHERE id=" + id);
522 			}
523 			updateOPDSCatalogLastUsage(url);
524 
525 		} catch (Exception e) {
526 			log.e("exception while saving OPDS catalog item", e);
527 			return false;
528 		}
529 		return true;
530 	}
531 
saveSearchHistory(BookInfo book, String sHist)532 	public boolean saveSearchHistory(BookInfo book, String sHist) {
533 		if (!isOpened())
534 			return false;
535 		if (sHist==null)
536 			return false;
537 		if (book.getFileInfo().id == null)
538 			return false; // unknown book id
539 		sHist = sHist.trim();
540 		if (sHist.length()==0)
541 			return false;
542 		try {
543 			execSQL("DELETE FROM search_history where book_fk = " + book.getFileInfo().id+
544 				" and search_text = "+quoteSqlString(sHist));
545 			execSQL("INSERT INTO search_history (book_fk, search_text) values (" + book.getFileInfo().id+
546 					", "+quoteSqlString(sHist)+")");
547 		} catch (Exception e) {
548 			log.e("exception while saving search history item", e);
549 			return false;
550 		}
551 		return true;
552 	}
553 
loadSearchHistory(BookInfo book)554 	public ArrayList<String> loadSearchHistory(BookInfo book) {
555 		log.i("loadSearchHistory()");
556 		String sql = "SELECT search_text FROM search_history where book_fk=" + book.getFileInfo().id + " ORDER BY id desc";
557 		ArrayList<String> list = new ArrayList<>();
558 		try (Cursor rs = mDB.rawQuery(sql, null)) {
559 			if (rs.moveToFirst()) {
560 				do {
561 					String sHist = rs.getString(0);
562 					list.add(sHist);
563 				} while (rs.moveToNext());
564 			}
565 		} catch (Exception e) {
566 			Log.e("cr3", "exception while loading search history", e);
567 		}
568 		return list;
569 	}
570 
loadOPDSCatalogs(ArrayList<FileInfo> list)571 	public boolean loadOPDSCatalogs(ArrayList<FileInfo> list) {
572 		log.i("loadOPDSCatalogs()");
573 		boolean found = false;
574 		String sql = "SELECT id, name, url, username, password FROM opds_catalog ORDER BY last_usage DESC, name";
575 		try (Cursor rs = mDB.rawQuery(sql, null)) {
576 			if (rs.moveToFirst()) {
577 				// remove existing entries
578 				list.clear();
579 				// read DB
580 				do {
581 					long id = rs.getLong(0);
582 					String name = rs.getString(1);
583 					String url = rs.getString(2);
584 					String username = rs.getString(3);
585 					String password = rs.getString(4);
586 					FileInfo opds = new FileInfo();
587 					opds.isDirectory = true;
588 					opds.pathname = FileInfo.OPDS_DIR_PREFIX + url;
589 					opds.filename = name;
590 					opds.username = username;
591 					opds.password = password;
592 					opds.isListed = true;
593 					opds.isScanned = true;
594 					opds.id = id;
595 					list.add(opds);
596 					found = true;
597 				} while (rs.moveToNext());
598 			}
599 		} catch (Exception e) {
600 			Log.e("cr3", "exception while loading list of OPDS catalogs", e);
601 		}
602 		return found;
603 	}
604 
removeOPDSCatalog(Long id)605 	public void removeOPDSCatalog(Long id) {
606 		log.i("removeOPDSCatalog(" + id + ")");
607 		execSQLIgnoreErrors("DELETE FROM opds_catalog WHERE id = " + id);
608 	}
609 
loadFavoriteFolders()610     public ArrayList<FileInfo> loadFavoriteFolders() {
611         log.i("loadFavoriteFolders()");
612         ArrayList<FileInfo> list = new ArrayList<>();
613         String sql = "SELECT id, path, position FROM favorite_folders ORDER BY position, path";
614         try (Cursor rs = mDB.rawQuery(sql, null)) {
615             if ( rs.moveToFirst() ) {
616                 do {
617                     long id = rs.getLong(0);
618                     String path = rs.getString(1);
619                     int pos = rs.getInt(2);
620                     FileInfo favorite = new FileInfo(path);
621                     favorite.id = id;
622                     favorite.seriesNumber = pos;
623                     favorite.setType(FileInfo.TYPE_NOT_SET);
624                     list.add(favorite);
625                 } while (rs.moveToNext());
626             }
627         } catch (Exception e) {
628             Log.e("cr3", "exception while loading list of favorite folders", e);
629         }
630         return list;
631     }
632 
deleteFavoriteFolder(FileInfo folder)633     public void deleteFavoriteFolder(FileInfo folder){
634         execSQLIgnoreErrors("DELETE FROM favorite_folders WHERE id = "+ folder.id);
635     }
636 
updateFavoriteFolder(FileInfo folder)637     public void updateFavoriteFolder(FileInfo folder){
638 		try (SQLiteStatement stmt = mDB.compileStatement("UPDATE favorite_folders SET position = ?, path = ? WHERE id = ?")) {
639 			stmt.bindLong(1, folder.seriesNumber);
640 			stmt.bindString(2, folder.pathname);
641 			stmt.bindLong(3, folder.id);
642 			stmt.execute();
643 		}
644     }
645 
createFavoritesFolder(FileInfo folder)646     public void createFavoritesFolder(FileInfo folder){
647 		try (SQLiteStatement stmt = mDB.compileStatement("INSERT INTO favorite_folders (id, path, position) VALUES (NULL, ?, ?)")) {
648 			stmt.bindString(1, folder.pathname);
649 			stmt.bindLong(2, folder.seriesNumber);
650 			folder.id = stmt.executeInsert();
651 		}
652     }
653 
654 	//=======================================================================================
655     // Bookmarks access code
656     //=======================================================================================
657 	private static final String READ_BOOKMARK_SQL =
658 		"SELECT " +
659 		"id, type, percent, shortcut, time_stamp, " +
660 		"start_pos, end_pos, title_text, pos_text, comment_text, time_elapsed " +
661 		"FROM bookmark b ";
readBookmarkFromCursor(Bookmark v, Cursor rs )662 	private void readBookmarkFromCursor(Bookmark v, Cursor rs )
663 	{
664 		int i=0;
665 		v.setId( rs.getLong(i++) );
666 		v.setType( (int)rs.getLong(i++) );
667 		v.setPercent( (int)rs.getLong(i++) );
668 		v.setShortcut( (int)rs.getLong(i++) );
669 		v.setTimeStamp( rs.getLong(i++) );
670 		v.setStartPos( rs.getString(i++) );
671 		v.setEndPos( rs.getString(i++) );
672 		v.setTitleText( rs.getString(i++) );
673 		v.setPosText( rs.getString(i++) );
674 		v.setCommentText( rs.getString(i++) );
675 		v.setTimeElapsed( rs.getLong(i++) );
676 	}
677 
findBy( Bookmark v, String condition )678 	public boolean findBy( Bookmark v, String condition ) {
679 		boolean found = false;
680 		try (Cursor rs = mDB.rawQuery(READ_BOOKMARK_SQL + " WHERE " + condition, null)) {
681 			if (rs.moveToFirst()) {
682 				readBookmarkFromCursor( v, rs );
683 				found = true;
684 			}
685 		}
686 		return found;
687 	}
688 
load( ArrayList<Bookmark> list, String condition )689 	public boolean load( ArrayList<Bookmark> list, String condition )
690 	{
691 		boolean found = false;
692 		try (Cursor rs = mDB.rawQuery(READ_BOOKMARK_SQL + " WHERE " + condition, null)) {
693 			if ( rs.moveToFirst() ) {
694 				do {
695 					Bookmark v = new Bookmark();
696 					readBookmarkFromCursor( v, rs );
697 					list.add(v);
698 					found = true;
699 				} while ( rs.moveToNext() );
700 			}
701 		}
702 		return found;
703 	}
704 
loadBookmarks(BookInfo book)705 	public void loadBookmarks(BookInfo book) {
706 		if (book.getFileInfo().id == null)
707 			return; // unknown book id
708 		ArrayList<Bookmark> bookmarks = new ArrayList<>();
709 		if (load( bookmarks, "book_fk=" + book.getFileInfo().id + " ORDER BY type" ) ) {
710 			book.setBookmarks(bookmarks);
711 		}
712 	}
713 
714 	//=======================================================================================
715     // Item groups access code
716     //=======================================================================================
717 
718 	/// add items range to parent dir
addItems(FileInfo parent, ArrayList<FileInfo> items, int start, int end)719 	private static void addItems(FileInfo parent, ArrayList<FileInfo> items, int start, int end) {
720 		for (int i=start; i<end; i++) {
721 			items.get(i).parent = parent;
722 			parent.addDir(items.get(i));
723 		}
724 	}
725 
726 	private static abstract class ItemGroupExtractor {
getComparisionField(FileInfo item)727 		public abstract String getComparisionField(FileInfo item);
getItemFirstLetters(FileInfo item, int level)728 		public String getItemFirstLetters(FileInfo item, int level) {
729 			String name = getComparisionField(item); //.filename;
730 			int l = name == null ? 0 : Math.min(name.length(), level);
731 			return l > 0 ? name.substring(0, l).toUpperCase() : "_";
732 		}
733 	}
734 
735 	private static class ItemGroupFilenameExtractor extends ItemGroupExtractor {
736 		@Override
getComparisionField(FileInfo item)737 		public String getComparisionField(FileInfo item) {
738 			return item.filename;
739 		}
740 	}
741 
742 	private static class ItemGroupTitleExtractor extends ItemGroupExtractor {
743 		@Override
getComparisionField(FileInfo item)744 		public String getComparisionField(FileInfo item) {
745 			return item.title;
746 		}
747 	}
748 
createItemGroup(String groupPrefix, String groupPrefixTag)749 	private FileInfo createItemGroup(String groupPrefix, String groupPrefixTag) {
750 		FileInfo groupDir = new FileInfo();
751 		groupDir.isDirectory = true;
752 		groupDir.pathname = groupPrefixTag + groupPrefix;
753 		groupDir.filename = groupPrefix + "...";
754 		groupDir.isListed = true;
755 		groupDir.isScanned = true;
756 		groupDir.id = 0l;
757 		return groupDir;
758 	}
759 
sortItems(ArrayList<FileInfo> items, final ItemGroupExtractor extractor)760 	private void sortItems(ArrayList<FileInfo> items, final ItemGroupExtractor extractor) {
761 		Collections.sort(items, (lhs, rhs) -> {
762 			String l = extractor.getComparisionField(lhs) != null ? extractor.getComparisionField(lhs).toUpperCase() : "";
763 			String r = extractor.getComparisionField(rhs) != null ? extractor.getComparisionField(rhs).toUpperCase() : "";
764 			return l.compareTo(r);
765 		});
766 	}
767 
addGroupedItems(FileInfo parent, ArrayList<FileInfo> items, int start, int end, String groupPrefixTag, int level, final ItemGroupExtractor extractor)768 	private void addGroupedItems(FileInfo parent, ArrayList<FileInfo> items, int start, int end, String groupPrefixTag, int level, final ItemGroupExtractor extractor) {
769 		int itemCount = end - start;
770 		if (itemCount < 1)
771 			return;
772 		// for nested level (>1), create base subgroup, otherwise use parent
773 		if (level > 1 && itemCount > 1) {
774 			String baseFirstLetter = extractor.getItemFirstLetters(items.get(start), level - 1);
775 			FileInfo newGroup = createItemGroup(baseFirstLetter, groupPrefixTag);
776 			newGroup.parent = parent;
777 			parent.addDir(newGroup);
778 			parent = newGroup;
779 		}
780 
781 		// check group count
782 		int topLevelGroupsCount = 0;
783 		String lastFirstLetter = "";
784 		for (int i=start; i<end; i++) {
785 			String firstLetter = extractor.getItemFirstLetters(items.get(i), level);
786 			if (!firstLetter.equals(lastFirstLetter)) {
787 				topLevelGroupsCount++;
788 				lastFirstLetter = firstLetter;
789 			}
790 		}
791 		if (itemCount <= topLevelGroupsCount * 11 / 10 || itemCount < 8) {
792 			// small number of items: add as is
793 			addItems(parent, items, start, end);
794 			return;
795 		}
796 
797 		// divide items into groups
798 		for (int i=start; i<end; ) {
799 			String firstLetter = extractor.getItemFirstLetters(items.get(i), level);
800 			int groupEnd = i + 1;
801 			for (; groupEnd < end; groupEnd++) {
802 				String firstLetter2 = groupEnd < end ? extractor.getItemFirstLetters(items.get(groupEnd), level) : "";
803 				if (!firstLetter.equals(firstLetter2))
804 					break;
805 			}
806 			// group is i..groupEnd
807 			addGroupedItems(parent, items, i, groupEnd, groupPrefixTag, level + 1, extractor);
808 			i = groupEnd;
809 		}
810 	}
811 
812 	private boolean loadItemList(ArrayList<FileInfo> list, String sql, String groupPrefixTag) {
813 		boolean found = false;
814 		try (Cursor rs = mDB.rawQuery(sql, null)) {
815 			if (rs.moveToFirst()) {
816 				// read DB
817 				do {
818 					long id = rs.getLong(0);
819 					String name = rs.getString(1);
820 					if (FileInfo.AUTHOR_PREFIX.equals(groupPrefixTag))
821 						name = Utils.authorNameFileAs(name);
822 					int bookCount = rs.getInt(2);
823 
824 					FileInfo item = new FileInfo();
825 					item.isDirectory = true;
826 					item.pathname = groupPrefixTag + id;
827 					item.filename = name;
828 					item.isListed = true;
829 					item.isScanned = true;
830 					item.id = id;
831 					item.tag = bookCount;
832 
833 					list.add(item);
834 					found = true;
835 				} while (rs.moveToNext());
836 			}
837 		} catch (Exception e) {
838 			Log.e("cr3", "exception while loading list of authors", e);
839 		}
840 		sortItems(list, new ItemGroupFilenameExtractor());
841 		return found;
842 	}
843 
844 	public boolean loadGenresList(FileInfo parent, boolean showEmptyGenres) {
845 		Log.i("cr3", "loadGenresList()");
846 		beginReading();
847 		parent.clear();
848 		ArrayList<FileInfo> list = new ArrayList<FileInfo>();
849 		String sql = "SELECT code, (SELECT COUNT(DISTINCT book_fk) FROM book_genre bg JOIN genre g ON g.id=bg.genre_fk JOIN genre_hier gh ON gh.genre_fk = g.id WHERE gh.group_fk=gg.id) as book_count FROM genre_group gg";
850 		try (Cursor rs = mDB.rawQuery(sql, null)) {
851 			if (rs.moveToFirst()) {
852 				// read DB
853 				do {
854 					String code = rs.getString(0);
855 					int bookCount = rs.getInt(1);
856 					if (bookCount > 0 || showEmptyGenres) {
857 						FileInfo item = new FileInfo();
858 						item.isDirectory = true;
859 						item.pathname = FileInfo.GENRES_PREFIX + code;
860 						item.filename = Services.getGenresCollection().translate(code);
861 						item.isListed = true;
862 						item.isScanned = true;
863 						item.id = (long) -1;        // fake id
864 						item.tag = bookCount;
865 						list.add(item);
866 					}
867 				} while (rs.moveToNext());
868 			}
869 		} catch (Exception e) {
870 			Log.e("cr3", "exception while loading list of authors", e);
871 		}
872 		endReading();
873 		addItems(parent, list, 0, list.size());
874 		return true;
875 	}
876 
877 
loadAuthorsList(FileInfo parent)878 	public boolean loadAuthorsList(FileInfo parent) {
879 		Log.i("cr3", "loadAuthorsList()");
880 		beginReading();
881 		parent.clear();
882 		ArrayList<FileInfo> list = new ArrayList<FileInfo>();
883 		String sql = "SELECT author.id, author.name, count(*) as book_count FROM author INNER JOIN book_author ON  book_author.author_fk = author.id GROUP BY author.name, author.id ORDER BY author.name";
884 		boolean found = loadItemList(list, sql, FileInfo.AUTHOR_PREFIX);
885 		addGroupedItems(parent, list, 0, list.size(), FileInfo.AUTHOR_GROUP_PREFIX, 1, new ItemGroupFilenameExtractor());
886 		endReading();
887 		return found;
888 	}
889 
loadSeriesList(FileInfo parent)890 	public boolean loadSeriesList(FileInfo parent) {
891 		Log.i("cr3", "loadSeriesList()");
892 		beginReading();
893 		parent.clear();
894 		ArrayList<FileInfo> list = new ArrayList<FileInfo>();
895 		String sql = "SELECT series.id, series.name, count(*) as book_count FROM series INNER JOIN book ON book.series_fk = series.id GROUP BY series.name, series.id ORDER BY series.name";
896 		boolean found = loadItemList(list, sql, FileInfo.SERIES_PREFIX);
897 		addGroupedItems(parent, list, 0, list.size(), FileInfo.SERIES_GROUP_PREFIX, 1, new ItemGroupFilenameExtractor());
898 		endReading();
899 		return found;
900 	}
901 
loadTitleList(FileInfo parent)902 	public boolean loadTitleList(FileInfo parent) {
903 		Log.i("cr3", "loadTitleList()");
904 		beginReading();
905 		parent.clear();
906 		ArrayList<FileInfo> list = new ArrayList<FileInfo>();
907 		String sql = READ_FILEINFO_SQL + " WHERE b.title IS NOT NULL AND b.title != '' ORDER BY b.title";
908 		boolean found = findBooks(sql, list);
909 		sortItems(list, new ItemGroupTitleExtractor());
910 		// remove duplicate titles
911 		for (int i=list.size() - 1; i>0; i--) {
912 			String title = list.get(i).title;
913 			if (title == null) {
914 				list.remove(i);
915 				continue;
916 			}
917 			String prevTitle = list.get(i - 1).title;
918 			if (title.equals(prevTitle))
919 				list.remove(i);
920 		}
921 		addGroupedItems(parent, list, 0, list.size(), FileInfo.TITLE_GROUP_PREFIX, 1, new ItemGroupTitleExtractor());
922 		endReading();
923 		return found;
924 	}
925 
findAuthorBooks(ArrayList<FileInfo> list, long authorId)926 	public boolean findAuthorBooks(ArrayList<FileInfo> list, long authorId)
927 	{
928 		if (!isOpened())
929 			return false;
930 		String sql = READ_FILEINFO_SQL + " INNER JOIN book_author ON book_author.book_fk = b.id WHERE book_author.author_fk = " + authorId + " ORDER BY b.title";
931 		return findBooks(sql, list);
932 	}
933 
findSeriesBooks(ArrayList<FileInfo> list, long seriesId)934 	public boolean findSeriesBooks(ArrayList<FileInfo> list, long seriesId)
935 	{
936 		if (!isOpened())
937 			return false;
938 		String sql = READ_FILEINFO_SQL + " INNER JOIN series ON series.id = b.series_fk WHERE series.id = " + seriesId + " ORDER BY b.series_number, b.title";
939 		return findBooks(sql, list);
940 	}
941 
findBooksByRating(ArrayList<FileInfo> list, int minRate, int maxRate)942 	public boolean findBooksByRating(ArrayList<FileInfo> list, int minRate, int maxRate)
943 	{
944 		if (!isOpened())
945 			return false;
946 		String sql = READ_FILEINFO_SQL + " WHERE ((flags>>20)&15) BETWEEN " + minRate + " AND " + maxRate + " ORDER BY ((flags>>20)&15) DESC, b.title LIMIT 1000";
947 		return findBooks(sql, list);
948 	}
949 
findBooksByState(ArrayList<FileInfo> list, int state)950 	public boolean findBooksByState(ArrayList<FileInfo> list, int state)
951 	{
952 		if (!isOpened())
953 			return false;
954 		String sql = READ_FILEINFO_SQL + " WHERE ((flags>>16)&15) = " + state + " ORDER BY b.title LIMIT 1000";
955 		return findBooks(sql, list);
956 	}
957 
findAuthors(int maxCount, String authorPattern)958 	private String findAuthors(int maxCount, String authorPattern) {
959 		StringBuilder buf = new StringBuilder();
960 		String sql = "SELECT id, name FROM author";
961 		int count = 0;
962 		try (Cursor rs = mDB.rawQuery(sql, null)) {
963 			if (rs.moveToFirst()) {
964 				do {
965 					long id = rs.getLong(0);
966 					String name = rs.getString(1);
967 					if (Utils.matchPattern(name, authorPattern)) {
968 						if (buf.length() != 0)
969 							buf.append(",");
970 						buf.append(id);
971 						count++;
972 						if (count >= maxCount)
973 							break;
974 					}
975 				} while (rs.moveToNext());
976 			}
977 		}
978 		return buf.toString();
979 	}
980 
981 
982 	//=======================================================================================
983     // Series access code
984     //=======================================================================================
985 
986 	private SQLiteStatement seriesStmt;
987 	private SQLiteStatement seriesSelectStmt;
988 	private HashMap<String,Long> seriesCache = new HashMap<String,Long>();
getSeriesId( String seriesName )989 	public Long getSeriesId( String seriesName )
990 	{
991 		if ( seriesName==null || seriesName.trim().length()==0 )
992 			return null;
993 		Long id = seriesCache.get(seriesName);
994 		if ( id!=null )
995 			return id;
996 		if (seriesSelectStmt == null)
997 			seriesSelectStmt = mDB.compileStatement("SELECT id FROM series WHERE name=?");
998 		try {
999 			seriesSelectStmt.bindString(1, seriesName);
1000 			return seriesSelectStmt.simpleQueryForLong();
1001 		} catch ( Exception e ) {
1002 			// not found
1003 		}
1004 		if (seriesStmt == null)
1005 			seriesStmt = mDB.compileStatement("INSERT INTO series (id, name) VALUES (NULL,?)");
1006 		seriesStmt.bindString(1, seriesName);
1007 		id = seriesStmt.executeInsert();
1008 		seriesCache.put( seriesName, id );
1009 		return id;
1010 	}
1011 
1012 	//=======================================================================================
1013     // Folder access code
1014     //=======================================================================================
1015 
1016 	private SQLiteStatement folderStmt;
1017 	private SQLiteStatement folderSelectStmt;
1018 	private HashMap<String,Long> folderCache = new HashMap<String,Long>();
getFolderId( String folderName )1019 	public Long getFolderId( String folderName )
1020 	{
1021 		if ( folderName==null || folderName.trim().length()==0 )
1022 			return null;
1023 		Long id = folderCache.get(folderName);
1024 		if ( id!=null )
1025 			return id;
1026 		if ( folderSelectStmt==null )
1027 			folderSelectStmt = mDB.compileStatement("SELECT id FROM folder WHERE name=?");
1028 		try {
1029 			folderSelectStmt.bindString(1, folderName);
1030 			return folderSelectStmt.simpleQueryForLong();
1031 		} catch ( Exception e ) {
1032 			// not found
1033 		}
1034 		if ( folderStmt==null )
1035 			folderStmt = mDB.compileStatement("INSERT INTO folder (id, name) VALUES (NULL,?)");
1036 		folderStmt.bindString(1, folderName);
1037 		id = folderStmt.executeInsert();
1038 		folderCache.put( folderName, id );
1039 		return id;
1040 	}
1041 
1042 	//=======================================================================================
1043     // Author access code
1044     //=======================================================================================
1045 
1046 	private SQLiteStatement authorStmt;
1047 	private SQLiteStatement authorSelectStmt;
1048 	private HashMap<String,Long> authorCache = new HashMap<String,Long>();
getAuthorId( String authorName )1049 	private Long getAuthorId( String authorName ) {
1050 		if ( authorName==null || authorName.trim().length()==0 )
1051 			return null;
1052 		Long id = authorCache.get(authorName);
1053 		if ( id!=null )
1054 			return id;
1055 		if ( authorSelectStmt==null )
1056 			authorSelectStmt = mDB.compileStatement("SELECT id FROM author WHERE name=?");
1057 		try {
1058 			authorSelectStmt.bindString(1, authorName);
1059 			return authorSelectStmt.simpleQueryForLong();
1060 		} catch ( Exception e ) {
1061 			// not found
1062 		}
1063 		if ( authorStmt==null )
1064 			authorStmt = mDB.compileStatement("INSERT INTO author (id, name) VALUES (NULL,?)");
1065 		authorStmt.bindString(1, authorName);
1066 		id = authorStmt.executeInsert();
1067 		authorCache.put( authorName, id );
1068 		return id;
1069 	}
1070 
getAuthorIds( String authorNames )1071 	private Long[] getAuthorIds( String authorNames ) {
1072 		if ( authorNames==null || authorNames.trim().length()==0 )
1073 			return null;
1074 		String[] names = authorNames.split("\\|");
1075 		if ( names==null || names.length==0 )
1076 			return null;
1077 		ArrayList<Long> ids = new ArrayList<Long>(names.length);
1078 		for ( String name : names ) {
1079 			Long id = getAuthorId(name);
1080 			if ( id!=null )
1081 				ids.add(id);
1082 		}
1083 		if ( ids.size()>0 )
1084 			return ids.toArray(new Long[ids.size()]);
1085 		return null;
1086 	}
1087 
saveBookAuthors( Long bookId, Long[] authors)1088 	public void saveBookAuthors( Long bookId, Long[] authors) {
1089 		if ( authors==null || authors.length==0 )
1090 			return;
1091 		String insertQuery = "INSERT OR IGNORE INTO book_author (book_fk,author_fk) VALUES ";
1092 		for ( Long id : authors ) {
1093 			String sql = insertQuery + "(" + bookId + "," + id + ")";
1094 			//Log.v("cr3", "executing: " + sql);
1095 			mDB.execSQL(sql);
1096 		}
1097 	}
1098 
getGenresIds( String keywords )1099 	private Integer[] getGenresIds( String keywords ) {
1100 		if ( keywords==null || keywords.trim().length()==0 )
1101 			return null;
1102 		String[] codes = keywords.split("\\|");
1103 		if ( codes==null || codes.length==0 )
1104 			return null;
1105 		GenresCollection genresCollection = Services.getGenresCollection();
1106 		ArrayList<Integer> ids = new ArrayList<Integer>(codes.length);
1107 		for ( String code : codes ) {
1108 			GenresCollection.GenreRecord genre = genresCollection.byCode(code);
1109 			if (null != genre) {
1110 				int id = genre.getId();
1111 				ids.add(id);
1112 			}
1113 		}
1114 		if ( ids.size()>0 )
1115 			return ids.toArray(new Integer[0]);
1116 		return null;
1117 	}
1118 
saveBookGenres( Long bookId, Integer[] genres)1119 	public void saveBookGenres( Long bookId, Integer[] genres) {
1120 		if ( genres==null || genres.length==0 )
1121 			return;
1122 		String insertQuery = "INSERT OR IGNORE INTO book_genre (book_fk,genre_fk) VALUES ";
1123 		for ( Integer id : genres ) {
1124 			String sql = insertQuery + "(" + bookId + "," + id + ")";
1125 			//Log.v("cr3", "executing: " + sql);
1126 			mDB.execSQL(sql);
1127 		}
1128 	}
1129 
eq(String s1, String s2)1130 	private static boolean eq(String s1, String s2)
1131 	{
1132 		if ( s1!=null )
1133 			return s1.equals(s2);
1134 		return s2==null;
1135 	}
1136 
1137 	private final static int FILE_INFO_CACHE_SIZE = 3000;
1138 	private FileInfoCache fileInfoCache = new FileInfoCache(FILE_INFO_CACHE_SIZE);
1139 
findMovedFileInfo(String path)1140 	private FileInfo findMovedFileInfo(String path) {
1141 		ArrayList<FileInfo> list = new ArrayList<FileInfo>();
1142 		FileInfo fi = new FileInfo(path);
1143 		if (fi.exists()) {
1144 			if (findAllBy(list, "filename", fi.filename)) {
1145 				for (FileInfo item : list) {
1146 					if (item.exists())
1147 						continue;
1148 					if (item.size == fi.size) {
1149 						log.i("Found record for file of the same name and size: treat as moved " + item.filename + " " + item.size);
1150 						// fix and save
1151 						item.pathname = fi.pathname;
1152 						item.arcname = fi.arcname;
1153 						item.arcsize = fi.arcsize;
1154 						item.path = fi.path;
1155 						item.createTime = fi.createTime;
1156 						save(item);
1157 						fileInfoCache.put(item);
1158 						return item;
1159 					}
1160 				}
1161 			}
1162 		}
1163 		return null;
1164 	}
1165 
findFileInfoByPathname(String path, boolean detectMoved)1166 	private FileInfo findFileInfoByPathname(String path, boolean detectMoved)
1167 	{
1168 		FileInfo existing = fileInfoCache.get(path);
1169 		if (existing != null)
1170 			return existing;
1171 		FileInfo fileInfo = new FileInfo();
1172 		if (findBy(fileInfo, "pathname", path)) {
1173 			fileInfoCache.put(fileInfo);
1174 			return fileInfo;
1175 		}
1176 		if (!detectMoved)
1177 			return null;
1178 		return findMovedFileInfo(path);
1179 	}
1180 
findFileInfoById(Long id)1181 	private FileInfo findFileInfoById(Long id)
1182 	{
1183 		if (id == null)
1184 			return null;
1185 		FileInfo existing = fileInfoCache.get(id);
1186 		if (existing != null)
1187 			return existing;
1188 		FileInfo fileInfo = new FileInfo();
1189 		if (findBy( fileInfo, "b.id", id)) {
1190 			return fileInfo;
1191 		}
1192 		return null;
1193 	}
1194 
findBy( FileInfo fileInfo, String fieldName, Object fieldValue )1195 	private boolean findBy( FileInfo fileInfo, String fieldName, Object fieldValue )
1196 	{
1197 		String condition;
1198 		StringBuilder buf = new StringBuilder(" WHERE ");
1199 		buf.append(fieldName);
1200 		if ( fieldValue==null ) {
1201 			buf.append(" IS NULL ");
1202 		} else {
1203 			buf.append("=");
1204 			DatabaseUtils.appendValueToSql(buf, fieldValue);
1205 			buf.append(" ");
1206 		}
1207 		condition = buf.toString();
1208 		boolean found = false;
1209 		try (Cursor rs = mDB.rawQuery(READ_FILEINFO_SQL +
1210 				condition, null)) {
1211 			if (rs.moveToFirst()) {
1212 				readFileInfoFromCursor(fileInfo, rs);
1213 				found = true;
1214 			}
1215 		}
1216 		return found;
1217 	}
1218 
findAllBy( ArrayList<FileInfo> result, String fieldName, Object fieldValue )1219 	private boolean findAllBy( ArrayList<FileInfo> result, String fieldName, Object fieldValue )
1220 	{
1221 		String condition;
1222 		StringBuilder buf = new StringBuilder(" WHERE ");
1223 		buf.append(fieldName);
1224 		if ( fieldValue==null ) {
1225 			buf.append(" IS NULL ");
1226 		} else {
1227 			buf.append("=");
1228 			DatabaseUtils.appendValueToSql(buf, fieldValue);
1229 			buf.append(" ");
1230 		}
1231 		condition = buf.toString();
1232 		boolean found = false;
1233 		try (Cursor rs = mDB.rawQuery(READ_FILEINFO_SQL +
1234 				condition, null)) {
1235 			if (rs.moveToFirst()) {
1236 				do {
1237 					FileInfo fileInfo = new FileInfo();
1238 					readFileInfoFromCursor(fileInfo, rs);
1239 					result.add(fileInfo);
1240 					found = true;
1241 				} while (rs.moveToNext());
1242 			}
1243 		}
1244 		return found;
1245 	}
1246 
loadBookmarks(FileInfo fileInfo)1247 	private HashMap<String, Bookmark> loadBookmarks(FileInfo fileInfo) {
1248 		HashMap<String, Bookmark> map = new HashMap<>();
1249 		if (fileInfo.id != null) {
1250 			ArrayList<Bookmark> bookmarks = new ArrayList<>();
1251 			if (load(bookmarks, "book_fk=" + fileInfo.id + " ORDER BY type")) {
1252 				for (Bookmark b : bookmarks) {
1253 					// delete non-unique bookmarks
1254 					String key = b.getUniqueKey();
1255 					if (!map.containsKey(key))
1256 						map.put(key, b);
1257 					else {
1258 						log.w("Removing non-unique bookmark " + b + " for " + fileInfo.getPathName());
1259 						deleteBookmark(b);
1260 					}
1261 				}
1262 			}
1263 		}
1264 		return map;
1265 	}
1266 
save( Bookmark v, long bookId )1267 	private boolean save( Bookmark v, long bookId )
1268 	{
1269 		Log.d("cr3db", "saving bookmark id=" + v.getId() + ", bookId=" + bookId + ", pos=" + v.getStartPos());
1270 		Bookmark oldValue = new Bookmark();
1271 		if ( v.getId()!=null ) {
1272 			// update
1273 			oldValue.setId(v.getId());
1274 			if ( findBy(oldValue, "book_fk=" + bookId + " AND id=" + v.getId()) ) {
1275 				// found, updating
1276 				QueryHelper h = new QueryHelper(v, oldValue, bookId);
1277 				h.update(v.getId());
1278 			} else {
1279 				oldValue = new Bookmark();
1280 				QueryHelper h = new QueryHelper(v, oldValue, bookId);
1281 				v.setId( h.insert() );
1282 			}
1283 		} else {
1284 			QueryHelper h = new QueryHelper(v, oldValue, bookId);
1285 			v.setId( h.insert() );
1286 		}
1287 		return true;
1288 	}
1289 
saveBookInfo(BookInfo bookInfo)1290 	public void saveBookInfo(BookInfo bookInfo)	{
1291 		if (!isOpened()) {
1292 			Log.e("cr3db", "cannot save book info : DB is closed");
1293 			return;
1294 		}
1295 		if (bookInfo == null || bookInfo.getFileInfo() == null)
1296 			return;
1297 
1298 		// save main data
1299 		save(bookInfo.getFileInfo());
1300 		fileInfoCache.put(bookInfo.getFileInfo());
1301 
1302 		// save bookmarks
1303 		HashMap<String, Bookmark> existingBookmarks = loadBookmarks(bookInfo.getFileInfo());
1304 		int changed = 0;
1305 		int removed = 0;
1306 		int added = 0;
1307 		for (Bookmark bmk : bookInfo.getAllBookmarks()) {
1308 			 Bookmark existing = existingBookmarks.get(bmk.getUniqueKey());
1309 			 if (existing != null) {
1310 				 bmk.setId(existing.getId());
1311 				 if (!bmk.equals(existing)) {
1312 					 save(bmk, bookInfo.getFileInfo().id);
1313 					 changed++;
1314 				 }
1315 				 existingBookmarks.remove(bmk.getUniqueKey()); // saved
1316 			 } else {
1317 				 // create new
1318 			 	 save(bmk, bookInfo.getFileInfo().id);
1319 			 	 added++;
1320 			 }
1321 		}
1322 		if (existingBookmarks.size() > 0) {
1323 			// remove bookmarks not found in new object
1324 			for (Bookmark bmk : existingBookmarks.values()) {
1325 				deleteBookmark(bmk);
1326 				removed++;
1327 			}
1328 		}
1329 		if (added + changed + removed > 0)
1330 			vlog.i("bookmarks added:" + added + ", updated: " + changed + ", removed:" + removed);
1331 	}
1332 
save(FileInfo fileInfo)1333 	private boolean save(FileInfo fileInfo)	{
1334 		boolean authorsChanged = true;
1335 		boolean genresChanged = true;
1336 		try {
1337 			FileInfo oldValue = findFileInfoByPathname(fileInfo.getPathName(), false);
1338 			if (oldValue == null && fileInfo.id != null)
1339 				oldValue = findFileInfoById(fileInfo.id);
1340 			if (oldValue != null && fileInfo.id == null && oldValue.id != null)
1341 				fileInfo.id = oldValue.id;
1342 			if (oldValue != null) {
1343 				// found, updating
1344 				if (!fileInfo.equals(oldValue)) {
1345 					vlog.d("updating file " + fileInfo.getPathName());
1346 					beginChanges();
1347 					QueryHelper h = new QueryHelper(fileInfo, oldValue);
1348 					h.update(fileInfo.id);
1349 				}
1350 				authorsChanged = !eq(fileInfo.authors, oldValue.authors);
1351 				genresChanged = !eq(fileInfo.genres, oldValue.genres);
1352 			} else {
1353 				// inserting
1354 				vlog.d("inserting new file " + fileInfo.getPathName());
1355 				beginChanges();
1356 				QueryHelper h = new QueryHelper(fileInfo, new FileInfo());
1357 				fileInfo.id = h.insert();
1358 				authorsChanged = true;
1359 				genresChanged = true;
1360 			}
1361 
1362 			fileInfoCache.put(fileInfo);
1363 			if (fileInfo.id != null) {
1364 				if ( authorsChanged ) {
1365 					vlog.d("updating authors for file " + fileInfo.getPathName());
1366 					beginChanges();
1367 					Long[] authorIds = getAuthorIds(fileInfo.authors);
1368 					saveBookAuthors(fileInfo.id, authorIds);
1369 				}
1370 				if (genresChanged) {
1371 					vlog.d("updating genres for file " + fileInfo.getPathName());
1372 					beginChanges();
1373 					Integer[] genresIds = getGenresIds(fileInfo.genres);
1374 					saveBookGenres(fileInfo.id, genresIds);
1375 				}
1376 				return true;
1377 			}
1378 			return false;
1379 		} catch (SQLiteException e) {
1380 			log.e("error while writing to DB", e);
1381 			return false;
1382 		}
1383 	}
1384 
saveFileInfos(Collection<FileInfo> list)1385 	public void saveFileInfos(Collection<FileInfo> list)
1386 	{
1387 		Log.v("cr3db", "save BookInfo collection: " + list.size() + " items");
1388 		if (!isOpened()) {
1389 			Log.e("cr3db", "cannot save book info : DB is closed");
1390 			return;
1391 		}
1392 		for (FileInfo fileInfo : list) {
1393 			save(fileInfo);
1394 		}
1395 	}
1396 
1397 	/**
1398 	 * Load recent books list, with bookmarks
1399 	 * @param maxCount is max number of recent books to get
1400 	 * @return list of loaded books
1401 	 */
loadRecentBooks(int maxCount)1402 	public ArrayList<BookInfo> loadRecentBooks(int maxCount)
1403 	{
1404 		ArrayList<FileInfo> list = new ArrayList<FileInfo>();
1405 		if (!isOpened())
1406 			return null;
1407 		beginReading();
1408 		findRecentBooks(list, maxCount, maxCount*10);
1409 		ArrayList<BookInfo> res = new ArrayList<BookInfo>(list.size());
1410 		for (FileInfo f : list) {
1411 			FileInfo file = fileInfoCache.get(f.getPathName()); // try using cached value instead
1412 			if (file == null) {
1413 				file = f;
1414 				fileInfoCache.put(file);
1415 			}
1416 			BookInfo item = new BookInfo(new FileInfo(file));
1417 			loadBookmarks(item);
1418 			res.add(item);
1419 		}
1420 		endReading();
1421 		return res;
1422 	}
1423 
findRecentBooks( ArrayList<FileInfo> list, int maxCount, int limit )1424 	private boolean findRecentBooks( ArrayList<FileInfo> list, int maxCount, int limit )
1425 	{
1426 		String sql = READ_FILEINFO_SQL + " WHERE last_access_time>0 ORDER BY last_access_time DESC LIMIT " + limit;
1427 		boolean found = false;
1428 		try (Cursor rs = mDB.rawQuery(sql, null)) {
1429 			if (rs.moveToFirst()) {
1430 				do {
1431 					FileInfo fileInfo = new FileInfo();
1432 					readFileInfoFromCursor(fileInfo, rs);
1433 					if (!fileInfo.fileExists())
1434 						continue;
1435 					list.add(fileInfo);
1436 					fileInfoCache.put(fileInfo);
1437 					found = true;
1438 					if (list.size() > maxCount)
1439 						break;
1440 				} while (rs.moveToNext());
1441 			}
1442 		}
1443 		return found;
1444 	}
1445 
1446 
1447 	//=======================================================================================
1448     // File info access code
1449     //=======================================================================================
1450 
1451 	public class QueryHelper {
1452 		String tableName;
QueryHelper(String tableName)1453 		QueryHelper(String tableName)
1454 		{
1455 			this.tableName = tableName;
1456 		}
1457 		ArrayList<String> fields = new ArrayList<String>();
1458 		ArrayList<Object> values = new ArrayList<Object>();
add(String fieldName, int value, int oldValue )1459 		QueryHelper add(String fieldName, int value, int oldValue )
1460 		{
1461 			if ( value!=oldValue ) {
1462 				fields.add(fieldName);
1463 				values.add(Long.valueOf(value));
1464 			}
1465 			return this;
1466 		}
add(String fieldName, Long value, Long oldValue )1467 		QueryHelper add(String fieldName, Long value, Long oldValue )
1468 		{
1469 			if ( value!=null && (oldValue==null || !oldValue.equals(value))) {
1470 				fields.add(fieldName);
1471 				values.add(value);
1472 			}
1473 			return this;
1474 		}
add(String fieldName, String value, String oldValue)1475 		QueryHelper add(String fieldName, String value, String oldValue)
1476 		{
1477 			if ( value!=null && (oldValue==null || !oldValue.equals(value))) {
1478 				fields.add(fieldName);
1479 				values.add(value);
1480 			}
1481 			return this;
1482 		}
add(String fieldName, Double value, Double oldValue)1483 		QueryHelper add(String fieldName, Double value, Double oldValue)
1484 		{
1485 			if ( value!=null && (oldValue==null || !oldValue.equals(value))) {
1486 				fields.add(fieldName);
1487 				values.add(value);
1488 			}
1489 			return this;
1490 		}
insert()1491 		Long insert()
1492 		{
1493 			if ( fields.size()==0 )
1494 				return null;
1495 			beginChanges();
1496 			StringBuilder valueBuf = new StringBuilder();
1497 			try {
1498 				String ignoreOption = ""; //"OR IGNORE ";
1499 				StringBuilder buf = new StringBuilder("INSERT " + ignoreOption + " INTO ");
1500 				buf.append(tableName);
1501 				buf.append(" (id");
1502 				for ( String field : fields ) {
1503 					buf.append(",");
1504 					buf.append(field);
1505 				}
1506 				buf.append(") VALUES (NULL");
1507 				for ( @SuppressWarnings("unused") String field : fields ) {
1508 					buf.append(",");
1509 					buf.append("?");
1510 				}
1511 				buf.append(")");
1512 				String sql = buf.toString();
1513 				Log.d("cr3db", "going to execute " + sql);
1514 				long id;
1515 				try (SQLiteStatement stmt = mDB.compileStatement(sql)) {
1516 					for (int i = 1; i <= values.size(); i++) {
1517 						Object v = values.get(i - 1);
1518 						valueBuf.append(v != null ? v.toString() : "null");
1519 						valueBuf.append(",");
1520 						if (v == null)
1521 							stmt.bindNull(i);
1522 						else if (v instanceof String)
1523 							stmt.bindString(i, (String) v);
1524 						else if (v instanceof Long)
1525 							stmt.bindLong(i, (Long) v);
1526 						else if (v instanceof Double)
1527 							stmt.bindDouble(i, (Double) v);
1528 					}
1529 					id = stmt.executeInsert();
1530 					Log.d("cr3db", "added book, id=" + id + ", query=" + sql);
1531 				}
1532 				return id;
1533 			} catch ( Exception e ) {
1534 				Log.e("cr3db", "insert failed: " + e.getMessage());
1535 				Log.e("cr3db", "values: " + valueBuf.toString());
1536 				return null;
1537 			}
1538 		}
update( Long id )1539 		boolean update( Long id )
1540 		{
1541 			if ( fields.size()==0 )
1542 				return false;
1543 			beginChanges();
1544 			StringBuilder buf = new StringBuilder("UPDATE ");
1545 			buf.append(tableName);
1546 			buf.append(" SET ");
1547 			boolean first = true;
1548 			for ( String field : fields ) {
1549 				if ( !first )
1550 					buf.append(",");
1551 				buf.append(field);
1552 				buf.append("=?");
1553 				first = false;
1554 			}
1555 			buf.append(" WHERE id=" + id );
1556 			vlog.v("executing " + buf);
1557 			mDB.execSQL(buf.toString(), values.toArray());
1558 			return true;
1559 		}
fromFormat( DocumentFormat f )1560 		Long fromFormat( DocumentFormat f )
1561 		{
1562 			if ( f==null )
1563 				return null;
1564 			return (long)f.ordinal();
1565 		}
QueryHelper( FileInfo newValue, FileInfo oldValue )1566 		QueryHelper( FileInfo newValue, FileInfo oldValue )
1567 		{
1568 			this("book");
1569 			add("pathname", newValue.getPathName(), oldValue.getPathName());
1570 			add("folder_fk", getFolderId(newValue.path), getFolderId(oldValue.path));
1571 			add("filename", newValue.filename, oldValue.filename);
1572 			add("arcname", newValue.arcname, oldValue.arcname);
1573 			add("title", newValue.title, oldValue.title);
1574 			add("series_fk", getSeriesId(newValue.series), getSeriesId(oldValue.series));
1575 			add("series_number", (long)newValue.seriesNumber, (long)oldValue.seriesNumber);
1576 			add("format", fromFormat(newValue.format), fromFormat(oldValue.format));
1577 			add("filesize", (long)newValue.size, (long)oldValue.size);
1578 			add("arcsize", (long)newValue.arcsize, (long)oldValue.arcsize);
1579 			add("last_access_time", (long)newValue.lastAccessTime, (long)oldValue.lastAccessTime);
1580 			add("create_time", (long)newValue.createTime, (long)oldValue.createTime);
1581 			add("flags", (long)newValue.flags, (long)oldValue.flags);
1582 			add("language", newValue.language, oldValue.language);
1583 			add("description", newValue.description, oldValue.description);
1584 			add("crc32", newValue.crc32, oldValue.crc32);
1585 			add("domVersion", newValue.domVersion, oldValue.domVersion);
1586 			add("rendFlags", newValue.blockRenderingFlags, oldValue.blockRenderingFlags);
1587 			if (fields.size() == 0)
1588 				vlog.v("QueryHelper: no fields to update");
1589 		}
QueryHelper( Bookmark newValue, Bookmark oldValue, long bookId )1590 		QueryHelper( Bookmark newValue, Bookmark oldValue, long bookId )
1591 		{
1592 			this("bookmark");
1593 			add("book_fk", bookId, oldValue.getId()!=null ? bookId : null);
1594 			add("type", newValue.getType(), oldValue.getType());
1595 			add("percent", newValue.getPercent(), oldValue.getPercent());
1596 			add("shortcut", newValue.getShortcut(), oldValue.getShortcut());
1597 			add("start_pos", newValue.getStartPos(), oldValue.getStartPos());
1598 			add("end_pos", newValue.getEndPos(), oldValue.getEndPos());
1599 			add("title_text", newValue.getTitleText(), oldValue.getTitleText());
1600 			add("pos_text", newValue.getPosText(), oldValue.getPosText());
1601 			add("comment_text", newValue.getCommentText(), oldValue.getCommentText());
1602 			add("time_stamp", newValue.getTimeStamp(), oldValue.getTimeStamp());
1603 			add("time_elapsed", newValue.getTimeElapsed(), oldValue.getTimeElapsed());
1604 		}
1605 	}
1606 
1607 	private static final String READ_FILEINFO_FIELDS =
1608 		"b.id AS id, pathname," +
1609 		"f.name as path, " +
1610 		"filename, arcname, title, " +
1611 		"(SELECT GROUP_CONCAT(a.name,'|') FROM author a JOIN book_author ba ON a.id=ba.author_fk WHERE ba.book_fk=b.id) as authors, " +
1612 		"(SELECT GROUP_CONCAT(g.code,'|') FROM genre g JOIN book_genre bg ON g.id=bg.genre_fk WHERE bg.book_fk=b.id) as genres, " +
1613 		"s.name as series_name, " +
1614 		"series_number, " +
1615 		"format, filesize, arcsize, " +
1616 		"create_time, last_access_time, flags, language, description, crc32, domVersion, rendFlags ";
1617 
1618 	private static final String READ_FILEINFO_SQL =
1619 		"SELECT " +
1620 		READ_FILEINFO_FIELDS +
1621 		"FROM book b " +
1622 		"LEFT JOIN series s ON s.id=b.series_fk " +
1623 		"LEFT JOIN folder f ON f.id=b.folder_fk ";
1624 
readFileInfoFromCursor(FileInfo fileInfo, Cursor rs)1625 	private void readFileInfoFromCursor(FileInfo fileInfo, Cursor rs) {
1626 		int i = 0;
1627 		fileInfo.id = rs.getLong(i++);
1628 		String pathName = rs.getString(i++);
1629 		String[] parts = FileInfo.splitArcName(pathName);
1630 		fileInfo.pathname = parts[0];
1631 		fileInfo.path = rs.getString(i++);
1632 		fileInfo.filename = rs.getString(i++);
1633 		fileInfo.arcname = rs.getString(i++);
1634 		fileInfo.title = rs.getString(i++);
1635 		fileInfo.authors = rs.getString(i++);
1636 		fileInfo.genres = rs.getString(i++);
1637 		fileInfo.series = rs.getString(i++);
1638 		fileInfo.seriesNumber = rs.getInt(i++);
1639 		fileInfo.format = DocumentFormat.byId(rs.getInt(i++));
1640 		fileInfo.size = rs.getInt(i++);
1641 		fileInfo.arcsize = rs.getInt(i++);
1642 		fileInfo.createTime = rs.getInt(i++);
1643 		fileInfo.lastAccessTime = rs.getInt(i++);
1644 		fileInfo.flags = rs.getInt(i++);
1645 		fileInfo.language = rs.getString(i++);
1646 		fileInfo.description = rs.getString(i++);
1647 		fileInfo.crc32 = rs.getLong(i++);
1648 		fileInfo.domVersion = rs.getInt(i++);
1649 		fileInfo.blockRenderingFlags = rs.getInt(i++);
1650 		fileInfo.isArchive = fileInfo.arcname != null;
1651 	}
1652 
findBooks(String sql, ArrayList<FileInfo> list)1653 	private boolean findBooks(String sql, ArrayList<FileInfo> list) {
1654 		boolean found = false;
1655 		try (Cursor rs = mDB.rawQuery(sql, null)) {
1656 			if (rs.moveToFirst()) {
1657 				do {
1658 					FileInfo fileInfo = new FileInfo();
1659 					readFileInfoFromCursor(fileInfo, rs);
1660 					if (!fileInfo.fileExists())
1661 						continue;
1662 					fileInfoCache.put(fileInfo);
1663 					list.add(new FileInfo(fileInfo));
1664 					found = true;
1665 				} while (rs.moveToNext());
1666 			}
1667 		}
1668 		return found;
1669 	}
1670 
findSeries(int maxCount, String seriesPattern)1671 	private String findSeries(int maxCount, String seriesPattern) {
1672 		StringBuilder buf = new StringBuilder();
1673 		String sql = "SELECT id, name FROM series";
1674 		int count = 0;
1675 		try (Cursor rs = mDB.rawQuery(sql, null)) {
1676 			if (rs.moveToFirst()) {
1677 				do {
1678 					long id = rs.getLong(0);
1679 					String name = rs.getString(1);
1680 					if (Utils.matchPattern(name, seriesPattern)) {
1681 						if (buf.length() != 0)
1682 							buf.append(",");
1683 						buf.append(id);
1684 						count++;
1685 						if (count >= maxCount)
1686 							break;
1687 					}
1688 				} while (rs.moveToNext());
1689 			}
1690 		}
1691 		return buf.toString();
1692 	}
1693 
findByPatterns(int maxCount, String authors, String title, String series, String filename)1694 	public ArrayList<FileInfo> findByPatterns(int maxCount, String authors, String title, String series, String filename)
1695 	{
1696 		beginReading();
1697 		ArrayList<FileInfo> list = new ArrayList<>();
1698 		StringBuilder buf = new StringBuilder();
1699 		boolean hasCondition = false;
1700 		if ( authors!=null && authors.length()>0 ) {
1701 			// When synchronizing from the cloud, the 'authors' variable can contain multiple authors separated by '|'.
1702 			// See MainDB.READ_FILEINFO_FIELDS
1703 			String[] authorsArray = authors.split("\\|");
1704 			StringBuilder authorIds = new StringBuilder();
1705 			for (String author : authorsArray) {
1706 				String ids = findAuthors(maxCount, author);
1707 				if (ids.length() > 0) {
1708 					if (authorIds.length() > 0)
1709 						authorIds.append(",");
1710 					authorIds.append(ids);
1711 				}
1712 			}
1713 			if (authorIds.length() == 0)
1714 				return list;
1715 			if (buf.length() > 0)
1716 				buf.append(" AND ");
1717 			buf.append(" b.id IN (SELECT ba.book_fk FROM book_author ba WHERE ba.author_fk IN (").append(authorIds).append(")) ");
1718 			hasCondition = true;
1719 		}
1720 		if ( series!=null && series.length()>0 ) {
1721 			String seriesIds = findSeries(maxCount, series);
1722 			if (seriesIds.length() == 0)
1723 				return list;
1724 			if ( buf.length()>0 )
1725 				buf.append(" AND ");
1726 			buf.append(" b.series_fk IN (").append(seriesIds).append(") ");
1727 			hasCondition = true;
1728 		}
1729 		if ( title!=null && title.length()>0 ) {
1730 			hasCondition = true;
1731 		}
1732 		if ( filename!=null && filename.length()>0 ) {
1733 			hasCondition = true;
1734 		}
1735 		if (!hasCondition)
1736 			return list;
1737 
1738 		String condition = buf.length()==0 ? "" : " WHERE " + buf.toString();
1739 		String sql = READ_FILEINFO_SQL + condition;
1740 		Log.d("cr3", "sql: " + sql );
1741 		try (Cursor rs = mDB.rawQuery(sql, null)) {
1742 			if (rs.moveToFirst()) {
1743 				int count = 0;
1744 				do {
1745 					if (title != null && title.length() > 0)
1746 						if (!Utils.matchPattern(rs.getString(5), title))
1747 							continue;
1748 					if (filename != null && filename.length() > 0)
1749 						if (!Utils.matchPattern(rs.getString(3), filename))
1750 							continue;
1751 					FileInfo fi = new FileInfo();
1752 					readFileInfoFromCursor(fi, rs);
1753 					list.add(fi);
1754 					fileInfoCache.put(fi);
1755 					count++;
1756 				} while (count < maxCount && rs.moveToNext());
1757 			}
1758 		}
1759 		endReading();
1760 		return list;
1761 	}
1762 
findByGenre(String genreCode, boolean showEmptyGenres)1763 	public ArrayList<FileInfo> findByGenre(String genreCode, boolean showEmptyGenres) {
1764 		ArrayList<FileInfo> list = new ArrayList<>();
1765 		boolean OutpuSubGenres = true;
1766 		if (genreCode.endsWith(":all")) {
1767 			OutpuSubGenres = false;
1768 			genreCode = genreCode.substring(0, genreCode.length() - 4);
1769 		}
1770 		GenresCollection.GenreRecord genreRecord = Services.getGenresCollection().byCode(genreCode);
1771 		if (null == genreRecord)
1772 			return list;
1773 		int book_count = 0;
1774 		String sql;
1775 		beginReading();
1776 		if (genreRecord.getLevel() == 0 && OutpuSubGenres) {
1777 			// special item to include all child genres
1778 			FileInfo item = new FileInfo();
1779 			item.isDirectory = true;
1780 			item.pathname = FileInfo.GENRES_PREFIX + genreRecord.getCode() + ":all";
1781 			item.filename = genreRecord.getName();
1782 			item.isListed = true;
1783 			item.isScanned = true;
1784 			item.id = (long)-1;			// fake id
1785 			// get books count
1786 			StringBuilder where_clause = new StringBuilder(" WHERE ");
1787 			Iterator<GenresCollection.GenreRecord> it = genreRecord.getChilds().iterator();
1788 			while (it.hasNext()) {
1789 				where_clause.append("bg.genre_fk=").append(it.next().getId());
1790 				if (it.hasNext())
1791 					where_clause.append(" OR ");
1792 			}
1793 			sql = "SELECT count(DISTINCT book_fk) as book_count FROM book_genre bg " + where_clause.toString();
1794 			Log.d("cr3", "sql: " + sql );
1795 			try (Cursor rs = mDB.rawQuery(sql, null)) {
1796 				if (rs.moveToFirst()) {
1797 					do {
1798 						book_count = rs.getInt(0);
1799 					} while (rs.moveToNext());
1800 				}
1801 			}
1802 			item.tag = FileInfo.GENRE_DATA_INCCHILD_MASK | book_count;
1803 			list.add(item);
1804 
1805 			// child genres
1806 			sql = "SELECT code, " +
1807 					"(SELECT COUNT(DISTINCT book_fk) FROM book_genre bg " +
1808 					"  WHERE bg.genre_fk=g.id " +
1809 					") as book_count " +
1810 					"FROM genre g " +
1811 					"INNER JOIN genre_hier gh ON gh.genre_fk = g.id " +
1812 					"WHERE gh.group_fk=" + genreRecord.getId();
1813 			Log.d("cr3", "sql: " + sql );
1814 			try (Cursor rs = mDB.rawQuery(sql, null)) {
1815 				if (rs.moveToFirst()) {
1816 					do {
1817 						String code = rs.getString(0);
1818 						book_count = rs.getInt(1);
1819 						if (book_count > 0 || showEmptyGenres) {
1820 							item = new FileInfo();
1821 							item.isDirectory = true;
1822 							item.pathname = FileInfo.GENRES_PREFIX + code;
1823 							item.filename = Services.getGenresCollection().translate(code);
1824 							item.isListed = true;
1825 							item.isScanned = true;
1826 							item.id = (long) -1;            // fake id
1827 							item.tag = book_count;
1828 							list.add(item);
1829 						}
1830 					} while (rs.moveToNext());
1831 				}
1832 			}
1833 		} else {
1834 			// Find all books for this genre (or genre group)
1835 			StringBuilder where_clause = new StringBuilder(" WHERE ");
1836 			if (genreRecord.hasChilds()) {
1837 				Iterator<GenresCollection.GenreRecord> it = genreRecord.getChilds().iterator();
1838 				while (it.hasNext()) {
1839 					where_clause.append("bg.genre_fk=").append(it.next().getId());
1840 					if (it.hasNext())
1841 						where_clause.append(" OR ");
1842 				}
1843 			} else {
1844 				where_clause.append("bg.genre_fk=").append(genreRecord.getId());
1845 			}
1846 			sql = READ_FILEINFO_SQL + " JOIN book_genre bg ON (bg.book_fk=b.id)" + where_clause.toString();
1847 			Log.d("cr3", "sql: " + sql );
1848 			try (Cursor rs = mDB.rawQuery(sql, null)) {
1849 				if (rs.moveToFirst()) {
1850 					do {
1851 						FileInfo fi = new FileInfo();
1852 						readFileInfoFromCursor(fi, rs);
1853 						list.add(fi);
1854 						fileInfoCache.put(fi);
1855 					} while (rs.moveToNext());
1856 				}
1857 			}
1858 		}
1859 		endReading();
1860 		return list;
1861 	}
1862 
findByFingerprints(int maxCount, Collection<String> fingerprints)1863 	public ArrayList<FileInfo> findByFingerprints(int maxCount, Collection<String> fingerprints)
1864 	{
1865 		// TODO: replace crc32 with sha512
1866 
1867 		ArrayList<FileInfo> list = new ArrayList<>();
1868 		if (fingerprints.size() < 1)
1869 			return list;
1870 
1871 		beginReading();
1872 		StringBuilder condition = new StringBuilder(" WHERE ");
1873 		Iterator<String> it = fingerprints.iterator();
1874 		while (it.hasNext()) {
1875 			condition.append("b.crc32=").append(it.next());
1876 			if (it.hasNext())
1877 				condition.append(" OR ");
1878 		}
1879 		String sql = READ_FILEINFO_SQL + condition.toString();
1880 		Log.d("cr3", "sql: " + sql );
1881 		try (Cursor rs = mDB.rawQuery(sql, null)) {
1882 			if (rs.moveToFirst()) {
1883 				int count = 0;
1884 				do {
1885 					FileInfo fi = new FileInfo();
1886 					readFileInfoFromCursor(fi, rs);
1887 					list.add(fi);
1888 					fileInfoCache.put(fi);
1889 					count++;
1890 				} while (count < maxCount && rs.moveToNext());
1891 			}
1892 		}
1893 		endReading();
1894 		return list;
1895 	}
1896 
loadFileInfos(ArrayList<String> pathNames)1897 	public ArrayList<FileInfo> loadFileInfos(ArrayList<String> pathNames) {
1898 		ArrayList<FileInfo> list = new ArrayList<>();
1899 		if (!isOpened())
1900 			return list;
1901 		try {
1902 			beginReading();
1903 			for (String path : pathNames) {
1904 				FileInfo file = findFileInfoByPathname(path, true);
1905 				if (file != null)
1906 					list.add(new FileInfo(file));
1907 			}
1908 			endReading();
1909 		} catch (Exception e) {
1910 			log.e("Exception while loading books from DB", e);
1911 		}
1912 		return list;
1913 	}
1914 
deleteRecentPosition( FileInfo fileInfo )1915 	public void deleteRecentPosition( FileInfo fileInfo ) {
1916 		Long bookId = getBookId(fileInfo);
1917 		if (bookId == null)
1918 			return;
1919 		execSQLIgnoreErrors("DELETE FROM bookmark WHERE book_fk=" + bookId + " AND type=0");
1920 		execSQLIgnoreErrors("UPDATE book SET last_access_time=0 WHERE id=" + bookId);
1921 	}
1922 
deleteBookmark(Bookmark bm)1923 	public void deleteBookmark(Bookmark bm) {
1924 		if (bm.getId() == null)
1925 			return;
1926 		execSQLIgnoreErrors("DELETE FROM bookmark WHERE id=" + bm.getId());
1927 	}
1928 
loadBookInfo(FileInfo fileInfo)1929 	public BookInfo loadBookInfo(FileInfo fileInfo) {
1930 		if (!isOpened())
1931 			return null;
1932 		try {
1933 			FileInfo cached = fileInfoCache.get(fileInfo.getPathName());
1934 			if (cached != null) {
1935 				BookInfo book = new BookInfo(new FileInfo(cached));
1936 				loadBookmarks(book);
1937 				return book;
1938 			}
1939 			if (loadByPathname(fileInfo)) {
1940 				BookInfo book = new BookInfo(new FileInfo(fileInfo));
1941 				loadBookmarks(book);
1942 				return book;
1943 			}
1944 		} catch (Exception e) {
1945 			// ignore
1946 		}
1947 		return null;
1948 	}
1949 
loadFileInfo(String pathName)1950 	public FileInfo loadFileInfo(String pathName) {
1951 		if (!isOpened())
1952 			return null;
1953 		try {
1954 			FileInfo cached = fileInfoCache.get(pathName);
1955 			if (cached != null) {
1956 				return new FileInfo(cached);
1957 			}
1958 			FileInfo fileInfo = new FileInfo(pathName);
1959 			if (loadByPathname(fileInfo)) {
1960 				fileInfoCache.put(fileInfo);
1961 				return new FileInfo(fileInfo);
1962 			}
1963 		} catch (Exception e) {
1964 			// ignore
1965 		}
1966 		return null;
1967 	}
1968 
loadByPathname(FileInfo fileInfo)1969 	private boolean loadByPathname(FileInfo fileInfo) {
1970 		if (findBy(fileInfo, "pathname", fileInfo.getPathName())) {
1971 			fileInfoCache.put(fileInfo);
1972 			return true;
1973 		}
1974 
1975 		FileInfo moved = findMovedFileInfo(fileInfo.getPathName());
1976 		if (moved != null) {
1977 			fileInfo.assign(moved);
1978 			return true;
1979 		}
1980 
1981 		return false;
1982 	}
1983 
1984 //	private boolean loadById( FileInfo fileInfo ) {
1985 //		return findBy(fileInfo, "b.id", fileInfo.id);
1986 //	}
1987 
getBookId(FileInfo fileInfo)1988 	private Long getBookId(FileInfo fileInfo) {
1989 		Long bookId = null;
1990 		if (fileInfo == null)
1991 			return bookId;
1992 		String pathName = fileInfo.getPathName();
1993 		FileInfo cached = fileInfoCache.get(pathName);
1994 		if (cached != null) {
1995 			bookId = cached.id;
1996 		}
1997 		if (bookId == null)
1998 			bookId = fileInfo.id;
1999 		if (bookId == null)
2000 			loadByPathname(fileInfo);
2001 		return bookId;
2002 	}
deleteBook(FileInfo fileInfo)2003 	public Long deleteBook(FileInfo fileInfo)
2004 	{
2005 		if (fileInfo == null)
2006 			return null;
2007 		Long bookId = getBookId(fileInfo);
2008 		fileInfoCache.remove(fileInfo);
2009 		if (bookId == null)
2010 			return null;
2011 		execSQLIgnoreErrors("DELETE FROM bookmark WHERE book_fk=" + bookId);
2012 		execSQLIgnoreErrors("DELETE FROM book_author WHERE book_fk=" + bookId);
2013 		execSQLIgnoreErrors("DELETE FROM book_genre WHERE book_fk=" + bookId);
2014 		execSQLIgnoreErrors("DELETE FROM book WHERE id=" + bookId);
2015 		return bookId;
2016 	}
2017 
correctFilePaths()2018 	public void correctFilePaths() {
2019 		Log.i("cr3", "checking data for path correction");
2020 		beginReading();
2021 		int rowCount = 0;
2022 		Map<String, Long> map = new HashMap<>();
2023 		String sql = "SELECT id, pathname FROM book";
2024 		try (Cursor rs = mDB.rawQuery(sql, null)) {
2025 			if (rs.moveToFirst()) {
2026 				// read DB
2027 				do {
2028 					Long id = rs.getLong(0);
2029 					String pathname = rs.getString(1);
2030 					String corrected = pathCorrector.normalize(pathname);
2031 					if (pathname == null)
2032 						continue;
2033 					rowCount++;
2034 					if (corrected == null) {
2035 						Log.w("cr3", "DB contains unknown path " + pathname);
2036 					} else if (!pathname.equals(corrected)) {
2037 						map.put(pathname, id);
2038 					}
2039 				} while (rs.moveToNext());
2040 			}
2041 		} catch (Exception e) {
2042 			Log.e("cr3", "exception while loading list books to correct paths", e);
2043 		}
2044 		Log.i("cr3", "Total rows: " + rowCount + ", " + (map.size() > 0 ? "need to correct " + map.size() + " items" : "no corrections required"));
2045 		if (map.size() > 0) {
2046 			beginChanges();
2047 			int count = 0;
2048 			for (Map.Entry<String, Long> entry : map.entrySet()) {
2049 				String pathname = entry.getKey();
2050 				String corrected = pathCorrector.normalize(pathname);
2051 				if (corrected != null && !corrected.equals(pathname)) {
2052 					count++;
2053 					execSQLIgnoreErrors("update book set pathname=" + quoteSqlString(corrected) + " WHERE id=" + entry.getValue());
2054 				}
2055 			}
2056 			flush();
2057 			log.i("Finished. Rows corrected: " + count);
2058 		}
2059 	}
2060 
2061 	private MountPathCorrector pathCorrector;
setPathCorrector(MountPathCorrector corrector)2062 	public void setPathCorrector(MountPathCorrector corrector) {
2063 		this.pathCorrector = corrector;
2064 		if (pathCorrectionRequired) {
2065 			correctFilePaths();
2066 			pathCorrectionRequired = false;
2067 		}
2068 	}
2069 }
2070