From 3ad9cc9ef26bf5eb93d6a631e79318cdd1d4f2b8 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 13 Jun 2008 17:04:34 -0700 Subject: [PATCH] Initial implementation of file system based ebook database. Tested on linux. --- resources.py | 7 +- src/calibre/__init__.py | 4 +- src/calibre/gui2/dialogs/config.py | 13 +- src/calibre/gui2/dialogs/config.ui | 9 +- src/calibre/gui2/library.py | 73 ++-- src/calibre/gui2/main.py | 96 +++-- src/calibre/library/cli.py | 16 +- src/calibre/library/database.py | 8 - src/calibre/library/database2.py | 523 ++++++++++++++++++++++++ src/calibre/library/metadata_sqlite.sql | 342 ++++++++++++++++ 10 files changed, 984 insertions(+), 107 deletions(-) create mode 100644 src/calibre/library/database2.py create mode 100644 src/calibre/library/metadata_sqlite.sql diff --git a/resources.py b/resources.py index a969329a16..ae608eea9d 100644 --- a/resources.py +++ b/resources.py @@ -10,9 +10,10 @@ sys.path.insert(1, os.path.join(os.getcwd(), 'src')) from calibre import __appname__ RESOURCES = dict( - opf_template = '%p/ebooks/metadata/opf.xml', - ncx_template = '%p/ebooks/metadata/ncx.xml', - fb2_xsl = '%p/ebooks/lrf/fb2/fb2.xsl', + opf_template = '%p/ebooks/metadata/opf.xml', + ncx_template = '%p/ebooks/metadata/ncx.xml', + fb2_xsl = '%p/ebooks/lrf/fb2/fb2.xsl', + metadata_sqlite = '%p/library/metadata_sqlite.sql', ) def main(args=sys.argv): diff --git a/src/calibre/__init__.py b/src/calibre/__init__.py index 4574ed83f8..32a883b93f 100644 --- a/src/calibre/__init__.py +++ b/src/calibre/__init__.py @@ -15,7 +15,7 @@ from optparse import OptionParser as _OptionParser from optparse import IndentedHelpFormatter from logging import Formatter -from PyQt4.QtCore import QSettings, QVariant, QUrl +from PyQt4.QtCore import QSettings, QVariant, QUrl, QByteArray from PyQt4.QtGui import QDesktopServices from calibre.translations.msgfmt import make @@ -448,7 +448,7 @@ class Settings(QSettings): def set(self, key, val): val = cPickle.dumps(val, -1) - self.setValue(str(key), QVariant(val)) + self.setValue(str(key), QVariant(QByteArray(val))) _settings = Settings() if not _settings.get('migrated from QSettings'): diff --git a/src/calibre/gui2/dialogs/config.py b/src/calibre/gui2/dialogs/config.py index f94706e7cc..6248144683 100644 --- a/src/calibre/gui2/dialogs/config.py +++ b/src/calibre/gui2/dialogs/config.py @@ -24,11 +24,8 @@ class ConfigDialog(QDialog, Ui_Dialog): self.db = db self.current_cols = columns settings = Settings() - path = qstring_to_unicode(\ - settings.value("database path", - QVariant(os.path.join(os.path.expanduser('~'),'library1.db'))).toString()) - - self.location.setText(os.path.dirname(path)) + path = settings.get('library path') + self.location.setText(path) self.connect(self.browse_button, SIGNAL('clicked(bool)'), self.browse) self.connect(self.compact_button, SIGNAL('clicked(bool)'), self.compact) @@ -94,10 +91,12 @@ class ConfigDialog(QDialog, Ui_Dialog): settings.setValue('filename pattern', QVariant(pattern)) if not path or not os.path.exists(path) or not os.path.isdir(path): - d = error_dialog(self, _('Invalid database location'), _('Invalid database location ')+path+_('
Must be a directory.')) + d = error_dialog(self, _('Invalid database location'), + _('Invalid database location ')+path+_('
Must be a directory.')) d.exec_() elif not os.access(path, os.W_OK): - d = error_dialog(self, _('Invalid database location'), _('Invalid database location.
Cannot write to ')+path) + d = error_dialog(self, _('Invalid database location'), + _('Invalid database location.
Cannot write to ')+path) d.exec_() else: self.database_location = os.path.abspath(path) diff --git a/src/calibre/gui2/dialogs/config.ui b/src/calibre/gui2/dialogs/config.ui index f6b3957f34..1cf7df8dc1 100644 --- a/src/calibre/gui2/dialogs/config.ui +++ b/src/calibre/gui2/dialogs/config.ui @@ -81,8 +81,8 @@ 0 0 - 583 - 625 + 595 + 638 @@ -91,7 +91,10 @@ - &Location of books database (library1.db) + &Location of ebooks (The ebooks are stored in folders sorted by author and metadata is stored in the file metadata.db) + + + true location diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py index 1130a141c5..7d8134b668 100644 --- a/src/calibre/gui2/library.py +++ b/src/calibre/gui2/library.py @@ -3,15 +3,14 @@ __copyright__ = '2008, Kovid Goyal ' import os, textwrap, traceback, time from datetime import timedelta, datetime from operator import attrgetter -from collections import deque + from math import cos, sin, pi from PyQt4.QtGui import QTableView, QProgressDialog, QAbstractItemView, QColor, \ QItemDelegate, QPainterPath, QLinearGradient, QBrush, \ QPen, QStyle, QPainter, QLineEdit, QApplication, \ QPalette, QImage from PyQt4.QtCore import QAbstractTableModel, QVariant, Qt, QString, \ - QCoreApplication, SIGNAL, QObject, QSize, QModelIndex, \ - QTimer + QCoreApplication, SIGNAL, QObject, QSize, QModelIndex from calibre import Settings, preferred_encoding from calibre.ptempfile import PersistentTemporaryFile @@ -94,7 +93,7 @@ class BooksModel(QAbstractTableModel): num -= d return ''.join(result) - def __init__(self, parent=None, buffer=20): + def __init__(self, parent=None, buffer=40): QAbstractTableModel.__init__(self, parent) self.db = None self.cols = ['title', 'authors', 'size', 'date', 'rating', 'publisher', 'tags', 'series'] @@ -104,14 +103,11 @@ class BooksModel(QAbstractTableModel): self.last_search = '' # The last search performed on this model self.read_config() self.buffer_size = buffer - self.clear_caches() - self.load_timer = QTimer() - self.connect(self.load_timer, SIGNAL('timeout()'), self.load) - self.load_timer.start(50) + self.cover_cache = None def clear_caches(self): - self.buffer = {} - self.load_queue = deque() + if self.cover_cache: + self.cover_cache.clear_cache() def read_config(self): self.use_roman_numbers = bool(Settings().value('use roman numerals for series number', @@ -204,18 +200,6 @@ class BooksModel(QAbstractTableModel): def count(self): return self.rowCount(None) - def load(self): - if self.load_queue: - index = self.load_queue.popleft() - if self.buffer.has_key(index): - return - data = self.db.cover(index) - img = QImage() - img.loadFromData(data) - if img.isNull(): - img = self.default_image - self.buffer[index] = img - def get_book_display_info(self, idx): data = {} cdata = self.cover(idx) @@ -245,17 +229,22 @@ class BooksModel(QAbstractTableModel): return data + def set_cache(self, idx): + l, r = 0, self.count()-1 + if self.cover_cache: + l = max(l, idx-self.buffer_size) + r = min(r, idx+self.buffer_size) + k = min(r-idx, idx-l) + ids = [idx] + for i in range(1, k): + ids.extend([idx-i, idx+i]) + ids = ids + [i for i in range(l, r, 1) if i not in ids] + ids = [self.db.id(i) for i in ids] + self.cover_cache.set_cache(ids) + def current_changed(self, current, previous, emit_signal=True): - idx = current.row() - - for key in self.buffer.keys(): - if abs(key - idx) > self.buffer_size: - self.buffer.pop(key) - for i in range(max(0, idx-self.buffer_size), min(self.count(), idx+self.buffer_size)): - if not self.buffer.has_key(i): - self.load_queue.append(i) - + self.set_cache(idx) data = self.get_book_display_info(idx) if emit_signal: self.emit(SIGNAL('new_bookdisplay_data(PyQt_PyObject)'), data) @@ -333,14 +322,22 @@ class BooksModel(QAbstractTableModel): return self.db.title(row_number) def cover(self, row_number): - img = self.buffer.get(row_number, -1) - if img == -1: + id = self.db.id(row_number) + data = None + if self.cover_cache: + img = self.cover_cache.cover(id) + if img: + if img.isNull(): + img = self.default_image + return img + if not data: data = self.db.cover(row_number) - img = QImage() - img.loadFromData(data) - if img.isNull(): - img = self.default_image - self.buffer[row_number] = img + if not data: + return self.default_image + img = QImage() + img.loadFromData(data) + if img.isNull(): + img = self.default_image return img def data(self, index, role): diff --git a/src/calibre/gui2/main.py b/src/calibre/gui2/main.py index 4720cd17c2..c80247f815 100644 --- a/src/calibre/gui2/main.py +++ b/src/calibre/gui2/main.py @@ -1,6 +1,6 @@ __license__ = 'GPL v3' __copyright__ = '2008, Kovid Goyal ' -import os, sys, textwrap, collections, traceback, shutil, time +import os, sys, textwrap, collections, traceback, time from xml.parsers.expat import ExpatError from functools import partial from PyQt4.QtCore import Qt, SIGNAL, QObject, QCoreApplication, \ @@ -44,7 +44,7 @@ from calibre.ebooks.metadata.meta import set_metadata from calibre.ebooks.metadata import MetaInformation from calibre.ebooks import BOOK_EXTENSIONS from calibre.ebooks.lrf import preferred_source_formats as LRF_PREFERRED_SOURCE_FORMATS - +from calibre.library.database2 import LibraryDatabase2, CoverCache class Main(MainWindow, Ui_MainWindow): @@ -178,7 +178,6 @@ class Main(MainWindow, Ui_MainWindow): QObject.connect(self.advanced_search_button, SIGNAL('clicked(bool)'), self.do_advanced_search) ####################### Library view ######################## - self.library_view.set_database(self.database_path) QObject.connect(self.library_view, SIGNAL('files_dropped(PyQt_PyObject)'), self.files_dropped) for func, target in [ @@ -193,12 +192,26 @@ class Main(MainWindow, Ui_MainWindow): self.show() self.stack.setCurrentIndex(0) - self.library_view.migrate_database() + db = LibraryDatabase2(self.library_path) + self.library_view.set_database(db) + if self.olddb is not None: + from PyQt4.QtGui import QProgressDialog + pd = QProgressDialog('', '', 0, 100, self) + pd.setWindowModality(Qt.ApplicationModal) + pd.setCancelButton(None) + pd.setWindowTitle(_('Migrating database')) + pd.show() + db.migrate_old(self.olddb, pd) + self.olddb = None + Settings().set('library path', self.library_path) self.library_view.sortByColumn(3, Qt.DescendingOrder) if not self.library_view.restore_column_widths(): self.library_view.resizeColumnsToContents() self.library_view.resizeRowsToContents() self.search.setFocus(Qt.OtherFocusReason) + self.cover_cache = CoverCache(self.library_path) + self.cover_cache.start() + self.library_view.model().cover_cache = self.cover_cache ########################### Cover Flow ################################ self.cover_flow = None if CoverFlow is not None: @@ -949,37 +962,26 @@ class Main(MainWindow, Ui_MainWindow): self.tool_bar.setIconSize(settings.value('toolbar icon size', QVariant(QSize(48, 48))).toSize()) self.tool_bar.setToolButtonStyle(Qt.ToolButtonTextUnderIcon if settings.get('show text in toolbar', True) else Qt.ToolButtonIconOnly) - if os.path.dirname(self.database_path) != d.database_location: + if self.library_path != d.database_location: try: - newloc = os.path.join(d.database_location, os.path.basename(self.database_path)) - if not os.path.exists(newloc): - dirname = os.path.dirname(newloc) - if not os.path.isdir(dirname): - os.makedirs(dirname) - dest = open(newloc, 'wb') - if os.access(self.database_path, os.R_OK): - self.status_bar.showMessage(_('Copying database to ')+newloc) + newloc = d.database_location + if not os.path.exists(os.path.join(newloc, 'metadata.db')): + if os.access(self.library_path, os.R_OK): + self.status_bar.showMessage(_('Copying library to ')+newloc) self.setCursor(Qt.BusyCursor) self.library_view.setEnabled(False) - self.library_view.close() - src = open(self.database_path, 'rb') - shutil.copyfileobj(src, dest) - src.close() - dest.close() - os.unlink(self.database_path) + self.library_view.model().db.move_library_to(newloc) else: try: - db = LibraryDatabase(newloc) - db.close() + db = LibraryDatabase2(newloc) + self.library_view.set_database(db) except Exception, err: traceback.print_exc() d = error_dialog(self, _('Invalid database'), _('

An invalid database already exists at %s, delete it before trying to move the existing database.
Error: %s')%(newloc, str(err))) d.exec_() - newloc = self.database_path - self.database_path = newloc - settings = Settings() - settings.setValue("database path", QVariant(self.database_path)) + self.library_path = self.library_view.model().db.library_path + Settings().set('library path', self.library_path) except Exception, err: traceback.print_exc() d = error_dialog(self, _('Could not move database'), unicode(err)) @@ -990,7 +992,6 @@ class Main(MainWindow, Ui_MainWindow): self.status_bar.clearMessage() self.search.clear_to_help() self.status_bar.reset_info() - self.library_view.set_database(self.database_path) self.library_view.sortByColumn(3, Qt.DescendingOrder) self.library_view.resizeRowsToContents() if hasattr(d, 'directories'): @@ -1085,24 +1086,41 @@ class Main(MainWindow, Ui_MainWindow): ConversionErrorDialog(self, 'Conversion Error', msg, show=True) + def initialize_database(self, settings): + self.library_path = settings.get('library path', None) + self.olddb = None + if self.library_path is None: # Need to migrate to new database layout + dbpath = os.path.join(os.path.expanduser('~'), 'library1.db').decode(sys.getfilesystemencoding()) + self.database_path = qstring_to_unicode(settings.value("database path", + QVariant(QString.fromUtf8(dbpath.encode('utf-8')))).toString()) + if not os.access(os.path.dirname(self.database_path), os.W_OK): + error_dialog(self, _('Database does not exist'), _('The directory in which the database should be: %s no longer exists. Please choose a new database location.')%self.database_path).exec_() + self.database_path = choose_dir(self, 'database path dialog', 'Choose new location for database') + if not self.database_path: + self.database_path = os.path.expanduser('~').decode(sys.getfilesystemencoding()) + if not os.path.exists(self.database_path): + os.makedirs(self.database_path) + self.database_path = os.path.join(self.database_path, 'library1.db') + settings.setValue('database path', QVariant(QString.fromUtf8(self.database_path.encode('utf-8')))) + home = os.path.dirname(self.database_path) + if not os.path.exists(home): + home = os.getcwd() + from PyQt4.QtGui import QFileDialog + dir = qstring_to_unicode(QFileDialog.getExistingDirectory(self, _('Choose a location for your ebook library.'), home)) + if not dir: + dir = os.path.dirname(self.database_path) + self.library_path = os.path.abspath(dir) + self.olddb = LibraryDatabase(self.database_path) + + + def read_settings(self): settings = Settings() settings.beginGroup("Main Window") geometry = settings.value('main window geometry', QVariant()).toByteArray() self.restoreGeometry(geometry) settings.endGroup() - dbpath = os.path.join(os.path.expanduser('~'), 'library1.db').decode(sys.getfilesystemencoding()) - self.database_path = qstring_to_unicode(settings.value("database path", - QVariant(QString.fromUtf8(dbpath.encode('utf-8')))).toString()) - if not os.access(os.path.dirname(self.database_path), os.W_OK): - error_dialog(self, _('Database does not exist'), _('The directory in which the database should be: %s no longer exists. Please choose a new database location.')%self.database_path).exec_() - self.database_path = choose_dir(self, 'database path dialog', 'Choose new location for database') - if not self.database_path: - self.database_path = os.path.expanduser('~').decode(sys.getfilesystemencoding()) - if not os.path.exists(self.database_path): - os.makedirs(self.database_path) - self.database_path = os.path.join(self.database_path, 'library1.db') - settings.setValue('database path', QVariant(QString.fromUtf8(self.database_path.encode('utf-8')))) + self.initialize_database(settings) set_sidebar_directories(None) set_filename_pat(qstring_to_unicode(settings.value('filename pattern', QVariant(get_filename_pat())).toString())) self.tool_bar.setIconSize(settings.value('toolbar icon size', QVariant(QSize(48, 48))).toSize()) @@ -1138,9 +1156,11 @@ class Main(MainWindow, Ui_MainWindow): self.job_manager.terminate_all_jobs() self.write_settings() self.detector.keep_going = False + self.cover_cache.stop() self.hide() time.sleep(2) self.detector.terminate() + self.cover_cache.terminate() e.accept() def update_found(self, version): diff --git a/src/calibre/library/cli.py b/src/calibre/library/cli.py index b13a7a3680..f440ba108a 100644 --- a/src/calibre/library/cli.py +++ b/src/calibre/library/cli.py @@ -10,26 +10,26 @@ Command line interface to the calibre database. import sys, os from textwrap import TextWrapper -from PyQt4.QtCore import QVariant - from calibre import OptionParser, Settings, terminal_controller, preferred_encoding from calibre.gui2 import SingleApplication from calibre.ebooks.metadata.meta import get_metadata -from calibre.library.database import LibraryDatabase, text_to_tokens +from calibre.library.database2 import LibraryDatabase2 +from calibre.library.database import text_to_tokens FIELDS = set(['title', 'authors', 'publisher', 'rating', 'timestamp', 'size', 'tags', 'comments', 'series', 'series_index', 'formats']) def get_parser(usage): parser = OptionParser(usage) go = parser.add_option_group('GLOBAL OPTIONS') - go.add_option('--database', default=None, help=_('Path to the calibre database. Default is to use the path stored in the settings.')) + go.add_option('--library-path', default=None, help=_('Path to the calibre library. Default is to use the path stored in the settings.')) return parser def get_db(dbpath, options): - if options.database is not None: - dbpath = options.database + if options.library_path is not None: + dbpath = options.library_path dbpath = os.path.abspath(dbpath) - return LibraryDatabase(dbpath, row_factory=True) + print _('Using library at'), dbpath + return LibraryDatabase2(dbpath, row_factory=True) def do_list(db, fields, sort_by, ascending, search_text): db.refresh(sort_by, ascending) @@ -330,7 +330,7 @@ For help on an individual command: %%prog command --help return 1 command = eval('command_'+args[1]) - dbpath = unicode(Settings().value('database path', QVariant(os.path.expanduser('~/library1.db'))).toString()) + dbpath = Settings().get('library path', os.path.expanduser('~')) return command(args[2:], dbpath) diff --git a/src/calibre/library/database.py b/src/calibre/library/database.py index 580e4a327d..a09505b007 100644 --- a/src/calibre/library/database.py +++ b/src/calibre/library/database.py @@ -794,14 +794,6 @@ ALTER TABLE books ADD COLUMN isbn TEXT DEFAULT "" COLLATE NOCASE; conn.commit() - def __del__(self): - global _lock_file - import os - if _lock_file is not None: - _lock_file.close() - if os.path.exists(_lock_file.name): - os.unlink(_lock_file.name) - def __init__(self, dbpath, row_factory=False): self.dbpath = dbpath self.conn = _connect(dbpath) diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py new file mode 100644 index 0000000000..1c5eecad44 --- /dev/null +++ b/src/calibre/library/database2.py @@ -0,0 +1,523 @@ +#!/usr/bin/env python +__license__ = 'GPL v3' +__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net' +__docformat__ = 'restructuredtext en' + +''' +The database used to store ebook metadata +''' +import os, re, sys, shutil, cStringIO, glob, collections +import sqlite3 as sqlite +from itertools import repeat + +from PyQt4.QtCore import QCoreApplication, QThread, QReadWriteLock +from PyQt4.QtGui import QApplication, QPixmap, QImage +__app = None + +from calibre.library.database import LibraryDatabase + +copyfile = os.link if hasattr(os, 'link') else shutil.copyfile +filesystem_encoding = sys.getfilesystemencoding() +if filesystem_encoding is None: filesystem_encoding = 'utf-8' + +_filename_sanitize = re.compile(r'[\0\\|\?\*<":>\+\[\]/]') +def sanitize_file_name(name, substitute='_'): + ''' + Sanitize the filename `name`. All invalid characters are replaced by `substitute`. + The set of invalid characters is the union of the invalid characters in Windows, + OS X and Linux. + **WARNING:** This function also replaces path separators, so only pass file names + and not full paths to it. + *NOTE:* This function always returns byte strings, not unicode objects. The byte strings + are encoded in the filesystem encoding of the platform, or UTF-8. + ''' + if isinstance(name, unicode): + name = name.encode(filesystem_encoding, 'ignore') + return _filename_sanitize.sub(substitute, name) + +class CoverCache(QThread): + + def __init__(self, library_path, parent=None): + QThread.__init__(self, parent) + self.library_path = library_path + self.id_map = None + self.id_map_lock = QReadWriteLock() + self.load_queue = collections.deque() + self.load_queue_lock = QReadWriteLock(QReadWriteLock.Recursive) + self.cache = {} + self.cache_lock = QReadWriteLock() + self.keep_running = True + + def build_id_map(self): + self.id_map_lock.lockForWrite() + self.id_map = {} + for f in glob.glob(os.path.join(self.library_path, '*', '* (*)', 'cover.jpg')): + c = os.path.basename(os.path.dirname(f)) + try: + id = int(re.search(r'\((\d+)\)', c[c.rindex('('):]).group(1)) + self.id_map[id] = f + except: + continue + self.id_map_lock.unlock() + + + def set_cache(self, ids): + self.cache_lock.lockForWrite() + already_loaded = set([]) + for id in self.cache.keys(): + if id in ids: + already_loaded.add(id) + else: + self.cache.pop(id) + self.cache_lock.unlock() + ids = [i for i in ids if i not in already_loaded] + self.load_queue_lock.lockForWrite() + self.load_queue = collections.deque(ids) + self.load_queue_lock.unlock() + + + def run(self): + while self.keep_running: + if self.id_map is None: + self.build_id_map() + while True: + self.load_queue_lock.lockForWrite() + try: + id = self.load_queue.popleft() + except IndexError: + break + finally: + self.load_queue_lock.unlock() + + self.cache_lock.lockForRead() + need = True + if id in self.cache.keys(): + need = False + self.cache_lock.unlock() + if not need: + continue + path = None + self.id_map_lock.lockForRead() + if id in self.id_map.keys(): + path = self.id_map[id] + self.id_map_lock.unlock() + if path and os.access(path, os.R_OK): + try: + img = QImage() + data = open(path, 'rb').read() + img.loadFromData(data) + if img.isNull(): + continue + except: + continue + self.cache_lock.lockForWrite() + self.cache[id] = img + self.cache_lock.unlock() + + self.sleep(1) + + def stop(self): + self.keep_running = False + + def cover(self, id): + val = None + if self.cache_lock.tryLockForRead(50): + val = self.cache.get(id, None) + self.cache_lock.unlock() + return val + + def clear_cache(self): + self.cache_lock.lockForWrite() + self.cache = {} + self.cache_lock.unlock() + +class Concatenate(object): + '''String concatenation aggregator for sqlite''' + def __init__(self, sep=','): + self.sep = sep + self.ans = '' + + def step(self, value): + if value is not None: + self.ans += value + self.sep + + def finalize(self): + if not self.ans: + return None + if self.sep: + return self.ans[:-len(self.sep)] + return self.ans + + +class LibraryDatabase2(LibraryDatabase): + ''' + An ebook metadata database that stores references to ebook files on disk. + ''' + PATH_LIMIT = 40 if 'win32' in sys.platform else 100 + @apply + def user_version(): + doc = 'The user version of this database' + def fget(self): + return self.conn.execute('pragma user_version;').next()[0] + def fset(self, val): + self.conn.execute('pragma user_version=%d'%int(val)) + self.conn.commit() + return property(doc=doc, fget=fget, fset=fset) + + def connect(self): + if 'win32' in sys.platform and len(self.library_path) + 4*self.PATH_LIMIT + 10 > 259: + raise ValueError('Path to library too long. Must be less than %d characters.'%(259-4*self.PATH_LIMIT-10)) + exists = os.path.exists(self.dbpath) + self.conn = sqlite.connect(self.dbpath, + detect_types=sqlite.PARSE_DECLTYPES|sqlite.PARSE_COLNAMES) + if exists and self.user_version == 0: + self.conn.close() + os.remove(self.dbpath) + self.conn = sqlite.connect(self.dbpath, + detect_types=sqlite.PARSE_DECLTYPES|sqlite.PARSE_COLNAMES) + self.conn.row_factory = sqlite.Row if self.row_factory else lambda cursor, row : list(row) + self.conn.create_aggregate('concat', 1, Concatenate) + title_pat = re.compile('^(A|The|An)\s+', re.IGNORECASE) + + def title_sort(title): + match = title_pat.search(title) + if match: + prep = match.group(1) + title = title.replace(prep, '') + ', ' + prep + return title.strip() + + self.conn.create_function('title_sort', 1, title_sort) + if self.user_version == 0: + self.initialize_database() + + def __init__(self, library_path, row_factory=False): + if not os.path.exists(library_path): + os.makedirs(library_path) + self.library_path = os.path.abspath(library_path) + self.row_factory = row_factory + self.dbpath = os.path.join(library_path, 'metadata.db') + if isinstance(self.dbpath, unicode): + self.dbpath = self.dbpath.encode(filesystem_encoding) + self.connect() + + + def initialize_database(self): + from calibre.resources import metadata_sqlite + self.conn.executescript(metadata_sqlite) + self.user_version = 1 + + def path(self, index, index_is_id=False): + 'Return the relative path to the directory containing this books files as a unicode string.' + id = index if index_is_id else self.id() + path = self.conn.execute('SELECT path FROM books WHERE id=?', (id,)).fetchone()[0].replace('/', os.sep) + return path + + + def set_path(self, index, index_is_id=False): + ''' + Set the path to the directory containing this books files based on its + current title and author. If there was a previous directory, its contents + are copied and it is deleted. + ''' + id = index if index_is_id else self.id(index) + authors = self.authors(id, index_is_id=True) + if not authors: + authors = _('Unknown') + author = sanitize_file_name(authors.split(',')[0][:self.PATH_LIMIT]).decode(filesystem_encoding) + title = sanitize_file_name(self.title(id, index_is_id=True)[:self.PATH_LIMIT]).decode(filesystem_encoding) + path = author + '/' + title + ' (%d)'%id + current_path = self.path(id, index_is_id=True).replace(os.sep, '/') + if path == current_path: + return + tpath = os.path.join(self.library_path, *path.split('/')) + if not os.path.exists(tpath): + os.makedirs(tpath) + spath = os.path.join(self.library_path, *current_path.split('/')) + if current_path and os.path.exists(spath): + for f in os.listdir(spath): + copyfile(os.path.join(spath, f), os.path.join(tpath, f)) + self.conn.execute('UPDATE books SET path=? WHERE id=?', (path, id)) + self.conn.commit() + if current_path and os.path.exists(spath): + shutil.rmtree(spath) + + def cover(self, index, index_is_id=False, as_file=False, as_image=False): + ''' + Return the cover image as a bytestring (in JPEG format) or None. + + `as_file` : If True return the image as an open file object + `as_image`: If True return the image as a QImage object + ''' + id = index if index_is_id else self.id(index) + path = os.path.join(self.library_path, self.path(id, index_is_id=True), 'cover.jpg') + if os.access(path, os.R_OK): + f = open(path, 'rb') + if as_image: + img = QImage() + img.loadFromData(f.read()) + return img + return f if as_file else f.read() + + def set_cover(self, id, data): + ''' + Set the cover for this book. + + `data`: Can be either a QImage, QPixmap, file object or bytestring + ''' + path = os.path.join(self.library_path, self.path(id, index_is_id=True), 'cover.jpg') + if callable(getattr(data, 'save', None)): + data.save(path) + else: + if not QCoreApplication.instance(): + global __app + __app = QApplication([]) + p = QPixmap() + if callable(getattr(data, 'read', None)): + data = data.read() + p.loadFromData(data) + p.save(path) + + def format(self, index, format, index_is_id=False, as_file=False, mode='r+b'): + ''' + Return the ebook format as a bytestring or `None` if the format doesn't exist, + or we don't have permission to write to the ebook file. + + `as_file`: If True the ebook format is returned as a file object opened in `mode` + ''' + id = index if index_is_id else self.id(index) + path = os.path.join(self.library_path, self.path(id, index_is_id=True)) + name = self.conn.execute('SELECT name FROM data WHERE book=? AND format=?', (id, format)).fetchone()[0] + if name: + format = ('.' + format.lower()) if format else '' + path = os.path.join(path, name+format) + if os.access(path, os.R_OK|os.W_OK): + f = open(path, mode) + return f if as_file else f.read() + + def add_format(self, index, format, stream, index_is_id=False): + id = index if index_is_id else self.id(index) + authors = self.authors(id, index_is_id=True) + if not authors: + authors = _('Unknown') + path = os.path.join(self.library_path, self.path(id, index_is_id=True)) + author = sanitize_file_name(authors.split(',')[0][:self.PATH_LIMIT]).decode(filesystem_encoding) + title = sanitize_file_name(self.title(id, index_is_id=True)[:self.PATH_LIMIT]).decode(filesystem_encoding) + name = self.conn.execute('SELECT name FROM data WHERE book=? AND format=?', (id, format)).fetchone() + if name: + self.conn.execute('DELETE FROM data WHERE book=? AND format=?', (id, format)) + name = title + ' - ' + author + ext = ('.' + format.lower()) if format else '' + shutil.copyfileobj(stream, open(os.path.join(path, name+ext), 'wb')) + stream.seek(0, 2) + size=stream.tell() + self.conn.execute('INSERT INTO data (book,format,uncompressed_size,name) VALUES (?,?,?,?)', + (id, format.upper(), size, name)) + self.conn.commit() + + def delete_book(self, id): + ''' + Removes book from self.cache, self.data and underlying database. + ''' + try: + self.cache.pop(self.index(id, cache=True)) + self.data.pop(self.index(id, cache=False)) + except TypeError: #If data and cache are the same object + pass + path = os.path.join(self.library_path, self.path(id, True)) + if os.path.exists(path): + shutil.rmtree(path) + self.conn.execute('DELETE FROM books WHERE id=?', (id,)) + self.conn.commit() + + def remove_format(self, index, format, index_is_id=False): + id = index if index_is_id else self.id(index) + path = os.path.join(self.library_path, self.path(id, index_is_id=True)) + name = self.conn.execute('SELECT name FROM data WHERE book=? AND format=?', (id, format)).fetchone()[0] + if name: + ext = ('.' + format.lower()) if format else '' + path = os.path.join(path, name+ext) + if os.access(path, os.W_OK): + os.unlink(path) + self.conn.execute('DELETE FROM data WHERE book=? AND format=?', (id, format.upper())) + self.conn.commit() + + def set_metadata(self, id, mi): + ''' + Set metadata for the book `id` from the `MetaInformation` object `mi` + ''' + if not mi.authors: + mi.authors = ['Unknown'] + authors = [] + for a in mi.authors: + authors += a.split('&') + self.set_authors(id, authors) + if mi.author_sort: + self.set_author_sort(id, mi.author_sort) + if mi.publisher: + self.set_publisher(id, mi.publisher) + if mi.rating: + self.set_rating(id, mi.rating) + if mi.series: + self.set_series(id, mi.series) + if mi.cover_data[1] is not None: + self.set_cover(id, mi.cover_data[1]) + self.set_path(id, True) + + def set_authors(self, id, authors): + ''' + `authors`: A list of authors. + ''' + self.conn.execute('DELETE FROM books_authors_link WHERE book=?',(id,)) + for a in authors: + if not a: + continue + a = a.strip() + author = self.conn.execute('SELECT id from authors WHERE name=?', (a,)).fetchone() + if author: + aid = author[0] + # Handle change of case + self.conn.execute('UPDATE authors SET name=? WHERE id=?', (a, aid)) + else: + aid = self.conn.execute('INSERT INTO authors(name) VALUES (?)', (a,)).lastrowid + try: + self.conn.execute('INSERT INTO books_authors_link(book, author) VALUES (?,?)', (id, aid)) + except sqlite.IntegrityError: # Sometimes books specify the same author twice in their metadata + pass + self.set_path(id, True) + + def set_title(self, id, title): + if not title: + return + self.conn.execute('UPDATE books SET title=? WHERE id=?', (title, id)) + self.set_path(id, True) + + def add_books(self, paths, formats, metadata, uris=[], add_duplicates=True): + ''' + Add a book to the database. self.data and self.cache are not updated. + @param paths: List of paths to book files of file-like objects + ''' + formats, metadata, uris = iter(formats), iter(metadata), iter(uris) + duplicates = [] + for path in paths: + mi = metadata.next() + format = formats.next() + try: + uri = uris.next() + except StopIteration: + uri = None + if not add_duplicates and self.has_book(mi): + duplicates.append((path, format, mi, uri)) + continue + series_index = 1 if mi.series_index is None else mi.series_index + aus = mi.author_sort if mi.author_sort else ', '.join(mi.authors) + obj = self.conn.execute('INSERT INTO books(title, uri, series_index, author_sort) VALUES (?, ?, ?, ?)', + (mi.title, uri, series_index, aus)) + id = obj.lastrowid + self.set_path(id, True) + self.conn.commit() + self.set_metadata(id, mi) + stream = path if hasattr(path, 'read') else open(path, 'rb') + stream.seek(0) + + self.add_format(id, format, stream, index_is_id=True) + if not hasattr(path, 'read'): + stream.close() + self.conn.commit() + if duplicates: + paths = tuple(duplicate[0] for duplicate in duplicates) + formats = tuple(duplicate[1] for duplicate in duplicates) + metadata = tuple(duplicate[2] for duplicate in duplicates) + uris = tuple(duplicate[3] for duplicate in duplicates) + return (paths, formats, metadata, uris) + return None + + def import_book(self, mi, formats): + series_index = 1 if mi.series_index is None else mi.series_index + if not mi.authors: + mi.authors = ['Unknown'] + aus = mi.author_sort if mi.author_sort else ', '.join(mi.authors) + obj = self.conn.execute('INSERT INTO books(title, uri, series_index, author_sort) VALUES (?, ?, ?, ?)', + (mi.title, None, series_index, aus)) + id = obj.lastrowid + self.set_path(id, True) + self.set_metadata(id, mi) + for path in formats: + ext = os.path.splitext(path)[1][1:].lower() + stream = open(path, 'rb') + self.add_format(id, ext, stream, index_is_id=True) + self.conn.commit() + + def move_library_to(self, newloc): + if not os.path.exists(newloc): + os.makedirs(newloc) + old_dirs = set([]) + for book in self.conn.execute('SELECT id, path FROM books').fetchall(): + path = book[1] + if not path: + continue + dir = path.split('/')[0] + srcdir = os.path.join(self.library_path, dir) + tdir = os.path.join(newloc, dir) + if os.path.exists(tdir): + shutil.rmtree(tdir) + shutil.copytree(srcdir, tdir) + old_dirs.add(srcdir) + + dbpath = os.path.join(newloc, os.path.basename(self.dbpath)) + shutil.copyfile(self.dbpath, dbpath) + opath = self.dbpath + self.conn.close() + self.library_path, self.dbpath = newloc, dbpath + self.connect() + try: + os.unlink(opath) + for dir in old_dirs: + shutil.rmtree(dir) + except: + pass + + + def migrate_old(self, db, progress): + header = _(u'

Migrating old database to ebook library in %s

')%self.library_path + db.conn.row_factory = lambda cursor, row : tuple(row) + books = db.conn.execute('SELECT id, title, sort, timestamp, uri, series_index, author_sort, isbn FROM books ORDER BY id ASC').fetchall() + progress.setRange(0, len(books)) + progress.setValue(0) + progress.setLabelText(header) + for book in books: + self.conn.execute('INSERT INTO books(id, title, sort, timestamp, uri, series_index, author_sort, isbn) VALUES(?, ?, ?, ?, ?, ?, ?, ?);', book) + + tables = ''' +authors ratings tags series books_tags_link +comments publishers +books_authors_link conversion_options +books_publishers_link +books_ratings_link +books_series_link feeds +'''.split() + for table in tables: + rows = db.conn.execute('SELECT * FROM %s ORDER BY id ASC'%table).fetchall() + for row in rows: + self.conn.execute('INSERT INTO %s VALUES(%s)'%(table, ','.join(repeat('?', len(row)))), row) + + for i, book in enumerate(books): + progress.setLabelText(header+_(u'Copying %s')%book[1]) + id = book[0] + self.set_path(id, True) + formats = db.formats(id, index_is_id=True) + if not formats: + formats = [] + else: + formats = formats.split(',') + for format in formats: + data = db.format(id, format, index_is_id=True) + if data: + self.add_format(id, format, cStringIO.StringIO(data), index_is_id=True) + cover = db.cover(id, index_is_id=True) + if cover: + self.set_cover(id, cover) + progress.setValue(i+1) + self.conn.commit() + progress.setLabelText(_('Compacting database')) + self.vacuum() + \ No newline at end of file diff --git a/src/calibre/library/metadata_sqlite.sql b/src/calibre/library/metadata_sqlite.sql new file mode 100644 index 0000000000..36391daea0 --- /dev/null +++ b/src/calibre/library/metadata_sqlite.sql @@ -0,0 +1,342 @@ +CREATE TABLE authors ( id INTEGER PRIMARY KEY, + name TEXT NOT NULL COLLATE NOCASE, + sort TEXT COLLATE NOCASE, + UNIQUE(name) + ); +CREATE TABLE books ( id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL DEFAULT 'Unknown' COLLATE NOCASE, + sort TEXT COLLATE NOCASE, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + uri TEXT, + series_index INTEGER NOT NULL DEFAULT 1, + author_sort TEXT COLLATE NOCASE, + isbn TEXT DEFAULT "" COLLATE NOCASE, + path TEXT NOT NULL DEFAULT "" + ); +CREATE TABLE books_authors_link ( id INTEGER PRIMARY KEY, + book INTEGER NOT NULL, + author INTEGER NOT NULL, + UNIQUE(book, author) + ); +CREATE TABLE books_publishers_link ( id INTEGER PRIMARY KEY, + book INTEGER NOT NULL, + publisher INTEGER NOT NULL, + UNIQUE(book) + ); +CREATE TABLE books_ratings_link ( id INTEGER PRIMARY KEY, + book INTEGER NOT NULL, + rating INTEGER NOT NULL, + UNIQUE(book, rating) + ); +CREATE TABLE books_series_link ( id INTEGER PRIMARY KEY, + book INTEGER NOT NULL, + series INTEGER NOT NULL, + UNIQUE(book) + ); +CREATE TABLE books_tags_link ( id INTEGER PRIMARY KEY, + book INTEGER NOT NULL, + tag INTEGER NOT NULL, + UNIQUE(book, tag) + ); +CREATE TABLE comments ( id INTEGER PRIMARY KEY, + book INTEGER NON NULL, + text TEXT NON NULL COLLATE NOCASE, + UNIQUE(book) + ); +CREATE TABLE conversion_options ( id INTEGER PRIMARY KEY, + format TEXT NOT NULL COLLATE NOCASE, + book INTEGER, + data BLOB NOT NULL, + UNIQUE(format,book) + ); +CREATE TABLE feeds ( id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + script TEXT NOT NULL, + UNIQUE(title) + ); +CREATE TABLE publishers ( id INTEGER PRIMARY KEY, + name TEXT NOT NULL COLLATE NOCASE, + sort TEXT COLLATE NOCASE, + UNIQUE(name) + ); +CREATE TABLE ratings ( id INTEGER PRIMARY KEY, + rating INTEGER CHECK(rating > -1 AND rating < 11), + UNIQUE (rating) + ); +CREATE TABLE series ( id INTEGER PRIMARY KEY, + name TEXT NOT NULL COLLATE NOCASE, + sort TEXT COLLATE NOCASE, + UNIQUE (name) + ); +CREATE TABLE tags ( id INTEGER PRIMARY KEY, + name TEXT NOT NULL COLLATE NOCASE, + UNIQUE (name) + ); +CREATE TABLE data ( id INTEGER PRIMARY KEY, + book INTEGER NON NULL, + format TEXT NON NULL COLLATE NOCASE, + uncompressed_size INTEGER NON NULL, + name TEXT NON NULL, + UNIQUE(book, format) +); + +CREATE VIEW meta AS + SELECT id, title, + (SELECT concat(name) FROM authors WHERE authors.id IN (SELECT author from books_authors_link WHERE book=books.id)) authors, + (SELECT name FROM publishers WHERE publishers.id IN (SELECT publisher from books_publishers_link WHERE book=books.id)) publisher, + (SELECT rating FROM ratings WHERE ratings.id IN (SELECT rating from books_ratings_link WHERE book=books.id)) rating, + timestamp, + (SELECT MAX(uncompressed_size) FROM data WHERE book=books.id) size, + (SELECT concat(name) FROM tags WHERE tags.id IN (SELECT tag from books_tags_link WHERE book=books.id)) tags, + (SELECT text FROM comments WHERE book=books.id) comments, + (SELECT name FROM series WHERE series.id IN (SELECT series FROM books_series_link WHERE book=books.id)) series, + series_index, + sort, + author_sort, + (SELECT concat(format) FROM data WHERE data.book=books.id) formats + FROM books; +CREATE INDEX authors_idx ON books (author_sort COLLATE NOCASE); +CREATE INDEX books_authors_link_aidx ON books_authors_link (author); +CREATE INDEX books_authors_link_bidx ON books_authors_link (book); +CREATE INDEX books_idx ON books (sort COLLATE NOCASE); +CREATE INDEX books_publishers_link_aidx ON books_publishers_link (publisher); +CREATE INDEX books_publishers_link_bidx ON books_publishers_link (book); +CREATE INDEX books_ratings_link_aidx ON books_ratings_link (rating); +CREATE INDEX books_ratings_link_bidx ON books_ratings_link (book); +CREATE INDEX books_series_link_aidx ON books_series_link (series); +CREATE INDEX books_series_link_bidx ON books_series_link (book); +CREATE INDEX books_tags_link_aidx ON books_tags_link (tag); +CREATE INDEX books_tags_link_bidx ON books_tags_link (book); +CREATE INDEX comments_idx ON comments (book); +CREATE INDEX conversion_options_idx_a ON conversion_options (format COLLATE NOCASE); +CREATE INDEX conversion_options_idx_b ON conversion_options (book); +CREATE INDEX data_idx ON data (book); +CREATE INDEX publishers_idx ON publishers (name COLLATE NOCASE); +CREATE INDEX series_idx ON series (sort COLLATE NOCASE); +CREATE INDEX tags_idx ON tags (name COLLATE NOCASE); +CREATE TRIGGER books_delete_trg + AFTER DELETE ON books + BEGIN + DELETE FROM books_authors_link WHERE book=OLD.id; + DELETE FROM books_publishers_link WHERE book=OLD.id; + DELETE FROM books_ratings_link WHERE book=OLD.id; + DELETE FROM books_series_link WHERE book=OLD.id; + DELETE FROM books_tags_link WHERE book=OLD.id; + DELETE FROM data WHERE book=OLD.id; + DELETE FROM comments WHERE book=OLD.id; + DELETE FROM conversion_options WHERE book=OLD.id; + END; +CREATE TRIGGER books_insert_trg + AFTER INSERT ON books + BEGIN + UPDATE books SET sort=title_sort(NEW.title) WHERE id=NEW.id; + END; +CREATE TRIGGER books_update_trg + AFTER UPDATE ON books + BEGIN + UPDATE books SET sort=title_sort(NEW.title) WHERE id=NEW.id; + END; +CREATE TRIGGER fkc_comments_insert + BEFORE INSERT ON comments + BEGIN + SELECT CASE + WHEN (SELECT id from books WHERE id=NEW.book) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: book not in books') + END; + END; +CREATE TRIGGER fkc_comments_update + BEFORE UPDATE OF book ON comments + BEGIN + SELECT CASE + WHEN (SELECT id from books WHERE id=NEW.book) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: book not in books') + END; + END; +CREATE TRIGGER fkc_data_insert + BEFORE INSERT ON data + BEGIN + SELECT CASE + WHEN (SELECT id from books WHERE id=NEW.book) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: book not in books') + END; + END; +CREATE TRIGGER fkc_data_update + BEFORE UPDATE OF book ON data + BEGIN + SELECT CASE + WHEN (SELECT id from books WHERE id=NEW.book) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: book not in books') + END; + END; +CREATE TRIGGER fkc_delete_books_authors_link + BEFORE DELETE ON authors + BEGIN + SELECT CASE + WHEN (SELECT COUNT(id) FROM books_authors_link WHERE book=OLD.book) > 0 + THEN RAISE(ABORT, 'Foreign key violation: author is still referenced') + END; + END; +CREATE TRIGGER fkc_delete_books_publishers_link + BEFORE DELETE ON publishers + BEGIN + SELECT CASE + WHEN (SELECT COUNT(id) FROM books_publishers_link WHERE book=OLD.book) > 0 + THEN RAISE(ABORT, 'Foreign key violation: publisher is still referenced') + END; + END; +CREATE TRIGGER fkc_delete_books_series_link + BEFORE DELETE ON series + BEGIN + SELECT CASE + WHEN (SELECT COUNT(id) FROM books_series_link WHERE series=OLD.id) > 0 + THEN RAISE(ABORT, 'Foreign key violation: series is still referenced') + END; + END; +CREATE TRIGGER fkc_delete_books_tags_link + BEFORE DELETE ON tags + BEGIN + SELECT CASE + WHEN (SELECT COUNT(id) FROM books_tags_link WHERE tag=OLD.id) > 0 + THEN RAISE(ABORT, 'Foreign key violation: tag is still referenced') + END; + END; +CREATE TRIGGER fkc_insert_books_authors_link + BEFORE INSERT ON books_authors_link + BEGIN + SELECT CASE + WHEN (SELECT id from books WHERE id=NEW.book) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: book not in books') + WHEN (SELECT id from authors WHERE id=NEW.author) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: author not in authors') + END; + END; +CREATE TRIGGER fkc_insert_books_publishers_link + BEFORE INSERT ON books_publishers_link + BEGIN + SELECT CASE + WHEN (SELECT id from books WHERE id=NEW.book) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: book not in books') + WHEN (SELECT id from publishers WHERE id=NEW.publisher) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: publisher not in publishers') + END; + END; +CREATE TRIGGER fkc_insert_books_ratings_link + BEFORE INSERT ON books_ratings_link + BEGIN + SELECT CASE + WHEN (SELECT id from books WHERE id=NEW.book) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: book not in books') + WHEN (SELECT id from ratings WHERE id=NEW.rating) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: rating not in ratings') + END; + END; +CREATE TRIGGER fkc_insert_books_series_link + BEFORE INSERT ON books_series_link + BEGIN + SELECT CASE + WHEN (SELECT id from books WHERE id=NEW.book) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: book not in books') + WHEN (SELECT id from series WHERE id=NEW.series) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: series not in series') + END; + END; +CREATE TRIGGER fkc_insert_books_tags_link + BEFORE INSERT ON books_tags_link + BEGIN + SELECT CASE + WHEN (SELECT id from books WHERE id=NEW.book) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: book not in books') + WHEN (SELECT id from tags WHERE id=NEW.tag) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: tag not in tags') + END; + END; +CREATE TRIGGER fkc_update_books_authors_link_a + BEFORE UPDATE OF book ON books_authors_link + BEGIN + SELECT CASE + WHEN (SELECT id from books WHERE id=NEW.book) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: book not in books') + END; + END; +CREATE TRIGGER fkc_update_books_authors_link_b + BEFORE UPDATE OF author ON books_authors_link + BEGIN + SELECT CASE + WHEN (SELECT id from authors WHERE id=NEW.author) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: author not in authors') + END; + END; +CREATE TRIGGER fkc_update_books_publishers_link_a + BEFORE UPDATE OF book ON books_publishers_link + BEGIN + SELECT CASE + WHEN (SELECT id from books WHERE id=NEW.book) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: book not in books') + END; + END; +CREATE TRIGGER fkc_update_books_publishers_link_b + BEFORE UPDATE OF publisher ON books_publishers_link + BEGIN + SELECT CASE + WHEN (SELECT id from publishers WHERE id=NEW.publisher) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: publisher not in publishers') + END; + END; +CREATE TRIGGER fkc_update_books_ratings_link_a + BEFORE UPDATE OF book ON books_ratings_link + BEGIN + SELECT CASE + WHEN (SELECT id from books WHERE id=NEW.book) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: book not in books') + END; + END; +CREATE TRIGGER fkc_update_books_ratings_link_b + BEFORE UPDATE OF rating ON books_ratings_link + BEGIN + SELECT CASE + WHEN (SELECT id from ratings WHERE id=NEW.rating) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: rating not in ratings') + END; + END; +CREATE TRIGGER fkc_update_books_series_link_a + BEFORE UPDATE OF book ON books_series_link + BEGIN + SELECT CASE + WHEN (SELECT id from books WHERE id=NEW.book) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: book not in books') + END; + END; +CREATE TRIGGER fkc_update_books_series_link_b + BEFORE UPDATE OF series ON books_series_link + BEGIN + SELECT CASE + WHEN (SELECT id from series WHERE id=NEW.series) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: series not in series') + END; + END; +CREATE TRIGGER fkc_update_books_tags_link_a + BEFORE UPDATE OF book ON books_tags_link + BEGIN + SELECT CASE + WHEN (SELECT id from books WHERE id=NEW.book) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: book not in books') + END; + END; +CREATE TRIGGER fkc_update_books_tags_link_b + BEFORE UPDATE OF tag ON books_tags_link + BEGIN + SELECT CASE + WHEN (SELECT id from tags WHERE id=NEW.tag) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: tag not in tags') + END; + END; +CREATE TRIGGER series_insert_trg + AFTER INSERT ON series + BEGIN + UPDATE series SET sort=NEW.name WHERE id=NEW.id; + END; +CREATE TRIGGER series_update_trg + AFTER UPDATE ON series + BEGIN + UPDATE series SET sort=NEW.name WHERE id=NEW.id; + END;