Initial implementation of file system based ebook database. Tested on linux.

This commit is contained in:
Kovid Goyal 2008-06-13 17:04:34 -07:00
parent 48930e7847
commit 3ad9cc9ef2
10 changed files with 984 additions and 107 deletions

View File

@ -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):

View File

@ -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'):

View File

@ -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+_('<br>Must be a directory.'))
d = error_dialog(self, _('Invalid database location'),
_('Invalid database location ')+path+_('<br>Must be a directory.'))
d.exec_()
elif not os.access(path, os.W_OK):
d = error_dialog(self, _('Invalid database location'), _('Invalid database location.<br>Cannot write to ')+path)
d = error_dialog(self, _('Invalid database location'),
_('Invalid database location.<br>Cannot write to ')+path)
d.exec_()
else:
self.database_location = os.path.abspath(path)

View File

@ -81,8 +81,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>583</width>
<height>625</height>
<width>595</width>
<height>638</height>
</rect>
</property>
<layout class="QGridLayout" >
@ -91,7 +91,10 @@
<item>
<widget class="QLabel" name="label" >
<property name="text" >
<string>&amp;Location of books database (library1.db)</string>
<string>&amp;Location of ebooks (The ebooks are stored in folders sorted by author and metadata is stored in the file metadata.db)</string>
</property>
<property name="wordWrap" >
<bool>true</bool>
</property>
<property name="buddy" >
<cstring>location</cstring>

View File

@ -3,15 +3,14 @@ __copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
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):

View File

@ -1,6 +1,6 @@
__license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
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'),
_('<p>An invalid database already exists at %s, delete it before trying to move the existing database.<br>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):

View File

@ -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)

View File

@ -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)

View File

@ -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'<p>Migrating old database to ebook library in %s<br><center>')%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 <b>%s</b>')%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()

View File

@ -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;