mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
Initial implementation of file system based ebook database. Tested on linux.
This commit is contained in:
parent
48930e7847
commit
3ad9cc9ef2
@ -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):
|
||||
|
@ -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'):
|
||||
|
@ -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)
|
||||
|
@ -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>&Location of books database (library1.db)</string>
|
||||
<string>&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>
|
||||
|
@ -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):
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
523
src/calibre/library/database2.py
Normal file
523
src/calibre/library/database2.py
Normal 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()
|
||||
|
342
src/calibre/library/metadata_sqlite.sql
Normal file
342
src/calibre/library/metadata_sqlite.sql
Normal 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;
|
Loading…
x
Reference in New Issue
Block a user