mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
KG 0.7.7 update
This commit is contained in:
commit
c481203b4c
@ -4,6 +4,61 @@
|
|||||||
# for important features/bug fixes.
|
# for important features/bug fixes.
|
||||||
# Also, each release can have new and improved recipes.
|
# Also, each release can have new and improved recipes.
|
||||||
|
|
||||||
|
- version: 0.7.7
|
||||||
|
date: 2010-07-02
|
||||||
|
|
||||||
|
new features:
|
||||||
|
- title: "Support for the Nokia E52"
|
||||||
|
|
||||||
|
- title: "Searching on the size column"
|
||||||
|
|
||||||
|
- title: "iTunes driver: Add option to disable cover fetching for speeding up the fetching of large book collections"
|
||||||
|
|
||||||
|
bug fixes:
|
||||||
|
- title: "SONY driver: Only update metadata when books are sent to device."
|
||||||
|
|
||||||
|
- title: "TXT Input: Ensure the generated html is splittable"
|
||||||
|
tickets: [5904]
|
||||||
|
|
||||||
|
- title: "Fix infinite loop in default cover generation."
|
||||||
|
tickets: [6061]
|
||||||
|
|
||||||
|
- title: "HTML Input: Fix a parsing bug that was triggered in rare conditions"
|
||||||
|
tickets: [6064]
|
||||||
|
|
||||||
|
- title: "HTML2Zip plugin: Do not replace ligatures"
|
||||||
|
tickets: [6019]
|
||||||
|
|
||||||
|
- title: "iTunes driver: Fix transmission of non integral series numbers"
|
||||||
|
tickets: [6046]
|
||||||
|
|
||||||
|
- title: "Simplify implementation of cover caching and ensure cover browser is updated when covers are changed"
|
||||||
|
|
||||||
|
- title: "PDF metadata: Fix last character corrupted when setting metadata in encrypted files."
|
||||||
|
|
||||||
|
- title: "PDF metadata: Update the version of PoDoFo used to set metadata to 0.8.1. Hopefully that means more PDF files will work"
|
||||||
|
|
||||||
|
- title: "Device drivers: Speedup for dumping metadata cache to devices on Windows XP"
|
||||||
|
|
||||||
|
- title: "EPUB Output: Ensure that language setting is conformant to the specs"
|
||||||
|
|
||||||
|
- title: "MOBI Output: Fix a memory leak and a crash in the palmdoc compression routine"
|
||||||
|
|
||||||
|
- title: "Metadata download: Fix a regressiont at resulted in a failed download for some books"
|
||||||
|
|
||||||
|
new recipes:
|
||||||
|
- title: "Foreign Policy and Alo!"
|
||||||
|
author: Darko Miletic
|
||||||
|
|
||||||
|
- title: Statesman and ifzm
|
||||||
|
author: rty
|
||||||
|
|
||||||
|
improved recipes:
|
||||||
|
- Akter
|
||||||
|
- The Old New Thing
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- version: 0.7.6
|
- version: 0.7.6
|
||||||
date: 2010-06-28
|
date: 2010-06-28
|
||||||
|
|
||||||
|
@ -7,7 +7,7 @@ class AdvancedUserRecipe1277228948(BasicNewsRecipe):
|
|||||||
|
|
||||||
__author__ = 'rty'
|
__author__ = 'rty'
|
||||||
__version__ = '1.0'
|
__version__ = '1.0'
|
||||||
language = 'zh_CN'
|
language = 'zh'
|
||||||
pubisher = 'www.chinapressusa.com'
|
pubisher = 'www.chinapressusa.com'
|
||||||
description = 'Overseas Chinese Network Newspaper in the USA'
|
description = 'Overseas Chinese Network Newspaper in the USA'
|
||||||
category = 'News in Chinese, USA'
|
category = 'News in Chinese, USA'
|
||||||
|
45
resources/recipes/foreign_policy.recipe
Normal file
45
resources/recipes/foreign_policy.recipe
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
__license__ = 'GPL v3'
|
||||||
|
__copyright__ = '2010, Darko Miletic <darko.miletic at gmail.com>'
|
||||||
|
'''
|
||||||
|
www.foreignpolicy.com
|
||||||
|
'''
|
||||||
|
|
||||||
|
from calibre.web.feeds.news import BasicNewsRecipe
|
||||||
|
|
||||||
|
class ForeignPolicy(BasicNewsRecipe):
|
||||||
|
title = 'Foreign Policy'
|
||||||
|
__author__ = 'Darko Miletic'
|
||||||
|
description = 'International News'
|
||||||
|
publisher = 'Washingtonpost.Newsweek Interactive, LLC'
|
||||||
|
category = 'news, politics, USA'
|
||||||
|
oldest_article = 31
|
||||||
|
max_articles_per_feed = 200
|
||||||
|
no_stylesheets = True
|
||||||
|
encoding = 'utf8'
|
||||||
|
use_embedded_content = False
|
||||||
|
language = 'en'
|
||||||
|
remove_empty_feeds = True
|
||||||
|
extra_css = ' body{font-family: Georgia,"Times New Roman",Times,serif } img{margin-bottom: 0.4em} h1,h2,h3,h4,h5,h6{font-family: Arial,Helvetica,sans-serif} '
|
||||||
|
|
||||||
|
conversion_options = {
|
||||||
|
'comment' : description
|
||||||
|
, 'tags' : category
|
||||||
|
, 'publisher' : publisher
|
||||||
|
, 'language' : language
|
||||||
|
}
|
||||||
|
|
||||||
|
keep_only_tags = [dict(attrs={'id':['art-mast','art-body','auth-bio']})]
|
||||||
|
remove_tags = [dict(name='iframe'),dict(attrs={'id':['share-box','base-ad']})]
|
||||||
|
remove_attributes = ['height','width']
|
||||||
|
|
||||||
|
|
||||||
|
feeds = [(u'Articles', u'http://www.foreignpolicy.com/node/feed')]
|
||||||
|
|
||||||
|
def print_version(self, url):
|
||||||
|
return url + '?print=yes&page=full'
|
||||||
|
|
||||||
|
def preprocess_html(self, soup):
|
||||||
|
for item in soup.findAll(style=True):
|
||||||
|
del item['style']
|
||||||
|
return soup
|
||||||
|
|
50
resources/recipes/ifzm.recipe
Normal file
50
resources/recipes/ifzm.recipe
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
from calibre.web.feeds.news import BasicNewsRecipe
|
||||||
|
|
||||||
|
class AdvancedUserRecipe1277305250(BasicNewsRecipe):
|
||||||
|
title = u'infzm - China Southern Weekly'
|
||||||
|
oldest_article = 14
|
||||||
|
max_articles_per_feed = 100
|
||||||
|
|
||||||
|
feeds = [(u'\u5357\u65b9\u5468\u672b-\u70ed\u70b9\u65b0\u95fb', u'http://www.infzm.com/rss/home/rss2.0.xml'),
|
||||||
|
(u'\u5357\u65b9\u5468\u672b-\u7ecf\u6d4e\u65b0\u95fb', u'http://www.infzm.com/rss/economic.xml'),
|
||||||
|
(u'\u5357\u65b9\u5468\u672b-\u6587\u5316\u65b0\u95fb', u'http://www.infzm.com/rss/culture.xml'),
|
||||||
|
(u'\u5357\u65b9\u5468\u672b-\u751f\u6d3b\u65f6\u5c1a', u'http://www.infzm.com/rss/lifestyle.xml'),
|
||||||
|
(u'\u5357\u65b9\u5468\u672b-\u89c2\u70b9', u'http://www.infzm.com/rss/opinion.xml')
|
||||||
|
]
|
||||||
|
__author__ = 'rty'
|
||||||
|
__version__ = '1.0'
|
||||||
|
language = 'zh'
|
||||||
|
pubisher = 'http://www.infzm.com'
|
||||||
|
description = 'Chinese Weekly Tabloid'
|
||||||
|
category = 'News, China'
|
||||||
|
remove_javascript = True
|
||||||
|
use_embedded_content = False
|
||||||
|
no_stylesheets = True
|
||||||
|
#encoding = 'GB2312'
|
||||||
|
encoding = 'UTF-8'
|
||||||
|
conversion_options = {'linearize_tables':True}
|
||||||
|
masthead_url = 'http://i50.tinypic.com/2qmfb7l.jpg'
|
||||||
|
|
||||||
|
extra_css = '''
|
||||||
|
@font-face { font-family: "DroidFont", serif, sans-serif; src: url(res:///system/fonts/DroidSansFallback.ttf); }\n
|
||||||
|
body {
|
||||||
|
margin-right: 8pt;
|
||||||
|
font-family: 'DroidFont', serif;}
|
||||||
|
.detailContent {font-family: 'DroidFont', serif, sans-serif}
|
||||||
|
'''
|
||||||
|
|
||||||
|
keep_only_tags = [
|
||||||
|
dict(name='div', attrs={'id':'detailContent'}),
|
||||||
|
]
|
||||||
|
remove_tags = [
|
||||||
|
dict(name='div', attrs={'id':['detailTools', 'detailSideL', 'pageNum']}),
|
||||||
|
]
|
||||||
|
remove_tags_after = [
|
||||||
|
dict(name='div', attrs={'id':'pageNum'}),
|
||||||
|
]
|
||||||
|
def preprocess_html(self, soup):
|
||||||
|
for item in soup.findAll(color=True):
|
||||||
|
del item['font']
|
||||||
|
for item in soup.findAll(style=True):
|
||||||
|
del item['style']
|
||||||
|
return soup
|
35
resources/recipes/statesman.recipe
Normal file
35
resources/recipes/statesman.recipe
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
from calibre.web.feeds.news import BasicNewsRecipe
|
||||||
|
|
||||||
|
class AdvancedUserRecipe1278049615(BasicNewsRecipe):
|
||||||
|
title = u'Statesman'
|
||||||
|
pubisher = 'http://www.statesman.com/'
|
||||||
|
description = 'Austin Texas Daily Newspaper'
|
||||||
|
category = 'News, Austin, Texas'
|
||||||
|
__author__ = 'rty'
|
||||||
|
oldest_article = 3
|
||||||
|
|
||||||
|
max_articles_per_feed = 100
|
||||||
|
|
||||||
|
feeds = [(u'News', u'http://www.statesman.com/section-rss.do?source=news&includeSubSections=true'),
|
||||||
|
(u'Business', u'http://www.statesman.com/section-rss.do?source=business&includeSubSections=true'),
|
||||||
|
(u'Life', u'http://www.statesman.com/section-rss.do?source=life&includesubsection=true'),
|
||||||
|
(u'Editorial', u'http://www.statesman.com/section-rss.do?source=opinion&includesubsections=true'),
|
||||||
|
(u'Sports', u'http://www.statesman.com/section-rss.do?source=sports&includeSubSections=true')
|
||||||
|
]
|
||||||
|
masthead_url = "http://www.statesman.com/images/cmg-logo.gif"
|
||||||
|
#temp_files = []
|
||||||
|
#articles_are_obfuscated = True
|
||||||
|
|
||||||
|
remove_javascript = True
|
||||||
|
use_embedded_content = False
|
||||||
|
no_stylesheets = True
|
||||||
|
language = 'en'
|
||||||
|
encoding = 'utf-8'
|
||||||
|
conversion_options = {'linearize_tables':True}
|
||||||
|
remove_tags = [
|
||||||
|
dict(name='div', attrs={'id':'cxArticleOptions'}),
|
||||||
|
]
|
||||||
|
keep_only_tags = [
|
||||||
|
dict(name='div', attrs={'class':'cxArticleHeader'}),
|
||||||
|
dict(name='div', attrs={'id':'cxArticleBodyText'}),
|
||||||
|
]
|
@ -2,7 +2,7 @@ __license__ = 'GPL v3'
|
|||||||
__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
|
__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
|
||||||
__docformat__ = 'restructuredtext en'
|
__docformat__ = 'restructuredtext en'
|
||||||
__appname__ = 'calibre'
|
__appname__ = 'calibre'
|
||||||
__version__ = '0.7.6'
|
__version__ = '0.7.7'
|
||||||
__author__ = "Kovid Goyal <kovid@kovidgoyal.net>"
|
__author__ = "Kovid Goyal <kovid@kovidgoyal.net>"
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
@ -30,6 +30,7 @@ every time you add an HTML file to the library.\
|
|||||||
|
|
||||||
with TemporaryDirectory('_plugin_html2zip') as tdir:
|
with TemporaryDirectory('_plugin_html2zip') as tdir:
|
||||||
recs =[('debug_pipeline', tdir, OptionRecommendation.HIGH)]
|
recs =[('debug_pipeline', tdir, OptionRecommendation.HIGH)]
|
||||||
|
recs.append(['keep_ligatures', True, OptionRecommendation.HIGH])
|
||||||
if self.site_customization and self.site_customization.strip():
|
if self.site_customization and self.site_customization.strip():
|
||||||
recs.append(['input_encoding', self.site_customization.strip(),
|
recs.append(['input_encoding', self.site_customization.strip(),
|
||||||
OptionRecommendation.HIGH])
|
OptionRecommendation.HIGH])
|
||||||
@ -81,7 +82,7 @@ class PML2PMLZ(FileTypePlugin):
|
|||||||
|
|
||||||
return of.name
|
return of.name
|
||||||
|
|
||||||
|
# Metadata reader plugins {{{
|
||||||
class ComicMetadataReader(MetadataReaderPlugin):
|
class ComicMetadataReader(MetadataReaderPlugin):
|
||||||
|
|
||||||
name = 'Read comic metadata'
|
name = 'Read comic metadata'
|
||||||
@ -319,7 +320,9 @@ class ZipMetadataReader(MetadataReaderPlugin):
|
|||||||
def get_metadata(self, stream, ftype):
|
def get_metadata(self, stream, ftype):
|
||||||
from calibre.ebooks.metadata.zip import get_metadata
|
from calibre.ebooks.metadata.zip import get_metadata
|
||||||
return get_metadata(stream)
|
return get_metadata(stream)
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
# Metadata writer plugins {{{
|
||||||
|
|
||||||
class EPUBMetadataWriter(MetadataWriterPlugin):
|
class EPUBMetadataWriter(MetadataWriterPlugin):
|
||||||
|
|
||||||
@ -395,6 +398,7 @@ class TOPAZMetadataWriter(MetadataWriterPlugin):
|
|||||||
from calibre.ebooks.metadata.topaz import set_metadata
|
from calibre.ebooks.metadata.topaz import set_metadata
|
||||||
set_metadata(stream, mi)
|
set_metadata(stream, mi)
|
||||||
|
|
||||||
|
# }}}
|
||||||
|
|
||||||
from calibre.ebooks.comic.input import ComicInput
|
from calibre.ebooks.comic.input import ComicInput
|
||||||
from calibre.ebooks.epub.input import EPUBInput
|
from calibre.ebooks.epub.input import EPUBInput
|
||||||
@ -444,7 +448,7 @@ from calibre.devices.kindle.driver import KINDLE, KINDLE2, KINDLE_DX
|
|||||||
from calibre.devices.nook.driver import NOOK
|
from calibre.devices.nook.driver import NOOK
|
||||||
from calibre.devices.prs505.driver import PRS505
|
from calibre.devices.prs505.driver import PRS505
|
||||||
from calibre.devices.android.driver import ANDROID, S60
|
from calibre.devices.android.driver import ANDROID, S60
|
||||||
from calibre.devices.nokia.driver import N770, N810, E71X
|
from calibre.devices.nokia.driver import N770, N810, E71X, E52
|
||||||
from calibre.devices.eslick.driver import ESLICK, EBK52
|
from calibre.devices.eslick.driver import ESLICK, EBK52
|
||||||
from calibre.devices.nuut2.driver import NUUT2
|
from calibre.devices.nuut2.driver import NUUT2
|
||||||
from calibre.devices.iriver.driver import IRIVER_STORY
|
from calibre.devices.iriver.driver import IRIVER_STORY
|
||||||
@ -519,6 +523,7 @@ plugins += [
|
|||||||
S60,
|
S60,
|
||||||
N770,
|
N770,
|
||||||
E71X,
|
E71X,
|
||||||
|
E52,
|
||||||
N810,
|
N810,
|
||||||
COOL_ER,
|
COOL_ER,
|
||||||
ESLICK,
|
ESLICK,
|
||||||
|
@ -20,7 +20,7 @@ from calibre.utils.config import config_dir
|
|||||||
from calibre.utils.date import isoformat, now, parse_date
|
from calibre.utils.date import isoformat, now, parse_date
|
||||||
from calibre.utils.localization import get_lang
|
from calibre.utils.localization import get_lang
|
||||||
from calibre.utils.logging import Log
|
from calibre.utils.logging import Log
|
||||||
from calibre.utils.zipfile import ZipFile, safe_replace
|
from calibre.utils.zipfile import ZipFile
|
||||||
|
|
||||||
from PIL import Image as PILImage
|
from PIL import Image as PILImage
|
||||||
|
|
||||||
@ -1727,7 +1727,6 @@ class ITUNES(DriverBase):
|
|||||||
return thumb_data
|
return thumb_data
|
||||||
|
|
||||||
thumb_path = book_path.rpartition('.')[0] + '.jpg'
|
thumb_path = book_path.rpartition('.')[0] + '.jpg'
|
||||||
format = book_path.rpartition('.')[2].lower()
|
|
||||||
if isosx:
|
if isosx:
|
||||||
title = book.name()
|
title = book.name()
|
||||||
elif iswindows:
|
elif iswindows:
|
||||||
|
@ -67,3 +67,24 @@ class E71X(USBMS):
|
|||||||
VENDOR_NAME = 'NOKIA'
|
VENDOR_NAME = 'NOKIA'
|
||||||
WINDOWS_MAIN_MEM = 'S60'
|
WINDOWS_MAIN_MEM = 'S60'
|
||||||
|
|
||||||
|
class E52(USBMS):
|
||||||
|
|
||||||
|
name = 'Nokia E52 device interface'
|
||||||
|
gui_name = 'Nokia E52'
|
||||||
|
description = _('Communicate with the Nokia E52')
|
||||||
|
author = 'David Ignjic'
|
||||||
|
supported_platforms = ['windows', 'linux', 'osx']
|
||||||
|
|
||||||
|
VENDOR_ID = [0x421]
|
||||||
|
PRODUCT_ID = [0x1CD]
|
||||||
|
BCD = [0x100]
|
||||||
|
|
||||||
|
|
||||||
|
FORMATS = ['mobi', 'prc']
|
||||||
|
|
||||||
|
EBOOK_DIR_MAIN = 'eBooks'
|
||||||
|
SUPPORTS_SUB_DIRS = True
|
||||||
|
|
||||||
|
VENDOR_NAME = 'NOKIA'
|
||||||
|
WINDOWS_MAIN_MEM = 'S60'
|
||||||
|
|
||||||
|
@ -350,9 +350,7 @@ class XMLCache(object):
|
|||||||
record = lpath_map.get(book.lpath, None)
|
record = lpath_map.get(book.lpath, None)
|
||||||
if record is None:
|
if record is None:
|
||||||
record = self.create_text_record(root, i, book.lpath)
|
record = self.create_text_record(root, i, book.lpath)
|
||||||
date = self.check_timestamp(record, book, path)
|
self.update_text_record(record, book, path, i)
|
||||||
if date is not None:
|
|
||||||
self.update_text_record(record, book, date, path, i)
|
|
||||||
# Ensure the collections in the XML database are recorded for
|
# Ensure the collections in the XML database are recorded for
|
||||||
# this book
|
# this book
|
||||||
if book.device_collections is None:
|
if book.device_collections is None:
|
||||||
@ -390,23 +388,35 @@ class XMLCache(object):
|
|||||||
debug_print('WARNING: Some elements in the JSON cache were not'
|
debug_print('WARNING: Some elements in the JSON cache were not'
|
||||||
' found in the XML cache')
|
' found in the XML cache')
|
||||||
records = [x for x in records if x is not None]
|
records = [x for x in records if x is not None]
|
||||||
ids = set()
|
# Ensure each book has an ID.
|
||||||
for rec in records:
|
for rec in records:
|
||||||
id = rec.get('id', None)
|
if rec.get('id', None) is None:
|
||||||
if id is None:
|
|
||||||
rec.set('id', str(self.max_id(root)+1))
|
rec.set('id', str(self.max_id(root)+1))
|
||||||
id = rec.get('id', None)
|
ids = [x.get('id', None) for x in records]
|
||||||
ids.add(id)
|
# Given that we set the ids, there shouldn't be any None's. But
|
||||||
# ids cannot contain None, so no reason to check
|
# better to be safe...
|
||||||
|
if None in ids:
|
||||||
|
debug_print('WARNING: Some <text> elements do not have ids')
|
||||||
|
ids = [x for x in ids if x is not None]
|
||||||
|
|
||||||
playlist = self.get_or_create_playlist(bl_index, category)
|
playlist = self.get_or_create_playlist(bl_index, category)
|
||||||
# Reduce ids to books not already in the playlist
|
# Get the books currently in the playlist. We will need them to be
|
||||||
|
# sure to put back any books that were manually added.
|
||||||
|
playlist_ids = []
|
||||||
for item in playlist:
|
for item in playlist:
|
||||||
id_ = item.get('id', None)
|
id_ = item.get('id', None)
|
||||||
if id_ is not None:
|
if id_ is not None:
|
||||||
ids.discard(id_)
|
playlist_ids.append(id_)
|
||||||
# Add the books in ids that were not already in the playlist
|
# Empty the playlist. We do this so that the playlist will have the
|
||||||
for id_ in ids:
|
# order specified by get_collections
|
||||||
|
for item in list(playlist):
|
||||||
|
playlist.remove(item)
|
||||||
|
|
||||||
|
# Get a list of ids not known by get_collections
|
||||||
|
extra_ids = [x for x in playlist_ids if x not in ids]
|
||||||
|
# Rebuild the collection in the order specified by get_collections. Then
|
||||||
|
# add the ids that get_collections didn't know about.
|
||||||
|
for id_ in ids + extra_ids:
|
||||||
item = playlist.makeelement(
|
item = playlist.makeelement(
|
||||||
'{%s}item'%self.namespaces[bl_index],
|
'{%s}item'%self.namespaces[bl_index],
|
||||||
nsmap=playlist.nsmap, attrib={'id':id_})
|
nsmap=playlist.nsmap, attrib={'id':id_})
|
||||||
@ -443,23 +453,15 @@ class XMLCache(object):
|
|||||||
root.append(ans)
|
root.append(ans)
|
||||||
return ans
|
return ans
|
||||||
|
|
||||||
def check_timestamp(self, record, book, path):
|
def update_text_record(self, record, book, path, bl_index):
|
||||||
'''
|
|
||||||
Checks the timestamp in the Sony DB against the file. If different,
|
|
||||||
return the file timestamp. Otherwise return None.
|
|
||||||
'''
|
|
||||||
timestamp = os.path.getmtime(path)
|
|
||||||
date = strftime(timestamp)
|
|
||||||
if date != record.get('date', None):
|
|
||||||
return date
|
|
||||||
return None
|
|
||||||
|
|
||||||
def update_text_record(self, record, book, date, path, bl_index):
|
|
||||||
'''
|
'''
|
||||||
Update the Sony database from the book. This is done if the timestamp in
|
Update the Sony database from the book. This is done if the timestamp in
|
||||||
the db differs from the timestamp on the file.
|
the db differs from the timestamp on the file.
|
||||||
'''
|
'''
|
||||||
record.set('date', date)
|
timestamp = os.path.getmtime(path)
|
||||||
|
date = strftime(timestamp)
|
||||||
|
if date != record.get('date', None):
|
||||||
|
record.set('date', date)
|
||||||
record.set('size', str(os.stat(path).st_size))
|
record.set('size', str(os.stat(path).st_size))
|
||||||
title = book.title if book.title else _('Unknown')
|
title = book.title if book.title else _('Unknown')
|
||||||
record.set('title', title)
|
record.set('title', title)
|
||||||
|
@ -174,8 +174,8 @@ class CollectionsBookList(BookList):
|
|||||||
if lpath not in collections_lpaths[category]:
|
if lpath not in collections_lpaths[category]:
|
||||||
collections_lpaths[category].add(lpath)
|
collections_lpaths[category].add(lpath)
|
||||||
collections[category].append(book)
|
collections[category].append(book)
|
||||||
if attr == 'series':
|
if attr == 'series':
|
||||||
series_categories.add(category)
|
series_categories.add(category)
|
||||||
# Sort collections
|
# Sort collections
|
||||||
for category, books in collections.items():
|
for category, books in collections.items():
|
||||||
def tgetter(x):
|
def tgetter(x):
|
||||||
|
15
src/calibre/ebooks/metadata/covers.py
Normal file
15
src/calibre/ebooks/metadata/covers.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
|
||||||
|
|
||||||
|
__license__ = 'GPL v3'
|
||||||
|
__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
|
||||||
|
__docformat__ = 'restructuredtext en'
|
||||||
|
|
||||||
|
|
||||||
|
from calibre.customize import Plugin
|
||||||
|
|
||||||
|
class CoverDownload(Plugin):
|
||||||
|
|
||||||
|
supported_platforms = ['windows', 'osx', 'linux']
|
||||||
|
author = 'Kovid Goyal'
|
||||||
|
type = _('Cover download')
|
@ -15,7 +15,6 @@ from calibre.utils.config import OptionParser
|
|||||||
from calibre.ebooks.metadata.fetch import MetadataSource
|
from calibre.ebooks.metadata.fetch import MetadataSource
|
||||||
from calibre.utils.date import parse_date, utcnow
|
from calibre.utils.date import parse_date, utcnow
|
||||||
|
|
||||||
DOUBAN_API_KEY = None
|
|
||||||
NAMESPACES = {
|
NAMESPACES = {
|
||||||
'openSearch':'http://a9.com/-/spec/opensearchrss/1.0/',
|
'openSearch':'http://a9.com/-/spec/opensearchrss/1.0/',
|
||||||
'atom' : 'http://www.w3.org/2005/Atom',
|
'atom' : 'http://www.w3.org/2005/Atom',
|
||||||
@ -35,13 +34,15 @@ date = XPath("descendant::db:attribute[@name='pubdate']")
|
|||||||
creator = XPath("descendant::db:attribute[@name='author']")
|
creator = XPath("descendant::db:attribute[@name='author']")
|
||||||
tag = XPath("descendant::db:tag")
|
tag = XPath("descendant::db:tag")
|
||||||
|
|
||||||
|
CALIBRE_DOUBAN_API_KEY = '0bd1672394eb1ebf2374356abec15c3d'
|
||||||
|
|
||||||
class DoubanBooks(MetadataSource):
|
class DoubanBooks(MetadataSource):
|
||||||
|
|
||||||
name = 'Douban Books'
|
name = 'Douban Books'
|
||||||
description = _('Downloads metadata from Douban.com')
|
description = _('Downloads metadata from Douban.com')
|
||||||
supported_platforms = ['windows', 'osx', 'linux'] # Platforms this plugin will run on
|
supported_platforms = ['windows', 'osx', 'linux'] # Platforms this plugin will run on
|
||||||
author = 'Li Fanxi <lifanxi@freemindworld.com>' # The author of this plugin
|
author = 'Li Fanxi <lifanxi@freemindworld.com>' # The author of this plugin
|
||||||
version = (1, 0, 0) # The version number of this plugin
|
version = (1, 0, 1) # The version number of this plugin
|
||||||
|
|
||||||
def fetch(self):
|
def fetch(self):
|
||||||
try:
|
try:
|
||||||
@ -65,7 +66,7 @@ class Query(object):
|
|||||||
type = "search"
|
type = "search"
|
||||||
|
|
||||||
def __init__(self, title=None, author=None, publisher=None, isbn=None,
|
def __init__(self, title=None, author=None, publisher=None, isbn=None,
|
||||||
max_results=20, start_index=1):
|
max_results=20, start_index=1, api_key=''):
|
||||||
assert not(title is None and author is None and publisher is None and \
|
assert not(title is None and author is None and publisher is None and \
|
||||||
isbn is None)
|
isbn is None)
|
||||||
assert (int(max_results) < 21)
|
assert (int(max_results) < 21)
|
||||||
@ -89,16 +90,16 @@ class Query(object):
|
|||||||
|
|
||||||
if self.type == "isbn":
|
if self.type == "isbn":
|
||||||
self.url = self.ISBN_URL + q
|
self.url = self.ISBN_URL + q
|
||||||
if DOUBAN_API_KEY is not None:
|
if api_key != '':
|
||||||
self.url = self.url + "?apikey=" + DOUBAN_API_KEY
|
self.url = self.url + "?apikey=" + api_key
|
||||||
else:
|
else:
|
||||||
self.url = self.SEARCH_URL+urlencode({
|
self.url = self.SEARCH_URL+urlencode({
|
||||||
'q':q,
|
'q':q,
|
||||||
'max-results':max_results,
|
'max-results':max_results,
|
||||||
'start-index':start_index,
|
'start-index':start_index,
|
||||||
})
|
})
|
||||||
if DOUBAN_API_KEY is not None:
|
if api_key != '':
|
||||||
self.url = self.url + "&apikey=" + DOUBAN_API_KEY
|
self.url = self.url + "&apikey=" + api_key
|
||||||
|
|
||||||
def __call__(self, browser, verbose):
|
def __call__(self, browser, verbose):
|
||||||
if verbose:
|
if verbose:
|
||||||
@ -177,7 +178,7 @@ class ResultList(list):
|
|||||||
d = None
|
d = None
|
||||||
return d
|
return d
|
||||||
|
|
||||||
def populate(self, entries, browser, verbose=False):
|
def populate(self, entries, browser, verbose=False, api_key=''):
|
||||||
for x in entries:
|
for x in entries:
|
||||||
try:
|
try:
|
||||||
id_url = entry_id(x)[0].text
|
id_url = entry_id(x)[0].text
|
||||||
@ -186,8 +187,8 @@ class ResultList(list):
|
|||||||
report(verbose)
|
report(verbose)
|
||||||
mi = MetaInformation(title, self.get_authors(x))
|
mi = MetaInformation(title, self.get_authors(x))
|
||||||
try:
|
try:
|
||||||
if DOUBAN_API_KEY is not None:
|
if api_key != '':
|
||||||
id_url = id_url + "?apikey=" + DOUBAN_API_KEY
|
id_url = id_url + "?apikey=" + api_key
|
||||||
raw = browser.open(id_url).read()
|
raw = browser.open(id_url).read()
|
||||||
feed = etree.fromstring(raw)
|
feed = etree.fromstring(raw)
|
||||||
x = entry(feed)[0]
|
x = entry(feed)[0]
|
||||||
@ -203,12 +204,16 @@ class ResultList(list):
|
|||||||
self.append(mi)
|
self.append(mi)
|
||||||
|
|
||||||
def search(title=None, author=None, publisher=None, isbn=None,
|
def search(title=None, author=None, publisher=None, isbn=None,
|
||||||
verbose=False, max_results=40):
|
verbose=False, max_results=40, api_key=None):
|
||||||
br = browser()
|
br = browser()
|
||||||
start, entries = 1, []
|
start, entries = 1, []
|
||||||
|
|
||||||
|
if api_key is None:
|
||||||
|
api_key = CALIBRE_DOUBAN_API_KEY
|
||||||
|
|
||||||
while start > 0 and len(entries) <= max_results:
|
while start > 0 and len(entries) <= max_results:
|
||||||
new, start = Query(title=title, author=author, publisher=publisher,
|
new, start = Query(title=title, author=author, publisher=publisher,
|
||||||
isbn=isbn, max_results=max_results, start_index=start)(br, verbose)
|
isbn=isbn, max_results=max_results, start_index=start, api_key=api_key)(br, verbose)
|
||||||
if not new:
|
if not new:
|
||||||
break
|
break
|
||||||
entries.extend(new)
|
entries.extend(new)
|
||||||
@ -216,7 +221,7 @@ def search(title=None, author=None, publisher=None, isbn=None,
|
|||||||
entries = entries[:max_results]
|
entries = entries[:max_results]
|
||||||
|
|
||||||
ans = ResultList()
|
ans = ResultList()
|
||||||
ans.populate(entries, br, verbose)
|
ans.populate(entries, br, verbose, api_key)
|
||||||
return ans
|
return ans
|
||||||
|
|
||||||
def option_parser():
|
def option_parser():
|
||||||
|
@ -808,7 +808,8 @@ class Manifest(object):
|
|||||||
pat = re.compile(r'&(%s);'%('|'.join(user_entities.keys())))
|
pat = re.compile(r'&(%s);'%('|'.join(user_entities.keys())))
|
||||||
data = pat.sub(lambda m:user_entities[m.group(1)], data)
|
data = pat.sub(lambda m:user_entities[m.group(1)], data)
|
||||||
|
|
||||||
parser = etree.XMLParser(no_network=True, huge_tree=True)
|
# Setting huge_tree=True causes crashes in windows with large files
|
||||||
|
parser = etree.XMLParser(no_network=True)
|
||||||
# Try with more & more drastic measures to parse
|
# Try with more & more drastic measures to parse
|
||||||
def first_pass(data):
|
def first_pass(data):
|
||||||
try:
|
try:
|
||||||
@ -844,7 +845,7 @@ class Manifest(object):
|
|||||||
nroot = etree.fromstring('<html></html>')
|
nroot = etree.fromstring('<html></html>')
|
||||||
has_body = False
|
has_body = False
|
||||||
for child in list(data):
|
for child in list(data):
|
||||||
if barename(child.tag) == 'body':
|
if isinstance(child.tag, (unicode, str)) and barename(child.tag) == 'body':
|
||||||
has_body = True
|
has_body = True
|
||||||
break
|
break
|
||||||
parent = nroot
|
parent = nroot
|
||||||
|
@ -25,10 +25,17 @@ from calibre.ebooks.oeb.base import XHTML, XHTML_NS, CSS_MIME, OEB_STYLES
|
|||||||
from calibre.ebooks.oeb.base import XPNSMAP, xpath, urlnormalize
|
from calibre.ebooks.oeb.base import XPNSMAP, xpath, urlnormalize
|
||||||
from calibre.ebooks.oeb.profile import PROFILES
|
from calibre.ebooks.oeb.profile import PROFILES
|
||||||
|
|
||||||
html_css = open(P('templates/html.css'), 'rb').read()
|
_html_css_stylesheet = None
|
||||||
|
|
||||||
|
def html_css_stylesheet():
|
||||||
|
global _html_css_stylesheet
|
||||||
|
if _html_css_stylesheet is None:
|
||||||
|
html_css = open(P('templates/html.css'), 'rb').read()
|
||||||
|
_html_css_stylesheet = cssutils.parseString(html_css)
|
||||||
|
_html_css_stylesheet.namespaces['h'] = XHTML_NS
|
||||||
|
return _html_css_stylesheet
|
||||||
|
|
||||||
XHTML_CSS_NAMESPACE = '@namespace "%s";\n' % XHTML_NS
|
XHTML_CSS_NAMESPACE = '@namespace "%s";\n' % XHTML_NS
|
||||||
HTML_CSS_STYLESHEET = cssutils.parseString(html_css)
|
|
||||||
HTML_CSS_STYLESHEET.namespaces['h'] = XHTML_NS
|
|
||||||
|
|
||||||
INHERITED = set(['azimuth', 'border-collapse', 'border-spacing',
|
INHERITED = set(['azimuth', 'border-collapse', 'border-spacing',
|
||||||
'caption-side', 'color', 'cursor', 'direction', 'elevation',
|
'caption-side', 'color', 'cursor', 'direction', 'elevation',
|
||||||
@ -120,7 +127,7 @@ class Stylizer(object):
|
|||||||
item = oeb.manifest.hrefs[path]
|
item = oeb.manifest.hrefs[path]
|
||||||
basename = os.path.basename(path)
|
basename = os.path.basename(path)
|
||||||
cssname = os.path.splitext(basename)[0] + '.css'
|
cssname = os.path.splitext(basename)[0] + '.css'
|
||||||
stylesheets = [HTML_CSS_STYLESHEET]
|
stylesheets = [html_css_stylesheet()]
|
||||||
head = xpath(tree, '/h:html/h:head')
|
head = xpath(tree, '/h:html/h:head')
|
||||||
if head:
|
if head:
|
||||||
head = head[0]
|
head = head[0]
|
||||||
|
@ -63,7 +63,8 @@ class TXTInput(InputFormatPlugin):
|
|||||||
raise ValueError('This txt file has malformed markup, it cannot be'
|
raise ValueError('This txt file has malformed markup, it cannot be'
|
||||||
' converted by calibre. See http://daringfireball.net/projects/markdown/syntax')
|
' converted by calibre. See http://daringfireball.net/projects/markdown/syntax')
|
||||||
else:
|
else:
|
||||||
html = convert_basic(txt)
|
flow_size = getattr(options, 'flow_size', 0)
|
||||||
|
html = convert_basic(txt, epub_split_size_kb=flow_size)
|
||||||
|
|
||||||
from calibre.customize.ui import plugin_for_input_format
|
from calibre.customize.ui import plugin_for_input_format
|
||||||
html_input = plugin_for_input_format('html')
|
html_input = plugin_for_input_format('html')
|
||||||
|
@ -17,14 +17,11 @@ __docformat__ = 'restructuredtext en'
|
|||||||
|
|
||||||
HTML_TEMPLATE = u'<html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"/><title>%s</title></head><body>\n%s\n</body></html>'
|
HTML_TEMPLATE = u'<html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"/><title>%s</title></head><body>\n%s\n</body></html>'
|
||||||
|
|
||||||
def convert_basic(txt, title=''):
|
def convert_basic(txt, title='', epub_split_size_kb=0):
|
||||||
lines = []
|
|
||||||
# Strip whitespace from the beginning and end of the line. Also replace
|
# Strip whitespace from the beginning and end of the line. Also replace
|
||||||
# all line breaks with \n.
|
# all line breaks with \n.
|
||||||
for line in txt.splitlines():
|
txt = '\n'.join([line.strip() for line in txt.splitlines()])
|
||||||
lines.append(line.strip())
|
|
||||||
txt = '\n'.join(lines)
|
|
||||||
|
|
||||||
# Condense redundant spaces
|
# Condense redundant spaces
|
||||||
txt = re.sub('[ ]{2,}', ' ', txt)
|
txt = re.sub('[ ]{2,}', ' ', txt)
|
||||||
|
|
||||||
@ -34,6 +31,15 @@ def convert_basic(txt, title=''):
|
|||||||
# Remove excessive line breaks.
|
# Remove excessive line breaks.
|
||||||
txt = re.sub('\n{3,}', '\n\n', txt)
|
txt = re.sub('\n{3,}', '\n\n', txt)
|
||||||
|
|
||||||
|
#Takes care if there is no point to split
|
||||||
|
if epub_split_size_kb > 0:
|
||||||
|
length_byte = len(txt.encode('utf-8'))
|
||||||
|
#Calculating the average chunk value for easy splitting as EPUB (+2 as a safe margin)
|
||||||
|
chunk_size = long(length_byte / (int(length_byte / (epub_split_size_kb * 1024) ) + 2 ))
|
||||||
|
#if there are chunks with a superior size then go and break
|
||||||
|
if (len(filter(lambda x: len(x.encode('utf-8')) > chunk_size, txt.split('\n\n')))) :
|
||||||
|
txt = u'\n\n'.join([split_string_separator(line, chunk_size) for line in txt.split('\n\n')])
|
||||||
|
|
||||||
lines = []
|
lines = []
|
||||||
# Split into paragraphs based on having a blank line between text.
|
# Split into paragraphs based on having a blank line between text.
|
||||||
for line in txt.split('\n\n'):
|
for line in txt.split('\n\n'):
|
||||||
@ -71,3 +77,10 @@ def opf_writer(path, opf_name, manifest, spine, mi):
|
|||||||
with open(os.path.join(path, opf_name), 'wb') as opffile:
|
with open(os.path.join(path, opf_name), 'wb') as opffile:
|
||||||
opf.render(opffile)
|
opf.render(opffile)
|
||||||
|
|
||||||
|
def split_string_separator(txt, size) :
|
||||||
|
if len(txt.encode('utf-8')) > size:
|
||||||
|
txt = u''.join([re.sub(u'\.(?P<ends>[^.]*)$', u'.\n\n\g<ends>',
|
||||||
|
txt[i:i+size], 1) for i in
|
||||||
|
xrange(0, len(txt.encode('utf-8')), size)])
|
||||||
|
return txt
|
||||||
|
|
||||||
|
@ -84,6 +84,7 @@ typedef unsigned short QRgb565;
|
|||||||
#define REFLECTION_FACTOR 1.5
|
#define REFLECTION_FACTOR 1.5
|
||||||
|
|
||||||
#define MAX(x, y) ((x > y) ? x : y)
|
#define MAX(x, y) ((x > y) ? x : y)
|
||||||
|
#define MIN(x, y) ((x < y) ? x : y)
|
||||||
|
|
||||||
#define RGB565_RED_MASK 0xF800
|
#define RGB565_RED_MASK 0xF800
|
||||||
#define RGB565_GREEN_MASK 0x07E0
|
#define RGB565_GREEN_MASK 0x07E0
|
||||||
@ -800,7 +801,7 @@ QRect PictureFlowPrivate::renderCenterSlide(const SlideInfo &slide) {
|
|||||||
QRect rect(buffer.width()/2 - sw/2, 0, sw, h-1);
|
QRect rect(buffer.width()/2 - sw/2, 0, sw, h-1);
|
||||||
int left = rect.left();
|
int left = rect.left();
|
||||||
|
|
||||||
for(int x = 0; x < sh-1; x++)
|
for(int x = 0; x < MIN(h-1, sh-1); x++)
|
||||||
for(int y = 0; y < sw; y++)
|
for(int y = 0; y < sw; y++)
|
||||||
buffer.setPixel(left + y, 1+x, src->pixel(x, y));
|
buffer.setPixel(left + y, 1+x, src->pixel(x, y));
|
||||||
|
|
||||||
|
@ -162,6 +162,9 @@ turned into a collection on the reader. Note that the PRS-500 does not support c
|
|||||||
How do I use |app| with my iPad/iPhone/iTouch?
|
How do I use |app| with my iPad/iPhone/iTouch?
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Over the air
|
||||||
|
^^^^^^^^^^^^^^
|
||||||
|
|
||||||
The easiest way to browse your |app| collection on your Apple device (iPad/iPhone/iPod) is by using the *free* Stanza app, available from the Apple app store. You need at least Stanza version 3.0. Stanza allows you to access your |app| collection wirelessly, over the air.
|
The easiest way to browse your |app| collection on your Apple device (iPad/iPhone/iPod) is by using the *free* Stanza app, available from the Apple app store. You need at least Stanza version 3.0. Stanza allows you to access your |app| collection wirelessly, over the air.
|
||||||
|
|
||||||
First perform the following steps in |app|
|
First perform the following steps in |app|
|
||||||
@ -181,13 +184,13 @@ Replace ``192.168.1.2`` with the local IP address of the computer running |app|.
|
|||||||
|
|
||||||
If you get timeout errors while browsing the calibre catalog in Stanza, try increasing the connection timeout value in the stanza settings. Go to Info->Settings and increase the value of Download Timeout.
|
If you get timeout errors while browsing the calibre catalog in Stanza, try increasing the connection timeout value in the stanza settings. Go to Info->Settings and increase the value of Download Timeout.
|
||||||
|
|
||||||
Alternative for the iPad
|
With the USB cable
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
As of |app| version 0.7.0, you can plugin your iPad into the computer using its charging cable, and |app| will detect it and show you a list of books on the iPad. You can then use the Send to device button to send books directly to iBooks on the iPad.
|
As of |app| version 0.7.0, you can plug your iDevice into the computer using its charging cable, and |app| will detect it and show you a list of books on the device. You can then use the *Send to device button* to send books directly to iBooks on the device. Note that you must have at least iOS 4 installed on your iPhone/iTouch for this to work.
|
||||||
|
|
||||||
This method only works on Windows XP and higher and OS X 10.5 and higher. Linux is not supported (iTunes is not available in linux) and OS X 10.4 is not supported. For more details, see
|
This method only works on Windows XP and higher and OS X 10.5 and higher. Linux is not supported (iTunes is not available in linux) and OS X 10.4 is not supported.
|
||||||
`this forum post <http://www.mobileread.com/forums/showpost.php?p=944079&postcount=1>`_.
|
For more details on how this works, see `this forum post <http://www.mobileread.com/forums/showpost.php?p=944079&postcount=1>`_.
|
||||||
|
|
||||||
How do I use |app| with my Android phone?
|
How do I use |app| with my Android phone?
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -65,6 +65,9 @@ class TextLine(object):
|
|||||||
self.bottom_margin = bottom_margin
|
self.bottom_margin = bottom_margin
|
||||||
self.font_path = font_path
|
self.font_path = font_path
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return u'TextLine:%r:%f'%(self.text, self.font_size)
|
||||||
|
|
||||||
def alloc_wand(name):
|
def alloc_wand(name):
|
||||||
ans = getattr(p, name)()
|
ans = getattr(p, name)()
|
||||||
if ans < 0:
|
if ans < 0:
|
||||||
@ -120,6 +123,10 @@ def draw_centered_text(img, dw, text, top, margin=10):
|
|||||||
tokens = text.split(' ')
|
tokens = text.split(' ')
|
||||||
while tokens:
|
while tokens:
|
||||||
line, tokens = _get_line(img, dw, tokens, img_width-2*margin)
|
line, tokens = _get_line(img, dw, tokens, img_width-2*margin)
|
||||||
|
if not line:
|
||||||
|
# Could not fit the first token on the line
|
||||||
|
line = tokens[:1]
|
||||||
|
tokens = tokens[1:]
|
||||||
bottom = draw_centered_line(img, dw, ' '.join(line), top)
|
bottom = draw_centered_line(img, dw, ' '.join(line), top)
|
||||||
top = bottom
|
top = bottom
|
||||||
return top
|
return top
|
||||||
|
@ -8,7 +8,7 @@ import copy
|
|||||||
|
|
||||||
from lxml import html, etree
|
from lxml import html, etree
|
||||||
from lxml.html.builder import HTML, HEAD, TITLE, STYLE, DIV, BODY, \
|
from lxml.html.builder import HTML, HEAD, TITLE, STYLE, DIV, BODY, \
|
||||||
STRONG, EM, BR, SPAN, A, HR, UL, LI, H2, H3, IMG, P as PT, \
|
STRONG, BR, SPAN, A, HR, UL, LI, H2, H3, IMG, P as PT, \
|
||||||
TABLE, TD, TR
|
TABLE, TD, TR
|
||||||
|
|
||||||
from calibre import preferred_encoding, strftime, isbytestring
|
from calibre import preferred_encoding, strftime, isbytestring
|
||||||
|
Loading…
x
Reference in New Issue
Block a user