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