1''' Data class for library books and collections. ''' 2 3import os 4import threading 5import datetime 6 7from mcomix import callback 8from mcomix import archive_tools 9from mcomix import tools 10 11 12class _BackendObject(object): 13 14 def get_backend(self): 15 # XXX: Delayed import to avoid circular import 16 from mcomix.library.backend import LibraryBackend 17 return LibraryBackend() 18 19 20class _Book(_BackendObject): 21 ''' Library book instance. ''' 22 23 def __init__(self, id, name, path, pages, format, size, added): 24 ''' Creates a book instance. 25 @param id: Book id 26 @param name: Base name of the book 27 @param path: Full path to the book 28 @param pages: Number of pages 29 @param format: One of the archive formats in L{constants} 30 @param size: File size in bytes 31 @param added: Datetime when book was added to library ''' 32 33 self.id = id 34 self.name = name 35 self.path = path 36 self.pages = pages 37 self.format = format 38 self.size = size 39 self.added = added 40 41 def get_collections(self): 42 ''' Gets a list of collections this book is part of. If it 43 belongs to no collections, [DefaultCollection] is returned. ''' 44 cursor = self.get_backend().execute( 45 '''SELECT id, name, supercollection FROM collection 46 JOIN contain on contain.collection = collection.id 47 WHERE contain.book = ?''', (self.id,)) 48 rows = cursor.fetchall() 49 if rows: 50 return [_Collection(*row) for row in rows] 51 else: 52 return [DefaultCollection] 53 54 def get_last_read_page(self): 55 ''' Gets the page of this book that was last read when the book was 56 closed. Returns C{None} if no such page exists. ''' 57 cursor = self.get_backend().execute( 58 '''SELECT page FROM recent WHERE book = ?''', (self.id,)) 59 row = cursor.fetchone() 60 cursor.close() 61 return row 62 63 def get_last_read_date(self): 64 ''' Gets the datetime the book was most recently read. Returns 65 C{None} if no information was set, or a datetime object otherwise. ''' 66 cursor = self.get_backend().execute( 67 '''SELECT time_set FROM recent WHERE book = ?''', (self.id,)) 68 date = cursor.fetchone() 69 cursor.close() 70 71 if date: 72 try: 73 return datetime.datetime.strptime(date, '%Y-%m-%d %H:%M:%S.%f') 74 except ValueError: 75 # Certain operating systems do not store fractions 76 return datetime.datetime.strptime(date, '%Y-%m-%d %H:%M:%S') 77 else: 78 return None 79 80 def set_last_read_page(self, page, time=None): 81 ''' Sets the page that was last read when the book was closed. 82 Passing C{None} as argument clears the recent information. 83 84 @param page: Page number, starting from 1 (page 1 throws ValueError) 85 @param time: Time of reading. If None, current time is used. ''' 86 87 if page is not None and page < 1: 88 # Avoid wasting memory by creating a recently viewed entry when 89 # an archive was opened on page 1. 90 raise ValueError('Invalid page (must start from 1)') 91 92 # Remove any old recent row for this book 93 cursor = self.get_backend().execute( 94 '''DELETE FROM recent WHERE book = ?''', (self.id,)) 95 # If a new page was passed, set it as recently read 96 if page is not None: 97 if not time: 98 time = datetime.datetime.now() 99 cursor.execute('''INSERT INTO recent (book, page, time_set) 100 VALUES (?, ?, ?)''', 101 (self.id, page, time)) 102 103 cursor.close() 104 105 106class _Collection(_BackendObject): 107 ''' Library collection instance. 108 This class should NOT be instianted directly, but only with methods from 109 L{LibraryBackend} instead. ''' 110 111 def __init__(self, id, name, supercollection=None): 112 ''' Creates a collection instance. 113 @param id: Collection id 114 @param name: Name of the collection 115 @param supercollection: Parent collection, or C{None} ''' 116 117 self.id = id 118 self.name = name 119 self.supercollection = supercollection 120 121 def __eq__(self, other): 122 if isinstance(other, _Collection): 123 return self.id == other.id 124 elif isinstance(other, (int, int)): 125 return self.id == other 126 else: 127 return False 128 129 def get_books(self, filter_string=None): 130 ''' Returns all books that are part of this collection, 131 including subcollections. ''' 132 133 books = [] 134 for collection in [ self ] + self.get_all_collections(): 135 sql = '''SELECT book.id, book.name, book.path, book.pages, book.format, 136 book.size, book.added 137 FROM book 138 JOIN contain ON contain.book = book.id 139 AND contain.collection = ? 140 ''' 141 142 sql_args = [collection.id] 143 if filter_string: 144 sql += ''' WHERE book.name LIKE '%' || ? || '%' ''' 145 sql_args.append(filter_string) 146 147 cursor = self.get_backend().execute(sql, sql_args) 148 rows = cursor.fetchall() 149 cursor.close() 150 151 books.extend([ _Book(*cols) for cols in rows ]) 152 153 return books 154 155 def get_collections(self): 156 ''' Returns a list of all direct subcollections of this instance. ''' 157 158 cursor = self.get_backend().execute('''SELECT id, name, supercollection 159 FROM collection 160 WHERE supercollection = ? 161 ORDER by name''', [self.id]) 162 result = cursor.fetchall() 163 cursor.close() 164 165 return [ _Collection(*row) for row in result ] 166 167 def get_all_collections(self): 168 ''' Returns all collections that are subcollections of this instance, 169 or subcollections of a subcollection of this instance. ''' 170 171 to_search = [ self ] 172 collections = [ ] 173 # This assumes that the library is built like a tree, so no circular references. 174 while len(to_search) > 0: 175 collection = to_search.pop() 176 subcollections = collection.get_collections() 177 collections.extend(subcollections) 178 to_search.extend(subcollections) 179 180 return collections 181 182 def add_collection(self, subcollection): 183 ''' Sets C{subcollection} as child of this collection. ''' 184 185 self.get_backend().execute('''UPDATE collection 186 SET supercollection = ? 187 WHERE id = ?''', (self.id, subcollection.id)) 188 subcollection.supercollection = self.id 189 190 191class _DefaultCollection(_Collection): 192 ''' Represents the default collection that books belong to if 193 no explicit collection was specified. ''' 194 195 def __init__(self): 196 197 self.id = None 198 self.name = _('All books') 199 self.supercollection = None 200 201 def get_books(self, filter_string=None): 202 ''' Returns all books in the library ''' 203 sql = '''SELECT book.id, book.name, book.path, book.pages, book.format, 204 book.size, book.added 205 FROM book 206 ''' 207 208 sql_args = [] 209 if filter_string: 210 sql += ''' WHERE book.name LIKE '%' || ? || '%' ''' 211 sql_args.append(filter_string) 212 213 cursor = self.get_backend().execute(sql, sql_args) 214 rows = cursor.fetchall() 215 cursor.close() 216 217 return [ _Book(*cols) for cols in rows ] 218 219 def add_collection(self, subcollection): 220 ''' Removes C{subcollection} from any supercollections and moves 221 it to the root level of the tree. ''' 222 223 assert subcollection is not DefaultCollection, 'Cannot change DefaultCollection' 224 225 self.get_backend().execute('''UPDATE collection 226 SET supercollection = NULL 227 WHERE id = ?''', (subcollection.id,)) 228 subcollection.supercollection = None 229 230 def get_collections(self): 231 ''' Returns a list of all root collections. ''' 232 233 cursor = self.get_backend().execute('''SELECT id, name, supercollection 234 FROM collection 235 WHERE supercollection IS NULL 236 ORDER by name''') 237 result = cursor.fetchall() 238 cursor.close() 239 240 return [ _Collection(*row) for row in result ] 241 242 243DefaultCollection = _DefaultCollection() 244 245 246class _WatchList(object): 247 ''' Scans watched directories and updates the database when new books have 248 been added. This object is part of the library backend, i.e. 249 C{library.backend.watchlist}. ''' 250 251 def __init__(self, backend): 252 self.backend = backend 253 254 def add_directory(self, path, collection=DefaultCollection, recursive=False): 255 ''' Adds a new watched directory. ''' 256 257 sql = '''INSERT OR IGNORE INTO watchlist (path, collection, recursive) 258 VALUES (?, ?, ?)''' 259 cursor = self.backend.execute(sql, [path, collection.id, recursive]) 260 cursor.close() 261 262 def get_watchlist(self): 263 ''' Returns a list of watched directories. 264 @return: List of L{_WatchListEntry} objects. ''' 265 266 sql = '''SELECT watchlist.path, 267 watchlist.recursive, 268 collection.id, collection.name, 269 collection.supercollection 270 FROM watchlist 271 LEFT JOIN collection ON watchlist.collection = collection.id''' 272 273 cursor = self.backend.execute(sql) 274 entries = [self._result_row_to_watchlist_entry(row) for row in cursor.fetchall()] 275 cursor.close() 276 277 return entries 278 279 def get_watchlist_entry(self, path): 280 ''' Returns a single watchlist entry, specified by C{path} ''' 281 sql = '''SELECT watchlist.path, 282 watchlist.recursive, 283 collection.id, collection.name, 284 collection.supercollection 285 FROM watchlist 286 LEFT JOIN collection ON watchlist.collection = collection.id 287 WHERE watchlist.path = ?''' 288 289 cursor = self.backend.execute(sql, (path, )) 290 result = cursor.fetchone() 291 cursor.close() 292 293 if result: 294 return self._result_row_to_watchlist_entry(result) 295 else: 296 raise ValueError('Watchlist entry doesn\'t exist') 297 298 def scan_for_new_files(self): 299 ''' Begins scanning for new files in the watched directories. 300 When the scan finishes, L{new_files_found} will be called 301 asynchronously. ''' 302 thread = threading.Thread(target=self._scan_for_new_files_thread) 303 thread.name += '-scan_for_new_files' 304 thread.start() 305 306 def _scan_for_new_files_thread(self): 307 ''' Executes the actual scanning operation in a new thread. ''' 308 existing_books = [book.path for book in DefaultCollection.get_books() 309 # Also add book if it was only found in Recent collection 310 if book.get_collections() != [-2]] 311 for entry in self.get_watchlist(): 312 new_files = entry.get_new_files(existing_books) 313 self.new_files_found(new_files, entry) 314 315 def _result_row_to_watchlist_entry(self, row): 316 ''' Converts the result of a SELECT statement to a WatchListEntry. ''' 317 collection_id = row[2] 318 if collection_id: 319 collection = _Collection(*row[2:]) 320 else: 321 collection = DefaultCollection 322 323 return _WatchListEntry(row[0], row[1], collection) 324 325 326 @callback.Callback 327 def new_files_found(self, paths, watchentry): 328 ''' Called after scan_for_new_files finishes. 329 @param paths: List of filenames for newly added files. This list 330 may be empty if no new files were found during the scan. 331 @param watchentry: Watchentry for files/directory. 332 ''' 333 pass 334 335 336class _WatchListEntry(_BackendObject): 337 ''' A watched directory. ''' 338 339 def __init__(self, directory, recursive, collection): 340 self.directory = directory 341 self.recursive = bool(recursive) 342 self.collection = collection 343 344 def get_new_files(self, filelist): 345 ''' Returns a list of files that are present in the watched directory, 346 but not in the list of files passed in C{filelist}. ''' 347 348 if not self.is_valid(): 349 return [] 350 351 old_files = frozenset([os.path.abspath(path) for path in filelist]) 352 353 if not self.recursive: 354 available_files = frozenset([os.path.join(self.directory, filename) 355 for filename in os.listdir(self.directory) 356 if archive_tools.is_archive_file(filename)]) 357 else: 358 available_files = [] 359 for dirpath, dirnames, filenames in os.walk(self.directory): 360 for filename in filter(archive_tools.is_archive_file, filenames): 361 path = os.path.join(dirpath, filename) 362 available_files.append(path) 363 364 available_files = frozenset(available_files) 365 366 return list(available_files.difference(old_files)) 367 368 def is_valid(self): 369 ''' Check if the watched directory is a valid directory and exists. ''' 370 return os.path.isdir(self.directory) 371 372 def remove(self): 373 ''' Removes this entry from the watchlist, deleting its associated 374 path from the database. ''' 375 sql = '''DELETE FROM watchlist WHERE path = ?''' 376 cursor = self.get_backend().execute(sql, (self.directory,)) 377 cursor.close() 378 379 self.directory = '' 380 self.collection = None 381 382 def set_collection(self, new_collection): 383 ''' Updates the collection associated with this watchlist entry. ''' 384 if new_collection != self.collection: 385 sql = '''UPDATE watchlist SET collection = ? WHERE path = ?''' 386 cursor = self.get_backend().execute(sql, 387 (new_collection.id, self.directory)) 388 cursor.close() 389 self.collection = new_collection 390 391 def set_recursive(self, recursive): 392 ''' Enables or disables recursive scanning. ''' 393 if recursive != self.recursive: 394 sql = '''UPDATE watchlist SET recursive = ? WHERE path = ?''' 395 cursor = self.get_backend().execute(sql, 396 (recursive, self.directory)) 397 cursor.close() 398 self.recursive = recursive 399 400 401# vim: expandtab:sw=4:ts=4 402