diff --git a/.gitignore b/.gitignore
index 090d11fd24..192b503429 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,7 +14,6 @@ build
dist
docs
resources/localization
-resources/images.qrc
resources/scripts.pickle
resources/ebook-convert-complete.pickle
resources/builtin_recipes.xml
@@ -42,3 +41,4 @@ calibre_plugins/
recipes/*.mobi
recipes/*.epub
recipes/debug
+/.metadata/
diff --git a/Changelog.yaml b/Changelog.yaml
index 8462264e38..da03033cb9 100644
--- a/Changelog.yaml
+++ b/Changelog.yaml
@@ -20,6 +20,950 @@
# new recipes:
# - title:
+- version: 1.8.0
+ date: 2013-10-25
+
+ new features:
+ - title: "DOCX Input: Support linked (as opposed to embedded) images, if the linked image is found on the local computer."
+ tickets: [1243597]
+
+ - title: 'FB2 Input: Add support for note and cite back references. Link pairs of type="note" and type="cite" now automatically generate the correct back reference.'
+ tickets: [1243714]
+
+ - title: "When automerging books during during an add, include the author as well as the title in the report of merged books."
+
+ - title: "OS X Mavericks (10.9) breaks connecting to iTunes and iBooks on iOS devices. For more details see: http://www.mobileread.com/forums/showthread.php?t=215624"
+
+ bug fixes:
+ - title: "OS X: Fix system tray notifications causing crashes on some OS X 10.9 (Mavericks) systems (those that had Growl installed at some point)."
+ tickets: [1224491]
+
+ - title: "OSX: Fix font size in completion popups too small on Mavericks (I hope)"
+ tickets: [1243761]
+
+ - title: "PDF Output: Fix rendering of some semi-transparent images. All semi-transparent images are now rendered using soft masks."
+ tickets: [1243829]
+
+ - title: "MOBI Output: Fix text marked with white-space:pre-wrap causing the Kindle to break lines at arbitrary points inside words."
+ tickets: [1240235]
+
+ - title: "FB2 Input: Fix a regression that broke conversion of FB2 files with paragraphs having both a style and an id attribute."
+ tickets: [1243709]
+
+ - title: "TXT Input: Ensure that
in the generated HTML has a meaningful value."
+ tickets: [1236923]
+
+ - title: "Book details panel: Fix HTML in author names and identifiers not being escaped"
+ tickets: [1243976]
+
+ - title: "HTML 5 parsing: Fix handling of xml:lang attributes on all elements xml:lang is now mapped to a plain lang on all elements, not just "
+
+ - title: "Update HTML 5 parser used in calibre (html5lib-python) to fix a few corner cases"
+
+ - title: "When bulk deleting formats, use a single temporary directory for the deleted files. This makes restoring them from the recycle bin a little cleaner. Also might fix the reported issue with the windows recycle bin choking on creating a large number of folders."
+
+ - title: "DOCX Input: Add support for hyperlink fields that have only anchors and not URLs"
+
+ - title: "DOCX Input: Fix handling of multiple block level bookmarks at the same location."
+ tickets: [1241451]
+
+ - title: "HTMLZ Output: Fix Htmlz does not apply inline css from ."
+ tickets: [1242261]
+
+ - title: "Fix the restore database operation failing on windows installs with long usernames (this would cause the path to the temporary folder used to restore the database to become too long)."
+
+ - title: "ODT Input: Various workarounds for broken ODT files generated my mk4ht"
+
+ - title: "Fix a bug with non-ascii text in the create catalog dialog"
+ ticket: [1241515]
+
+ improved recipes:
+ - A List Apart
+
+- version: 1.7.0
+ date: 2013-10-18
+
+ new features:
+ - title: "Cover grid: Allow using images as the background for the cover grid. To choose an image, go to Preferences->Look & Feel->Cover Grid."
+ tickets: [1239194]
+
+ - title: "An option to mark newly added books with a temporary mark. Option is in Preferences->Adding books."
+ tickets: [1238609]
+
+ - title: "Edit metadata dialog: Allow turning off the cover size displayed in the bottom right corner of the cover by right clicking the cover and choosing 'Hide cover size'. It can be restored the same way."
+
+ bug fixes:
+ - title: "Conversion: If both embed font family and the filter css option to remove fonts are set, do not remove the font specified by the embed font family option."
+
+ - title: "Fix a few remaining situations that could cause formats column to show an error message about SHLock"
+
+ - title: "Make deleting books to recycle bin more robust. Ensure that the temporary directory created during the move to recycle bin process is not left behind in case of errors."
+
+ - title: "Windows: Check if the books' files are in use before deleting"
+
+ - title: "Fix custom device driver swap main and card option not working. Also fix swapping not happening for a few devices on linux"
+ tickets: [1240504]
+
+ - title: "Edit metadata dialog: The Edit metadata dialog currently limits its max size based on the geometry of the smallest attached screen. Change that to use the geometry of the screen on which it will be shown."
+ tickets: [1239597]
+
+ - title: "HTMLZ Output: Fix {title} {msg}
{body} ''' # noqa
+ style = '''
+ body { font-family: sans-serif; background-color: #eee; }
+ a { text-decoration: none; }
+ a:visited { color: blue }
+ a:hover { color: red }
+ ul { list-style-type: none }
+ li { padding-bottom: 1ex }
+ dd li { text-indent: 0; margin: 0 }
+ dd ul { padding: 0; margin: 0 }
+ dt { font-weight: bold }
+ dd { margin-bottom: 2ex }
+ '''
+ body = []
+ for series in rmap:
+ body.append('{0}.x \xa0\xa0\xa0[{1} releases] '.format( # noqa
+ '.'.join(map(type(''), series)), len(rmap[series])))
+ body = ''.format(' '.join(body))
+ index = template.format(title='Previous calibre releases', style=style, msg='Choose a series of calibre releases', body=body)
+ with open('index.html', 'wb') as f:
+ f.write(index.encode('utf-8'))
+
+ for series, releases in rmap.iteritems():
+ sname = '.'.join(map(type(''), series))
+ body = [
+ '{0} '.format('.'.join(map(type(''), r)))
+ for r in releases]
+ body = ''.format(' '.join(body))
+ index = template.format(title='Previous calibre releases (%s.x)' % sname, style=style,
+ msg='Choose a calibre release', body=body)
+ with open('%s.html' % sname, 'wb') as f:
+ f.write(index.encode('utf-8'))
+
+ for r in releases:
+ rname = '.'.join(map(type(''), r))
+ os.chdir(rname)
+ try:
+ body = []
+ files = os.listdir('.')
+ windows = [x for x in files if x.endswith('.msi')]
+ if windows:
+ windows = ['{1} '.format(
+ x, 'Windows 64-bit Installer' if '64bit' in x else 'Windows 32-bit Installer')
+ for x in windows]
+ body.append('Windows '.format(' '.join(windows)))
+ portable = [x for x in files if '-portable-' in x]
+ if portable:
+ body.append('Calibre Portable {1} '.format(
+ portable[0], 'Calibre Portable Installer'))
+ osx = [x for x in files if x.endswith('.dmg')]
+ if osx:
+ body.append('Apple Mac {1} '.format(
+ osx[0], 'OS X Disk Image (.dmg)'))
+ linux = [x for x in files if x.endswith('.bz2')]
+ if linux:
+ linux = ['{1} '.format(
+ x, 'Linux 64-bit binary' if 'x86_64' in x else 'Linux 32-bit binary')
+ for x in linux]
+ body.append('Linux '.format(' '.join(linux)))
+ source = [x for x in files if x.endswith('.xz') or x.endswith('.gz')]
+ if source:
+ body.append('Source Code {1} '.format(
+ source[0], 'Source code (all platforms)'))
+
+ body = '{0} '.format(''.join(body))
+ index = template.format(title='calibre release (%s)' % rname, style=style,
+ msg='', body=body)
+ with open('index.html', 'wb') as f:
+ f.write(index.encode('utf-8'))
+ finally:
+ os.chdir('..')
+
+# }}}
+
+def upload_to_servers(files, version): # {{{
+ base = '/srv/download/'
+ dest = os.path.join(base, version)
+ if not os.path.exists(dest):
+ os.mkdir(dest)
+ for src in files:
+ shutil.copyfile(src, os.path.join(dest, os.path.basename(src)))
+ cwd = os.getcwd()
+ try:
+ generate_index()
+ finally:
+ os.chdir(cwd)
+
+ for server, rdir in {'files':'/srv/download/'}.iteritems():
+ print('Uploading to server:', server)
+ server = '%s.calibre-ebook.com' % server
+ # Copy the generated index files
+ print ('Copying generated index')
+ check_call(['rsync', '-hza', '-e', 'ssh -x', '--include', '*.html',
+ '--filter', '-! */', base, 'root@%s:%s' % (server, rdir)])
+ # Copy the release files
+ rdir = '%s%s/' % (rdir, version)
+ for x in files:
+ start = time.time()
+ print ('Uploading', x)
+ for i in range(5):
+ try:
+ check_call(['rsync', '-h', '-z', '--progress', '-e', 'ssh -x', x,
+ 'root@%s:%s'%(server, rdir)])
+ except KeyboardInterrupt:
+ raise SystemExit(1)
+ except:
+ print ('\nUpload failed, trying again in 30 seconds')
+ time.sleep(30)
+ else:
+ break
+ print ('Uploaded in', int(time.time() - start), 'seconds\n\n')
+
+# }}}
+
+def upload_to_dbs(files, version): # {{{
+ print('Uploading to fosshub.com')
+ sys.stdout.flush()
+ server = 'mirror1.fosshub.com'
+ rdir = 'release/'
+ check_call(['ssh', 'kovid@%s' % server, 'rm -f release/*'])
+ for x in files:
+ start = time.time()
+ print ('Uploading', x)
+ sys.stdout.flush()
+ for i in range(5):
+ try:
+ check_call(['rsync', '-h', '-z', '--progress', '-e', 'ssh -x', x,
+ 'kovid@%s:%s'%(server, rdir)])
+ except KeyboardInterrupt:
+ raise SystemExit(1)
+ except:
+ print ('\nUpload failed, trying again in 30 seconds')
+ sys.stdout.flush()
+ time.sleep(30)
+ else:
+ break
+ print ('Uploaded in', int(time.time() - start), 'seconds\n\n')
+ sys.stdout.flush()
+ check_call(['ssh', 'kovid@%s' % server, '/home/kovid/uploadFiles'])
+# }}}
+
# CLI {{{
def cli_parser():
epilog='Copyright Kovid Goyal 2012'
@@ -409,6 +564,8 @@ def cli_parser():
sf = subparsers.add_parser('sourceforge', help='Upload to sourceforge',
epilog=epilog)
cron = subparsers.add_parser('cron', help='Call script from cron')
+ subparsers.add_parser('calibre', help='Upload to calibre file servers')
+ subparsers.add_parser('dbs', help='Upload to downloadbestsoftware.com')
a = gc.add_argument
@@ -471,8 +628,14 @@ def main(args=None):
sf()
elif args.service == 'cron':
login_to_google(args.username, args.password)
+ elif args.service == 'calibre':
+ upload_to_servers(ofiles, args.version)
+ elif args.service == 'dbs':
+ upload_to_dbs(ofiles, args.version)
if __name__ == '__main__':
main()
# }}}
+
+
diff --git a/setup/install.py b/setup/install.py
index b1698d88ed..df691ae0f7 100644
--- a/setup/install.py
+++ b/setup/install.py
@@ -6,7 +6,7 @@ __license__ = 'GPL v3'
__copyright__ = '2009, Kovid Goyal '
__docformat__ = 'restructuredtext en'
-import sys, os, textwrap, subprocess, shutil, tempfile, atexit, shlex, glob
+import sys, os, textwrap, subprocess, shutil, tempfile, atexit, glob
from setup import (Command, islinux, isbsd, basenames, modules, functions,
__appname__, __version__)
@@ -133,7 +133,6 @@ class Develop(Command):
self.regain_privileges()
self.consolidate_paths()
self.write_templates()
- self.setup_mount_helper()
self.install_files()
self.run_postinstall()
self.install_env_module()
@@ -150,23 +149,6 @@ class Develop(Command):
else:
self.warn('Cannot install calibre environment module to: '+libdir)
- def setup_mount_helper(self):
- def warn():
- self.warn('Failed to compile mount helper. Auto mounting of',
- ' devices will not work')
-
- src = os.path.join(self.SRC, 'calibre', 'devices', 'linux_mount_helper.c')
- dest = os.path.join(self.staging_bindir, 'calibre-mount-helper')
- self.info('Installing mount helper to '+ dest)
- cflags = os.environ.get('OVERRIDE_CFLAGS', '-Wall -pedantic')
- cflags = shlex.split(cflags)
- p = subprocess.Popen(['gcc']+cflags+[src, '-o', dest])
- ret = p.wait()
- if ret != 0:
- return warn()
- self.manifest.append(dest)
- return dest
-
def install_files(self):
pass
diff --git a/setup/installer/__init__.py b/setup/installer/__init__.py
index e31e018f43..52af5f1f67 100644
--- a/setup/installer/__init__.py
+++ b/setup/installer/__init__.py
@@ -6,7 +6,7 @@ __license__ = 'GPL v3'
__copyright__ = '2009, Kovid Goyal '
__docformat__ = 'restructuredtext en'
-import subprocess, tempfile, os, time, socket
+import subprocess, tempfile, os, time
from setup import Command, installer_name
from setup.build_environment import HOST, PROJECT
@@ -57,23 +57,25 @@ class Push(Command):
def run(self, opts):
from threading import Thread
- threads = []
+ threads = {}
for host, vmname in {
r'Owner@winxp:/cygdrive/c/Documents\ and\ Settings/Owner/calibre':'winxp',
'kovid@ox:calibre':None,
r'kovid@win7:/cygdrive/c/Users/kovid/calibre':'Windows 7',
- 'kovid@getafix:calibre-src':None,
+ 'kovid@win7-x64:calibre-src':'win7-x64',
}.iteritems():
- if '@getafix:' in host and socket.gethostname() == 'getafix':
- continue
if vmname is None or is_vm_running(vmname):
rcmd = BASE_RSYNC + EXCLUDES + ['.', host]
print '\n\nPushing to:', vmname or host, '\n'
- threads.append(Thread(target=subprocess.check_call, args=(rcmd,),
- kwargs={'stdout':open(os.devnull, 'wb')}))
- threads[-1].start()
- for thread in threads:
- thread.join()
+ threads[vmname or host] = thread = Thread(target=subprocess.check_call, args=(rcmd,),
+ kwargs={'stdout':open(os.devnull, 'wb')})
+ thread.start()
+ while threads:
+ for name, thread in tuple(threads.iteritems()):
+ thread.join(0.01)
+ if not thread.is_alive():
+ print '\n\n', name, 'done'
+ threads.pop(name)
class VMInstaller(Command):
diff --git a/setup/installer/linux/freeze2.py b/setup/installer/linux/freeze2.py
index 6167109d97..6b9c101b7e 100644
--- a/setup/installer/linux/freeze2.py
+++ b/setup/installer/linux/freeze2.py
@@ -16,10 +16,10 @@ SITE_PACKAGES = ['PIL', 'dateutil', 'dns', 'PyQt4', 'mechanize',
'sip.so', 'BeautifulSoup.py', 'cssutils', 'encutils', 'lxml',
'sipconfig.py', 'xdg', 'dbus', '_dbus_bindings.so', 'dbus_bindings.py',
'_dbus_glib_bindings.so', 'netifaces.so', '_psutil_posix.so',
- '_psutil_linux.so', 'psutil', 'cssselect']
+ '_psutil_linux.so', 'psutil', 'cssselect', 'apsw.so']
QTDIR = '/usr/lib/qt4'
-QTDLLS = ('QtCore', 'QtGui', 'QtNetwork', 'QtSvg', 'QtXml', 'QtWebKit', 'QtDBus')
+QTDLLS = ('QtCore', 'QtGui', 'QtNetwork', 'QtSvg', 'QtXml', 'QtWebKit', 'QtDBus', 'QtXmlPatterns')
MAGICK_PREFIX = '/usr'
binary_includes = [
'/usr/bin/pdftohtml',
@@ -86,7 +86,6 @@ class LinuxFreeze(Command):
self.initbase()
self.copy_libs()
self.copy_python()
- self.compile_mount_helper()
self.build_launchers()
self.create_tarfile()
@@ -144,13 +143,6 @@ class LinuxFreeze(Command):
else:
shutil.copyfile(x, d)
- def compile_mount_helper(self):
- self.info('Compiling mount helper...')
- dest = self.j(self.bin_dir, 'calibre-mount-helper')
- subprocess.check_call(['gcc', '-Wall', '-pedantic',
- self.j(self.SRC, 'calibre', 'devices',
- 'linux_mount_helper.c'), '-o', dest])
-
def copy_python(self):
self.info('Copying python...')
@@ -191,12 +183,17 @@ class LinuxFreeze(Command):
if os.path.isdir(x):
shutil.copytree(x, self.j(dest, self.b(x)),
ignore=ignore_in_lib)
- if os.path.isfile(x) and ext in ('.py', '.so'):
+ elif os.path.isfile(x) and ext in ('.py', '.so'):
shutil.copy2(x, dest)
+ else:
+ raise ValueError('%s does not exist in site-packages' % x)
for x in os.listdir(self.SRC):
- shutil.copytree(self.j(self.SRC, x), self.j(dest, x),
- ignore=ignore_in_lib)
+ if os.path.isdir(self.j(self.SRC, x)):
+ shutil.copytree(self.j(self.SRC, x), self.j(dest, x),
+ ignore=ignore_in_lib)
+ else:
+ shutil.copy2(self.j(self.SRC, x), self.j(dest, x))
for x in ('trac',):
x = self.j(dest, 'calibre', x)
if os.path.exists(x):
diff --git a/setup/installer/osx/app/site.py b/setup/installer/osx/app/site.py
index 5232706727..a0b408f728 100644
--- a/setup/installer/osx/app/site.py
+++ b/setup/installer/osx/app/site.py
@@ -80,7 +80,7 @@ def addpackage(sitedir, name):
f = open(fullname)
except IOError:
return
- while 1:
+ while True:
dir = f.readline()
if not dir:
break
@@ -99,14 +99,12 @@ def addpackage(sitedir, name):
_dirs_in_sys_path = None
-sys.setdefaultencoding('utf-8')
-
-#
# Remove sys.setdefaultencoding() so that users cannot change the
# encoding after initialization. The test for presence is needed when
# this module is run as a script, because this code is executed twice.
#
if hasattr(sys, "setdefaultencoding"):
+ sys.setdefaultencoding('utf-8')
del sys.setdefaultencoding
def run_entry_point():
@@ -127,6 +125,62 @@ def add_calibre_vars(base):
if dv and os.path.exists(dv):
sys.path.insert(0, os.path.abspath(dv))
+def setup_asl():
+ # On Mac OS X 10.8 or later the contents of stdout and stderr
+ # do not end up in Console.app
+ # This function detects if "asl_log_descriptor" is available
+ # (introduced in 10.8), and if it is configures ASL to redirect
+ # all writes to stdout/stderr to Console.app
+ import ctypes
+ try:
+ syslib = ctypes.CDLL("/usr/lib/libSystem.dylib")
+ except EnvironmentError:
+ import ctypes.util
+ syslib = ctypes.CDLL(ctypes.util.find_library('System'))
+
+ asl_log_descriptor_proto = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_uint32)
+ try:
+ asl_log_descriptor = asl_log_descriptor_proto(('asl_log_descriptor', syslib), ((1, 'asl'), (1, 'msg'), (1, 'level'), (1, 'descriptor'), (1, 'fd_type')))
+ except AttributeError:
+ # OS X < 10.8 no need to redirect
+ return
+ asl_open_proto = ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint32)
+ asl_open = asl_open_proto(('asl_open', syslib), ((1, "ident"), (1, "facility"), (1, 'opts')))
+ asl_new_proto = ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_uint32)
+ asl_new = asl_new_proto(('asl_new', syslib), ((1, "type"),))
+ asl_set_proto = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p)
+ asl_set = asl_set_proto(('asl_set', syslib), ((1, "msg"), (1, "key"), (1, "value")))
+
+ CONSOLE = b'com.apple.console'
+ # Taken from asl.h
+ ASL_OPT_NO_DELAY = 2
+ ASL_TYPE_MSG = 0
+ ASL_KEY_FACILITY = b'Facility'
+ ASL_KEY_LEVEL = b'Level'
+ ASL_KEY_READ_UID = b'ReadUID'
+ ASL_STRING_NOTICE = b'Notice'
+ ASL_LEVEL_NOTICE = 4
+ ASL_LOG_DESCRIPTOR_WRITE = 2
+
+ cl = asl_open(ident=getattr(sys, 'calibre_basename', b'calibre'), facility=CONSOLE, opts=ASL_OPT_NO_DELAY)
+ if cl is None:
+ return
+
+ # Create an ASL template message for the STDOUT/STDERR redirection.
+ msg = asl_new(ASL_TYPE_MSG)
+ if msg is None:
+ return
+ if asl_set(msg, ASL_KEY_FACILITY, CONSOLE) != 0:
+ return
+ if asl_set(msg, ASL_KEY_LEVEL, ASL_STRING_NOTICE) != 0:
+ return
+ if asl_set(msg, ASL_KEY_READ_UID, bytes('%d' % os.getuid())) != 0:
+ return
+ # Redirect the STDOUT/STDERR file descriptors to ASL
+ if asl_log_descriptor(cl, msg, ASL_LEVEL_NOTICE, sys.stdout.fileno(), ASL_LOG_DESCRIPTOR_WRITE) != 0:
+ return
+ if asl_log_descriptor(cl, msg, ASL_LEVEL_NOTICE, sys.stderr.fileno(), ASL_LOG_DESCRIPTOR_WRITE) != 0:
+ return
def main():
global __file__
@@ -139,11 +193,16 @@ def main():
add_calibre_vars(base)
addsitedir(sys.site_packages)
+ launched_by_launch_services = False
- for arg in list(sys.argv[1:]):
- if arg.startswith('-psn'):
+ for arg in tuple(sys.argv[1:]):
+ if arg.startswith('-psn_'):
sys.argv.remove(arg)
+ launched_by_launch_services = True
+ if launched_by_launch_services:
+ try:
+ setup_asl()
+ except:
+ pass # Failure to log to Console.app is not critical
return run_entry_point()
-
-
diff --git a/setup/installer/windows/eject.c b/setup/installer/windows/eject.c
new file mode 100644
index 0000000000..dcff769ad3
--- /dev/null
+++ b/setup/installer/windows/eject.c
@@ -0,0 +1,423 @@
+/*
+ * eject.c
+ * Copyright (C) 2013 Kovid Goyal
+ *
+ * Distributed under terms of the GPL3 license.
+ */
+
+#include "Windows.h"
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define BUFSIZE 4096
+#define LOCK_TIMEOUT 10000 // 10 Seconds
+#define LOCK_RETRIES 20
+
+#define BOOL2STR(x) ((x) ? L"True" : L"False")
+
+// Error handling {{{
+
+static void show_error(LPCWSTR msg) {
+ MessageBeep(MB_ICONERROR);
+ MessageBoxW(NULL, msg, L"Error", MB_OK|MB_ICONERROR);
+}
+
+static void show_detailed_error(LPCWSTR preamble, LPCWSTR msg, int code) {
+ LPWSTR buf;
+ buf = (LPWSTR)LocalAlloc(LMEM_ZEROINIT, sizeof(WCHAR)*
+ (wcslen(msg) + wcslen(preamble) + 80));
+
+ _snwprintf_s(buf,
+ LocalSize(buf) / sizeof(WCHAR), _TRUNCATE,
+ L"%s\r\n %s (Error Code: %d)\r\n",
+ preamble, msg, code);
+
+ show_error(buf);
+ LocalFree(buf);
+}
+
+static void print_detailed_error(LPCWSTR preamble, LPCWSTR msg, int code) {
+ fwprintf_s(stderr, L"%s\r\n %s (Error Code: %d)\r\n", preamble, msg, code);
+ fflush(stderr);
+}
+
+static void show_last_error_crt(LPCWSTR preamble) {
+ WCHAR buf[BUFSIZE];
+ int err = 0;
+
+ _get_errno(&err);
+ _wcserror_s(buf, BUFSIZE, err);
+ show_detailed_error(preamble, buf, err);
+}
+
+static void show_last_error(LPCWSTR preamble) {
+ WCHAR *msg = NULL;
+ DWORD dw = GetLastError();
+
+ FormatMessageW(
+ FORMAT_MESSAGE_ALLOCATE_BUFFER |
+ FORMAT_MESSAGE_FROM_SYSTEM |
+ FORMAT_MESSAGE_IGNORE_INSERTS,
+ NULL,
+ dw,
+ MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT),
+ (LPWSTR)&msg,
+ 0, NULL );
+
+ show_detailed_error(preamble, msg, (int)dw);
+}
+
+static void print_last_error(LPCWSTR preamble) {
+ WCHAR *msg = NULL;
+ DWORD dw = GetLastError();
+
+ FormatMessageW(
+ FORMAT_MESSAGE_ALLOCATE_BUFFER |
+ FORMAT_MESSAGE_FROM_SYSTEM |
+ FORMAT_MESSAGE_IGNORE_INSERTS,
+ NULL,
+ dw,
+ MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT),
+ (LPWSTR)&msg,
+ 0, NULL );
+
+ print_detailed_error(preamble, msg, (int)dw);
+}
+
+// }}}
+
+static void print_help() {
+ fwprintf_s(stderr, L"Usage: calibre-eject.exe drive-letter1 [drive-letter2 drive-letter3 ...]");
+}
+
+static LPWSTR root_path = L"X:\\", device_path = L"X:", volume_access_path = L"\\\\.\\X:";
+static wchar_t dos_device_name[MAX_PATH];
+static UINT drive_type = 0;
+static long device_number = -1;
+static DEVINST dev_inst = 0, dev_inst_parent = 0;
+
+// Unmount and eject volumes (drives) {{{
+static HANDLE open_volume(wchar_t drive_letter) {
+ DWORD access_flags;
+
+ switch(drive_type) {
+ case DRIVE_REMOVABLE:
+ access_flags = GENERIC_READ | GENERIC_WRITE;
+ break;
+ case DRIVE_CDROM:
+ access_flags = GENERIC_READ;
+ break;
+ default:
+ fwprintf_s(stderr, L"Cannot eject %c: Drive type is incorrect.\r\n", drive_letter);
+ fflush(stderr);
+ return INVALID_HANDLE_VALUE;
+ }
+
+ return CreateFileW(volume_access_path, access_flags,
+ FILE_SHARE_READ | FILE_SHARE_WRITE,
+ NULL, OPEN_EXISTING, 0, NULL);
+}
+
+static BOOL lock_volume(HANDLE volume) {
+ DWORD bytes_returned;
+ DWORD sleep_amount = LOCK_TIMEOUT / LOCK_RETRIES;
+ int try_count;
+
+ // Do this in a loop until a timeout period has expired
+ for (try_count = 0; try_count < LOCK_RETRIES; try_count++) {
+ if (DeviceIoControl(volume,
+ FSCTL_LOCK_VOLUME,
+ NULL, 0,
+ NULL, 0,
+ &bytes_returned,
+ NULL))
+ return TRUE;
+
+ Sleep(sleep_amount);
+ }
+
+ return FALSE;
+}
+
+static BOOL dismount_volume(HANDLE volume) {
+ DWORD bytes_returned;
+
+ return DeviceIoControl( volume,
+ FSCTL_DISMOUNT_VOLUME,
+ NULL, 0,
+ NULL, 0,
+ &bytes_returned,
+ NULL);
+}
+
+static BOOL disable_prevent_removal_of_volume(HANDLE volume) {
+ DWORD bytes_returned;
+ PREVENT_MEDIA_REMOVAL PMRBuffer;
+
+ PMRBuffer.PreventMediaRemoval = FALSE;
+
+ return DeviceIoControl( volume,
+ IOCTL_STORAGE_MEDIA_REMOVAL,
+ &PMRBuffer, sizeof(PREVENT_MEDIA_REMOVAL),
+ NULL, 0,
+ &bytes_returned,
+ NULL);
+}
+
+static BOOL auto_eject_volume(HANDLE volume) {
+ DWORD bytes_returned;
+
+ return DeviceIoControl( volume,
+ IOCTL_STORAGE_EJECT_MEDIA,
+ NULL, 0,
+ NULL, 0,
+ &bytes_returned,
+ NULL);
+}
+
+static BOOL unmount_drive(wchar_t drive_letter, BOOL *remove_safely, BOOL *auto_eject) {
+ // Unmount the drive identified by drive_letter. Code adapted from:
+ // http://support.microsoft.com/kb/165721
+ HANDLE volume;
+ *remove_safely = FALSE; *auto_eject = FALSE;
+
+ volume = open_volume(drive_letter);
+ if (volume == INVALID_HANDLE_VALUE) return FALSE;
+
+ // Lock and dismount the volume.
+ if (lock_volume(volume) && dismount_volume(volume)) {
+ *remove_safely = TRUE;
+
+ // Set prevent removal to false and eject the volume.
+ if (disable_prevent_removal_of_volume(volume) && auto_eject_volume(volume))
+ *auto_eject = TRUE;
+ }
+ CloseHandle(volume);
+ return TRUE;
+
+}
+// }}}
+
+// Eject USB device {{{
+static void get_device_number(wchar_t drive_letter) {
+ HANDLE volume;
+ DWORD bytes_returned = 0;
+ STORAGE_DEVICE_NUMBER sdn;
+
+ volume = CreateFileW(volume_access_path, 0,
+ FILE_SHARE_READ | FILE_SHARE_WRITE,
+ NULL, OPEN_EXISTING, 0, NULL);
+ if (volume == INVALID_HANDLE_VALUE) {
+ print_last_error(L"Failed to open volume while getting device number");
+ return;
+ }
+
+ if (DeviceIoControl(volume,
+ IOCTL_STORAGE_GET_DEVICE_NUMBER,
+ NULL, 0, &sdn, sizeof(sdn),
+ &bytes_returned, NULL))
+ device_number = sdn.DeviceNumber;
+ CloseHandle(volume);
+}
+
+static DEVINST get_dev_inst_by_device_number(long device_number, UINT drive_type, LPWSTR dos_device_name) {
+ GUID *guid;
+ HDEVINFO dev_info;
+ DWORD index, bytes_returned;
+ BOOL bRet, is_floppy;
+ BYTE Buf[1024];
+ PSP_DEVICE_INTERFACE_DETAIL_DATA pspdidd;
+ long res;
+ HANDLE drive;
+ STORAGE_DEVICE_NUMBER sdn;
+ SP_DEVICE_INTERFACE_DATA spdid;
+ SP_DEVINFO_DATA spdd;
+ DWORD size;
+
+ is_floppy = (wcsstr(dos_device_name, L"\\Floppy") != NULL); // is there a better way?
+
+ switch (drive_type) {
+ case DRIVE_REMOVABLE:
+ guid = ( (is_floppy) ? (GUID*)&GUID_DEVINTERFACE_FLOPPY : (GUID*)&GUID_DEVINTERFACE_DISK );
+ break;
+ case DRIVE_FIXED:
+ guid = (GUID*)&GUID_DEVINTERFACE_DISK;
+ break;
+ case DRIVE_CDROM:
+ guid = (GUID*)&GUID_DEVINTERFACE_CDROM;
+ break;
+ default:
+ fwprintf_s(stderr, L"Invalid drive type at line: %d\r\n", __LINE__);
+ fflush(stderr);
+ return 0;
+ }
+
+ // Get device interface info set handle
+ // for all devices attached to system
+ dev_info = SetupDiGetClassDevs(guid, NULL, NULL, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE);
+
+ if (dev_info == INVALID_HANDLE_VALUE) {
+ fwprintf_s(stderr, L"Failed to setup class devs at line: %d\r\n", __LINE__);
+ fflush(stderr);
+ return 0;
+ }
+
+ // Retrieve a context structure for a device interface
+ // of a device information set.
+ index = 0;
+ bRet = FALSE;
+
+ pspdidd = (PSP_DEVICE_INTERFACE_DETAIL_DATA)Buf;
+ spdid.cbSize = sizeof(spdid);
+
+ while ( TRUE ) {
+ bRet = SetupDiEnumDeviceInterfaces(dev_info, NULL,
+ guid, index, &spdid);
+ if ( !bRet ) break;
+
+ size = 0;
+ SetupDiGetDeviceInterfaceDetail(dev_info,
+ &spdid, NULL, 0, &size, NULL);
+
+ if ( size!=0 && size<=sizeof(Buf) ) {
+ pspdidd->cbSize = sizeof(*pspdidd); // 5 Bytes!
+
+ ZeroMemory((PVOID)&spdd, sizeof(spdd));
+ spdd.cbSize = sizeof(spdd);
+
+ res = SetupDiGetDeviceInterfaceDetail(dev_info, &spdid, pspdidd, size, &size, &spdd);
+ if ( res ) {
+ drive = CreateFile(pspdidd->DevicePath,0,
+ FILE_SHARE_READ | FILE_SHARE_WRITE,
+ NULL, OPEN_EXISTING, 0, NULL);
+ if ( drive != INVALID_HANDLE_VALUE ) {
+ bytes_returned = 0;
+ res = DeviceIoControl(drive,
+ IOCTL_STORAGE_GET_DEVICE_NUMBER,
+ NULL, 0, &sdn, sizeof(sdn),
+ &bytes_returned, NULL);
+ if ( res ) {
+ if ( device_number == (long)sdn.DeviceNumber ) {
+ CloseHandle(drive);
+ SetupDiDestroyDeviceInfoList(dev_info);
+ return spdd.DevInst;
+ }
+ }
+ CloseHandle(drive);
+ }
+ }
+ }
+ index++;
+ }
+
+ SetupDiDestroyDeviceInfoList(dev_info);
+ fwprintf_s(stderr, L"Invalid device number at line: %d\r\n", __LINE__);
+ fflush(stderr);
+
+ return 0;
+}
+
+
+static void get_parent_device(wchar_t drive_letter) {
+ get_device_number(drive_letter);
+ if (device_number == -1) return;
+ if (QueryDosDeviceW(device_path, dos_device_name, MAX_PATH) == 0) {
+ print_last_error(L"Failed to query DOS device name");
+ return;
+ }
+
+ dev_inst = get_dev_inst_by_device_number(device_number,
+ drive_type, dos_device_name);
+ if (dev_inst == 0) {
+ fwprintf_s(stderr, L"Failed to get device by device number");
+ fflush(stderr);
+ return;
+ }
+ if (CM_Get_Parent(&dev_inst_parent, dev_inst, 0) != CR_SUCCESS) {
+ fwprintf_s(stderr, L"Failed to get device parent from CM");
+ fflush(stderr);
+ return;
+ }
+}
+
+static int eject_device() {
+ int tries;
+ CONFIGRET res;
+ PNP_VETO_TYPE VetoType;
+ WCHAR VetoNameW[MAX_PATH];
+ BOOL success;
+
+ for ( tries = 0; tries < 3; tries++ ) {
+ VetoNameW[0] = 0;
+
+ res = CM_Request_Device_EjectW(dev_inst_parent,
+ &VetoType, VetoNameW, MAX_PATH, 0);
+
+ success = (res==CR_SUCCESS &&
+ VetoType==PNP_VetoTypeUnknown);
+ if ( success ) {
+ break;
+ }
+
+ Sleep(500); // required to give the next tries a chance!
+ }
+ if (!success) {
+ fwprintf_s(stderr, L"CM_Request_Device_Eject failed after three tries\r\n");
+ fflush(stderr);
+ }
+
+ return (success) ? 0 : 1;
+}
+
+// }}}
+
+int wmain(int argc, wchar_t *argv[ ]) {
+ int i = 0;
+ wchar_t drive_letter;
+ BOOL remove_safely, auto_eject;
+
+ // Validate command line arguments
+ if (argc < 2) { print_help(); return 1; }
+ for (i = 1; i < argc; i++) {
+ if (wcsnlen_s(argv[i], 2) != 1) { print_help(); return 1; }
+ }
+
+ // Unmount all mounted volumes and eject volume media
+ for (i = 1; i < argc; i++) {
+ drive_letter = *argv[i];
+ root_path[0] = drive_letter;
+ device_path[0] = drive_letter;
+ volume_access_path[4] = drive_letter;
+ drive_type = GetDriveTypeW(root_path);
+ if (i == 1 && device_number == -1) {
+ get_parent_device(drive_letter);
+ }
+ if (device_number != -1) {
+ unmount_drive(drive_letter, &remove_safely, &auto_eject);
+ fwprintf_s(stdout, L"Unmounting: %c: Remove safely: %s Media Ejected: %s\r\n",
+ drive_letter, BOOL2STR(remove_safely), BOOL2STR(auto_eject));
+ fflush(stdout);
+ }
+ }
+
+ // Eject the parent USB device
+ if (device_number == -1) {
+ fwprintf_s(stderr, L"Cannot eject, failed to get device number\r\n");
+ fflush(stderr);
+ return 1;
+ }
+
+ if (dev_inst_parent == 0) {
+ fwprintf_s(stderr, L"Cannot eject, failed to get device parent\r\n");
+ fflush(stderr);
+ return 1;
+ }
+
+ return eject_device();
+}
+
+
diff --git a/setup/installer/windows/freeze.py b/setup/installer/windows/freeze.py
index 6237fa0071..d59c086f26 100644
--- a/setup/installer/windows/freeze.py
+++ b/setup/installer/windows/freeze.py
@@ -41,6 +41,8 @@ DESCRIPTIONS = {
'calibre-server': 'Standalone calibre content server',
'calibre-parallel': 'calibre worker process',
'calibre-smtp' : 'Command line interface for sending books via email',
+ 'calibre-recycle' : 'Helper program for deleting to recycle bin',
+ 'calibre-eject' : 'Helper program for ejecting connected reader devices',
}
def walk(dir):
@@ -81,6 +83,8 @@ class Win32Freeze(Command, WixMixIn):
self.initbase()
self.build_launchers()
+ self.build_eject()
+ self.build_recycle()
self.add_plugins()
self.freeze()
self.embed_manifests()
@@ -218,7 +222,10 @@ class Win32Freeze(Command, WixMixIn):
self.info('Adding calibre sources...')
for x in glob.glob(self.j(self.SRC, '*')):
- shutil.copytree(x, self.j(sp_dir, self.b(x)))
+ if os.path.isdir(x):
+ shutil.copytree(x, self.j(sp_dir, self.b(x)))
+ else:
+ shutil.copy(x, self.j(sp_dir, self.b(x)))
for x in (r'calibre\manual', r'calibre\trac', 'pythonwin'):
deld = self.j(sp_dir, x)
@@ -383,17 +390,21 @@ class Win32Freeze(Command, WixMixIn):
os.remove(y)
def run_builder(self, cmd, show_output=False):
- p = subprocess.Popen(cmd, stdout=subprocess.PIPE,
- stderr=subprocess.PIPE)
- if p.wait() != 0:
+ p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ buf = []
+ while p.poll() is None:
+ x = p.stdout.read() + p.stderr.read()
+ if x:
+ buf.append(x)
+ if p.returncode != 0:
self.info('Failed to run builder:')
self.info(*cmd)
- self.info(p.stdout.read())
- self.info(p.stderr.read())
+ self.info(''.join(buf))
+ self.info('')
+ sys.stdout.flush()
sys.exit(1)
if show_output:
- self.info(p.stdout.read())
- self.info(p.stderr.read())
+ self.info(''.join(buf) + '\n')
def build_portable_installer(self):
zf = self.a(self.j('dist', 'calibre-portable-%s.zip.lz'%VERSION))
@@ -480,7 +491,7 @@ class Win32Freeze(Command, WixMixIn):
'/LIBPATH:'+self.obj_dir, '/SUBSYSTEM:WINDOWS',
'/RELEASE',
'/ENTRY:wWinMainCRTStartup',
- '/OUT:'+exe, self.embed_resources(exe),
+ '/OUT:'+exe, self.embed_resources(exe, desc='Calibre Portable', product_description='Calibre Portable'),
obj, 'User32.lib']
self.run_builder(cmd)
@@ -534,6 +545,36 @@ class Win32Freeze(Command, WixMixIn):
finally:
os.chdir(cwd)
+ def build_recycle(self):
+ self.info('Building calibre-recycle.exe')
+ base = self.j(self.src_root, 'setup', 'installer', 'windows')
+ src = self.j(base, 'recycle.c')
+ obj = self.j(self.obj_dir, self.b(src)+'.obj')
+ cflags = '/c /EHsc /MD /W3 /Ox /nologo /D_UNICODE'.split()
+ if self.newer(obj, src):
+ cmd = [msvc.cc] + cflags + ['/Fo'+obj, '/Tc'+src]
+ self.run_builder(cmd, show_output=True)
+ exe = self.j(self.base, 'calibre-recycle.exe')
+ cmd = [msvc.linker] + ['/MACHINE:'+machine,
+ '/SUBSYSTEM:CONSOLE', '/RELEASE',
+ '/OUT:'+exe] + [self.embed_resources(exe), obj, 'Shell32.lib']
+ self.run_builder(cmd)
+
+ def build_eject(self):
+ self.info('Building calibre-eject.exe')
+ base = self.j(self.src_root, 'setup', 'installer', 'windows')
+ src = self.j(base, 'eject.c')
+ obj = self.j(self.obj_dir, self.b(src)+'.obj')
+ cflags = '/c /EHsc /MD /W3 /Ox /nologo /D_UNICODE'.split()
+ if self.newer(obj, src):
+ cmd = [msvc.cc] + cflags + ['/Fo'+obj, '/Tc'+src]
+ self.run_builder(cmd, show_output=True)
+ exe = self.j(self.base, 'calibre-eject.exe')
+ cmd = [msvc.linker] + ['/MACHINE:'+machine,
+ '/SUBSYSTEM:CONSOLE', '/RELEASE',
+ '/OUT:'+exe] + [self.embed_resources(exe), obj, 'setupapi.lib']
+ self.run_builder(cmd)
+
def build_launchers(self, debug=False):
if not os.path.exists(self.obj_dir):
os.makedirs(self.obj_dir)
@@ -647,6 +688,8 @@ class Win32Freeze(Command, WixMixIn):
for d in self.get_pth_dirs(self.j(sp, 'easy-install.pth')):
handled.add(self.b(d))
+ if os.path.basename(d).startswith('six-'):
+ continue # We prefer the version bundled with calibre
for x in os.listdir(d):
if x in {'EGG-INFO', 'site.py', 'site.pyc', 'site.pyo'}:
continue
diff --git a/setup/installer/windows/notes.rst b/setup/installer/windows/notes.rst
index 297279a8b2..b58c564f84 100644
--- a/setup/installer/windows/notes.rst
+++ b/setup/installer/windows/notes.rst
@@ -69,7 +69,8 @@ to login as the normal user account with ssh. To do this, follow these steps:
rm -R /etc/ssh*
mkpasswd -cl > /etc/passwd
mkgroup --local > /etc/group
- * Assign the necessary rights to the normal user account::
+ * Assign the necessary rights to the normal user account (administrator
+ command prompt needed)::
editrights.exe -a SeAssignPrimaryTokenPrivilege -u kovid
editrights.exe -a SeCreateTokenPrivilege -u kovid
editrights.exe -a SeTcbPrivilege -u kovid
diff --git a/setup/installer/windows/recycle.c b/setup/installer/windows/recycle.c
new file mode 100644
index 0000000000..3e51bc07a1
--- /dev/null
+++ b/setup/installer/windows/recycle.c
@@ -0,0 +1,28 @@
+/*
+ * recycle.c
+ * Copyright (C) 2013 Kovid Goyal
+ *
+ * Distributed under terms of the GPL3 license.
+ */
+
+#include "Windows.h"
+#include "Shellapi.h"
+/* #include */
+
+int wmain(int argc, wchar_t *argv[ ]) {
+ wchar_t buf[512] = {0};
+ SHFILEOPSTRUCTW op = {0};
+ if (argc != 2) return 1;
+ if (wcsnlen_s(argv[1], 512) > 510) return 1;
+ if (wcscpy_s(buf, 512, argv[1]) != 0) return 1;
+
+ op.wFunc = FO_DELETE;
+ op.pFrom = buf;
+ op.pTo = NULL;
+ op.fFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOCONFIRMMKDIR | FOF_NOERRORUI | FOF_SILENT | FOF_RENAMEONCOLLISION;
+
+ /* wprintf(L"%ls\n", buf); */
+ return SHFileOperationW(&op);
+}
+
+
diff --git a/setup/iso_639_3.xml b/setup/iso_639_3.xml
new file mode 100644
index 0000000000..6b94a3850b
--- /dev/null
+++ b/setup/iso_639_3.xml
@@ -0,0 +1,39178 @@
+
+
+
+
+
+
+
+]>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/setup/plugins_mirror.py b/setup/plugins_mirror.py
new file mode 100644
index 0000000000..bead10567c
--- /dev/null
+++ b/setup/plugins_mirror.py
@@ -0,0 +1,555 @@
+#!/usr/bin/env python
+# vim:fileencoding=utf-8
+from __future__ import (unicode_literals, division, absolute_import,
+ print_function)
+
+__license__ = 'GPL v3'
+__copyright__ = '2013, Kovid Goyal '
+
+import urllib2, re, HTMLParser, zlib, gzip, io, sys, bz2, json, errno, urlparse, os, zipfile, ast, tempfile, glob, fcntl, atexit, stat
+from future_builtins import map, zip, filter
+from collections import namedtuple
+from multiprocessing.pool import ThreadPool
+from datetime import datetime
+from email.utils import parsedate
+from contextlib import closing
+from functools import partial
+from xml.sax.saxutils import escape, quoteattr
+
+USER_AGENT = 'calibre mirror'
+MR_URL = 'http://www.mobileread.com/forums/'
+WORKDIR = '/srv/plugins' if os.path.exists('/srv') else '/t/plugins'
+PLUGINS = 'plugins.json.bz2'
+INDEX = MR_URL + 'showpost.php?p=1362767&postcount=1'
+# INDEX = 'file:///t/raw.html'
+
+IndexEntry = namedtuple('IndexEntry', 'name url donate history uninstall deprecated thread_id')
+u = HTMLParser.HTMLParser().unescape
+
+def read(url, get_info=False): # {{{
+ if url.startswith("file://"):
+ return urllib2.urlopen(url).read()
+ opener = urllib2.build_opener()
+ opener.addheaders = [
+ ('User-Agent', USER_AGENT),
+ ('Accept-Encoding', 'gzip,deflate'),
+ ]
+ res = opener.open(url)
+ info = res.info()
+ encoding = info.get('Content-Encoding')
+ raw = res.read()
+ res.close()
+ if encoding and encoding.lower() in {'gzip', 'x-gzip', 'deflate'}:
+ if encoding.lower() == 'deflate':
+ raw = zlib.decompress(raw)
+ else:
+ raw = gzip.GzipFile(fileobj=io.BytesIO(raw)).read()
+ if get_info:
+ return raw, info
+ return raw
+# }}}
+
+def url_to_plugin_id(url, deprecated):
+ query = urlparse.parse_qs(urlparse.urlparse(url).query)
+ ans = (query['t'] if 't' in query else query['p'])[0]
+ if deprecated:
+ ans += '-deprecated'
+ return ans
+
+def parse_index(raw=None): # {{{
+ raw = raw or read(INDEX).decode('utf-8', 'replace')
+
+ dep_start = raw.index('>Deprecated/Renamed/Retired Plugins:<')
+ dpat = re.compile(r'''(?is)Donate\s*:\s*(.+?)<(.+?)''', raw):
+ deprecated = match.start() > dep_start
+ donate = uninstall = None
+ history = False
+ name, url, rest = u(match.group(2)), u(match.group(1)), match.group(3)
+ m = dpat.search(rest)
+ if m is not None:
+ donate = u(m.group(1))
+ for m in key_pat.finditer(rest):
+ k = m.group(1).lower()
+ if k == 'history' and m.group(2).strip().lower() in {'yes', 'true'}:
+ history = True
+ elif k == 'uninstall':
+ uninstall = tuple(x.strip() for x in m.group(2).strip().split(','))
+
+ thread_id = url_to_plugin_id(url, deprecated)
+ if thread_id in seen:
+ raise ValueError('thread_id for %s and %s is the same: %s' % (seen[thread_id], name, thread_id))
+ seen[thread_id] = name
+ entry = IndexEntry(name, url, donate, history, uninstall, deprecated, thread_id)
+ yield entry
+# }}}
+
+def parse_plugin_zip_url(raw):
+ for m in re.finditer(r'''(?is) ]*>([^<>]+?\.zip)\s*<''', raw):
+ url, name = u(m.group(1)), u(m.group(2).strip())
+ if name.lower().endswith('.zip'):
+ return MR_URL + url, name
+ return None, None
+
+def load_plugins_index():
+ try:
+ with open(PLUGINS, 'rb') as f:
+ raw = f.read()
+ except IOError as err:
+ if err.errno == errno.ENOENT:
+ return {}
+ raise
+ return json.loads(bz2.decompress(raw))
+
+# Get metadata from plugin zip file {{{
+def convert_node(fields, x, names={}, import_data=None):
+ name = x.__class__.__name__
+ conv = lambda x:convert_node(fields, x, names=names, import_data=import_data)
+ if name == 'Str':
+ return x.s.decode('utf-8') if isinstance(x.s, bytes) else x.s
+ elif name == 'Num':
+ return x.n
+ elif name in {'Set', 'List', 'Tuple'}:
+ func = {'Set':set, 'List':list, 'Tuple':tuple}[name]
+ return func(map(conv, x.elts))
+ elif name == 'Dict':
+ keys, values = map(conv, x.keys), map(conv, x.values)
+ return dict(zip(keys, values))
+ elif name == 'Call':
+ if len(x.args) != 1 and len(x.keywords) != 0:
+ raise TypeError('Unsupported function call for fields: %s' % (fields,))
+ return tuple(map(conv, x.args))[0]
+ elif name == 'Name':
+ if x.id not in names:
+ if import_data is not None and x.id in import_data[0]:
+ return get_import_data(x.id, import_data[0][x.id], *import_data[1:])
+ raise ValueError('Could not find name %s for fields: %s' % (x.id, fields))
+ return names[x.id]
+ raise TypeError('Unknown datatype %s for fields: %s' % (x, fields))
+
+Alias = namedtuple('Alias', 'name asname')
+
+def get_import_data(name, mod, zf, names):
+ mod = mod.split('.')
+ if mod[0] == 'calibre_plugins':
+ mod = mod[2:]
+ mod = '/'.join(mod) + '.py'
+ if mod in names:
+ raw = zf.open(names[mod]).read()
+ module = ast.parse(raw, filename='__init__.py')
+ top_level_assigments = filter(lambda x:x.__class__.__name__ == 'Assign', ast.iter_child_nodes(module))
+ for node in top_level_assigments:
+ targets = {getattr(t, 'id', None) for t in node.targets}
+ targets.discard(None)
+ for x in targets:
+ if x == name:
+ return convert_node({x}, node.value)
+ raise ValueError('Failed to find name: %r in module: %r' % (name, mod))
+ else:
+ raise ValueError('Failed to find module: %r' % mod)
+
+def parse_metadata(raw, namelist, zf):
+ module = ast.parse(raw, filename='__init__.py')
+ top_level_imports = filter(lambda x:x.__class__.__name__ == 'ImportFrom', ast.iter_child_nodes(module))
+ top_level_classes = tuple(filter(lambda x:x.__class__.__name__ == 'ClassDef', ast.iter_child_nodes(module)))
+ top_level_assigments = filter(lambda x:x.__class__.__name__ == 'Assign', ast.iter_child_nodes(module))
+ defaults = {'name':'', 'description':'', 'supported_platforms':['windows', 'osx', 'linux'],
+ 'version':(1, 0, 0), 'author':'Unknown', 'minimum_calibre_version':(0, 9, 42)}
+ field_names = set(defaults)
+ imported_names = {}
+
+ plugin_import_found = set()
+ all_imports = []
+ for node in top_level_imports:
+ names = getattr(node, 'names', [])
+ mod = getattr(node, 'module', None)
+ if names and mod:
+ names = [Alias(n.name, getattr(n, 'asname', None)) for n in names]
+ if mod in {
+ 'calibre.customize', 'calibre.customize.conversion',
+ 'calibre.ebooks.metadata.sources.base', 'calibre.ebooks.metadata.covers',
+ 'calibre.devices.interface', 'calibre.ebooks.metadata.fetch',
+ } or re.match(r'calibre\.devices\.[a-z0-9]+\.driver', mod) is not None:
+ inames = {n.asname or n.name for n in names}
+ inames = {x for x in inames if x.lower() != x}
+ plugin_import_found |= inames
+ else:
+ all_imports.append((mod, [n.name for n in names]))
+ imported_names[n.asname or n.name] = mod
+ if not plugin_import_found:
+ return all_imports
+
+ import_data = (imported_names, zf, namelist)
+
+ names = {}
+ for node in top_level_assigments:
+ targets = {getattr(t, 'id', None) for t in node.targets}
+ targets.discard(None)
+ for x in targets - field_names:
+ try:
+ val = convert_node({x}, node.value, import_data=import_data)
+ except Exception:
+ pass
+ else:
+ names[x] = val
+
+ def parse_class(node):
+ class_assigments = filter(lambda x:x.__class__.__name__ == 'Assign', ast.iter_child_nodes(node))
+ found = {}
+ for node in class_assigments:
+ targets = {getattr(t, 'id', None) for t in node.targets}
+ targets.discard(None)
+ fields = field_names.intersection(targets)
+ if fields:
+ val = convert_node(fields, node.value, names=names, import_data=import_data)
+ for field in fields:
+ found[field] = val
+ return found
+
+ if top_level_classes:
+ for node in top_level_classes:
+ bases = {getattr(x, 'id', None) for x in node.bases}
+ if not bases.intersection(plugin_import_found):
+ continue
+ found = parse_class(node)
+ if 'name' in found and 'author' in found:
+ defaults.update(found)
+ return defaults
+ for node in top_level_classes:
+ found = parse_class(node)
+ if 'name' in found and 'author' in found and 'version' in found:
+ defaults.update(found)
+ return defaults
+
+ raise ValueError('Could not find plugin class')
+
+def get_plugin_info(raw):
+ metadata = None
+ with zipfile.ZipFile(io.BytesIO(raw)) as zf:
+ names = {x.decode('utf-8') if isinstance(x, bytes) else x : x for x in zf.namelist()}
+ inits = [x for x in names if x.rpartition('/')[-1] == '__init__.py']
+ inits.sort(key=lambda x:x.count('/'))
+ if inits and inits[0] == '__init__.py':
+ metadata = names[inits[0]]
+ else:
+ # Legacy plugin
+ for name, val in names.iteritems():
+ if name.endswith('plugin.py'):
+ metadata = val
+ break
+ if metadata is None:
+ raise ValueError('No __init__.py found in plugin')
+ raw = zf.open(metadata).read()
+ ans = parse_metadata(raw, names, zf)
+ if isinstance(ans, dict):
+ return ans
+ # The plugin is importing its base class from somewhere else, le sigh
+ for mod, _ in ans:
+ mod = mod.split('.')
+ if mod[0] == 'calibre_plugins':
+ mod = mod[2:]
+ mod = '/'.join(mod) + '.py'
+ if mod in names:
+ raw = zf.open(names[mod]).read()
+ ans = parse_metadata(raw, names, zf)
+ if isinstance(ans, dict):
+ return ans
+
+ raise ValueError('Failed to find plugin class')
+
+
+# }}}
+
+def update_plugin_from_entry(plugin, entry):
+ plugin['index_name'] = entry.name
+ plugin['thread_url'] = entry.url
+ for x in ('donate', 'history', 'deprecated', 'uninstall', 'thread_id'):
+ plugin[x] = getattr(entry, x)
+
+def fetch_plugin(old_index, entry):
+ lm_map = {plugin['thread_id']:plugin for plugin in old_index.itervalues()}
+ raw = read(entry.url)
+ url, name = parse_plugin_zip_url(raw)
+ plugin = lm_map.get(entry.thread_id, None)
+
+ if plugin is not None:
+ # Previously downloaded plugin
+ lm = datetime(*tuple(map(int, re.split(r'\D', plugin['last_modified'])))[:6])
+ request = urllib2.Request(url)
+ request.get_method = lambda : 'HEAD'
+ with closing(urllib2.urlopen(request)) as response:
+ info = response.info()
+ slm = datetime(*parsedate(info.get('Last-Modified'))[:6])
+ if lm >= slm:
+ # The previously downloaded plugin zip file is up-to-date
+ update_plugin_from_entry(plugin, entry)
+ return plugin
+
+ raw, info = read(url, get_info=True)
+ slm = datetime(*parsedate(info.get('Last-Modified'))[:6])
+ plugin = get_plugin_info(raw)
+ plugin['last_modified'] = slm.isoformat()
+ plugin['file'] = 'staging_%s.zip' % entry.thread_id
+ plugin['size'] = len(raw)
+ plugin['original_url'] = url
+ update_plugin_from_entry(plugin, entry)
+ with open(plugin['file'], 'wb') as f:
+ f.write(raw)
+ return plugin
+
+def parallel_fetch(old_index, entry):
+ try:
+ return fetch_plugin(old_index, entry)
+ except Exception:
+ import traceback
+ return traceback.format_exc()
+
+def log(*args, **kwargs):
+ print (*args, **kwargs)
+ with open('log', 'a') as f:
+ kwargs['file'] = f
+ print (*args, **kwargs)
+
+def atomic_write(raw, name):
+ with tempfile.NamedTemporaryFile(dir=os.getcwdu(), delete=False) as f:
+ f.write(raw)
+ os.fchmod(f.fileno(), stat.S_IREAD|stat.S_IWRITE|stat.S_IRGRP|stat.S_IROTH)
+ os.rename(f.name, name)
+
+def fetch_plugins(old_index):
+ ans = {}
+ pool = ThreadPool(processes=10)
+ entries = tuple(parse_index())
+ result = pool.map(partial(parallel_fetch, old_index), entries)
+ for entry, plugin in zip(entries, result):
+ if isinstance(plugin, dict):
+ ans[entry.name] = plugin
+ else:
+ if entry.name in old_index:
+ ans[entry.name] = old_index[entry.name]
+ log('Failed to get plugin', entry.name, 'at', datetime.utcnow().isoformat(), 'with error:')
+ log(plugin)
+ # Move staged files
+ for plugin in ans.itervalues():
+ if plugin['file'].startswith('staging_'):
+ src = plugin['file']
+ plugin['file'] = src.partition('_')[-1]
+ os.rename(src, plugin['file'])
+ raw = bz2.compress(json.dumps(ans, sort_keys=True, indent=4, separators=(',', ': ')))
+ atomic_write(raw, PLUGINS)
+ # Cleanup any extra .zip files
+ all_plugin_files = {p['file'] for p in ans.itervalues()}
+ extra = set(glob.glob('*.zip')) - all_plugin_files
+ for x in extra:
+ os.unlink(x)
+ return ans
+
+def plugin_to_index(plugin):
+ title = '' % ( # noqa
+ quoteattr(plugin['thread_url']), escape(plugin['name']))
+ released = datetime(*tuple(map(int, re.split(r'\D', plugin['last_modified'])))[:6]).strftime('%e %b, %Y').lstrip()
+ details = [
+ 'Version: %s ' % escape('.'.join(map(str, plugin['version']))),
+ 'Released: %s ' % escape(released),
+ 'Author: %s' % escape(plugin['author']),
+ 'History: %s' % escape('Yes' if plugin['history'] else 'No'),
+ 'calibre: %s' % escape('.'.join(map(str, plugin['minimum_calibre_version']))),
+ 'Platforms: %s' % escape(', '.join(sorted(plugin['supported_platforms']) or ['all'])),
+ ]
+ if plugin['uninstall']:
+ details.append('Uninstall: %s' % escape(', '.join(plugin['uninstall'])))
+ if plugin['donate']:
+ details.append(' Donate ' % quoteattr(plugin['donate']))
+ block = []
+ for li in details:
+ if li.startswith('calibre:'):
+ block.append(' ')
+ block.append('%s ' % li)
+ block = '' % ('\n'.join(block))
+ zipfile = '' % (
+ quoteattr(plugin['file']), quoteattr(plugin['name'] + '.zip'))
+ desc = plugin['description'] or ''
+ if desc:
+ desc = '%s
' % desc
+ return '%s\n%s\n%s\n%s\n\n' % (title, desc, block, zipfile)
+
+def create_index(index):
+ plugins = []
+ for name in sorted(index):
+ plugin = index[name]
+ if not plugin['deprecated']:
+ plugins.append(
+ plugin_to_index(plugin))
+ index = '''\
+
+
+Index of calibre plugins
+
+
+
+
+ Index of calibre plugins
+
+%s
+
+