Merge custom columns branch

This commit is contained in:
Kovid Goyal 2010-05-30 22:54:24 -06:00
commit 9c91a54a16
148 changed files with 18252 additions and 15414 deletions

View File

@ -30,6 +30,12 @@ License: BSD
The full text of the BSD license is distributed as in
/usr/share/common-licenses/BSD on Debian systems.
Files: /src/routes/*
Copyright: Copyright (c) 2005-2008 Ben Bangert <ben@groovie.org>
License: BSD
The full text of the BSD license is distributed as in
/usr/share/common-licenses/BSD on Debian systems.
Files: src/odf/*
Copyright: Copyright (C) 2006-2008 Søren Roug, European Environment Agency
License: LGPL2.1+
@ -50,12 +56,6 @@ License: BSD
The full text of the BSD license is distributed as in
/usr/share/common-licenses/BSD on Debian systems.
Files: src/calibre/utils/genshi/*
Copyright: Copyright (C) 2006-2008 Edgewall Software
License: BSD
The full text of the BSD license is distributed as in
/usr/share/common-licenses/BSD on Debian systems.
Files: src/calibre/utils/lzx/*
Copyright: Copyright (C) 2002, Matthew T. Russotto
Copyright: Copyright (C) 2008, Marshall T. Vandegrift <llasram@gmail.com>

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

@ -123,7 +123,7 @@ function fetch_library_books(start, num, timeout, sort, order, search) {
current_library_request = $.ajax({
type: "GET",
url: "library",
url: "xml",
data: data,
cache: false,
timeout: timeout, //milliseconds

View File

@ -11,7 +11,7 @@
</head>
<body>
<div id="banner">
<a style="border: 0pt" href="http://calibre-ebook.com" alt="calibre" title="calibre"><img style="border:0pt" src="/static/calibre.png" alt="calibre" /></a>
<a style="border: 0pt" href="http://calibre-ebook.com" alt="calibre" title="calibre"><img style="border:0pt" src="/static/calibre_banner.png" alt="calibre" /></a>
</div>
<div id="search_box">

View File

@ -25,3 +25,39 @@ series_index_auto_increment = 'next'
# copy : copy author to author_sort without modification
# comma : use 'copy' if there is a ',' in the name, otherwise use 'invert'
author_sort_copy_method = 'invert'
# Set whether boolean custom columns are two- or three-valued.
# Two-values for true booleans
# three-values for yes/no/unknown
# Set to 'yes' for three-values, 'no' for two-values
bool_custom_columns_are_tristate = 'yes'
# Provide a set of columns to be sorted on when calibre starts
# The argument is None if saved sort history is to be used
# otherwise it is a list of column,order pairs. Column is the
# lookup/search name, found using the tooltip for the column
# Order is 0 for ascending, 1 for descending
# For example, set it to [('authors',0),('title',0)] to sort by
# title within authors.
sort_columns_at_startup = None
# Format to be used for publication date
# A string controlling how the publication date is displayed in the GUI
# d the day as number without a leading zero (1 to 31)
# dd the day as number with a leading zero (01 to 31)
# ddd the abbreviated localized day name (e.g. 'Mon' to 'Sun').
# dddd the long localized day name (e.g. 'Monday' to 'Qt::Sunday').
# M the month as number without a leading zero (1-12)
# MM the month as number with a leading zero (01-12)
# MMM the abbreviated localized month name (e.g. 'Jan' to 'Dec').
# MMMM the long localized month name (e.g. 'January' to 'December').
# yy the year as two digit number (00-99)
# yyyy the year as four digit number
# For example, given the date of 9 Jan 2010, the following formats show
# MMM yyyy ==> Jan 2010 yyyy ==> 2010 dd MMM yyyy ==> 09 Jan 2010
# MM/yyyy ==> 01/2010 d/M/yy ==> 9/1/10 yy ==> 10
# default if not set: MMM yyyy
gui_pubdate_display_format = 'MMM yyyy'

View File

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="500"
height="500"
id="svg3152"
version="1.1"
inkscape:version="0.47 r22583"
sodipodi:docname="New document 1">
<defs
id="defs3154">
<inkscape:perspective
sodipodi:type="inkscape:persp3d"
inkscape:vp_x="0 : 526.18109 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_z="744.09448 : 526.18109 : 1"
inkscape:persp3d-origin="372.04724 : 350.78739 : 1"
id="perspective3160" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="1.0"
inkscape:pageopacity="1.0"
inkscape:pageshadow="0"
inkscape:zoom="0.34"
inkscape:cx="350"
inkscape:cy="520"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:window-width="643"
inkscape:window-height="666"
inkscape:window-x="125"
inkscape:window-y="125"
inkscape:window-maximized="0" />
<metadata
id="metadata3157">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-552.36218)" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 12.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 51448) --><svg height="253.5" id="Layer_1" inkscape:version="0.40+cvs" sodipodi:docbase="F:\openclip\svg3" sodipodi:docname="Capitello modanatura moulure.svg" sodipodi:version="0.32" style="overflow:visible;enable-background:new 0 0 277.433 253.5;" version="1.0" viewBox="0 0 277.433 253.5" width="277.433" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://web.resource.org/cc/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:sodipodi="http://inkscape.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:svg="http://www.w3.org/2000/svg" xmlns:xml="http://www.w3.org/XML/1998/namespace"><metadata><rdf:RDF xmlns:cc="http://web.resource.org/cc/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><cc:Work rdf:about=""><dc:title>Capitello modanatura modanature moulure moulures</dc:title><dc:description></dc:description><dc:subject><rdf:Bag><rdf:li>building</rdf:li></rdf:Bag></dc:subject><dc:publisher><cc:Agent rdf:about="http://www.openclipart.org"><dc:title>Architetto Francesco Rollandin</dc:title></cc:Agent></dc:publisher><dc:creator><cc:Agent><dc:title>Architetto Francesco Rollandin</dc:title></cc:Agent></dc:creator><dc:rights><cc:Agent><dc:title>Architetto Francesco Rollandin</dc:title></cc:Agent></dc:rights><dc:date></dc:date><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><cc:license rdf:resource="http://web.resource.org/cc/PublicDomain"/><dc:language>en</dc:language></cc:Work><cc:License rdf:about="http://web.resource.org/cc/PublicDomain"><cc:permits rdf:resource="http://web.resource.org/cc/Reproduction"/><cc:permits rdf:resource="http://web.resource.org/cc/Distribution"/><cc:permits rdf:resource="http://web.resource.org/cc/DerivativeWorks"/></cc:License></rdf:RDF></metadata>
<defs id="defs56"></defs>
<sodipodi:namedview bordercolor="#666666" borderopacity="1.0" id="base" inkscape:current-layer="Layer_1" inkscape:cx="138.71651" inkscape:cy="126.75000" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:window-height="540" inkscape:window-width="640" inkscape:window-x="22" inkscape:window-y="22" inkscape:zoom="1.4911243" pagecolor="#ffffff"></sodipodi:namedview>
<g id="g3">
<path d="M269.987,16.446c11.514,10.547,7.726,33.31,1.663,45.651c-9.559,19.459-32.822,21.546-51.841,19.009 c-2.021,22.636-2.369,45.377-2.399,68.09c-0.015,10.891,0.025,21.781,0.025,32.672c0,5.381,0,10.762,0,16.143 c0,4.35,1.145,5.354,5.544,5.098c2.975-0.172,12.557-3.239,12.893,1.706c0.221,3.245-2.241,9.014-0.016,11.713 c1.997,2.421,5.807,3.181,8.67,3.993c3.729,1.058,7.776,3.027,11.667,3.265c2.984,0.182,1.435,5.441,1.368,7.535 c-0.148,4.68,0.078,9.268,0.319,13.928c0.24,4.638-1.359,4.189-5.543,4.594c-21.966,2.125-44.538,0.341-66.583,0.291 c-22.807-0.052-45.619,0.078-68.409,1.014c-22.082,0.906-44.168,0.835-66.252,1.523c-9,0.28-18.09,0.771-27.071,0.831 c-3.313,0.022-3.382-1.347-2.935-4.079c0.726-4.434,0.467-8.914,0.467-13.398c0-2.373-1.074-7.706,1.34-9.398 c2.938-2.061,7.151-2.742,10.401-4.265c9.135-4.276,6.441-7.791,6.591-16.34c0.073-4.137,5.426-1.096,8.395-1.154 c4.361-0.087,2.593-4.402,2.559-7.859c-0.064-6.333-0.206-12.665-0.382-18.995c-0.639-23.031-1.182-46.073-2.019-69.097 c-0.196-5.4-0.352-10.801-0.472-16.204c-0.058-2.589,1.452-11.419-2.132-11.275c-8.722,0.349-17.113-0.684-25.05-4.56 C-12.651,60.547-3.602,8.372,32.616,2.474c9.309-1.516,19.017-0.612,28.399-0.511c11.773,0.126,23.546,0.163,35.32,0.157 c23.119-0.013,46.238-0.115,69.354-0.556C185.921,1.177,206.175-0.019,226.411,0C242.655,0.015,259.596,3.013,269.987,16.446" id="path5"></path>
<path d="M263.497,12.805c17.229,17.231,13.408,52.826-9.883,63.111c-11.111,4.906-29.468,6.652-39.829-0.784 c-4.768-3.42-10.636-8.188-13.193-13.588c-2.79-5.893-0.708-14.659,1.955-20.272c5.144-10.839,15.963-19.15,28.248-18.245 c13.067,0.963,21.35,10.229,19.214,23.444c-1.684,10.42-12.965,22.841-24.252,15.978c-4.984-3.032-8.286-9.521-6.396-15.307 c1.08-3.303,8.425-8.779,10.105-2.996c-3.617-0.933-3.468,5.562-2.88,7.464c1.694,5.481,8.471,5.282,12.069,1.689 c10.924-10.907-4.807-28.457-17.827-22.066c-15.255,7.487-15.464,27.982-1.153,36.475c13.494,8.008,31.806,1.371,39.313-11.699 c6.751-11.753,4.543-29.552-5.98-38.545c-6.412-5.48-16.133-5.78-24.13-6.6C217.514,9.7,206.07,9.611,194.659,9.88 c-45.654,1.077-91.252,2.635-136.93,2.865c-10.516,0.053-26.706-3.068-35.7,3.59C13.072,22.966,7.624,34.89,8.907,45.965 c2.465,21.27,26.852,34.439,45.764,23.854C63.05,65.13,69.272,56.151,68.154,46.26c-1.048-9.266-8.71-17.296-18.334-17.278 c-10.437,0.02-18.804,10.469-13.622,20.189c2.062,3.867,8.494,5.687,11.636,1.998c2.241-2.63,1.254-7.959-2.931-7.419 c4.833-5.223,11.914,1.513,11.38,7.142c-0.623,6.566-7.648,11.135-13.593,12.024c-15.159,2.267-23.777-11.625-20.532-25.271 c3.998-16.809,21.359-21.542,36.573-17.014c17.902,5.329,19.484,20.76,13.54,36.295C59.813,89.49,7.194,84.666,2.164,49.499 c-2.4-16.776,6.42-36.324,22.822-42.628c7.058-2.713,14.77-2.768,22.225-2.692C58.154,4.29,69.098,4.427,80.042,4.491 c32.615,0.19,65.303,0.147,97.908-0.728c16.363-0.439,32.709-1.273,49.082-0.806C239.347,3.309,254.146,3.507,263.497,12.805" id="path7" style="fill:#BFBFBF;"></path>
<path d="M258.749,31.246c3.035,11.892-0.551,27.027-11.674,33.584c-10.348,6.101-28.37,4.505-33.847-7.551 c-4.545-10.002,2.436-25.982,14.791-24.727c6.887,0.7,10.927,5.838,10.588,12.623c-0.167,3.326-0.945,6.588-4.631,7.439 c-3.448,0.796-6.992-1.519-4.906-5.223c4.045,5.535,5.103-6.75-1.358-8.312c-6.246-1.511-10.79,3.854-10.992,9.564 c-0.493,13.941,14.36,23.351,26.353,15.28c11.628-7.826,13.93-28.233,3.285-37.841c-6.293-5.681-15.592-6.508-23.673-6.923 c-11.101-0.57-22.227,0.536-33.31,1.041c-22.333,1.019-44.661,2.149-66.996,3.14c-10.522,0.466-21.048,0.829-31.577,1.124 c-5.074,0.142-10.148,0.27-15.223,0.378c-5.138,0.109-9.622-3.251-14.107-5.271c-13.721-6.181-33.438-2.347-40.341,11.952 c-5.646,11.696-1.719,30.13,12.218,33.742c10.756,2.788,31.213-5.459,25.068-19.708c-2.384-5.527-9.835-7.564-14.897-4.53 c-1.53,0.917-5.698,8.159-1.748,6.881c1.042-0.337,1.828-1.721,3.053-1.544c2.031,0.294,1.867,1.657,0.453,2.672 c-6.196,4.449-9.433-4.01-7.865-8.951c2.52-7.944,13.175-10.17,19.805-6.519c13.573,7.474,8.818,25.74-2.296,32.597 c-14.444,8.912-34.788,3.045-41.537-12.791C7.439,39.422,15.236,19.093,30.86,15.584c9.506-2.136,20.457-0.466,30.129-0.384 c12.077,0.103,24.155,0.117,36.232,0.044c23.102-0.14,46.172-0.626,69.248-1.748c19.153-0.93,38.758-2.352,57.946-1.18 C237.174,13.095,255.954,15.957,258.749,31.246" id="path9" style="fill:#808080;"></path>
<path d="M216.96,22.065c-4.189,1.457-6.926,5.1-10.035,8.035c-3.926,3.706-7.758,3.477-12.894,3.057 c-13.75-1.124-27.989-0.009-41.786,0.081c-14.754,0.095-29.439,0.667-44.163,1.609c-7.355,0.471-14.71,0.961-22.071,1.337 c-3.388,0.173-7.721,1.855-8.424-1.65c-0.532-2.656-2.151-4.901-3.401-7.245c25.986-0.478,51.909-1.521,77.857-2.958 c12.748-0.706,25.585-1.691,38.35-1.883c5.152-0.078,10.293,0.221,15.423-0.352C209.098,21.729,213.818,20.556,216.96,22.065" id="path11" style="fill:#5E5E5E;"></path>
<path d="M202.556,35.282c-3.06,6.708-6.111,13.132-5.917,20.674c0.065,2.534,0.354,5.179,1.679,7.404 c1.462,2.459,4.185,3.993,4.92,6.893c1.413,5.582-0.519,12.133-0.678,17.817c-0.233,8.271-0.503,16.542-0.691,24.813 c-0.373,16.291-0.483,32.586-0.646,48.88c-0.081,8.149-0.175,16.3-0.321,24.449c-0.058,3.206,1.931,16.342-2.582,17.381 c-4.207,0.97-9.048,1.308-9.19-3.83c-0.206-7.431,0.059-14.804,0.187-22.237c0.296-17.278,0.149-34.562,0.442-51.841 c0.278-16.383,0.703-32.763,1.037-49.146c0.156-7.656,0.222-15.316,0.411-22.972c0.161-6.488-5.237-11.803-7.485-17.813 C189.794,33.726,196.337,35.792,202.556,35.282" id="path13" style="fill:#E3E3E3;"></path>
<path d="M149.926,35.678c-1.179,5.098-7.83,6.24-8.979,10.95c-1.648,6.761-0.583,15.063-0.745,22.007 c-0.406,17.411-0.884,34.822-0.57,52.239c0.308,17.057,0.717,34.095,0.717,51.156c0,8.01,0,16.02,0,24.028 c0,7.207,0.133,7.996-7.093,7.5c-6.914-0.475-3.747-15.828-3.729-20.809c0.059-16.925,0.119-33.849,0.178-50.773 c0.061-17.404-0.096-34.828,0.439-52.226c0.247-8.041,0.696-16.035,0.593-24.084c-0.11-8.56-2.827-11.851-8.037-18.326 C131.735,36.181,140.884,36.717,149.926,35.678" id="path15" style="fill:#E3E3E3;"></path>
<path d="M180.872,35.678c-0.979,4.869-5.544,7.579-8.596,11.063c-4.358,4.976-2.346,12.479-2.414,18.523 c-0.192,17.04-1.516,33.989-1.317,51.069c0.191,16.582,0.597,33.16,0.705,49.742c0.054,8.282,0.034,16.565-0.123,24.847 c-0.069,3.652,0.211,7.544-0.281,11.169c-0.41,3.021-5.537,2.033-7.736,2.147c-1.793,0.094-1.443-80.056-1.404-87.238 c0.085-16.094,0.108-32.21,0.636-48.297c0.19-5.829,0.481-11.56,1.162-17.345c0.758-6.434-3.308-10.796-7.856-15.206 c3.301-1.833,8.131-0.664,11.682-0.447C170.432,36.017,175.776,36.123,180.872,35.678" id="path17" style="fill:#E3E3E3;"></path>
<path d="M118.269,37.735c-1.283,2.154-2.983,4.106-4.986,5.619c-1.565,1.183-3.853,1.641-4.952,3.395 c-1.47,2.347,0.599,5.242,0.599,7.685c0,4.216-0.604,8.428-0.717,12.647c-0.438,16.338-0.463,32.68-0.272,49.021 c0.194,16.553,0.592,33.098,1.683,49.619c0.389,5.881,5.109,37.916-2.616,38.886c-3.479,0.438-8.817,1.344-9.113-3.157 c-0.499-7.577-0.5-15.216-0.751-22.806c-0.55-16.556-1.148-33.111-1.437-49.675c-0.284-16.31-0.281-32.628,0.23-48.934 c0.255-8.16,0.637-16.315,1.172-24.461c0.475-7.23-3.972-10.534-5.669-17.048C100.385,38.349,109.315,37.358,118.269,37.735" id="path19" style="fill:#E3E3E3;"></path>
<path d="M158.395,46.125c-0.533,31.886-1.188,63.758-1.188,95.65c0,15.317,0,30.634,0,45.95 c0,4.617,0.757,9.24,0.664,13.833c-0.068,3.371-8.199,3.735-8.344,0.423c-1.413-32.226-1.844-64.548-1.556-96.803 c0.143-15.907,0.771-31.809,1.218-47.709c0.135-4.806-1.466-13.862,1.021-18.2C152.52,35.243,157.134,45.118,158.395,46.125" id="path21" style="fill:#969696;"></path>
<path d="M86.137,40.11c-1.802,4.444-6.351,6.834-8.785,10.843c0.118-3.233-1.075-7.139,0.521-10.162 C78.82,38.997,86.6,37.518,86.137,40.11" id="path23" style="fill:#E3E3E3;"></path>
<path d="M127.924,47.708c-0.864,29.137-0.867,58.291-0.963,87.438c-0.048,14.845-0.097,29.689-0.146,44.534 c-0.023,6.938,0.175,13.919-0.089,20.853c-0.133,3.509-2.079,3.183-5.074,3.256c-3.186,0.077-1.998-5.488-2.133-8.231 c-0.182-3.69-0.362-7.381-0.538-11.071c-0.351-7.383-0.684-14.767-0.974-22.152c-0.581-14.771-0.985-29.548-1.057-44.33 c-0.071-14.73,0.919-29.391,1.103-44.1c0.088-7.041-0.18-14.086-0.18-21.128c0-5.268-1.026-9.582,2.85-13.457 C123.231,42.02,125.295,45.115,127.924,47.708" id="path25" style="fill:#969696;"></path>
<path d="M188.469,83.005c-0.868,27.782-1.484,55.573-1.786,83.367c-0.124,11.389,0.632,23.096-0.191,34.44 c-0.213,2.944-4.001,4.863-6.649,2.729c-2.821-2.274-2.137-11.711-2.193-14.737c-0.244-12.991,0.978-25.921,0.54-38.927 c-0.438-12.993-0.009-25.956,0.292-38.947c0.301-13.003,0.601-26.007,0.907-39.01c0.25-10.571-2.265-22.762,2.749-32.602 C192.517,51.294,189.273,68.576,188.469,83.005" id="path27" style="fill:#969696;"></path>
<path d="M95.001,51.744c-1.75,28.572-2.194,57.212-1.738,85.831c0.216,13.561,0.746,27.112,1.23,40.665 c0.248,6.934,0.492,13.867,0.735,20.801c0.229,6.533-2.363,4.685-7.43,5.211c-1.578-28.564-2.779-57.147-2.978-85.757 c-0.093-13.326-0.059-26.656,0.183-39.98c0.121-6.69,0.447-13.376,0.565-20.066c0.106-6.027,0.059-12.19,3.418-17.468 C90.991,44.568,92.996,48.156,95.001,51.744" id="path29" style="fill:#969696;"></path>
<path d="M145.177,87.437c0.988,25.058,1.155,50.139,1.183,75.214c0.013,11.358,0.005,22.718,0.005,34.077 c0,1.782,0.651,4.979-0.428,6.579c-2.152,3.19-2.427-2.293-2.446-2.981c-0.175-6.101-0.312-12.202-0.37-18.306 c-0.233-24.164-0.411-48.273-0.979-72.433c-0.275-11.732,0.069-23.448,0.522-35.172c0.243-6.293,0.513-12.586,0.71-18.882 c0.128-4.116-0.891-8.615,2.99-11.388C146.64,58.581,146.323,73.043,145.177,87.437" id="path31" style="fill:#BFBFBF;"></path>
<path d="M115.42,50.953c-1.174,28.427-1.397,56.849-0.762,85.292c0.306,13.686,0.66,27.37,1.144,41.051 c0.247,6.976,0.493,13.949,0.709,20.926c0.113,3.651,0.938,5.553-3.386,6.031c-0.947-29.533-2.22-59.063-2.748-88.608 c-0.248-13.869-0.502-27.894,0.314-41.748c0.369-6.252,1.427-12.552,0.738-18.821c-0.473-4.301-1.087-7.463,3.278-9.742 C114.97,47.199,115.34,49.065,115.42,50.953" id="path33" style="fill:#BFBFBF;"></path>
<path d="M177.626,47.312c-1.061,29.439-2.265,58.83-1.941,88.295c0.16,14.534-0.268,29.064-0.391,43.597 c-0.057,6.632-0.034,13.265,0.191,19.893c0.047,1.372,0.716,3.423-0.136,4.674c-0.996,1.462-2.972,0.332-3.359-0.947 c-1.224-4.041-0.131-9.819-0.212-13.987c-0.144-7.308-0.277-14.616-0.389-21.924c-0.224-14.616-0.359-29.235-0.309-43.853 c0.051-14.634,0.195-29.277,0.585-43.906c0.269-10.124-2.24-25.542,5.248-33.028C177.136,46.464,177.512,46.979,177.626,47.312" id="path35" style="fill:#BFBFBF;"></path>
<path d="M82.18,85.063c0.151,25.021-0.078,50.047,0.583,75.063c0.338,12.787,1.207,25.561,1.396,38.35 c0.032,2.203,1.505,6.316-2.127,5.501c-1.73-0.388-1.261-14.663-1.35-16.759c-1.034-24.321-2.365-48.646-2.663-72.991 c-0.149-12.221-0.477-24.412,0.259-36.618c0.576-9.556-1.54-21.67,4.694-29.426C82.115,60.46,82.981,72.783,82.18,85.063" id="path37" style="fill:#BFBFBF;"></path>
<path d="M76.56,148.457c0.157,10.251,0.532,20.496,0.961,30.738c0.296,7.057,2.459,16.297,1.102,23.311 c-0.727,3.755-9.991,3.729-10.081-0.096c-0.255-10.852-0.904-21.675-1.379-32.518c-0.936-21.354-1.054-42.732-1.057-64.104 c-0.001-9.405-0.544-18.909-0.314-28.295c0.123-5.044,2.847-7.453,5.167-11.591c2.299-4.102,3.548-8.677,5.603-12.893 C75.467,84.813,75.458,116.654,76.56,148.457" id="path39" style="fill:#E3E3E3;"></path>
<path d="M207.781,73.825c0.317,21.825-0.792,43.493-0.792,65.278c0,10.738,0,21.478,0,32.216 c0,5.481,0,10.963,0,16.445c0,2.65,1.686,12.848-0.612,14.655c-4.082,3.21-2.532-8.896-2.516-10.782 c0.052-5.517,0.092-11.034,0.129-16.552c0.074-11.035,0.133-22.071,0.23-33.106c0.202-22.987,0.569-45.978,1.582-68.945 C206.461,73.297,207.122,73.561,207.781,73.825" id="path41" style="fill:#BFBFBF;"></path>
<path d="M63.66,154.472c0.783,16.721,1.787,33.437,2.058,50.177c-2.401-0.132-4.802-0.265-7.203-0.396 c4.111-2.813,0.355-17.984,0.207-22.342c-0.486-14.264-0.922-28.527-1.294-42.794c-0.371-14.189-1.13-28.355-1.666-42.537 c-0.121-3.195-2.525-15.268-0.633-17.566c1.9-2.309,5.626-3.449,8.214-4.792c0.066,13.375,0.132,26.75,0.198,40.125 C63.607,127.713,64.891,141.124,63.66,154.472" id="path43" style="fill:#BFBFBF;"></path>
<path d="M216.96,80.235c0.446,21.664-0.711,43.313-1.313,64.962c-0.296,10.651-0.357,21.305-0.45,31.959 c-0.03,3.503,1.627,25.193-1.97,25.515c-5.911,0.527-3.371-20.418-3.351-24.031c0.063-10.85,0.132-21.698,0.181-32.548 c0.104-23.14,0.117-46.282,0.571-69.418C213.076,77.254,214.519,79.697,216.96,80.235" id="path45" style="fill:#E3E3E3;"></path>
<path d="M52.421,79.84c0.88,22.406,1.916,44.805,2.742,67.213c0.432,11.725,0.802,23.45,1.042,35.18 c0.088,4.317-2.453,18.714,1.124,22.02c-4.652,0-3.771-2.246-3.999-6.369c-0.426-7.703-0.767-15.425-0.831-23.14 c-0.122-14.887-0.063-29.809-0.743-44.682C51.631,127.329,46.842,79.502,52.421,79.84" id="path47" style="fill:#E3E3E3;"></path>
<path d="M233.027,205.044c2.539,9.07-2.377,10.524-9.861,11.079c-9.534,0.707-19.151,0.729-28.707,0.77 c-20.313,0.088-40.627,0.037-60.94,0.133c-19.374,0.091-38.575,1.29-57.922,1.899c-9.106,0.287-18.38,0.342-27.438-0.75 c-3.971-0.479-5.018,0.146-5.204-3.817c-0.107-2.286-0.019-4.574-0.109-6.859c31.892,0,63.831-1.94,95.708-1.301 c16.213,0.325,32.394,0.372,48.607,0.431C202.487,206.683,217.678,205.044,233.027,205.044" id="path49" style="fill:#BFBFBF;"></path>
<path d="M249.092,224.275c-37.66,2.193-75.668-0.496-113.257,2.421c-36.526,2.834-73.583,2.146-110.243,1.22 c14.839-9.382,31.379-6.451,47.913-6.075c20.089,0.457,40.228-2.287,60.35-2.391c20.478-0.105,40.953,0.454,61.432,0.33 c10.018-0.061,19.98-0.497,29.972-1.229C234.292,217.891,240.738,221.712,249.092,224.275" id="path51" style="fill:#E3E3E3;"></path>
<path d="M255.108,245.961c-1.281,0.09-1.54,1.658-2.793,1.662c-2.189,0.008-4.379,0.015-6.568,0.022 c-5.926,0.021-11.852,0.04-17.777,0.061c-11.854,0.041-23.707,0.08-35.561,0.121c-23.952,0.081-47.894,0.144-71.839,0.773 c-23.992,0.63-47.979,1.343-71.98,1.604c-6.661,0.072-13.326,0.174-19.988,0.186c-3.327,0.005-4.571,1.054-4.644-2.605 c-0.115-5.806-0.229-11.61-0.344-17.415c23.308,2.299,47.056,0.97,70.449,1.107c23.82,0.141,47.644-2.438,71.469-3.03 c23.765-0.591,47.532-0.933,71.303-0.928c3.688,0.001,17.249-3.522,18.395,1.103C256.617,234.223,255.108,240.288,255.108,245.961" id="path53" style="fill:#BFBFBF;"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,553 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://web.resource.org/cc/"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.0"
x="0.0000000"
y="0.0000000"
width="48.000000px"
height="48.000000px"
id="svg1"
sodipodi:version="0.32"
inkscape:version="0.44"
sodipodi:docname="folder.svg"
sodipodi:docbase="/home/lapo/Icone/Crux/crux-icon-theme/scalable/places"
inkscape:export-filename="/home/lapo/Icone/Crux/folderx-daritaliare.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90"
inkscape:output_extension="org.inkscape.output.svg.inkscape">
<metadata
id="metadata162">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title>Folder</dc:title>
<dc:creator>
<cc:Agent>
<dc:title>Lapo Calamandrei</dc:title>
</cc:Agent>
</dc:creator>
<dc:date>2006-06-26</dc:date>
<cc:license
rdf:resource="http://creativecommons.org/licenses/GPL/2.0/" />
<dc:identifier />
<dc:subject>
<rdf:Bag>
<rdf:li>folder</rdf:li>
<rdf:li>directory</rdf:li>
<rdf:li>storage</rdf:li>
</rdf:Bag>
</dc:subject>
</cc:Work>
<cc:License
rdf:about="http://creativecommons.org/licenses/GPL/2.0/">
<cc:permits
rdf:resource="http://web.resource.org/cc/Reproduction" />
<cc:permits
rdf:resource="http://web.resource.org/cc/Distribution" />
<cc:requires
rdf:resource="http://web.resource.org/cc/Notice" />
<cc:permits
rdf:resource="http://web.resource.org/cc/DerivativeWorks" />
<cc:requires
rdf:resource="http://web.resource.org/cc/ShareAlike" />
<cc:requires
rdf:resource="http://web.resource.org/cc/SourceCode" />
</cc:License>
</rdf:RDF>
</metadata>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666"
borderopacity="1"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:window-width="1041"
inkscape:window-height="655"
inkscape:cy="24.626698"
inkscape:cx="45.136759"
inkscape:zoom="8"
inkscape:document-units="px"
showgrid="false"
inkscape:window-x="504"
inkscape:window-y="101"
inkscape:current-layer="layer2"
inkscape:showpageshadow="false"
showguides="false"
inkscape:guide-bbox="true"
inkscape:object-paths="false"
gridspacingx="0.5px"
gridspacingy="0.5px"
gridempspacing="2"
inkscape:grid-points="false"
showborder="true"
borderlayer="true">
<sodipodi:guide
orientation="horizontal"
position="36.062446"
id="guide1934" />
<sodipodi:guide
orientation="horizontal"
position="15.003922"
id="guide1941" />
<sodipodi:guide
orientation="vertical"
position="4.5"
id="guide1943" />
<sodipodi:guide
orientation="vertical"
position="44.503533"
id="guide1945" />
<sodipodi:guide
orientation="horizontal"
position="43.125"
id="guide1947" />
<sodipodi:guide
orientation="horizontal"
position="39"
id="guide1949" />
<sodipodi:guide
orientation="horizontal"
position="19.003495"
id="guide2919" />
<sodipodi:guide
orientation="vertical"
position="0.97227183"
id="guide2212" />
<sodipodi:guide
orientation="vertical"
position="47.994873"
id="guide2214" />
<sodipodi:guide
orientation="horizontal"
position="111"
id="guide4328" />
<sodipodi:guide
orientation="vertical"
position="65.75"
id="guide3135" />
<sodipodi:guide
orientation="vertical"
position="129.75"
id="guide3137" />
<sodipodi:guide
orientation="vertical"
position="190.75"
id="guide3139" />
<sodipodi:guide
orientation="vertical"
position="212.48559"
id="guide3316" />
<sodipodi:guide
orientation="horizontal"
position="178.01413"
id="guide3318" />
</sodipodi:namedview>
<defs
id="defs3">
<linearGradient
inkscape:collect="always"
id="linearGradient4232">
<stop
style="stop-color:#ad7fa8;stop-opacity:1;"
offset="0"
id="stop4234" />
<stop
style="stop-color:#75507b;stop-opacity:1"
offset="1"
id="stop4236" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient3311">
<stop
style="stop-color:#888a85;stop-opacity:1;"
offset="0"
id="stop3313" />
<stop
style="stop-color:#555753;stop-opacity:1"
offset="1"
id="stop3315" />
</linearGradient>
<linearGradient
id="linearGradient3076">
<stop
id="stop3078"
offset="0"
style="stop-color:#5c3566;stop-opacity:1" />
<stop
id="stop3080"
offset="1"
style="stop-color:#5c3566;stop-opacity:0" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient2994">
<stop
style="stop-color:#5c3566;stop-opacity:1"
offset="0"
id="stop2996" />
<stop
style="stop-color:#5c3566;stop-opacity:0"
offset="1"
id="stop2998" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient3923">
<stop
style="stop-color:white;stop-opacity:1;"
offset="0"
id="stop3925" />
<stop
style="stop-color:white;stop-opacity:0;"
offset="1"
id="stop3927" />
</linearGradient>
<linearGradient
id="linearGradient3908">
<stop
style="stop-color:white;stop-opacity:1;"
offset="0"
id="stop3910" />
<stop
style="stop-color:white;stop-opacity:0;"
offset="1"
id="stop3912" />
</linearGradient>
<linearGradient
id="linearGradient2894">
<stop
style="stop-color:#39213f;stop-opacity:1;"
offset="0"
id="stop2896" />
<stop
id="stop2900"
offset="0.47619048"
style="stop-color:#75507b;stop-opacity:1;" />
<stop
style="stop-color:#5c3566;stop-opacity:1"
offset="1"
id="stop2898" />
</linearGradient>
<linearGradient
id="linearGradient3100">
<stop
style="stop-color:white;stop-opacity:1;"
offset="0"
id="stop3102" />
<stop
id="stop2071"
offset="1"
style="stop-color:white;stop-opacity:0;" />
</linearGradient>
<linearGradient
id="linearGradient3018">
<stop
style="stop-color:white;stop-opacity:1;"
offset="0"
id="stop3020" />
<stop
style="stop-color:white;stop-opacity:0;"
offset="1"
id="stop3022" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3100"
id="linearGradient3685"
gradientUnits="userSpaceOnUse"
x1="17.02047"
y1="-16.276186"
x2="17.02047"
y2="-29.344501" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient2894"
id="linearGradient3687"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(1,0,0,0.996152,0,85.74795)"
x1="9.4176369"
y1="-44.922661"
x2="9.4176369"
y2="-59.636772" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3076"
id="linearGradient3689"
gradientUnits="userSpaceOnUse"
x1="16.749592"
y1="21.616077"
x2="16.749592"
y2="32.797989" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient2994"
id="linearGradient3691"
gradientUnits="userSpaceOnUse"
x1="16.749592"
y1="40.51022"
x2="16.749592"
y2="36.268337" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3908"
id="linearGradient3693"
gradientUnits="userSpaceOnUse"
x1="8.25"
y1="-14.375"
x2="8.25"
y2="-30.879261" />
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient3923"
id="radialGradient3695"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(2.268741,0,-1.646661e-6,0.184077,-10.86781,18.45272)"
cx="10.189716"
cy="16.554359"
fx="10.189716"
fy="16.554359"
r="22.5" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3311"
id="linearGradient3938"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(0,1)"
x1="20.625"
y1="-90.064087"
x2="20.625"
y2="-84.029831" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3018"
id="linearGradient3129"
gradientUnits="userSpaceOnUse"
x1="9"
y1="-92.805496"
x2="9"
y2="-83.4375" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4232"
id="linearGradient4238"
x1="0.99999888"
y1="30.499076"
x2="53.999733"
y2="37.624077"
gradientUnits="userSpaceOnUse" />
</defs>
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="vectors"
style="display:inline">
<g
id="g891"
transform="matrix(0.186703,0,0,0.186703,-21.1073,57.62299)" />
<g
id="g3131"
inkscape:export-filename="/home/lapo/Icone/Crux/folderx.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90"
transform="translate(-1,102)">
<path
inkscape:export-ydpi="90"
inkscape:export-xdpi="90"
inkscape:export-filename="/home/lapo/Icone/Crux/folderx-alt.png"
sodipodi:nodetypes="czcccccccscc"
id="path3108"
d="M 8.5,-94.500002 C 7.5975,-94.500002 7.168127,-94.186392 7,-93.04412 C 5.118026,-83.070943 5.215756,-74.96574 5.5,-64.864899 C 5.5,-64.115602 6.116307,-63.50001 6.875,-63.50001 L 42.125,-63.50001 C 42.88369,-63.50001 43.44816,-64.117293 43.5,-64.864899 C 44.5,-82.91177 42.5,-88.135111 42.5,-88.135111 C 42.5,-88.884408 41.88369,-89.5 41.125,-89.5 L 23.5,-89.5 L 22.5,-92.897058 C 22.254867,-93.729789 21.9025,-94.500002 21,-94.500002 L 8.5,-94.500002 z "
style="fill:url(#linearGradient3938);fill-opacity:1;stroke:#555753;stroke-width:0.99999934;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;display:inline" />
<path
id="path3110"
d="M 8.5,-93.5 C 8.167757,-93.5 8.1531396,-93.465856 8.15625,-93.46875 C 8.1593604,-93.471644 8.0371607,-93.339789 7.96875,-92.875 C 7.9689149,-92.864584 7.9689149,-92.854166 7.96875,-92.84375 C 6.2697576,-83.840251 6.2178889,-74.709407 6.4375,-65.5 L 42.625,-65.5 C 43.308005,-81.554915 41.5625,-87.875 41.5625,-87.875 C 41.530831,-87.955244 41.509819,-88.039293 41.5,-88.125 C 41.5,-88.333118 41.341065,-88.5 41.125,-88.5 L 23.5,-88.5 C 23.062797,-88.50549 22.681309,-88.797964 22.5625,-89.21875 L 21.5625,-92.625 C 21.45311,-92.996605 21.310238,-93.289949 21.21875,-93.40625 C 21.127262,-93.522551 21.173845,-93.5 21,-93.5 L 8.5,-93.5 z "
style="opacity:0.5;fill:none;fill-opacity:1;stroke:url(#linearGradient3129);stroke-width:0.99999934;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;display:inline" />
</g>
<g
style="display:inline"
transform="matrix(0.216083,0,0,0.263095,-1.89323,-11.2424)"
id="g3112"
inkscape:export-filename="/home/lapo/Icone/Crux/folderx.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<path
d="M 32.706693,164.36026 C 22.319193,164.36026 13.956693,172.72276 13.956693,183.11026 C 13.956693,193.49776 22.319193,201.86026 32.706693,201.86026 L 205.20669,201.86026 C 215.59419,201.86026 223.95669,193.49776 223.95669,183.11026 C 223.95669,172.72276 215.59419,164.36026 205.20669,164.36026 L 32.706693,164.36026 z "
style="opacity:0.04787233;fill-rule:evenodd;stroke-width:3pt"
id="path3114" />
<path
d="M 32.706693,165.61026 C 23.011693,165.61026 15.206693,173.41526 15.206693,183.11026 C 15.206693,192.80526 23.011693,200.61026 32.706693,200.61026 L 205.20669,200.61026 C 214.90169,200.61026 222.70669,192.80526 222.70669,183.11026 C 222.70669,173.41526 214.90169,165.61026 205.20669,165.61026 L 32.706693,165.61026 z "
style="opacity:0.04787233;fill-rule:evenodd;stroke-width:3pt"
id="path3116" />
<path
d="M 32.706694,166.86026 C 23.704194,166.86026 16.456694,174.10776 16.456694,183.11026 C 16.456694,192.11276 23.704194,199.36026 32.706694,199.36026 L 205.20669,199.36026 C 214.20919,199.36026 221.45669,192.11276 221.45669,183.11026 C 221.45669,174.10776 214.20919,166.86026 205.20669,166.86026 L 32.706694,166.86026 z "
style="opacity:0.04787233;fill-rule:evenodd;stroke-width:3pt"
id="path3118" />
<path
d="M 32.706694,168.11026 C 24.396694,168.11026 17.706694,174.80026 17.706694,183.11026 C 17.706694,191.42026 24.396694,198.11026 32.706694,198.11026 L 205.20669,198.11026 C 213.51669,198.11026 220.20669,191.42026 220.20669,183.11026 C 220.20669,174.80026 213.51669,168.11026 205.20669,168.11026 L 32.706694,168.11026 z "
style="opacity:0.04787233;fill-rule:evenodd;stroke-width:3pt"
id="path3120" />
<path
d="M 32.707764,169.36026 C 25.090264,169.36026 18.957764,175.49276 18.957764,183.11026 C 18.957764,190.72776 25.090264,196.86026 32.707764,196.86026 L 205.20618,196.86026 C 212.82368,196.86026 218.95618,190.72776 218.95618,183.11026 C 218.95618,175.49276 212.82368,169.36026 205.20618,169.36026 L 32.707764,169.36026 z "
style="opacity:0.04787233;fill-rule:evenodd;stroke-width:3pt"
id="path3122" />
<path
d="M 32.706694,170.61026 C 25.781694,170.61026 20.206694,176.18526 20.206694,183.11026 C 20.206694,190.03526 25.781694,195.61026 32.706694,195.61026 L 205.20669,195.61026 C 212.13169,195.61026 217.70669,190.03526 217.70669,183.11026 C 217.70669,176.18526 212.13169,170.61026 205.20669,170.61026 L 32.706694,170.61026 z "
style="opacity:0.04787233;fill-rule:evenodd;stroke-width:3pt"
id="path3124" />
<path
d="M 32.706694,171.86026 C 26.474194,171.86026 21.456694,176.87776 21.456694,183.11026 C 21.456694,189.34276 26.474194,194.36026 32.706694,194.36026 L 205.20669,194.36026 C 211.43919,194.36026 216.45669,189.34276 216.45669,183.11026 C 216.45669,176.87776 211.43919,171.86026 205.20669,171.86026 L 32.706694,171.86026 z "
style="opacity:0.04787233;fill-rule:evenodd;stroke-width:3pt"
id="path3126" />
<path
d="M 32.706694,173.11026 C 27.166694,173.11026 22.706694,177.57026 22.706694,183.11026 C 22.706694,188.65026 27.166694,193.11026 32.706694,193.11026 L 205.20669,193.11026 C 210.74669,193.11026 215.20669,188.65026 215.20669,183.11026 C 215.20669,177.57026 210.74669,173.11026 205.20669,173.11026 L 32.706694,173.11026 z "
style="opacity:0.04787233;fill-rule:evenodd;stroke-width:3pt"
id="path3128" />
</g>
<g
style="display:inline"
id="g3661"
transform="translate(-1,-2)"
inkscape:export-filename="/home/lapo/Icone/Crux/folderx.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<g
id="g3663">
<path
sodipodi:type="inkscape:offset"
inkscape:radius="-0.99436891"
inkscape:original="M 2.65625 -33.5 C 2.021877 -33.5 1.5 -33.140103 1.5 -32.6875 C 4.500001 -25.004483 4.5 -23.258435 4.5 -16.5 C 4.5 -15.5 5.5111718 -13.51291 6.65625 -13.4375 L 42.34375 -13.4375 C 43.989096 -13.51291 44.500268 -15.758435 44.5 -16.5 C 44.5 -21.258435 44.500267 -25.004482 47.5 -32.6875 C 47.5 -33.140104 46.978125 -33.5 46.34375 -33.5 L 2.65625 -33.5 z "
style="opacity:0.32156863;fill:none;fill-opacity:1;stroke:url(#linearGradient3685);stroke-width:1.00000036;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;display:inline"
id="path3667"
d="M 2.625,-32.5 C 5.3597846,-25.370787 5.5,-23.037216 5.5,-16.5 C 5.5,-16.335159 5.6904313,-15.649773 6,-15.15625 C 6.3095687,-14.662727 6.6985247,-14.438832 6.71875,-14.4375 L 42.3125,-14.4375 L 42.34375,-14.4375 C 42.706073,-14.463351 42.916739,-14.679637 43.15625,-15.15625 C 43.401014,-15.643317 43.500051,-16.358058 43.5,-16.5 C 43.5,-21.130327 43.573003,-25.119501 46.375,-32.5 C 46.360642,-32.501163 46.358943,-32.5 46.34375,-32.5 L 2.65625,-32.5 C 2.6410568,-32.5 2.6393578,-32.501163 2.625,-32.5 z "
transform="translate(0,54)" />
<path
style="opacity:0.3254902;fill:none;fill-rule:evenodd;stroke:white;stroke-width:1px;stroke-linecap:round;stroke-linejoin:miter;stroke-opacity:1"
d="M 2.5,21.5 L 46.5,21.5"
id="path3669"
sodipodi:nodetypes="cc" />
</g>
<path
sodipodi:nodetypes="ccccccccc"
id="path3671"
d="M 2.645078,20.5 L 46.354654,20.5 C 46.989027,20.5 47.499732,20.862969 47.499732,21.313831 C 44.5,28.967284 44.5,32.694452 44.5,37.434576 C 44.500268,38.173288 44,40.423032 42.354654,40.498152 L 6.645078,40.498152 C 5.5,40.423032 4.5,38.430728 4.5,37.434576 C 4.5,30.702148 4.5,28.967284 1.499999,21.313831 C 1.499999,20.862969 2.010705,20.5 2.645078,20.5 z "
style="fill:url(#linearGradient4238);fill-opacity:1;stroke:url(#linearGradient3687);stroke-width:1.00000024;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;display:inline" />
<path
id="path3673"
d="M 2.65625,21 C 2.4314409,21 2.2397168,21.044736 2.125,21.125 C 2.0102832,21.205264 2,21.274638 2,21.3125 C 4.2218558,27.007434 4.7883072,29.513938 4.9375,33.3125 L 44.125,33.3125 C 44.328358,30.089308 44.943507,26.607344 47,21.3125 C 47,21.274637 46.989716,21.205264 46.875,21.125 C 46.760284,21.044736 46.56856,21 46.34375,21 L 2.65625,21 z "
style="opacity:0.8;fill:url(#linearGradient3689);fill-opacity:1;stroke:none;stroke-width:1.00000024;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;display:inline" />
<path
id="path3675"
d="M 4.625,30 C 4.9603306,32.150219 5,34.257337 5,37.4375 C 5,37.771087 5.2086862,38.497978 5.5625,39.0625 C 5.9077942,39.613428 6.3538112,39.964089 6.65625,40 L 6.6875,40 L 42.3125,40 L 42.34375,40 C 42.929781,39.95968 43.308738,39.567494 43.59375,39 C 43.883899,38.422277 44.000093,37.694547 44,37.4375 C 44,34.957143 44.019836,32.683092 44.46875,30 L 4.625,30 z "
style="opacity:0.44313725;fill:url(#linearGradient3691);fill-opacity:1;stroke:none;stroke-width:1.00000024;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;display:inline" />
<path
transform="translate(0,54)"
d="M 2.625,-32.5 C 5.3597846,-25.370787 5.5,-23.037216 5.5,-16.5 C 5.5,-16.335159 5.6904313,-15.649773 6,-15.15625 C 6.3095687,-14.662727 6.6985247,-14.438832 6.71875,-14.4375 L 42.3125,-14.4375 L 42.34375,-14.4375 C 42.706073,-14.463351 42.916739,-14.679637 43.15625,-15.15625 C 43.401014,-15.643317 43.500051,-16.358058 43.5,-16.5 C 43.5,-21.130327 43.573003,-25.119501 46.375,-32.5 C 46.360642,-32.501163 46.358943,-32.5 46.34375,-32.5 L 2.65625,-32.5 C 2.6410568,-32.5 2.6393578,-32.501163 2.625,-32.5 z "
id="path3677"
style="opacity:0.2;fill:none;fill-opacity:1;stroke:url(#linearGradient3693);stroke-width:1.00000036;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;display:inline"
inkscape:original="M 2.65625 -33.5 C 2.021877 -33.5 1.5 -33.140103 1.5 -32.6875 C 4.500001 -25.004483 4.5 -23.258435 4.5 -16.5 C 4.5 -15.5 5.5111718 -13.51291 6.65625 -13.4375 L 42.34375 -13.4375 C 43.989096 -13.51291 44.500268 -15.758435 44.5 -16.5 C 44.5 -21.258435 44.500267 -25.004482 47.5 -32.6875 C 47.5 -33.140104 46.978125 -33.5 46.34375 -33.5 L 2.65625 -33.5 z "
inkscape:radius="-0.99436891"
sodipodi:type="inkscape:offset" />
<path
sodipodi:nodetypes="cc"
id="path3679"
d="M 2.5,21.5 L 46.5,21.5"
style="opacity:0.4;fill:none;fill-opacity:1;fill-rule:evenodd;stroke:url(#radialGradient3695);stroke-width:1px;stroke-linecap:round;stroke-linejoin:miter;stroke-opacity:1" />
</g>
<g
id="g6102"
inkscape:label="pixmap"
style="display:inline"
transform="translate(188.7605,-103.2651)" />
<g
transform="matrix(0.186703,0,0,0.186703,167.6532,-45.64211)"
id="g6106" />
<g
inkscape:label="pattern"
id="g30621"
inkscape:r_cx="true"
inkscape:r_cy="true"
transform="translate(190.4218,35.092)" />
<g
inkscape:label="pattern"
id="g35222"
inkscape:r_cx="true"
inkscape:r_cy="true"
transform="translate(190.4218,81.092)" />
<g
id="g3198"
inkscape:export-filename="/home/lapo/Icone/Crux/folderx.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90"
transform="translate(-1,102)">
<rect
inkscape:export-ydpi="90"
inkscape:export-xdpi="90"
inkscape:export-filename="/home/lapo/Icone/Crux/folderx-alt.png"
ry="1.5"
rx="1.5"
y="-91.5"
x="9.4999981"
height="3"
width="10.000002"
id="rect2118"
style="fill:#eeeeec;fill-opacity:1;stroke:#d3d7cf;stroke-width:0.99999988;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;display:inline" />
<g
inkscape:export-ydpi="90"
inkscape:export-xdpi="90"
inkscape:export-filename="/home/lapo/Icone/Crux/folderx-alt.png"
transform="translate(66,0)"
id="g3371"
style="display:inline">
<rect
style="opacity:1;fill:#2e3436;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4.4000001;stroke-dasharray:none;stroke-dashoffset:10;stroke-opacity:1"
id="rect3373"
width="1"
height="1"
x="-55"
y="-90"
rx="0.5"
ry="0.5" />
<rect
style="opacity:1;fill:#2e3436;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4.4000001;stroke-dasharray:none;stroke-dashoffset:10;stroke-opacity:1;display:inline"
id="rect3375"
width="1"
height="1"
x="-53"
y="-90"
rx="0.5"
ry="0.5" />
<rect
style="opacity:1;fill:#2e3436;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4.4000001;stroke-dasharray:none;stroke-dashoffset:10;stroke-opacity:1;display:inline"
id="rect3377"
width="1"
height="1"
x="-51"
y="-90"
rx="0.5"
ry="0.5" />
<rect
style="opacity:1;fill:#2e3436;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4.4000001;stroke-dasharray:none;stroke-dashoffset:10;stroke-opacity:1;display:inline"
id="rect3379"
width="1"
height="1"
x="-49"
y="-90"
rx="0.5"
ry="0.5" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

2679
resources/images/drawer.svg Normal file

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -1,35 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<?python
from uuid import uuid4
import re
?>
<ncx version="2005-1"
xml:lang="en"
xmlns="http://www.daisy.org/z3986/2005/ncx/"
xmlns:py="http://genshi.edgewall.org/"
xmlns:calibre="http://calibre.kovidgoyal.net/2009/metadata"
>
<head>
<meta name="dtb:uid" content="${uid}"/>
<meta name="dtb:depth" content="${toc.depth()}"/>
<meta name="dtb:generator" content="${__appname__}"/>
<meta name="dtb:totalPageCount" content="0"/>
<meta name="dtb:maxPageNumber" content="0"/>
</head>
<docTitle><text>Table of Contents</text></docTitle>
<py:def function="navpoint(np, level)">
${'%*s'%(4*level,'')}<navPoint id="${str(uuid4())}" playOrder="${str(np.play_order)}">
${'%*s'%(4*level,'')}<navLabel>
${'%*s'%(4*level,'')}<text>${re.sub(r'\s+', ' ', np.text)}</text>
${'%*s'%(4*level,'')}</navLabel>
${'%*s'%(4*level,'')}<content src="${unicode(np.href)+(('#' + unicode(np.fragment)) if np.fragment else '')}" />
${'%*s'%(4*level,'')}<calibre:meta py:if="np.author" name="author">${np.author}</calibre:meta>
${'%*s'%(4*level,'')}<calibre:meta py:if="np.description" name="description">${np.description}</calibre:meta>
<py:for each="np2 in np">${navpoint(np2, level+1)}</py:for>
${'%*s'%(4*level,'')}</navPoint>
</py:def>
<navMap>
<py:for each="np in toc">${navpoint(np, 0)}</py:for>
</navMap>
</ncx>

View File

@ -1,49 +0,0 @@
<package version="2.0"
xmlns="http://www.idpf.org/2007/opf"
xmlns:py="http://genshi.edgewall.org/"
unique-identifier="${__appname__}_id"
>
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf" xmlns:calibre="http://calibre.kovidgoyal.net/2009/metadata">
<dc:title py:with="attrs={'opf:file-as':mi.title_sort}" py:attrs="attrs">${mi.title}</dc:title>
<dc:creator opf:role="aut" py:for="i, author in enumerate(mi.authors)" py:attrs="{'opf:file-as':mi.author_sort} if mi.author_sort and i == 0 else {}">${author}</dc:creator>
<dc:contributor opf:role="bkp" py:with="attrs={'opf:file-as':__appname__}" py:attrs="attrs">${'%s (%s)'%(__appname__, __version__)} [http://${__appname__}.kovidgoyal.net]</dc:contributor>
<dc:identifier opf:scheme="${__appname__}" id="${__appname__}_id">${mi.application_id}</dc:identifier>
<dc:date py:if="getattr(mi, 'pubdate', None) is not None">${mi.pubdate.isoformat()}</dc:date>
<dc:language>${mi.language if mi.language else 'UND'}</dc:language>
<dc:type py:if="getattr(mi, 'category', False)">${mi.category}</dc:type>
<dc:description py:if="mi.comments">${mi.comments}</dc:description>
<dc:publisher py:if="mi.publisher">${mi.publisher}</dc:publisher>
<dc:identifier opf:scheme="ISBN" py:if="mi.isbn">${mi.isbn}</dc:identifier>
<dc:rights py:if="mi.rights">${mi.rights}</dc:rights>
<meta py:if="mi.series is not None" name="calibre:series" content="${mi.series}"/>
<meta py:if="mi.series_index is not None" name="calibre:series_index" content="${mi.format_series_index()}"/>
<meta py:if="mi.rating is not None" name="calibre:rating" content="${mi.rating}"/>
<meta py:if="mi.timestamp is not None" name="calibre:timestamp" content="${mi.timestamp.isoformat()}"/>
<meta py:if="mi.publication_type is not None" name="calibre:publication_type" content="${mi.publication_type}" />
<py:for each="tag in mi.tags">
<dc:subject py:if="mi.tags is not None">${tag}</dc:subject>
</py:for>
</metadata>
<manifest py:if="getattr(mi, 'manifest', None)">
<py:for each="ref in mi.manifest">
<item id="${ref.id}" href="${ref.href()}" media-type="${ref.mime_type}" />
</py:for>
</manifest>
<guide py:if="getattr(mi, 'guide', None)">
<py:for each="ref in mi.guide">
<reference type="${ref.type}" href="${ref.href()}" py:with="attrs={'title': ref.title if ref.title else None}" py:attrs="attrs" />
</py:for>
</guide>
<spine py:if="getattr(mi, 'spine', None)"
py:with="attrs={'toc':'ncx' if mi.toc else None}" py:attrs="attrs">
<py:for each="resource in mi.spine">
<itemref idref="${resource.id}" />
</py:for>
</spine>
</package>

View File

@ -141,7 +141,10 @@ def prints(*args, **kwargs):
raise
arg = repr(arg)
file.write(arg)
try:
file.write(arg)
except:
file.write(repr(arg))
if i != len(args)-1:
file.write(sep)
file.write(end)
@ -445,6 +448,23 @@ def prepare_string_for_xml(raw, attribute=False):
raw = raw.replace('"', '&quot;').replace("'", '&apos;')
return raw
def isbytestring(obj):
return isinstance(obj, (str, bytes))
def human_readable(size):
""" Convert a size in bytes into a human readable form """
divisor, suffix = 1, "B"
for i, candidate in enumerate(('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB')):
if size < 1024**(i+1):
divisor, suffix = 1024**(i), candidate
break
size = str(float(size)/divisor)
if size.find(".") > -1:
size = size[:size.find(".")+2]
if size.endswith('.0'):
size = size[:-2]
return size + " " + suffix
if isosx:
import glob, shutil
fdir = os.path.expanduser('~/.fonts')

View File

@ -2,7 +2,7 @@ __license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
__docformat__ = 'restructuredtext en'
__appname__ = 'calibre'
__version__ = '0.6.55'
__version__ = '0.6.95'
__author__ = "Kovid Goyal <kovid@kovidgoyal.net>"
import re

View File

@ -430,7 +430,7 @@ from calibre.ebooks.txt.output import TXTOutput
from calibre.customize.profiles import input_profiles, output_profiles
from calibre.devices.apple.driver import ITUNES
from calibre.devices.hanlin.driver import HANLINV3, HANLINV5, BOOX
from calibre.devices.blackberry.driver import BLACKBERRY
from calibre.devices.cybook.driver import CYBOOK
@ -442,8 +442,7 @@ from calibre.devices.irexdr.driver import IREXDR1000, IREXDR800
from calibre.devices.jetbook.driver import JETBOOK
from calibre.devices.kindle.driver import KINDLE, KINDLE2, KINDLE_DX
from calibre.devices.nook.driver import NOOK
from calibre.devices.prs500.driver import PRS500
from calibre.devices.prs505.driver import PRS505, PRS700
from calibre.devices.prs505.driver import PRS505
from calibre.devices.android.driver import ANDROID, S60
from calibre.devices.nokia.driver import N770, N810, E71X
from calibre.devices.eslick.driver import ESLICK
@ -455,6 +454,7 @@ from calibre.devices.edge.driver import EDGE
from calibre.devices.teclast.driver import TECLAST_K3, NEWSMY, IPAPYRUS
from calibre.devices.sne.driver import SNE
from calibre.devices.misc import PALMPRE, AVANT
from calibre.devices.folder_device.driver import FOLDER_DEVICE_FOR_CONFIG
from calibre.devices.kobo.driver import KOBO
from calibre.ebooks.metadata.fetch import GoogleBooks, ISBNDB, Amazon
@ -496,6 +496,7 @@ plugins += [
]
# Order here matters. The first matched device is the one used.
plugins += [
ITUNES,
HANLINV3,
HANLINV5,
BLACKBERRY,
@ -511,8 +512,6 @@ plugins += [
KINDLE_DX,
NOOK,
PRS505,
PRS700,
PRS500,
ANDROID,
S60,
N770,
@ -544,6 +543,7 @@ plugins += [
PALMPRE,
KOBO,
AZBOOKA,
FOLDER_DEVICE_FOR_CONFIG,
AVANT,
]
plugins += [x for x in list(locals().values()) if isinstance(x, type) and \

View File

@ -27,6 +27,34 @@ def strftime(epoch, zone=time.gmtime):
src[2] = INVERSE_MONTH_MAP[int(src[2])]
return ' '.join(src)
def get_connected_device():
from calibre.customize.ui import device_plugins
from calibre.devices.scanner import DeviceScanner
dev = None
scanner = DeviceScanner()
scanner.scan()
connected_devices = []
for d in device_plugins():
ok, det = scanner.is_device_connected(d)
if ok:
dev = d
dev.reset(log_packets=False, detected_device=det)
connected_devices.append(dev)
if dev is None:
print >>sys.stderr, 'Unable to find a connected ebook reader.'
return
for d in connected_devices:
try:
d.open()
except:
continue
else:
dev = d
break
return dev
def debug(ioreg_to_tmp=False, buf=None):
from calibre.customize.ui import device_plugins
from calibre.devices.scanner import DeviceScanner, win_pnp_drives

View File

@ -0,0 +1,2 @@
__license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'

View File

@ -0,0 +1,895 @@
# -*- coding: utf-8 -*-
__license__ = 'GPL v3'
__copyright__ = '2010, Gregory Riker'
__docformat__ = 'restructuredtext en'
import cStringIO, os, re, shutil, sys, time, zipfile
from calibre.constants import DEBUG
from calibre import fit_image
from calibre.constants import isosx, iswindows
from calibre.devices.interface import DevicePlugin
from calibre.ebooks.metadata import MetaInformation
from calibre.library.server.utils import strftime
from calibre.ptempfile import PersistentTemporaryFile
from calibre.utils.config import Config, config_dir
from calibre.utils.date import parse_date
from calibre.utils.logging import Log
from calibre.devices.errors import UserFeedback
from PIL import Image as PILImage
if isosx:
import appscript
#if iswindows:
# import win32com.client
class ITUNES(DevicePlugin):
name = 'Apple device interface'
gui_name = 'Apple device'
icon = I('devices/ipad.png')
description = _('Communicate with iBooks through iTunes.')
supported_platforms = ['osx']
author = 'GRiker'
driver_version = '0.1'
OPEN_FEEDBACK_MESSAGE = _(
'Apple device detected, launching iTunes, please wait...')
FORMATS = ['epub']
VENDOR_ID = [0x05ac]
# Product IDs:
# 0x129a:iPad
# 0x1292:iPhone 3G
#PRODUCT_ID = [0x129a,0x1292]
PRODUCT_ID = [0x129a]
BCD = [0x01]
# Properties
cached_books = {}
cache_dir = os.path.join(config_dir, 'caches', 'itunes')
iTunes= None
log = Log()
path_template = 'iTunes/%s - %s.epub'
presync = False
problem_titles = []
problem_msg = None
report_progress = None
update_list = []
sources = None
update_msg = None
update_needed = False
use_thumbnail_as_cover = False
# Public methods
def add_books_to_metadata(self, locations, metadata, booklists):
'''
Add locations to the booklists. This function must not communicate with
the device.
@param locations: Result of a call to L{upload_books}
@param metadata: List of MetaInformation objects, same as for
:method:`upload_books`.
@param booklists: A tuple containing the result of calls to
(L{books}(oncard=None), L{books}(oncard='carda'),
L{books}(oncard='cardb')).
'''
if DEBUG:
self.log.info( "ITUNES.add_books_to_metadata()")
task_count = float(len(self.update_list))
# Delete any obsolete copies of the book from the booklist
if self.update_list:
for (j,p_book) in enumerate(self.update_list):
#self.log.info("ITUNES.add_books_to_metadata(): looking for %s" % p_book['lib_book'])
for i,bl_book in enumerate(booklists[0]):
#self.log.info("ITUNES.add_books_to_metadata(): evaluating %s" % bl_book.library_id)
if bl_book.library_id == p_book['lib_book']:
booklists[0].pop(i)
#self.log.info("ITUNES.add_books_to_metadata(): removing %s" % p_book['title'])
break
else:
self.log.error("ITUNES.add_books_to_metadata(): update_list item '%s' not found in booklists[0]" % p_book['title'])
if self.report_progress is not None:
self.report_progress(j+1/task_count, _('Updating device metadata listing...'))
if self.report_progress is not None:
self.report_progress(1.0, _('Updating device metadata listing...'))
# Add new books to booklists[0]
for new_book in locations[0]:
booklists[0].append(new_book)
def books(self, oncard=None, end_session=True):
"""
Return a list of ebooks on the device.
@param oncard: If 'carda' or 'cardb' return a list of ebooks on the
specific storage card, otherwise return list of ebooks
in main memory of device. If a card is specified and no
books are on the card return empty list.
@return: A BookList.
Implementation notes:
iTunes does not sync purchased books, they are only on the device. They are visible, but
they are not backed up to iTunes. Since calibre can't manage them, don't show them in the
list of device books.
"""
if DEBUG:
self.log.info("ITUNES:books(oncard=%s)" % oncard)
if not oncard:
# Fetch a list of books from iPod device connected to iTunes
if isosx:
# Fetch Library|Books
library_books = self._get_library_books()
if 'iPod' in self.sources:
device = self.sources['iPod']
if 'Books' in self.iTunes.sources[device].playlists.name():
booklist = BookList(self.log)
cached_books = {}
device_books = self._get_device_books()
book_count = float(len(device_books))
for (i,book) in enumerate(device_books):
this_book = Book(book.name(), book.artist())
this_book.path = self.path_template % (book.name(), book.artist())
this_book.datetime = parse_date(str(book.date_added())).timetuple()
this_book.db_id = None
this_book.device_collections = []
this_book.library_id = library_books[this_book.path] if this_book.path in library_books else None
this_book.size = book.size()
# Hack to discover if we're running in GUI environment
if self.report_progress is not None:
this_book.thumbnail = self._generate_thumbnail(this_book.path, book)
else:
this_book.thumbnail = None
booklist.add_book(this_book, False)
cached_books[this_book.path] = {
'title':book.name(),
'author':book.artist(),
'lib_book':library_books[this_book.path] if this_book.path in library_books else None
}
if self.report_progress is not None:
self.report_progress(i+1/book_count, _('%d of %d' % (i+1, book_count)))
if self.report_progress is not None:
self.report_progress(1.0, _('finished'))
self.cached_books = cached_books
if DEBUG:
self._dump_cached_books()
return booklist
else:
# No books installed on this device
return []
else:
return []
def can_handle(self, device_info, debug=False):
'''
Unix version of :method:`can_handle_windows`
:param device_info: Is a tupe of (vid, pid, bcd, manufacturer, product,
serial number)
Confirm that:
- iTunes is running
- there is an iPod-type device connected
This gets called first when the device fingerprint is read, so it needs to
instantiate iTunes if necessary
This gets called ~1x/second while device fingerprint is sensed
'''
if isosx:
if self.iTunes:
# Check for connected book-capable device
try:
names = [s.name() for s in self.iTunes.sources()]
kinds = [str(s.kind()).rpartition('.')[2] for s in self.iTunes.sources()]
self.sources = sources = dict(zip(kinds,names))
if 'iPod' in sources:
if DEBUG:
sys.stdout.write('.')
sys.stdout.flush()
return True
else:
if DEBUG:
self.log.info("ITUNES.can_handle(): device ejected")
return False
except:
# iTunes connection failed, probably not running anymore
self.log.error("ITUNES.can_handle(): lost connection to iTunes")
return False
else:
# can_handle() is called once before open(), so need to return True
# to keep things going
if DEBUG:
self.log.info("ITUNES:can_handle(): iTunes not yet instantiated")
return True
def can_handle_windows(self, device_id, debug=False):
'''
Optional method to perform further checks on a device to see if this driver
is capable of handling it. If it is not it should return False. This method
is only called after the vendor, product ids and the bcd have matched, so
it can do some relatively time intensive checks. The default implementation
returns True. This method is called only on windows. See also
:method:`can_handle`.
:param device_info: On windows a device ID string. On Unix a tuple of
``(vendor_id, product_id, bcd)``.
'''
return False
def card_prefix(self, end_session=True):
'''
Return a 2 element list of the prefix to paths on the cards.
If no card is present None is set for the card's prefix.
E.G.
('/place', '/place2')
(None, 'place2')
('place', None)
(None, None)
'''
if DEBUG:
self.log.info("ITUNES:card_prefix()")
return (None,None)
def delete_books(self, paths, end_session=True):
'''
Delete books at paths on device.
iTunes doesn't let us directly delete a book on the device.
If the requested paths are deletable (i.e., it's in the Library|Books list),
delete the paths from the library, then update iPad
'''
self.problem_titles = []
self.problem_msg = _("Certain books may only be deleted from within the iBooks app.\n"
"Click 'Show Details' for a list.")
for path in paths:
if self.cached_books[path]['lib_book']:
if DEBUG:
self.log.info("ITUNES:delete_books(): Deleting '%s' from iTunes library" % (path))
self._remove_iTunes_dir(self.cached_books[path])
self.iTunes.delete(self.cached_books[path]['lib_book'])
self.update_needed = True
self.update_msg = "Deleted books from device"
else:
self.problem_titles.append("'%s' by %s" %
(self.cached_books[path]['title'],self.cached_books[path]['author']))
def eject(self):
'''
Un-mount / eject the device from the OS. This does not check if there
are pending GUI jobs that need to communicate with the device.
'''
if DEBUG:
self.log.info("ITUNES:eject(): ejecting '%s'" % self.sources['iPod'])
self.iTunes.eject(self.sources['iPod'])
self.iTunes = None
self.sources = None
def free_space(self, end_session=True):
"""
Get free space available on the mountpoints:
1. Main memory
2. Card A
3. Card B
@return: A 3 element list with free space in bytes of (1, 2, 3). If a
particular device doesn't have any of these locations it should return -1.
"""
if DEBUG:
self.log.info("ITUNES:free_space()")
free_space = 0
if isosx:
if 'iPod' in self.sources:
connected_device = self.sources['iPod']
free_space = self.iTunes.sources[connected_device].free_space()
return (free_space,-1,-1)
def get_device_information(self, end_session=True):
"""
Ask device for device information. See L{DeviceInfoQuery}.
@return: (device name, device version, software version on device, mime type)
"""
if DEBUG:
self.log.info("ITUNES:get_device_information()")
return ('iPad','hw v1.0','sw v1.0', 'mime type')
def get_file(self, path, outfile, end_session=True):
'''
Read the file at C{path} on the device and write it to outfile.
@param outfile: file object like C{sys.stdout} or the result of an C{open} call
'''
if DEBUG:
self.log.info("ITUNES.get_file(): exporting '%s'" % path)
outfile.write(open(self.cached_books[path]['lib_book'].location().path).read())
def open(self):
'''
Perform any device specific initialization. Called after the device is
detected but before any other functions that communicate with the device.
For example: For devices that present themselves as USB Mass storage
devices, this method would be responsible for mounting the device or
if the device has been automounted, for finding out where it has been
mounted. The base class within USBMS device.py has a implementation of
this function that should serve as a good example for USB Mass storage
devices.
'''
if isosx:
# Launch iTunes if not already running
if DEBUG:
self.log.info("ITUNES:open(): Instantiating iTunes")
# Instantiate iTunes
running_apps = appscript.app('System Events')
if not 'iTunes' in running_apps.processes.name():
if DEBUG:
self.log.info( "ITUNES:open(): Launching iTunes" )
self.iTunes = iTunes= appscript.app('iTunes', hide=True)
iTunes.run()
initial_status = 'launched'
else:
self.iTunes = appscript.app('iTunes')
initial_status = 'already running'
if DEBUG:
self.log.info( " %s - %s (%s), driver version %s" %
(self.iTunes.name(), self.iTunes.version(), self.driver_version, initial_status))
# Init the iTunes source list
names = [s.name() for s in self.iTunes.sources()]
kinds = [str(s.kind()).rpartition('.')[2] for s in self.iTunes.sources()]
self.sources = dict(zip(kinds,names))
# Check to see if Library|Books out of sync with Device|Books
if self.presync and 'iPod' in self.sources :
lb_count = len(self._get_library_books())
db_count = len(self._get_device_books())
pb_count = len(self._get_purchased_book_ids())
if db_count != lb_count + pb_count:
if DEBUG:
self.log.info( "ITUNES.open(): pre-syncing iTunes with device")
self.log.info( " Library|Books : %d" % lb_count)
self.log.info( " Devices|iPad|Books : %d" % db_count)
self.log.info( " Devices|iPad|Purchased: %d" % pb_count)
self._update_device(msg="Presyncing iTunes with device, mismatched book count")
# Confirm/create thumbs archive
archive_path = os.path.join(self.cache_dir, "thumbs.zip")
if not os.path.exists(self.cache_dir):
if DEBUG:
self.log.info(" creating thumb cache '%s'" % self.cache_dir)
os.makedirs(self.cache_dir)
if not os.path.exists(archive_path):
self.log.info(" creating zip archive")
zfw = zipfile.ZipFile(archive_path, mode='w')
zfw.writestr("iTunes Thumbs Archive",'')
zfw.close()
else:
if DEBUG:
self.log.info(" existing thumb cache at '%s'" % archive_path)
if iswindows:
# Launch iTunes if not already running
if DEBUG:
self.log.info("ITUNES:open(): Instantiating iTunes")
# Instantiate iTunes
# Init the iTunes source list
# Check to see if Library|Books out of sync with Device|Books
# Confirm/create thumbs archive
def remove_books_from_metadata(self, paths, booklists):
'''
Remove books from the metadata list. This function must not communicate
with the device.
@param paths: paths to books on the device.
@param booklists: A tuple containing the result of calls to
(L{books}(oncard=None), L{books}(oncard='carda'),
L{books}(oncard='cardb')).
'''
if DEBUG:
self.log.info("ITUNES.remove_books_from_metadata():")
for path in paths:
if self.cached_books[path]['lib_book']:
# Remove from the booklist
for i,book in enumerate(booklists[0]):
if book.path == path:
self.log.info(" removing '%s' from calibre booklist, index: %d" % (path, i))
booklists[0].pop(i)
break
else:
self.log.error("ITUNES.remove_books_from_metadata(): '%s' not found in self.cached_book" % path)
# Remove from cached_books
if DEBUG:
self.log.info("ITUNES.remove_books_from_metadata(): Removing '%s' from self.cached_books" % path)
self.cached_books.pop(path)
else:
self.log.warning("ITUNES.remove_books_from_metadata(): skipping purchased book, can't remove via automation interface")
def reset(self, key='-1', log_packets=False, report_progress=None,
detected_device=None) :
"""
:key: The key to unlock the device
:log_packets: If true the packet stream to/from the device is logged
:report_progress: Function that is called with a % progress
(number between 0 and 100) for various tasks
If it is called with -1 that means that the
task does not have any progress information
:detected_device: Device information from the device scanner
"""
if DEBUG:
self.log.info("ITUNE.reset()")
def set_progress_reporter(self, report_progress):
'''
@param report_progress: Function that is called with a % progress
(number between 0 and 100) for various tasks
If it is called with -1 that means that the
task does not have any progress information
'''
if DEBUG:
self.log.info("ITUNES:set_progress_reporter()")
self.report_progress = report_progress
def settings(self):
'''
Should return an opts object. The opts object should have one attribute
`format_map` which is an ordered list of formats for the device.
'''
if DEBUG:
self.log.info("ITUNES.settings()")
klass = self if isinstance(self, type) else self.__class__
c = Config('device_drivers_%s' % klass.__name__, _('settings for device drivers'))
c.add_opt('format_map', default=self.FORMATS,
help=_('Ordered list of formats the device will accept'))
return c.parse()
def sync_booklists(self, booklists, end_session=True):
'''
Update metadata on device.
@param booklists: A tuple containing the result of calls to
(L{books}(oncard=None), L{books}(oncard='carda'),
L{books}(oncard='cardb')).
'''
if DEBUG:
self.log.info("ITUNES:sync_booklists():")
if self.update_needed:
self._update_device(msg=self.update_msg)
self.update_needed = False
# Get actual size of updated books on device
if self.update_list:
if DEBUG:
self.log.info("ITUNES:sync_booklists(): update_list:")
for ub in self.update_list:
self.log.info(" '%s'" % ub['title'])
for updated_book in self.update_list:
size_on_device = self._get_device_book_size(updated_book['title'], updated_book['author'])
if size_on_device:
for book in booklists[0]:
if book.title == updated_book['title'] and \
book.author[0] == updated_book['author']:
book.size = size_on_device
break
else:
self.log.error("ITUNES:sync_booklists(): could not update book size for '%s'" % updated_book['title'])
else:
self.log.error("ITUNES:sync_booklists(): could not find '%s' on device" % updated_book['title'])
self.update_list = []
# Inform user of any problem books
if self.problem_titles:
raise UserFeedback(self.problem_msg,
details='\n'.join(self.problem_titles), level=UserFeedback.WARN)
self.problem_titles = []
self.problem_msg = None
def total_space(self, end_session=True):
"""
Get total space available on the mountpoints:
1. Main memory
2. Memory Card A
3. Memory Card B
@return: A 3 element list with total space in bytes of (1, 2, 3). If a
particular device doesn't have any of these locations it should return 0.
"""
if DEBUG:
self.log.info("ITUNES:total_space()")
capacity = 0
if isosx:
if 'iPod' in self.sources:
connected_device = self.sources['iPod']
capacity = self.iTunes.sources[connected_device].capacity()
return (capacity,-1,-1)
def upload_books(self, files, names, on_card=None, end_session=True,
metadata=None):
'''
Upload a list of books to the device. If a file already
exists on the device, it should be replaced.
This method should raise a L{FreeSpaceError} if there is not enough
free space on the device. The text of the FreeSpaceError must contain the
word "card" if C{on_card} is not None otherwise it must contain the word "memory".
:files: A list of paths and/or file-like objects.
:names: A list of file names that the books should have
once uploaded to the device. len(names) == len(files)
:return: A list of 3-element tuples. The list is meant to be passed
to L{add_books_to_metadata}.
:metadata: If not None, it is a list of :class:`MetaInformation` objects.
The idea is to use the metadata to determine where on the device to
put the book. len(metadata) == len(files). Apart from the regular
cover (path to cover), there may also be a thumbnail attribute, which should
be used in preference. The thumbnail attribute is of the form
(width, height, cover_data as jpeg).
'''
new_booklist = []
self.update_list = []
strip_tags = re.compile(r'<[^<]*?/?>')
if isosx:
file_count = float(len(files))
self.problem_titles = []
self.problem_msg = _("Some cover art could not be converted.\n"
"Click 'Show Details' for a list.")
for (i,file) in enumerate(files):
path = self.path_template % (metadata[i].title, metadata[i].author[0])
# Delete existing from Library|Books, add to self.update_list
# for deletion from booklist[0] during add_books_to_metadata
if path in self.cached_books:
self.update_list.append(self.cached_books[path])
if DEBUG:
self.log.info("ITUNES.upload_books():")
self.log.info( " deleting existing '%s'" % (path))
self._remove_iTunes_dir(self.cached_books[path])
self.iTunes.delete(self.cached_books[path]['lib_book'])
# Add to iTunes Library|Books
if isinstance(file,PersistentTemporaryFile):
added = self.iTunes.add(appscript.mactypes.File(file._name))
else:
added = self.iTunes.add(appscript.mactypes.File(file))
thumb = None
try:
if self.use_thumbnail_as_cover:
# Use thumbnail data as artwork
added.artworks[1].data_.set(metadata[i].thumbnail[2])
thumb = metadata[i].thumbnail[2]
else:
# Use cover data as artwork
cover_data = open(metadata[i].cover,'rb')
added.artworks[1].data_.set(cover_data.read())
# Resize for thumb
width = metadata[i].thumbnail[0]
height = metadata[i].thumbnail[1]
im = PILImage.open(metadata[i].cover)
im = im.resize((width, height), PILImage.ANTIALIAS)
of = cStringIO.StringIO()
im.convert('RGB').save(of, 'JPEG')
thumb = of.getvalue()
# Refresh the thumbnail cache
if DEBUG:
self.log.info( " refreshing cached thumb for '%s'" % metadata[i].title)
archive_path = os.path.join(self.cache_dir, "thumbs.zip")
zfw = zipfile.ZipFile(archive_path, mode='a')
thumb_path = path.rpartition('.')[0] + '.jpg'
zfw.writestr(thumb_path, thumb)
zfw.close()
except:
self.problem_titles.append("'%s' by %s" % (metadata[i].title, metadata[i].author[0]))
self.log.error("ITUNES.upload_books(): error converting '%s' to thumb for '%s'" % (metadata[i].cover,metadata[i].title))
# Create a new Book
this_book = Book(metadata[i].title, metadata[i].author[0])
this_book.datetime = parse_date(str(added.date_added())).timetuple()
this_book.db_id = None
this_book.device_collections = []
this_book.library_id = added
this_book.path = path
this_book.size = added.size() # Updated later from actual storage size
this_book.thumbnail = thumb
this_book.iTunes_id = added
new_booklist.append(this_book)
# Flesh out the iTunes metadata
added.description.set("added by calibre %s" % strftime('%Y-%m-%d %H:%M:%S'))
added.comment.set(strip_tags.sub('',metadata[i].comments))
if metadata[i].rating:
added.rating.set(metadata[i].rating*10)
added.sort_artist.set(metadata[i].author_sort.title())
added.sort_name.set(this_book.title_sorter)
# Set genre from metadata
# iTunes grabs the first dc:subject from the opf metadata,
# But we can manually override with first tag starting with alpha
for tag in metadata[i].tags:
if self._is_alpha(tag[0]):
added.genre.set(tag)
break
# Add new_book to self.cached_paths
self.cached_books[this_book.path] = {
'title': this_book.title,
'author': this_book.author,
'lib_book': added
}
# Report progress
if self.report_progress is not None:
self.report_progress(i+1/file_count, _('%d of %d' % (i+1, file_count)))
if self.report_progress is not None:
self.report_progress(1.0, _('finished'))
# Tell sync_booklists we need a re-sync
self.update_needed = True
self.update_msg = "Added books to device"
return (new_booklist, [], [])
# Private methods
def _dump_booklist(self,booklist, header="booklists[0]"):
'''
'''
self.log.info()
self.log.info(header)
self.log.info( "%s" % ('-' * len(header)))
for i,book in enumerate(booklist):
self.log.info( "%2d %-25.25s %s" % (i,book.title, book.library_id))
self.log.info()
def _dump_cached_books(self):
'''
'''
self.log.info("\n%-40.40s %-12.12s" % ('Device Books','In Library'))
self.log.info("%-40.40s %-12.12s" % ('------------','----------'))
for cb in self.cached_books.keys():
self.log.info("%-40.40s %6.6s" % (self.cached_books[cb]['title'], 'yes' if self.cached_books[cb]['lib_book'] else ' no'))
self.log.info("\n")
def _hexdump(self, src, length=16):
'''
'''
FILTER=''.join([(len(repr(chr(x)))==3) and chr(x) or '.' for x in range(256)])
N=0; result=''
while src:
s,src = src[:length],src[length:]
hexa = ' '.join(["%02X"%ord(x) for x in s])
s = s.translate(FILTER)
result += "%04X %-*s %s\n" % (N, length*3, hexa, s)
N+=length
print result
def _get_library_books(self):
'''
'''
lib = self.iTunes.sources['library']
library_books = {}
if 'Books' in lib.playlists.name():
lib_books = lib.playlists['Books'].file_tracks()
for book in lib_books:
path = self.path_template % (book.name(), book.artist())
library_books[path] = book
return library_books
def _get_device_book_size(self, title, author):
'''
Fetch the size of a book stored on the device
'''
if DEBUG:
self.log.info("ITUNES._get_device_book_size(): looking for title: '%s' author: %s" % (title,author))
device_books = self._get_device_books()
for d_book in device_books:
if DEBUG:
self.log.info(" evaluating title: '%s' author: '%s'" % (d_book.name(), d_book.artist()))
if d_book.name() == title and d_book.artist() == author:
return d_book.size()
else:
self.log.error("ITUNES._get_device_book_size(): could not find '%s' by '%s' in device_books" % (title,author))
return None
def _get_device_books(self):
'''
'''
if 'iPod' in self.sources:
device = self.sources['iPod']
if 'Books' in self.iTunes.sources[device].playlists.name():
return self.iTunes.sources[device].playlists['Books'].file_tracks()
def _generate_thumbnail(self, book_path, book):
'''
Convert iTunes artwork to thumbnail
Cache generated thumbnails
cache_dir = os.path.join(config_dir, 'caches', 'itunes')
'''
archive_path = os.path.join(self.cache_dir, "thumbs.zip")
thumb_path = book_path.rpartition('.')[0] + '.jpg'
try:
zfr = zipfile.ZipFile(archive_path)
thumb_data = zfr.read(thumb_path)
zfr.close()
except:
zfw = zipfile.ZipFile(archive_path, mode='a')
else:
if DEBUG:
self.log.info("ITUNES._generate_thumbnail(): cached thumb found for '%s'" % book.name())
return thumb_data
try:
# Resize the cover
data = book.artworks[1].raw_data().data
#self._hexdump(data[:256])
im = PILImage.open(cStringIO.StringIO(data))
scaled, width, height = fit_image(im.size[0],im.size[1], 60, 80)
im = im.resize((int(width),int(height)), PILImage.ANTIALIAS)
thumb = cStringIO.StringIO()
im.convert('RGB').save(thumb,'JPEG')
# Cache the tagged thumb
if DEBUG:
self.log.info("ITUNES._generate_thumbnail(): generated thumb for '%s', caching" % book.name())
zfw.writestr(thumb_path, thumb.getvalue())
zfw.close()
return thumb.getvalue()
except:
self.log.error("ITUNES._generate_thumbnail(): error generating thumb for '%s'" % book.name())
return None
def _get_purchased_book_ids(self):
'''
'''
if 'iPod' in self.sources:
device = self.sources['iPod']
if 'Purchased' in self.iTunes.sources[device].playlists.name():
return [pb.database_ID() for pb in self.iTunes.sources[device].playlists['Purchased'].file_tracks()]
else:
return []
def _is_alpha(self,char):
'''
'''
if not re.search('[a-zA-Z]',char):
return False
else:
return True
def _remove_iTunes_dir(self, cached_book):
'''
iTunes does not delete books from storage when removing from database
'''
storage_path = os.path.split(cached_book['lib_book'].location().path)
if DEBUG:
self.log.info( "ITUNES._remove_iTunes_dir():")
self.log.info( " removing storage_path: %s" % storage_path[0])
shutil.rmtree(storage_path[0])
def _update_device(self, msg='', wait=True):
'''
'''
if DEBUG:
self.log.info("ITUNES:_update_device(): %s" % msg)
self.iTunes.update()
if wait:
# This works if iTunes has books not yet synced to iPad.
if DEBUG:
self.log.info("Waiting for iPad sync to complete ...",)
while len(self._get_device_books()) != (len(self._get_library_books()) + len(self._get_purchased_book_ids())):
if DEBUG:
sys.stdout.write('.')
sys.stdout.flush()
time.sleep(2)
print
class BookList(list):
'''
A list of books. Each Book object must have the fields:
1. title
2. authors
3. size (file size of the book)
4. datetime (a UTC time tuple)
5. path (path on the device to the book)
6. thumbnail (can be None) thumbnail is either a str/bytes object with the
image data or it should have an attribute image_path that stores an
absolute (platform native) path to the image
7. tags (a list of strings, can be empty).
'''
__getslice__ = None
__setslice__ = None
log = None
def __init__(self, log):
self.log = log
def supports_collections(self):
''' Return True if the the device supports collections for this book list. '''
return False
def add_book(self, book, replace_metadata):
'''
Add the book to the booklist. Intent is to maintain any device-internal
metadata. Return True if booklists must be sync'ed
'''
if DEBUG:
self.log.info("BookList.add_book(): adding %s" % book)
self.append(book)
def remove_book(self, book):
'''
Remove a book from the booklist. Correct any device metadata at the
same time
'''
raise NotImplementedError()
def get_collections(self, collection_attributes):
'''
Return a dictionary of collections created from collection_attributes.
Each entry in the dictionary is of the form collection name:[list of
books]
The list of books is sorted by book title, except for collections
created from series, in which case series_index is used.
:param collection_attributes: A list of attributes of the Book object
'''
return {}
class Book(MetaInformation):
'''
A simple class describing a book in the iTunes Books Library.
Q's:
- Should thumbnail come from calibre if available?
- See ebooks.metadata.__init__ for all fields
'''
def __init__(self,title,author):
MetaInformation.__init__(self, title, authors=[author])
@dynamic_property
def title_sorter(self):
doc = '''String to sort the title. If absent, title is returned'''
def fget(self):
return re.sub('^\s*A\s+|^\s*The\s+|^\s*An\s+', '', self.title).rstrip()
return property(doc=doc, fget=fget)

View File

@ -22,9 +22,20 @@ class DeviceError(ProtocolError):
""" Raised when device is not found """
def __init__(self, msg=None):
if msg is None:
msg = "Unable to find SONY Reader. Is it connected?"
msg = "Unable to find SONY Reader. Is it connected?"
ProtocolError.__init__(self, msg)
class UserFeedback(DeviceError):
INFO = 0
WARN = WARNING = 1
ERROR = 2
def __init__(self, msg, details, level):
Exception.__init__(self, msg)
self.level = level
self.details = details
self.msg = msg
class DeviceBusy(ProtocolError):
""" Raised when device is busy """
def __init__(self, uerr=""):
@ -57,8 +68,8 @@ class ControlError(ProtocolError):
self.query = query
self.response = response
ProtocolError.__init__(self, desc)
def __str__(self):
def __str__(self):
if self.query and self.response:
return "Got unexpected response:\n" + \
"query:\n"+str(self.query.query)+"\n"+\

View File

@ -0,0 +1,10 @@
#!/usr/bin/env python
# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
from __future__ import with_statement
__license__ = 'GPL v3'
__copyright__ = '2009, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'

View File

@ -0,0 +1,93 @@
'''
Created on 15 May 2010
@author: charles
'''
import os
from calibre.devices.usbms.driver import USBMS, BookList
# This class is added to the standard device plugin chain, so that it can
# be configured. It has invalid vendor_id etc, so it will never match a
# device. The 'real' FOLDER_DEVICE will use the config from it.
class FOLDER_DEVICE_FOR_CONFIG(USBMS):
name = 'Folder Device Interface'
gui_name = 'Folder Device'
description = _('Use an arbitrary folder as a device.')
author = 'John Schember/Charles Haley'
supported_platforms = ['windows', 'osx', 'linux']
FORMATS = ['epub', 'fb2', 'mobi', 'lrf', 'tcr', 'pmlz', 'lit', 'rtf', 'rb', 'pdf', 'oeb', 'txt', 'pdb']
VENDOR_ID = 0xffff
PRODUCT_ID = 0xffff
BCD = 0xffff
class FOLDER_DEVICE(USBMS):
type = _('Device Interface')
name = 'Folder Device Interface'
gui_name = 'Folder Device'
description = _('Use an arbitrary folder as a device.')
author = 'John Schember/Charles Haley'
supported_platforms = ['windows', 'osx', 'linux']
FORMATS = ['epub', 'fb2', 'mobi', 'lrf', 'tcr', 'pmlz', 'lit', 'rtf', 'rb', 'pdf', 'oeb', 'txt', 'pdb']
VENDOR_ID = 0xffff
PRODUCT_ID = 0xffff
BCD = 0xffff
THUMBNAIL_HEIGHT = 68 # Height for thumbnails on device
CAN_SET_METADATA = True
SUPPORTS_SUB_DIRS = True
#: Icon for this device
icon = I('devices/folder.svg')
METADATA_CACHE = '.metadata.calibre'
_main_prefix = ''
_card_a_prefix = None
_card_b_prefix = None
is_connected = False
def __init__(self, path):
if not os.path.isdir(path):
raise IOError, 'Path is not a folder'
path = USBMS.normalize_path(path)
if path.endswith(os.sep):
self._main_prefix = path
else:
self._main_prefix = path + os.sep
self.booklist_class = BookList
self.is_connected = True
def reset(self, key='-1', log_packets=False, report_progress=None,
detected_device=None):
pass
def disconnect_from_folder(self):
self._main_prefix = ''
self.is_connected = False
def is_usb_connected(self, devices_on_system, debug=False,
only_presence=False):
return self.is_connected, self
def open(self):
if not self._main_prefix:
return False
return True
def set_progress_reporter(self, report_progress):
self.report_progress = report_progress
def card_prefix(self, end_session=True):
return (None, None)
def eject(self):
self.is_connected = False
@classmethod
def settings(self):
return FOLDER_DEVICE_FOR_CONFIG._config().parse()

View File

@ -47,6 +47,10 @@ class DevicePlugin(Plugin):
# Used by gui2.ui:annotations_fetched() and devices.kindle.driver:get_annotations()
UserAnnotation = namedtuple('Annotation','type, value')
#: GUI displays this as a message if not None. Useful if opening can take a
#: long time
OPEN_FEEDBACK_MESSAGE = None
@classmethod
def get_gui_name(cls):
if hasattr(cls, 'gui_name'):
@ -293,8 +297,7 @@ class DevicePlugin(Plugin):
put the book. len(metadata) == len(files). Apart from the regular
cover (path to cover), there may also be a thumbnail attribute, which should
be used in preference. The thumbnail attribute is of the form
(width, height, cover_data as jpeg). In addition the MetaInformation
objects can have a tag_order attribute.
(width, height, cover_data as jpeg).
'''
raise NotImplementedError()
@ -380,22 +383,46 @@ class BookList(list):
3. size (file size of the book)
4. datetime (a UTC time tuple)
5. path (path on the device to the book)
6. thumbnail (can be None)
6. thumbnail (can be None) thumbnail is either a str/bytes object with the
image data or it should have an attribute image_path that stores an
absolute (platform native) path to the image
7. tags (a list of strings, can be empty).
'''
__getslice__ = None
__setslice__ = None
def supports_tags(self):
''' Return True if the the device supports tags (collections) for this book list. '''
def __init__(self, oncard, prefix, settings):
pass
def supports_collections(self):
''' Return True if the the device supports collections for this book list. '''
raise NotImplementedError()
def set_tags(self, book, tags):
def add_book(self, book, replace_metadata):
'''
Set the tags for C{book} to C{tags}.
@param tags: A list of strings. Can be empty.
@param book: A book object that is in this BookList.
Add the book to the booklist. Intent is to maintain any device-internal
metadata. Return True if booklists must be sync'ed
'''
raise NotImplementedError()
def remove_book(self, book):
'''
Remove a book from the booklist. Correct any device metadata at the
same time
'''
raise NotImplementedError()
def get_collections(self, collection_attributes):
'''
Return a dictionary of collections created from collection_attributes.
Each entry in the dictionary is of the form collection name:[list of
books]
The list of books is sorted by book title, except for collections
created from series, in which case series_index is used.
:param collection_attributes: A list of attributes of the Book object
'''
raise NotImplementedError()

View File

@ -60,11 +60,6 @@ class KINDLE(USBMS):
'replace')
return mi
def filename_callback(self, fname, mi):
if fname.startswith('.'):
return 'x'+fname[1:]
return fname
def get_annotations(self, path_map):
MBP_FORMATS = [u'azw', u'mobi', u'prc', u'txt']
mbp_formats = set(MBP_FORMATS)

View File

@ -1,2 +1,6 @@
__license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
MEDIA_XML = 'database/cache/media.xml'
CACHE_XML = 'Sony Reader/database/cache.xml'

View File

@ -1,449 +0,0 @@
__license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
'''
'''
import re, time, functools
from uuid import uuid4 as _uuid
import xml.dom.minidom as dom
from base64 import b64decode as decode
from base64 import b64encode as encode
from calibre.devices.interface import BookList as _BookList
from calibre.devices import strftime as _strftime
from calibre.devices import strptime
strftime = functools.partial(_strftime, zone=time.gmtime)
MIME_MAP = {
"lrf" : "application/x-sony-bbeb",
'lrx' : 'application/x-sony-bbeb',
"rtf" : "application/rtf",
"pdf" : "application/pdf",
"txt" : "text/plain" ,
'epub': 'application/epub+zip',
}
def uuid():
return str(_uuid()).replace('-', '', 1).upper()
def sortable_title(title):
return re.sub('^\s*A\s+|^\s*The\s+|^\s*An\s+', '', title).rstrip()
class book_metadata_field(object):
""" Represents metadata stored as an attribute """
def __init__(self, attr, formatter=None, setter=None):
self.attr = attr
self.formatter = formatter
self.setter = setter
def __get__(self, obj, typ=None):
""" Return a string. String may be empty if self.attr is absent """
return self.formatter(obj.elem.getAttribute(self.attr)) if \
self.formatter else obj.elem.getAttribute(self.attr).strip()
def __set__(self, obj, val):
""" Set the attribute """
val = self.setter(val) if self.setter else val
if not isinstance(val, unicode):
val = unicode(val, 'utf8', 'replace')
obj.elem.setAttribute(self.attr, val)
class Book(object):
""" Provides a view onto the XML element that represents a book """
title = book_metadata_field("title")
authors = book_metadata_field("author", \
formatter=lambda x: x if x and x.strip() else _('Unknown'))
mime = book_metadata_field("mime")
rpath = book_metadata_field("path")
id = book_metadata_field("id", formatter=int)
sourceid = book_metadata_field("sourceid", formatter=int)
size = book_metadata_field("size", formatter=lambda x : int(float(x)))
# When setting this attribute you must use an epoch
datetime = book_metadata_field("date", formatter=strptime, setter=strftime)
@dynamic_property
def title_sorter(self):
doc = '''String to sort the title. If absent, title is returned'''
def fget(self):
src = self.elem.getAttribute('titleSorter').strip()
if not src:
src = self.title
return src
def fset(self, val):
self.elem.setAttribute('titleSorter', sortable_title(unicode(val)))
return property(doc=doc, fget=fget, fset=fset)
@dynamic_property
def thumbnail(self):
doc = \
"""
The thumbnail. Should be a height 68 image.
Setting is not supported.
"""
def fget(self):
th = self.elem.getElementsByTagName(self.prefix + "thumbnail")
if not len(th):
th = self.elem.getElementsByTagName("cache:thumbnail")
if len(th):
for n in th[0].childNodes:
if n.nodeType == n.ELEMENT_NODE:
th = n
break
rc = ""
for node in th.childNodes:
if node.nodeType == node.TEXT_NODE:
rc += node.data
return decode(rc)
return property(fget=fget, doc=doc)
@dynamic_property
def path(self):
doc = """ Absolute path to book on device. Setting not supported. """
def fget(self):
return self.mountpath + self.rpath
return property(fget=fget, doc=doc)
@dynamic_property
def db_id(self):
doc = '''The database id in the application database that this file corresponds to'''
def fget(self):
match = re.search(r'_(\d+)$', self.rpath.rpartition('.')[0])
if match:
return int(match.group(1))
return property(fget=fget, doc=doc)
def __init__(self, node, mountpath, tags, prefix=""):
self.elem = node
self.prefix = prefix
self.tags = tags
self.mountpath = mountpath
def __str__(self):
""" Return a utf-8 encoded string with title author and path information """
return self.title.encode('utf-8') + " by " + \
self.authors.encode('utf-8') + " at " + self.path.encode('utf-8')
class BookList(_BookList):
def __init__(self, xml_file, mountpath, report_progress=None):
_BookList.__init__(self)
xml_file.seek(0)
self.document = dom.parse(xml_file)
self.root_element = self.document.documentElement
self.mountpath = mountpath
records = self.root_element.getElementsByTagName('records')
self.tag_order = {}
if records:
self.prefix = 'xs1:'
self.root_element = records[0]
else:
self.prefix = ''
nodes = self.root_element.childNodes
for i, book in enumerate(nodes):
if report_progress:
report_progress((i+1) / float(len(nodes)), _('Getting list of books on device...'))
if hasattr(book, 'tagName') and book.tagName.endswith('text'):
tags = [i.getAttribute('title') for i in self.get_playlists(book.getAttribute('id'))]
self.append(Book(book, mountpath, tags, prefix=self.prefix))
def max_id(self):
max = 0
for child in self.root_element.childNodes:
if child.nodeType == child.ELEMENT_NODE and child.hasAttribute("id"):
nid = int(child.getAttribute('id'))
if nid > max:
max = nid
return max
def is_id_valid(self, id):
'''Return True iff there is an element with C{id==id}.'''
id = str(id)
for child in self.root_element.childNodes:
if child.nodeType == child.ELEMENT_NODE and child.hasAttribute("id"):
if child.getAttribute('id') == id:
return True
return False
def supports_tags(self):
return True
def book_by_path(self, path):
for child in self.root_element.childNodes:
if child.nodeType == child.ELEMENT_NODE and child.hasAttribute("path"):
if path == child.getAttribute('path'):
return child
return None
def add_book(self, mi, name, collections, size, ctime):
""" Add a node into the DOM tree, representing a book """
book = self.book_by_path(name)
if book is not None:
self.remove_book(name)
node = self.document.createElement(self.prefix + "text")
mime = MIME_MAP.get(name.rpartition('.')[-1].lower(), MIME_MAP['epub'])
cid = self.max_id()+1
try:
sourceid = str(self[0].sourceid) if len(self) else '1'
except:
sourceid = '1'
attrs = {
"title" : mi.title,
'titleSorter' : sortable_title(mi.title),
"author" : mi.format_authors() if mi.format_authors() else _('Unknown'),
"page":"0", "part":"0", "scale":"0", \
"sourceid":sourceid, "id":str(cid), "date":"", \
"mime":mime, "path":name, "size":str(size)
}
for attr in attrs.keys():
node.setAttributeNode(self.document.createAttribute(attr))
node.setAttribute(attr, attrs[attr])
try:
w, h, data = mi.thumbnail
except:
w, h, data = None, None, None
if data:
th = self.document.createElement(self.prefix + "thumbnail")
th.setAttribute("width", str(w))
th.setAttribute("height", str(h))
jpeg = self.document.createElement(self.prefix + "jpeg")
jpeg.appendChild(self.document.createTextNode(encode(data)))
th.appendChild(jpeg)
node.appendChild(th)
self.root_element.appendChild(node)
book = Book(node, self.mountpath, [], prefix=self.prefix)
book.datetime = ctime
self.append(book)
tags = []
for item in collections:
item = item.strip()
mitem = getattr(mi, item, None)
titems = []
if mitem:
if isinstance(mitem, list):
titems = mitem
else:
titems = [mitem]
if item == 'tags' and titems:
litems = []
for i in titems:
if not i.strip().startswith('[') and not i.strip().endswith(']'):
litems.append(i)
titems = litems
tags.extend(titems)
if tags:
tags = list(set(tags))
if hasattr(mi, 'tag_order'):
self.tag_order.update(mi.tag_order)
self.set_tags(book, tags)
def _delete_book(self, node):
nid = node.getAttribute('id')
self.remove_from_playlists(nid)
node.parentNode.removeChild(node)
node.unlink()
def delete_book(self, cid):
'''
Remove DOM node corresponding to book with C{id == cid}.
Also remove book from any collections it is part of.
'''
for book in self:
if str(book.id) == str(cid):
self.remove(book)
self._delete_book(book.elem)
break
def remove_book(self, path):
'''
Remove DOM node corresponding to book with C{path == path}.
Also remove book from any collections it is part of.
'''
for book in self:
if path.endswith(book.rpath):
self.remove(book)
self._delete_book(book.elem)
break
def playlists(self):
ans = []
for c in self.root_element.childNodes:
if hasattr(c, 'tagName') and c.tagName.endswith('playlist'):
ans.append(c)
return ans
def playlist_items(self):
plitems = []
for pl in self.playlists():
for c in pl.childNodes:
if hasattr(c, 'tagName') and c.tagName.endswith('item') and \
hasattr(c, 'getAttribute'):
try:
c.getAttribute('id')
except: # Unlinked node
continue
plitems.append(c)
return plitems
def purge_corrupted_files(self):
if not self.root_element:
return []
corrupted = self.root_element.getElementsByTagName(self.prefix+'corrupted')
paths = []
for c in corrupted:
paths.append(c.getAttribute('path'))
c.parentNode.removeChild(c)
c.unlink()
return paths
def purge_empty_playlists(self):
''' Remove all playlists that have no children. Also removes any invalid playlist items.'''
for pli in self.playlist_items():
try:
if not self.is_id_valid(pli.getAttribute('id')):
pli.parentNode.removeChild(pli)
pli.unlink()
except:
continue
for pl in self.playlists():
empty = True
for c in pl.childNodes:
if hasattr(c, 'tagName') and c.tagName.endswith('item'):
empty = False
break
if empty:
pl.parentNode.removeChild(pl)
pl.unlink()
def playlist_by_title(self, title):
for pl in self.playlists():
if pl.getAttribute('title').lower() == title.lower():
return pl
def add_playlist(self, title):
cid = self.max_id()+1
pl = self.document.createElement(self.prefix+'playlist')
pl.setAttribute('id', str(cid))
pl.setAttribute('title', title)
pl.setAttribute('uuid', uuid())
self.root_element.insertBefore(pl, self.root_element.childNodes[-1])
return pl
def remove_from_playlists(self, id):
for pli in self.playlist_items():
if pli.getAttribute('id') == str(id):
pli.parentNode.removeChild(pli)
pli.unlink()
def set_tags(self, book, tags):
tags = [t for t in tags if t]
book.tags = tags
self.set_playlists(book.id, tags)
def set_playlists(self, id, collections):
self.remove_from_playlists(id)
for collection in set(collections):
coll = self.playlist_by_title(collection)
if not coll:
coll = self.add_playlist(collection)
item = self.document.createElement(self.prefix+'item')
item.setAttribute('id', str(id))
coll.appendChild(item)
def get_playlists(self, bookid):
ans = []
for pl in self.playlists():
for item in pl.childNodes:
if hasattr(item, 'tagName') and item.tagName.endswith('item'):
if item.getAttribute('id') == str(bookid):
ans.append(pl)
return ans
def next_id(self):
return self.document.documentElement.getAttribute('nextID')
def set_next_id(self, id):
self.document.documentElement.setAttribute('nextID', str(id))
def write(self, stream):
""" Write XML representation of DOM tree to C{stream} """
src = self.document.toxml('utf-8') + '\n'
stream.write(src.replace("'", '&apos;'))
def book_by_id(self, id):
for book in self:
if str(book.id) == str(id):
return book
def reorder_playlists(self):
for title in self.tag_order.keys():
pl = self.playlist_by_title(title)
if not pl:
continue
db_ids = [i.getAttribute('id') for i in pl.childNodes if hasattr(i, 'getAttribute')]
pl_book_ids = [getattr(self.book_by_id(i), 'db_id', None) for i in db_ids]
imap = {}
for i, j in zip(pl_book_ids, db_ids):
imap[i] = j
pl_book_ids = [i for i in pl_book_ids if i is not None]
ordered_ids = [i for i in self.tag_order[title] if i in pl_book_ids]
if len(ordered_ids) < len(pl.childNodes):
continue
children = [i for i in pl.childNodes if hasattr(i, 'getAttribute')]
for child in children:
pl.removeChild(child)
child.unlink()
for id in ordered_ids:
item = self.document.createElement(self.prefix+'item')
item.setAttribute('id', str(imap[id]))
pl.appendChild(item)
def fix_ids(main, carda, cardb):
'''
Adjust ids the XML databases.
'''
if hasattr(main, 'purge_empty_playlists'):
main.purge_empty_playlists()
if hasattr(carda, 'purge_empty_playlists'):
carda.purge_empty_playlists()
if hasattr(cardb, 'purge_empty_playlists'):
cardb.purge_empty_playlists()
def regen_ids(db):
if not hasattr(db, 'root_element'):
return
id_map = {}
db.purge_empty_playlists()
cid = 0 if db == main else 1
for child in db.root_element.childNodes:
if child.nodeType == child.ELEMENT_NODE and child.hasAttribute('id'):
id_map[child.getAttribute('id')] = str(cid)
child.setAttribute("sourceid",
'0' if getattr(child, 'tagName', '').endswith('playlist') else '1')
child.setAttribute('id', str(cid))
cid += 1
for item in db.playlist_items():
oid = item.getAttribute('id')
try:
item.setAttribute('id', id_map[oid])
except KeyError:
item.parentNode.removeChild(item)
item.unlink()
db.reorder_playlists()
regen_ids(main)
regen_ids(carda)
regen_ids(cardb)
main.set_next_id(str(main.max_id()+1))

View File

@ -1,54 +1,57 @@
# -*- coding: utf-8 -*-
__license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net> ' \
'2009, John Schember <john at nachtimwald.com>'
__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
__docformat__ = 'restructuredtext en'
'''
Device driver for the SONY PRS-505
Device driver for the SONY devices
'''
import os
import re
import time
from itertools import cycle
from calibre.devices.usbms.cli import CLI
from calibre.devices.usbms.device import Device
from calibre.devices.prs505.books import BookList, fix_ids
from calibre.devices.usbms.driver import USBMS
from calibre.devices.prs505 import MEDIA_XML
from calibre.devices.prs505 import CACHE_XML
from calibre.devices.prs505.sony_cache import XMLCache
from calibre import __appname__
from calibre.devices.usbms.books import CollectionsBookList
class PRS505(CLI, Device):
class PRS505(USBMS):
name = 'PRS-300/505 Device Interface'
name = 'SONY Device Interface'
gui_name = 'SONY Reader'
description = _('Communicate with the Sony PRS-300/505/500 eBook reader.')
author = 'Kovid Goyal and John Schember'
description = _('Communicate with all the Sony eBook readers.')
author = 'Kovid Goyal'
supported_platforms = ['windows', 'osx', 'linux']
path_sep = '/'
booklist_class = CollectionsBookList
FORMATS = ['epub', 'lrf', 'lrx', 'rtf', 'pdf', 'txt']
CAN_SET_METADATA = True
VENDOR_ID = [0x054c] #: SONY Vendor Id
PRODUCT_ID = [0x031e] #: Product Id for the PRS 300/505/new 500
BCD = [0x229, 0x1000, 0x22a]
PRODUCT_ID = [0x031e]
BCD = [0x229, 0x1000, 0x22a, 0x31a]
VENDOR_NAME = 'SONY'
WINDOWS_MAIN_MEM = re.compile('PRS-(505|300|500)')
WINDOWS_CARD_A_MEM = re.compile(r'PRS-(505|500)[#/]\S+:MS')
WINDOWS_CARD_B_MEM = re.compile(r'PRS-(505|500)[#/]\S+:SD')
WINDOWS_MAIN_MEM = re.compile(
r'(PRS-(505|300|500))|'
r'(PRS-((700[#/])|((6|9)00&)))'
)
WINDOWS_CARD_A_MEM = re.compile(
r'(PRS-(505|500)[#/]\S+:MS)|'
r'(PRS-((700[/#]\S+:)|((6|9)00[#_]))MS)'
)
WINDOWS_CARD_B_MEM = re.compile(
r'(PRS-(505|500)[#/]\S+:SD)|'
r'(PRS-((700[/#]\S+:)|((6|9)00[#_]))SD)'
)
OSX_MAIN_MEM = re.compile(r'Sony PRS-(((505|300|500)/[^:]+)|(300)) Media')
OSX_CARD_A_MEM = re.compile(r'Sony PRS-(505|500)/[^:]+:MS Media')
OSX_CARD_B_MEM = re.compile(r'Sony PRS-(505|500)/[^:]+:SD Media')
MAIN_MEMORY_VOLUME_LABEL = 'Sony Reader Main Memory'
STORAGE_CARD_VOLUME_LABEL = 'Sony Reader Storage Card'
MEDIA_XML = 'database/cache/media.xml'
CACHE_XML = 'Sony Reader/database/cache.xml'
CARD_PATH_PREFIX = __appname__
SUPPORTS_SUB_DIRS = True
@ -63,64 +66,9 @@ class PRS505(CLI, Device):
def windows_filter_pnp_id(self, pnp_id):
return '_LAUNCHER' in pnp_id
def open(self):
self.report_progress = lambda x, y: x
Device.open(self)
def write_cache(prefix):
try:
cachep = os.path.join(prefix, *(self.CACHE_XML.split('/')))
if not os.path.exists(cachep):
dname = os.path.dirname(cachep)
if not os.path.exists(dname):
try:
os.makedirs(dname, mode=0777)
except:
time.sleep(5)
os.makedirs(dname, mode=0777)
with open(cachep, 'wb') as f:
f.write(u'''<?xml version="1.0" encoding="UTF-8"?>
<cache xmlns="http://www.kinoma.com/FskCache/1">
</cache>
'''.encode('utf8'))
return True
except:
import traceback
traceback.print_exc()
return False
if self._card_a_prefix is not None:
if not write_cache(self._card_a_prefix):
self._card_a_prefix = None
if self._card_b_prefix is not None:
if not write_cache(self._card_b_prefix):
self._card_b_prefix = None
def get_device_information(self, end_session=True):
return (self.gui_name, '', '', '')
def books(self, oncard=None, end_session=True):
if oncard == 'carda' and not self._card_a_prefix:
self.report_progress(1.0, _('Getting list of books on device...'))
return []
elif oncard == 'cardb' and not self._card_b_prefix:
self.report_progress(1.0, _('Getting list of books on device...'))
return []
elif oncard and oncard != 'carda' and oncard != 'cardb':
self.report_progress(1.0, _('Getting list of books on device...'))
return []
db = self.__class__.CACHE_XML if oncard else self.__class__.MEDIA_XML
prefix = self._card_a_prefix if oncard == 'carda' else self._card_b_prefix if oncard == 'cardb' else self._main_prefix
bl = BookList(open(prefix + db, 'rb'), prefix, self.report_progress)
paths = bl.purge_corrupted_files()
for path in paths:
path = os.path.join(prefix, path)
if os.path.exists(path):
os.unlink(path)
self.report_progress(1.0, _('Getting list of books on device...'))
return bl
def filename_callback(self, fname, mi):
if getattr(mi, 'application_id', None) is not None:
base = fname.rpartition('.')[0]
@ -129,117 +77,43 @@ class PRS505(CLI, Device):
fname = base + suffix + '.' + fname.rpartition('.')[-1]
return fname
def upload_books(self, files, names, on_card=None, end_session=True,
metadata=None):
def initialize_XML_cache(self):
paths, prefixes = {}, {}
for prefix, path, source_id in [
('main', MEDIA_XML, 0),
('card_a', CACHE_XML, 1),
('card_b', CACHE_XML, 2)
]:
prefix = getattr(self, '_%s_prefix'%prefix)
if prefix is not None and os.path.exists(prefix):
paths[source_id] = os.path.join(prefix, *(path.split('/')))
prefixes[source_id] = prefix
d = os.path.dirname(paths[source_id])
if not os.path.exists(d):
os.makedirs(d)
return XMLCache(paths, prefixes)
path = self._sanity_check(on_card, files)
paths, ctimes, sizes = [], [], []
names = iter(names)
metadata = iter(metadata)
for i, infile in enumerate(files):
mdata, fname = metadata.next(), names.next()
filepath = self.create_upload_path(path, mdata, fname)
paths.append(filepath)
self.put_file(infile, paths[-1], replace_file=True)
ctimes.append(os.path.getctime(paths[-1]))
sizes.append(os.stat(paths[-1]).st_size)
self.report_progress((i+1) / float(len(files)), _('Transferring books to device...'))
self.report_progress(1.0, _('Transferring books to device...'))
return zip(paths, sizes, ctimes, cycle([on_card]))
def add_books_to_metadata(self, locations, metadata, booklists):
if not locations or not metadata:
return
metadata = iter(metadata)
for location in locations:
info = metadata.next()
path = location[0]
oncard = location[3]
blist = 2 if oncard == 'cardb' else 1 if oncard == 'carda' else 0
if self._main_prefix and path.startswith(self._main_prefix):
name = path.replace(self._main_prefix, '')
elif self._card_a_prefix and path.startswith(self._card_a_prefix):
name = path.replace(self._card_a_prefix, '')
elif self._card_b_prefix and path.startswith(self._card_b_prefix):
name = path.replace(self._card_b_prefix, '')
name = name.replace('\\', '/')
name = name.replace('//', '/')
if name.startswith('/'):
name = name[1:]
opts = self.settings()
collections = opts.extra_customization.split(',') if opts.extra_customization else []
booklist = booklists[blist]
if not hasattr(booklist, 'add_book'):
raise ValueError(('Incorrect upload location %s. Did you choose the'
' correct card A or B, to send books to?')%oncard)
booklist.add_book(info, name, collections, *location[1:-1])
fix_ids(*booklists)
def delete_books(self, paths, end_session=True):
for i, path in enumerate(paths):
self.report_progress((i+1) / float(len(paths)), _('Removing books from device...'))
if os.path.exists(path):
os.unlink(path)
try:
os.removedirs(os.path.dirname(path))
except:
pass
self.report_progress(1.0, _('Removing books from device...'))
@classmethod
def remove_books_from_metadata(cls, paths, booklists):
for path in paths:
for bl in booklists:
if hasattr(bl, 'remove_book'):
bl.remove_book(path)
fix_ids(*booklists)
def books(self, oncard=None, end_session=True):
bl = USBMS.books(self, oncard=oncard, end_session=end_session)
c = self.initialize_XML_cache()
c.update_booklist(bl, {'carda':1, 'cardb':2}.get(oncard, 0))
return bl
def sync_booklists(self, booklists, end_session=True):
fix_ids(*booklists)
if not os.path.exists(self._main_prefix):
os.makedirs(self._main_prefix)
with open(self._main_prefix + self.__class__.MEDIA_XML, 'wb') as f:
booklists[0].write(f)
c = self.initialize_XML_cache()
blists = {}
for i in c.paths:
if booklists[i] is not None:
blists[i] = booklists[i]
opts = self.settings()
collections = ['series', 'tags']
if opts.extra_customization:
collections = [x.strip() for x in
opts.extra_customization.split(',')]
def write_card_prefix(prefix, listid):
if prefix is not None and hasattr(booklists[listid], 'write'):
tgt = os.path.join(prefix, *(self.CACHE_XML.split('/')))
base = os.path.dirname(tgt)
if not os.path.exists(base):
os.makedirs(base)
with open(tgt, 'wb') as f:
booklists[listid].write(f)
write_card_prefix(self._card_a_prefix, 1)
write_card_prefix(self._card_b_prefix, 2)
c.update(blists, collections)
c.write()
self.report_progress(1.0, _('Sending metadata to device...'))
class PRS700(PRS505):
name = 'PRS-600/700/900 Device Interface'
description = _('Communicate with the Sony PRS-600/700/900 eBook reader.')
author = 'Kovid Goyal and John Schember'
gui_name = 'SONY Reader'
supported_platforms = ['windows', 'osx', 'linux']
BCD = [0x31a]
WINDOWS_MAIN_MEM = re.compile('PRS-((700[#/])|((6|9)00&))')
WINDOWS_CARD_A_MEM = re.compile(r'PRS-((700[/#]\S+:)|((6|9)00[#_]))MS')
WINDOWS_CARD_B_MEM = re.compile(r'PRS-((700[/#]\S+:)|((6|9)00[#_]))SD')
OSX_MAIN_MEM = re.compile(r'Sony PRS-((700/[^:]+)|((6|9)00)) Media')
OSX_CARD_A_MEM = re.compile(r'Sony PRS-((700/[^:]+:)|((6|9)00 ))MS Media')
OSX_CARD_B_MEM = re.compile(r'Sony PRS-((700/[^:]+:)|((6|9)00 ))SD Media')
USBMS.sync_booklists(self, booklists, end_session=end_session)

View File

@ -0,0 +1,523 @@
#!/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'
import os, time
from pprint import pprint
from base64 import b64decode
from uuid import uuid4
from lxml import etree
from calibre import prints, guess_type
from calibre.devices.errors import DeviceError
from calibre.constants import DEBUG
from calibre.ebooks.chardet import xml_to_unicode
from calibre.ebooks.metadata import authors_to_string, title_sort
# Utility functions {{{
EMPTY_CARD_CACHE = '''\
<?xml version="1.0" encoding="UTF-8"?>
<cache xmlns="http://www.kinoma.com/FskCache/1">
</cache>
'''
MIME_MAP = {
"lrf" : "application/x-sony-bbeb",
'lrx' : 'application/x-sony-bbeb',
"rtf" : "application/rtf",
"pdf" : "application/pdf",
"txt" : "text/plain" ,
'epub': 'application/epub+zip',
}
DAY_MAP = dict(Sun=0, Mon=1, Tue=2, Wed=3, Thu=4, Fri=5, Sat=6)
MONTH_MAP = dict(Jan=1, Feb=2, Mar=3, Apr=4, May=5, Jun=6, Jul=7, Aug=8, Sep=9, Oct=10, Nov=11, Dec=12)
INVERSE_DAY_MAP = dict(zip(DAY_MAP.values(), DAY_MAP.keys()))
INVERSE_MONTH_MAP = dict(zip(MONTH_MAP.values(), MONTH_MAP.keys()))
def strptime(src):
src = src.strip()
src = src.split()
src[0] = str(DAY_MAP[src[0][:-1]])+','
src[2] = str(MONTH_MAP[src[2]])
return time.strptime(' '.join(src), '%w, %d %m %Y %H:%M:%S %Z')
def strftime(epoch, zone=time.gmtime):
src = time.strftime("%w, %d %m %Y %H:%M:%S GMT", zone(epoch)).split()
src[0] = INVERSE_DAY_MAP[int(src[0][:-1])]+','
src[2] = INVERSE_MONTH_MAP[int(src[2])]
return ' '.join(src)
def uuid():
return str(uuid4()).replace('-', '', 1).upper()
# }}}
class XMLCache(object):
def __init__(self, paths, prefixes):
if DEBUG:
prints('Building XMLCache...')
pprint(paths)
self.paths = paths
self.prefixes = prefixes
# Parse XML files {{{
parser = etree.XMLParser(recover=True)
self.roots = {}
for source_id, path in paths.items():
if source_id == 0:
if not os.path.exists(path):
raise DeviceError('The SONY XML cache media.xml does not exist. Try'
' disconnecting and reconnecting your reader.')
with open(path, 'rb') as f:
raw = f.read()
else:
raw = EMPTY_CARD_CACHE
if os.access(path, os.R_OK):
with open(path, 'rb') as f:
raw = f.read()
self.roots[source_id] = etree.fromstring(xml_to_unicode(
raw, strip_encoding_pats=True, assume_utf8=True,
verbose=DEBUG)[0],
parser=parser)
# }}}
recs = self.roots[0].xpath('//*[local-name()="records"]')
if not recs:
raise DeviceError('The SONY XML database is corrupted (no'
' <records>). Try disconnecting an reconnecting'
' your reader.')
self.record_roots = {}
self.record_roots.update(self.roots)
self.record_roots[0] = recs[0]
self.detect_namespaces()
# Playlist management {{{
def purge_broken_playlist_items(self, root):
for pl in root.xpath('//*[local-name()="playlist"]'):
seen = set([])
for item in list(pl):
id_ = item.get('id', None)
if id_ is None or id_ in seen or not root.xpath(
'//*[local-name()!="item" and @id="%s"]'%id_):
if DEBUG:
if id_ is None:
cause = 'invalid id'
elif id_ in seen:
cause = 'duplicate item'
else:
cause = 'id not found'
prints('Purging broken playlist item:',
id_, 'from playlist:', pl.get('title', None),
'because:', cause)
item.getparent().remove(item)
continue
seen.add(id_)
def prune_empty_playlists(self):
for i, root in self.record_roots.items():
self.purge_broken_playlist_items(root)
for playlist in root.xpath('//*[local-name()="playlist"]'):
if len(playlist) == 0 or not playlist.get('title', None):
if DEBUG:
prints('Removing playlist id:', playlist.get('id', None),
playlist.get('title', None))
playlist.getparent().remove(playlist)
def ensure_unique_playlist_titles(self):
for i, root in self.record_roots.items():
seen = set([])
for playlist in root.xpath('//*[local-name()="playlist"]'):
title = playlist.get('title', None)
if title is None:
title = _('Unnamed')
playlist.set('title', title)
if title in seen:
for i in range(2, 1000):
if title+str(i) not in seen:
title = title+str(i)
playlist.set('title', title)
break
else:
seen.add(title)
def get_playlist_map(self):
ans = {}
self.ensure_unique_playlist_titles()
self.prune_empty_playlists()
for i, root in self.record_roots.items():
ans[i] = []
for playlist in root.xpath('//*[local-name()="playlist"]'):
items = []
for item in playlist:
id_ = item.get('id', None)
records = root.xpath(
'//*[local-name()="text" and @id="%s"]'%id_)
if records:
items.append(records[0])
ans[i].append((playlist.get('title'), items))
return ans
def get_or_create_playlist(self, bl_idx, title):
root = self.record_roots[bl_idx]
for playlist in root.xpath('//*[local-name()="playlist"]'):
if playlist.get('title', None) == title:
return playlist
if DEBUG:
prints('Creating playlist:', title)
ans = root.makeelement('{%s}playlist'%self.namespaces[bl_idx],
nsmap=root.nsmap, attrib={
'uuid' : uuid(),
'title': title,
'id' : str(self.max_id(root)+1),
'sourceid': '1'
})
root.append(ans)
return ans
# }}}
def fix_ids(self): # {{{
if DEBUG:
prints('Running fix_ids()')
def ensure_numeric_ids(root):
idmap = {}
for x in root.xpath('child::*[@id]'):
id_ = x.get('id')
try:
id_ = int(id_)
except:
x.set('id', '-1')
idmap[id_] = '-1'
if DEBUG and idmap:
prints('Found non numeric ids:')
prints(list(idmap.keys()))
return idmap
def remap_playlist_references(root, idmap):
for playlist in root.xpath('//*[local-name()="playlist"]'):
for item in playlist.xpath(
'descendant::*[@id and local-name()="item"]'):
id_ = item.get('id')
if id_ in idmap:
item.set('id', idmap[id_])
if DEBUG:
prints('Remapping id %s to %s'%(id_, idmap[id_]))
def ensure_media_xml_base_ids(root):
for num, tag in enumerate(('library', 'watchSpecial')):
for x in root.xpath('//*[local-name()="%s"]'%tag):
x.set('id', str(num))
def rebase_ids(root, base, sourceid, pl_sourceid):
'Rebase all ids and also make them consecutive'
for item in root.xpath('//*[@sourceid]'):
sid = pl_sourceid if item.tag.endswith('playlist') else sourceid
item.set('sourceid', str(sid))
# Only rebase ids of nodes that are immediate children of the
# record root (that way playlist/itemnodes are unaffected
items = root.xpath('child::*[@id]')
items.sort(cmp=lambda x,y:cmp(int(x.get('id')), int(y.get('id'))))
idmap = {}
for i, item in enumerate(items):
old = int(item.get('id'))
new = base + i
if old != new:
item.set('id', str(new))
idmap[str(old)] = str(new)
return idmap
self.prune_empty_playlists()
for i in sorted(self.roots.keys()):
root = self.record_roots[i]
if i == 0:
ensure_media_xml_base_ids(root)
idmap = ensure_numeric_ids(root)
remap_playlist_references(root, idmap)
if i == 0:
sourceid, playlist_sid = 1, 0
base = 0
else:
previous = i-1
if previous not in self.roots:
previous = 0
max_id = self.max_id(self.roots[previous])
sourceid = playlist_sid = max_id + 1
base = max_id + 2
idmap = rebase_ids(root, base, sourceid, playlist_sid)
remap_playlist_references(root, idmap)
last_bl = max(self.roots.keys())
max_id = self.max_id(self.roots[last_bl])
self.roots[0].set('nextID', str(max_id+1))
# }}}
# Update JSON from XML {{{
def update_booklist(self, bl, bl_index):
if bl_index not in self.record_roots:
return
if DEBUG:
prints('Updating JSON cache:', bl_index)
root = self.record_roots[bl_index]
pmap = self.get_playlist_map()[bl_index]
playlist_map = {}
for title, records in pmap:
for record in records:
path = record.get('path', None)
if path:
if path not in playlist_map:
playlist_map[path] = []
playlist_map[path].append(title)
for book in bl:
record = self.book_by_lpath(book.lpath, root)
if record is not None:
title = record.get('title', None)
if title is not None and title != book.title:
if DEBUG:
prints('Renaming title', book.title, 'to', title)
book.title = title
# We shouldn't do this for Sonys, because the reader strips
# all but the first author.
# authors = record.get('author', None)
# if authors is not None:
# authors = string_to_authors(authors)
# if authors != book.authors:
# if DEBUG:
# prints('Renaming authors', book.authors, 'to',
# authors)
# book.authors = authors
for thumbnail in record.xpath(
'descendant::*[local-name()="thumbnail"]'):
for img in thumbnail.xpath(
'descendant::*[local-name()="jpeg"]|'
'descendant::*[local-name()="png"]'):
if img.text:
raw = b64decode(img.text.strip())
book.thumbnail = raw
break
break
if book.lpath in playlist_map:
tags = playlist_map[book.lpath]
book.device_collections = tags
# }}}
# Update XML from JSON {{{
def update(self, booklists, collections_attributes):
playlist_map = self.get_playlist_map()
for i, booklist in booklists.items():
if DEBUG:
prints('Updating XML Cache:', i)
root = self.record_roots[i]
for book in booklist:
path = os.path.join(self.prefixes[i], *(book.lpath.split('/')))
record = self.book_by_lpath(book.lpath, root)
if record is None:
record = self.create_text_record(root, i, book.lpath)
self.update_text_record(record, book, path, i)
bl_pmap = playlist_map[i]
self.update_playlists(i, root, booklist, bl_pmap,
collections_attributes)
self.fix_ids()
# This is needed to update device_collections
for i, booklist in booklists.items():
self.update_booklist(booklist, i)
def update_playlists(self, bl_index, root, booklist, playlist_map,
collections_attributes):
collections = booklist.get_collections(collections_attributes)
for category, books in collections.items():
records = [self.book_by_lpath(b.lpath, root) for b in books]
# Remove any books that were not found, although this
# *should* never happen
if DEBUG and None in records:
prints('WARNING: Some elements in the JSON cache were not'
' found in the XML cache')
records = [x for x in records if x is not None]
for rec in records:
if rec.get('id', None) is None:
rec.set('id', str(self.max_id(root)+1))
ids = [x.get('id', None) for x in records]
if None in ids:
if DEBUG:
prints('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_ids = []
for item in playlist:
id_ = item.get('id', None)
if id_ is not None:
playlist_ids.append(id_)
for item in list(playlist):
playlist.remove(item)
extra_ids = [x for x in playlist_ids if x not in ids]
for id_ in ids + extra_ids:
item = playlist.makeelement(
'{%s}item'%self.namespaces[bl_index],
nsmap=playlist.nsmap, attrib={'id':id_})
playlist.append(item)
# Delete playlist entries not in collections
for playlist in root.xpath('//*[local-name()="playlist"]'):
title = playlist.get('title', None)
if title not in collections:
if DEBUG:
prints('Deleting playlist:', playlist.get('title', ''))
playlist.getparent().remove(playlist)
continue
books = collections[title]
records = [self.book_by_lpath(b.lpath, root) for b in books]
records = [x for x in records if x is not None]
ids = [x.get('id', None) for x in records]
ids = [x for x in ids if x is not None]
for item in list(playlist):
if item.get('id', None) not in ids:
if DEBUG:
prints('Deleting item:', item.get('id', ''),
'from playlist:', playlist.get('title', ''))
playlist.remove(item)
def create_text_record(self, root, bl_id, lpath):
namespace = self.namespaces[bl_id]
id_ = self.max_id(root)+1
attrib = {
'page':'0', 'part':'0','pageOffset':'0','scale':'0',
'id':str(id_), 'sourceid':'1', 'path':lpath}
ans = root.makeelement('{%s}text'%namespace, attrib=attrib, nsmap=root.nsmap)
root.append(ans)
return ans
def update_text_record(self, record, book, path, bl_index):
timestamp = os.path.getctime(path)
date = strftime(timestamp)
if date != record.get('date', None):
if DEBUG:
prints('Changing date of', path, 'from',
record.get('date', ''), 'to', date)
prints('\tctime', strftime(os.path.getctime(path)))
prints('\tmtime', strftime(os.path.getmtime(path)))
record.set('date', date)
record.set('size', str(os.stat(path).st_size))
record.set('title', book.title)
ts = book.title_sort
if not ts:
ts = title_sort(book.title)
record.set('titleSorter', ts)
record.set('author', authors_to_string(book.authors))
ext = os.path.splitext(path)[1]
if ext:
ext = ext[1:].lower()
mime = MIME_MAP.get(ext, None)
if mime is None:
mime = guess_type('a.'+ext)[0]
if mime is not None:
record.set('mime', mime)
if 'sourceid' not in record.attrib:
record.set('sourceid', '1')
if 'id' not in record.attrib:
num = self.max_id(record.getroottree().getroot())
record.set('id', str(num+1))
# }}}
# Writing the XML files {{{
def cleanup_whitespace(self, bl_index):
root = self.record_roots[bl_index]
level = 2 if bl_index == 0 else 1
if len(root) > 0:
root.text = '\n'+'\t'*level
for child in root:
child.tail = '\n'+'\t'*level
if len(child) > 0:
child.text = '\n'+'\t'*(level+1)
for gc in child:
gc.tail = '\n'+'\t'*(level+1)
child.iterchildren(reversed=True).next().tail = '\n'+'\t'*level
root.iterchildren(reversed=True).next().tail = '\n'+'\t'*(level-1)
def move_playlists_to_bottom(self):
for root in self.record_roots.values():
seen = []
for pl in root.xpath('//*[local-name()="playlist"]'):
pl.getparent().remove(pl)
seen.append(pl)
for pl in seen:
root.append(pl)
def write(self):
for i, path in self.paths.items():
self.move_playlists_to_bottom()
self.cleanup_whitespace(i)
raw = etree.tostring(self.roots[i], encoding='UTF-8',
xml_declaration=True)
raw = raw.replace("<?xml version='1.0' encoding='UTF-8'?>",
'<?xml version="1.0" encoding="UTF-8"?>')
with open(path, 'wb') as f:
f.write(raw)
# }}}
# Utility methods {{{
def book_by_lpath(self, lpath, root):
matches = root.xpath(u'//*[local-name()="text" and @path="%s"]'%lpath)
if matches:
return matches[0]
def max_id(self, root):
ans = -1
for x in root.xpath('//*[@id]'):
id_ = x.get('id')
try:
num = int(id_)
if num > ans:
ans = num
except:
continue
return ans
def detect_namespaces(self):
self.nsmaps = {}
for i, root in self.roots.items():
self.nsmaps[i] = root.nsmap
self.namespaces = {}
for i in self.roots:
for c in ('library', 'text', 'image', 'playlist', 'thumbnail',
'watchSpecial'):
matches = self.record_roots[i].xpath('//*[local-name()="%s"]'%c)
if matches:
e = matches[0]
self.namespaces[i] = e.nsmap[e.prefix]
break
if i not in self.namespaces:
ns = self.nsmaps[i].get(None, None)
for prefix in self.nsmaps[i]:
if prefix is not None:
ns = self.nsmaps[i][prefix]
break
self.namespaces[i] = ns
if DEBUG:
prints('Found nsmaps:')
pprint(self.nsmaps)
prints('Found namespaces:')
pprint(self.namespaces)
# }}}

View File

@ -4,29 +4,59 @@ __license__ = 'GPL 3'
__copyright__ = '2009, John Schember <john@nachtimwald.com>'
__docformat__ = 'restructuredtext en'
import os
import re
import time
import os, re, time, sys
from calibre.ebooks.metadata import MetaInformation
from calibre.devices.mime import mime_type_ext
from calibre.devices.interface import BookList as _BookList
from calibre.constants import filesystem_encoding, preferred_encoding
from calibre import isbytestring
class Book(object):
class Book(MetaInformation):
def __init__(self, path, title, authors, mime):
self.title = title
self.authors = authors
self.mime = mime
self.size = os.path.getsize(path)
BOOK_ATTRS = ['lpath', 'size', 'mime', 'device_collections']
JSON_ATTRS = [
'lpath', 'title', 'authors', 'mime', 'size', 'tags', 'author_sort',
'title_sort', 'comments', 'category', 'publisher', 'series',
'series_index', 'rating', 'isbn', 'language', 'application_id',
'book_producer', 'lccn', 'lcc', 'ddc', 'rights', 'publication_type',
'uuid',
]
def __init__(self, prefix, lpath, size=None, other=None):
from calibre.ebooks.metadata.meta import path_to_ext
MetaInformation.__init__(self, '')
self.device_collections = []
self.path = os.path.join(prefix, lpath)
if os.sep == '\\':
self.path = self.path.replace('/', '\\')
self.lpath = lpath.replace('\\', '/')
else:
self.lpath = lpath
self.mime = mime_type_ext(path_to_ext(lpath))
self.size = size # will be set later if None
try:
self.datetime = time.gmtime(os.path.getctime(path))
except ValueError:
self.datetime = time.gmtime(os.path.getctime(self.path))
except:
self.datetime = time.gmtime()
self.path = path
self.thumbnail = None
self.tags = []
if other:
self.smart_update(other)
def __eq__(self, other):
return self.path == other.path
return self.path == getattr(other, 'path', None)
@dynamic_property
def db_id(self):
doc = '''The database id in the application database that this file corresponds to'''
def fget(self):
match = re.search(r'_(\d+)$', self.lpath.rpartition('.')[0])
if match:
return int(match.group(1))
return None
return property(fget=fget, doc=doc)
@dynamic_property
def title_sorter(self):
@ -39,18 +69,90 @@ class Book(object):
def thumbnail(self):
return None
def __str__(self):
def smart_update(self, other):
'''
Return a utf-8 encoded string with title author and path information
Merge the information in C{other} into self. In case of conflicts, the information
in C{other} takes precedence, unless the information in C{other} is NULL.
'''
return self.title.encode('utf-8') + " by " + \
self.authors.encode('utf-8') + " at " + self.path.encode('utf-8')
MetaInformation.smart_update(self, other)
for attr in self.BOOK_ATTRS:
if hasattr(other, attr):
val = getattr(other, attr, None)
setattr(self, attr, val)
def to_json(self):
json = {}
for attr in self.JSON_ATTRS:
val = getattr(self, attr)
if isbytestring(val):
enc = filesystem_encoding if attr == 'lpath' else preferred_encoding
val = val.decode(enc, 'replace')
elif isinstance(val, (list, tuple)):
val = [x.decode(preferred_encoding, 'replace') if
isbytestring(x) else x for x in val]
json[attr] = val
return json
class BookList(_BookList):
def supports_tags(self):
def supports_collections(self):
return False
def set_tags(self, book, tags):
pass
def add_book(self, book, replace_metadata):
if book not in self:
self.append(book)
return True
return False
def remove_book(self, book):
self.remove(book)
def get_collections(self):
return {}
class CollectionsBookList(BookList):
def supports_collections(self):
return True
def get_collections(self, collection_attributes):
collections = {}
series_categories = set([])
collection_attributes = list(collection_attributes)+['device_collections']
for attr in collection_attributes:
attr = attr.strip()
for book in self:
val = getattr(book, attr, None)
if not val: continue
if isbytestring(val):
val = val.decode(preferred_encoding, 'replace')
if isinstance(val, (list, tuple)):
val = list(val)
elif isinstance(val, unicode):
val = [val]
for category in val:
if attr == 'tags' and len(category) > 1 and \
category[0] == '[' and category[-1] == ']':
continue
if category not in collections:
collections[category] = []
if book not in collections[category]:
collections[category].append(book)
if attr == 'series':
series_categories.add(category)
# Sort collections
for category, books in collections.items():
def tgetter(x):
return getattr(x, 'title_sort', 'zzzz')
books.sort(cmp=lambda x,y:cmp(tgetter(x), tgetter(y)))
if category in series_categories:
# Ensures books are sub sorted by title
def getter(x):
return getattr(x, 'series_index', sys.maxint)
books.sort(cmp=lambda x,y:cmp(getter(x), getter(y)))
return collections

View File

@ -109,15 +109,16 @@ class Device(DeviceConfig, DevicePlugin):
def _windows_space(cls, prefix):
if not prefix:
return 0, 0
prefix = prefix[:-1]
win32file = __import__('win32file', globals(), locals(), [], -1)
try:
sectors_per_cluster, bytes_per_sector, free_clusters, total_clusters = \
win32file.GetDiskFreeSpace(prefix[:-1])
win32file.GetDiskFreeSpace(prefix)
except Exception, err:
if getattr(err, 'args', [None])[0] == 21: # Disk not ready
time.sleep(3)
sectors_per_cluster, bytes_per_sector, free_clusters, total_clusters = \
win32file.GetDiskFreeSpace(prefix[:-1])
win32file.GetDiskFreeSpace(prefix)
else: raise
mult = sectors_per_cluster * bytes_per_sector
return total_clusters * mult, free_clusters * mult
@ -827,7 +828,7 @@ class Device(DeviceConfig, DevicePlugin):
if not isinstance(template, unicode):
template = template.decode('utf-8')
app_id = str(getattr(mdata, 'application_id', ''))
# The SONY readers need to have the db id in the created filename
# The db id will be in the created filename
extra_components = get_components(template, mdata, fname,
length=250-len(app_id)-1)
if not extra_components:
@ -836,6 +837,9 @@ class Device(DeviceConfig, DevicePlugin):
else:
extra_components[-1] = sanitize(self.filename_callback(extra_components[-1]+ext, mdata))
if extra_components[-1] and extra_components[-1][0] in ('.', '_'):
extra_components[-1] = 'x' + extra_components[-1][1:]
if special_tag is not None:
name = extra_components[-1]
extra_components = []

View File

@ -11,15 +11,15 @@ for a particular device.
'''
import os
import fnmatch
import re
import json
from itertools import cycle
from calibre.ebooks.metadata import authors_to_string
from calibre import prints, isbytestring
from calibre.constants import filesystem_encoding
from calibre.devices.usbms.cli import CLI
from calibre.devices.usbms.device import Device
from calibre.devices.usbms.books import BookList, Book
from calibre.devices.mime import mime_type_ext
# CLI must come before Device as it implements the CLI functions that
# are inherited from the device interface in Device.
@ -29,8 +29,16 @@ class USBMS(CLI, Device):
author = _('John Schember')
supported_platforms = ['windows', 'osx', 'linux']
# Store type instances of BookList and Book. We must do this because
# a) we need to override these classes in some device drivers, and
# b) the classmethods seem only to see real attributes declared in the
# class, not attributes stored in the class
booklist_class = BookList
book_class = Book
FORMATS = []
CAN_SET_METADATA = False
METADATA_CACHE = 'metadata.calibre'
def get_device_information(self, end_session=True):
self.report_progress(1.0, _('Get device information...'))
@ -38,61 +46,111 @@ class USBMS(CLI, Device):
def books(self, oncard=None, end_session=True):
from calibre.ebooks.metadata.meta import path_to_ext
bl = BookList()
dummy_bl = BookList(None, None, None)
if oncard == 'carda' and not self._card_a_prefix:
self.report_progress(1.0, _('Getting list of books on device...'))
return bl
return dummy_bl
elif oncard == 'cardb' and not self._card_b_prefix:
self.report_progress(1.0, _('Getting list of books on device...'))
return bl
return dummy_bl
elif oncard and oncard != 'carda' and oncard != 'cardb':
self.report_progress(1.0, _('Getting list of books on device...'))
return bl
return dummy_bl
prefix = self._card_a_prefix if oncard == 'carda' else \
self._card_b_prefix if oncard == 'cardb' \
else self._main_prefix
prefix = self._card_a_prefix if oncard == 'carda' else self._card_b_prefix if oncard == 'cardb' else self._main_prefix
ebook_dirs = self.EBOOK_DIR_CARD_A if oncard == 'carda' else \
self.EBOOK_DIR_CARD_B if oncard == 'cardb' else \
self.get_main_ebook_dir()
# get the metadata cache
bl = self.booklist_class(oncard, prefix, self.settings)
need_sync = self.parse_metadata_cache(bl, prefix, self.METADATA_CACHE)
# make a dict cache of paths so the lookup in the loop below is faster.
bl_cache = {}
for idx,b in enumerate(bl):
bl_cache[b.lpath] = idx
def update_booklist(filename, path, prefix):
changed = False
if path_to_ext(filename) in self.FORMATS:
try:
lpath = os.path.join(path, filename).partition(self.normalize_path(prefix))[2]
if lpath.startswith(os.sep):
lpath = lpath[len(os.sep):]
lpath = lpath.replace('\\', '/')
idx = bl_cache.get(lpath, None)
if idx is not None:
bl_cache[lpath] = None
if self.update_metadata_item(bl[idx]):
#print 'update_metadata_item returned true'
changed = True
else:
if bl.add_book(self.book_from_path(prefix, lpath),
replace_metadata=False):
changed = True
except: # Probably a filename encoding error
import traceback
traceback.print_exc()
return changed
if isinstance(ebook_dirs, basestring):
ebook_dirs = [ebook_dirs]
for ebook_dir in ebook_dirs:
ebook_dir = os.path.join(prefix, *(ebook_dir.split('/'))) if ebook_dir else prefix
ebook_dir = self.path_to_unicode(ebook_dir)
ebook_dir = self.normalize_path( \
os.path.join(prefix, *(ebook_dir.split('/'))) \
if ebook_dir else prefix)
if not os.path.exists(ebook_dir): continue
# Get all books in the ebook_dir directory
if self.SUPPORTS_SUB_DIRS:
# build a list of files to check, so we can accurately report progress
flist = []
for path, dirs, files in os.walk(ebook_dir):
# Filter out anything that isn't in the list of supported ebook types
for book_type in self.FORMATS:
match = fnmatch.filter(files, '*.%s' % (book_type))
for i, filename in enumerate(match):
self.report_progress((i+1) / float(len(match)), _('Getting list of books on device...'))
try:
bl.append(self.__class__.book_from_path(os.path.join(path, filename)))
except: # Probably a filename encoding error
import traceback
traceback.print_exc()
continue
for filename in files:
if filename != self.METADATA_CACHE:
flist.append({'filename': self.path_to_unicode(filename),
'path':self.path_to_unicode(path)})
for i, f in enumerate(flist):
self.report_progress(i/float(len(flist)), _('Getting list of books on device...'))
changed = update_booklist(f['filename'], f['path'], prefix)
if changed:
need_sync = True
else:
paths = os.listdir(ebook_dir)
for i, filename in enumerate(paths):
self.report_progress((i+1) / float(len(paths)), _('Getting list of books on device...'))
if path_to_ext(filename) in self.FORMATS:
try:
bl.append(self.__class__.book_from_path(os.path.join(ebook_dir, filename)))
except: # Probably a file name encoding error
import traceback
traceback.print_exc()
continue
changed = update_booklist(self.path_to_unicode(filename), ebook_dir, prefix)
if changed:
need_sync = True
# Remove books that are no longer in the filesystem. Cache contains
# indices into the booklist if book not in filesystem, None otherwise
# Do the operation in reverse order so indices remain valid
for idx in sorted(bl_cache.itervalues(), reverse=True):
if idx is not None:
need_sync = True
del bl[idx]
#print "count found in cache: %d, count of files in metadata: %d, need_sync: %s" % \
# (len(bl_cache), len(bl), need_sync)
if need_sync: #self.count_found_in_bl != len(bl) or need_sync:
if oncard == 'cardb':
self.sync_booklists((None, None, bl))
elif oncard == 'carda':
self.sync_booklists((None, bl, None))
else:
self.sync_booklists((bl, None, None))
self.report_progress(1.0, _('Getting list of books on device...'))
return bl
def upload_books(self, files, names, on_card=None, end_session=True,
metadata=None):
path = self._sanity_check(on_card, files)
paths = []
@ -101,13 +159,14 @@ class USBMS(CLI, Device):
for i, infile in enumerate(files):
mdata, fname = metadata.next(), names.next()
filepath = self.create_upload_path(path, mdata, fname)
filepath = self.normalize_path(self.create_upload_path(path, mdata, fname))
paths.append(filepath)
if not hasattr(infile, 'read'):
infile = self.normalize_path(infile)
self.put_file(infile, filepath, replace_file=True)
try:
self.upload_cover(os.path.dirname(filepath), os.path.splitext(os.path.basename(filepath))[0], mdata)
self.upload_cover(os.path.dirname(filepath),
os.path.splitext(os.path.basename(filepath))[0], mdata)
except: # Failure to upload cover is not catastrophic
import traceback
traceback.print_exc()
@ -115,7 +174,6 @@ class USBMS(CLI, Device):
self.report_progress((i+1) / float(len(files)), _('Transferring books to device...'))
self.report_progress(1.0, _('Transferring books to device...'))
return zip(paths, cycle([on_card]))
def upload_cover(self, path, filename, metadata):
@ -128,20 +186,43 @@ class USBMS(CLI, Device):
pass
def add_books_to_metadata(self, locations, metadata, booklists):
metadata = iter(metadata)
for i, location in enumerate(locations):
self.report_progress((i+1) / float(len(locations)), _('Adding books to device metadata listing...'))
path = location[0]
info = metadata.next()
blist = 2 if location[1] == 'cardb' else 1 if location[1] == 'carda' else 0
book = self.book_from_path(path)
if not book in booklists[blist]:
booklists[blist].append(book)
# Extract the correct prefix from the pathname. To do this correctly,
# we must ensure that both the prefix and the path are normalized
# so that the comparison will work. Book's __init__ will fix up
# lpath, so we don't need to worry about that here.
path = self.normalize_path(location[0])
if self._main_prefix:
prefix = self._main_prefix if \
path.startswith(self.normalize_path(self._main_prefix)) else None
if not prefix and self._card_a_prefix:
prefix = self._card_a_prefix if \
path.startswith(self.normalize_path(self._card_a_prefix)) else None
if not prefix and self._card_b_prefix:
prefix = self._card_b_prefix if \
path.startswith(self.normalize_path(self._card_b_prefix)) else None
if prefix is None:
prints('in add_books_to_metadata. Prefix is None!', path,
self._main_prefix)
continue
lpath = path.partition(prefix)[2]
if lpath.startswith('/') or lpath.startswith('\\'):
lpath = lpath[1:]
book = self.book_class(prefix, lpath, other=info)
if book.size is None:
book.size = os.stat(self.normalize_path(path)).st_size
booklists[blist].add_book(book, replace_metadata=True)
self.report_progress(1.0, _('Adding books to device metadata listing...'))
def delete_books(self, paths, end_session=True):
for i, path in enumerate(paths):
self.report_progress((i+1) / float(len(paths)), _('Removing books from device...'))
path = self.normalize_path(path)
if os.path.exists(path):
# Delete the ebook
os.unlink(path)
@ -166,15 +247,79 @@ class USBMS(CLI, Device):
for bl in booklists:
for book in bl:
if path.endswith(book.path):
bl.remove(book)
bl.remove_book(book)
self.report_progress(1.0, _('Removing books from device metadata listing...'))
def sync_booklists(self, booklists, end_session=True):
# There is no meta data on the device to update. The device is treated
# as a mass storage device and does not use a meta data xml file like
# the Sony Readers.
if not os.path.exists(self.normalize_path(self._main_prefix)):
os.makedirs(self.normalize_path(self._main_prefix))
def write_prefix(prefix, listid):
if prefix is not None and isinstance(booklists[listid], self.booklist_class):
if not os.path.exists(prefix):
os.makedirs(self.normalize_path(prefix))
js = [item.to_json() for item in booklists[listid] if
hasattr(item, 'to_json')]
with open(self.normalize_path(os.path.join(prefix, self.METADATA_CACHE)), 'wb') as f:
json.dump(js, f, indent=2, encoding='utf-8')
write_prefix(self._main_prefix, 0)
write_prefix(self._card_a_prefix, 1)
write_prefix(self._card_b_prefix, 2)
self.report_progress(1.0, _('Sending metadata to device...'))
@classmethod
def path_to_unicode(cls, path):
if isbytestring(path):
path = path.decode(filesystem_encoding)
return path
@classmethod
def normalize_path(cls, path):
'Return path with platform native path separators'
if path is None:
return None
if os.sep == '\\':
path = path.replace('/', '\\')
else:
path = path.replace('\\', '/')
return cls.path_to_unicode(path)
@classmethod
def parse_metadata_cache(cls, bl, prefix, name):
# bl = cls.booklist_class()
js = []
need_sync = False
cache_file = cls.normalize_path(os.path.join(prefix, name))
if os.access(cache_file, os.R_OK):
try:
with open(cache_file, 'rb') as f:
js = json.load(f, encoding='utf-8')
for item in js:
book = cls.book_class(prefix, item.get('lpath', None))
for key in item.keys():
setattr(book, key, item[key])
bl.append(book)
except:
import traceback
traceback.print_exc()
bl = []
need_sync = True
else:
need_sync = True
return need_sync
@classmethod
def update_metadata_item(cls, book):
changed = False
size = os.stat(cls.normalize_path(book.path)).st_size
if size != book.size:
changed = True
mi = cls.metadata_from_path(book.path)
book.smart_update(mi)
book.size = size
return changed
@classmethod
def metadata_from_path(cls, path):
return cls.metadata_from_formats([path])
@ -187,23 +332,19 @@ class USBMS(CLI, Device):
return metadata_from_formats(fmts)
@classmethod
def book_from_path(cls, path):
from calibre.ebooks.metadata.meta import path_to_ext
def book_from_path(cls, prefix, path):
from calibre.ebooks.metadata import MetaInformation
mime = mime_type_ext(path_to_ext(path))
if cls.settings().read_metadata or cls.MUST_READ_METADATA:
mi = cls.metadata_from_path(path)
mi = cls.metadata_from_path(cls.normalize_path(os.path.join(prefix, path)))
else:
from calibre.ebooks.metadata.meta import metadata_from_filename
mi = metadata_from_filename(os.path.basename(path),
mi = metadata_from_filename(cls.normalize_path(os.path.basename(path)),
re.compile(r'^(?P<title>[ \S]+?)[ _]-[ _](?P<author>[ \S]+?)_+\d+'))
if mi is None:
mi = MetaInformation(os.path.splitext(os.path.basename(path))[0],
[_('Unknown')])
authors = authors_to_string(mi.authors)
book = Book(path, mi.title, authors, mime)
size = os.stat(cls.normalize_path(os.path.join(prefix, path))).st_size
book = cls.book_class(prefix, path, other=mi, size=size)
return book

View File

@ -10,7 +10,7 @@ import os, mimetypes, sys, re
from urllib import unquote, quote
from urlparse import urlparse
from calibre import relpath
from calibre import relpath, prints
from calibre.utils.config import tweaks
from calibre.utils.date import isoformat
@ -253,6 +253,15 @@ class MetaInformation(object):
):
setattr(self, x, getattr(mi, x, None))
def print_all_attributes(self):
for x in ('author', 'author_sort', 'title_sort', 'comments', 'category', 'publisher',
'series', 'series_index', 'tags', 'rating', 'isbn', 'language',
'application_id', 'manifest', 'toc', 'spine', 'guide', 'cover',
'book_producer', 'timestamp', 'lccn', 'lcc', 'ddc', 'pubdate',
'rights', 'publication_type', 'uuid'
):
prints(x, getattr(self, x, 'None'))
def smart_update(self, mi):
'''
Merge the information in C{mi} into self. In case of conflicts, the information
@ -269,7 +278,7 @@ class MetaInformation(object):
'isbn', 'application_id', 'manifest', 'spine', 'toc',
'cover', 'language', 'guide', 'book_producer',
'timestamp', 'lccn', 'lcc', 'ddc', 'pubdate', 'rights',
'publication_type', 'uuid',):
'publication_type', 'uuid'):
if hasattr(mi, attr):
val = getattr(mi, attr)
if val is not None:

View File

@ -0,0 +1,99 @@
#!/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'
'''
All fields must have a NULL value represented as None for simple types,
an empty list/dictionary for complex types and (None, None) for cover_data
'''
SOCIAL_METADATA_FIELDS = frozenset([
'tags', # Ordered list
# A floating point number between 0 and 10
'rating',
# A simple HTML enabled string
'comments',
# A simple string
'series',
# A floating point number
'series_index',
# Of the form { scheme1:value1, scheme2:value2}
# For example: {'isbn':'123456789', 'doi':'xxxx', ... }
'classifiers',
'isbn', # Pseudo field for convenience, should get/set isbn classifier
])
PUBLICATION_METADATA_FIELDS = frozenset([
# title must never be None. Should be _('Unknown')
'title',
# Pseudo field that can be set, but if not set is auto generated
# from title and languages
'title_sort',
# Ordered list of authors. Must never be None, can be [_('Unknown')]
'authors',
# Pseudo field that can be set, but if not set is auto generated
# from authors and languages
'author_sort',
'book_producer',
# Dates and times must be timezone aware
'timestamp',
'pubdate',
'rights',
# So far only known publication type is periodical:calibre
# If None, means book
'publication_type',
# A UUID usually of type 4
'uuid',
'languages', # ordered list
# Simple string, no special semantics
'publisher',
# Absolute path to image file encoded in filesystem_encoding
'cover',
# Of the form (format, data) where format is, for e.g. 'jpeg', 'png', 'gif'...
'cover_data',
# Either thumbnail data, or an object with the attribute
# image_path which is the path to an image file, encoded
# in filesystem_encoding
'thumbnail',
])
BOOK_STRUCTURE_FIELDS = frozenset([
# These are used by code, Null values are None.
'toc', 'spine', 'guide', 'manifest',
])
USER_METADATA_FIELDS = frozenset([
# A dict of a form to be specified
'user_metadata',
])
DEVICE_METADATA_FIELDS = frozenset([
# Ordered list of strings
'device_collections',
'lpath', # Unicode, / separated
# In bytes
'size',
# Mimetype of the book file being represented
'mime',
])
CALIBRE_METADATA_FIELDS = frozenset([
# An application id
# Semantics to be defined. Is it a db key? a db name + key? A uuid?
'application_id',
]
)
SERIALIZABLE_FIELDS = SOCIAL_METADATA_FIELDS.union(
USER_METADATA_FIELDS).union(
PUBLICATION_METADATA_FIELDS).union(
CALIBRE_METADATA_FIELDS).union(
frozenset(['lpath'])) # I don't think we need device_collections
# Serialization of covers/thumbnails will have to be handled carefully, maybe
# as an option to the serializer class

View File

@ -0,0 +1,120 @@
#!/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'
import copy
from calibre.ebooks.metadata.book import RESERVED_METADATA_FIELDS
NULL_VALUES = {
'user_metadata': {},
'cover_data' : (None, None),
'tags' : [],
'classifiers' : {},
'languages' : [],
'device_collections': [],
'authors' : [_('Unknown')],
'title' : _('Unknown'),
}
class Metadata(object):
'''
This class must expose a superset of the API of MetaInformation in terms
of attribute access and methods. Only the __init__ method is different.
MetaInformation will simply become a function that creates and fills in
the attributes of this class.
Please keep the method based API of this class to a minimum. Every method
becomes a reserved field name.
'''
def __init__(self):
object.__setattr__(self, '_data', copy.deepcopy(NULL_VALUES))
def __getattribute__(self, field):
_data = object.__getattribute__(self, '_data')
if field in RESERVED_METADATA_FIELDS:
return _data.get(field, None)
try:
return object.__getattribute__(self, field)
except AttributeError:
pass
if field in _data['user_metadata'].iterkeys():
# TODO: getting user metadata values
pass
raise AttributeError(
'Metadata object has no attribute named: '+ repr(field))
def __setattr__(self, field, val):
_data = object.__getattribute__(self, '_data')
if field in RESERVED_METADATA_FIELDS:
if field != 'user_metadata':
if not val:
val = NULL_VALUES[field]
_data[field] = val
else:
raise AttributeError('You cannot set user_metadata directly.')
elif field in _data['user_metadata'].iterkeys():
# TODO: Setting custom column values
pass
else:
# You are allowed to stick arbitrary attributes onto this object as
# long as they dont conflict with global or user metadata names
# Don't abuse this privilege
self.__dict__[field] = val
@property
def user_metadata_names(self):
'The set of user metadata names this object knows about'
_data = object.__getattribute__(self, '_data')
return frozenset(_data['user_metadata'].iterkeys())
# Old MetaInformation API {{{
def copy(self):
pass
def print_all_attributes(self):
pass
def smart_update(self, other):
pass
def format_series_index(self):
pass
def authors_from_string(self, raw):
pass
def format_authors(self):
pass
def format_tags(self):
pass
def format_rating(self):
return unicode(self.rating)
def __unicode__(self):
pass
def to_html(self):
pass
def __str__(self):
return self.__unicode__().encode('utf-8')
def __nonzero__(self):
return True
# }}}
# We don't need reserved field names for this object any more. Lets just use a
# protocol like the last char of a user field label should be _ when using this
# object
# So mi.tags returns the builtin tags and mi.tags_ returns the user tags

View File

@ -9,7 +9,6 @@ from threading import Thread
from calibre import prints
from calibre.utils.config import OptionParser
from calibre.utils.logging import default_log
from calibre.ebooks.metadata import MetaInformation
from calibre.customize import Plugin
metadata_config = None
@ -53,7 +52,7 @@ class MetadataSource(Plugin):
if self.results:
c = self.config_store().get(self.name, {})
res = self.results
if isinstance(res, MetaInformation):
if hasattr(res, 'authors'):
res = [res]
for mi in res:
if not c.get('rating', True):

View File

@ -5,9 +5,9 @@ __copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
import os, re, collections
from calibre.utils.config import prefs
from calibre.constants import filesystem_encoding
from calibre.ebooks.metadata.opf2 import OPF
from calibre import isbytestring
from calibre.customize.ui import get_file_type_metadata, set_file_type_metadata
from calibre.ebooks.metadata import MetaInformation, string_to_authors
@ -131,6 +131,8 @@ def set_metadata(stream, mi, stream_type='lrf'):
def metadata_from_filename(name, pat=None):
if isbytestring(name):
name = name.decode(filesystem_encoding, 'replace')
name = name.rpartition('.')[0]
mi = MetaInformation(None, None)
if pat is None:

0
src/calibre/ebooks/metadata/odt.py Executable file → Normal file
View File

View File

@ -1,538 +0,0 @@
__license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
'''Read/Write metadata from Open Packaging Format (.opf) files.'''
import re, os
import uuid
from urllib import unquote, quote
from calibre.constants import __appname__, __version__
from calibre.ebooks.metadata import MetaInformation, string_to_authors
from calibre.ebooks.BeautifulSoup import BeautifulStoneSoup, BeautifulSoup
from calibre.ebooks.lrf import entity_to_unicode
from calibre.ebooks.metadata import Resource, ResourceCollection
from calibre.ebooks.metadata.toc import TOC
class OPFSoup(BeautifulStoneSoup):
def __init__(self, raw):
BeautifulStoneSoup.__init__(self, raw,
convertEntities=BeautifulSoup.HTML_ENTITIES,
selfClosingTags=['item', 'itemref', 'reference'])
class ManifestItem(Resource):
@staticmethod
def from_opf_manifest_item(item, basedir):
if item.has_key('href'):
href = item['href']
if unquote(href) == href:
try:
href = quote(href)
except KeyError:
pass
res = ManifestItem(href, basedir=basedir, is_path=False)
mt = item.get('media-type', '').strip()
if mt:
res.mime_type = mt
return res
@dynamic_property
def media_type(self):
def fget(self):
return self.mime_type
def fset(self, val):
self.mime_type = val
return property(fget=fget, fset=fset)
def __unicode__(self):
return u'<item id="%s" href="%s" media-type="%s" />'%(self.id, self.href(), self.media_type)
def __str__(self):
return unicode(self).encode('utf-8')
def __repr__(self):
return unicode(self)
def __getitem__(self, index):
if index == 0:
return self.href()
if index == 1:
return self.media_type
raise IndexError('%d out of bounds.'%index)
class Manifest(ResourceCollection):
@staticmethod
def from_opf_manifest_element(manifest, dir):
m = Manifest()
for item in manifest.findAll(re.compile('item')):
try:
m.append(ManifestItem.from_opf_manifest_item(item, dir))
id = item.get('id', '')
if not id:
id = 'id%d'%m.next_id
m[-1].id = id
m.next_id += 1
except ValueError:
continue
return m
@staticmethod
def from_paths(entries):
'''
`entries`: List of (path, mime-type) If mime-type is None it is autodetected
'''
m = Manifest()
for path, mt in entries:
mi = ManifestItem(path, is_path=True)
if mt:
mi.mime_type = mt
mi.id = 'id%d'%m.next_id
m.next_id += 1
m.append(mi)
return m
def __init__(self):
ResourceCollection.__init__(self)
self.next_id = 1
def item(self, id):
for i in self:
if i.id == id:
return i
def id_for_path(self, path):
path = os.path.normpath(os.path.abspath(path))
for i in self:
if i.path and os.path.normpath(i.path) == path:
return i.id
def path_for_id(self, id):
for i in self:
if i.id == id:
return i.path
class Spine(ResourceCollection):
class Item(Resource):
def __init__(self, idfunc, *args, **kwargs):
Resource.__init__(self, *args, **kwargs)
self.is_linear = True
self.id = idfunc(self.path)
@staticmethod
def from_opf_spine_element(spine, manifest):
s = Spine(manifest)
for itemref in spine.findAll(re.compile('itemref')):
if itemref.has_key('idref'):
r = Spine.Item(s.manifest.id_for_path,
s.manifest.path_for_id(itemref['idref']), is_path=True)
r.is_linear = itemref.get('linear', 'yes') == 'yes'
s.append(r)
return s
@staticmethod
def from_paths(paths, manifest):
s = Spine(manifest)
for path in paths:
try:
s.append(Spine.Item(s.manifest.id_for_path, path, is_path=True))
except:
continue
return s
def __init__(self, manifest):
ResourceCollection.__init__(self)
self.manifest = manifest
def linear_items(self):
for r in self:
if r.is_linear:
yield r.path
def nonlinear_items(self):
for r in self:
if not r.is_linear:
yield r.path
def items(self):
for i in self:
yield i.path
class Guide(ResourceCollection):
class Reference(Resource):
@staticmethod
def from_opf_resource_item(ref, basedir):
title, href, type = ref.get('title', ''), ref['href'], ref['type']
res = Guide.Reference(href, basedir, is_path=False)
res.title = title
res.type = type
return res
def __repr__(self):
ans = '<reference type="%s" href="%s" '%(self.type, self.href())
if self.title:
ans += 'title="%s" '%self.title
return ans + '/>'
@staticmethod
def from_opf_guide(guide_elem, base_dir=os.getcwdu()):
coll = Guide()
for ref in guide_elem.findAll('reference'):
try:
ref = Guide.Reference.from_opf_resource_item(ref, base_dir)
coll.append(ref)
except:
continue
return coll
def set_cover(self, path):
map(self.remove, [i for i in self if 'cover' in i.type.lower()])
for type in ('cover', 'other.ms-coverimage-standard', 'other.ms-coverimage'):
self.append(Guide.Reference(path, is_path=True))
self[-1].type = type
self[-1].title = ''
class standard_field(object):
def __init__(self, name):
self.name = name
def __get__(self, obj, typ=None):
return getattr(obj, 'get_'+self.name)()
class OPF(MetaInformation):
MIMETYPE = 'application/oebps-package+xml'
ENTITY_PATTERN = re.compile(r'&(\S+?);')
uid = standard_field('uid')
application_id = standard_field('application_id')
title = standard_field('title')
authors = standard_field('authors')
language = standard_field('language')
title_sort = standard_field('title_sort')
author_sort = standard_field('author_sort')
comments = standard_field('comments')
category = standard_field('category')
publisher = standard_field('publisher')
isbn = standard_field('isbn')
cover = standard_field('cover')
series = standard_field('series')
series_index = standard_field('series_index')
rating = standard_field('rating')
tags = standard_field('tags')
def __init__(self):
raise NotImplementedError('Abstract base class')
@dynamic_property
def package(self):
def fget(self):
return self.soup.find(re.compile('package'))
return property(fget=fget)
@dynamic_property
def metadata(self):
def fget(self):
return self.package.find(re.compile('metadata'))
return property(fget=fget)
def get_title(self):
title = self.metadata.find('dc:title')
if title and title.string:
return self.ENTITY_PATTERN.sub(entity_to_unicode, title.string).strip()
return self.default_title.strip()
def get_authors(self):
creators = self.metadata.findAll('dc:creator')
for elem in creators:
role = elem.get('role')
if not role:
role = elem.get('opf:role')
if not role:
role = 'aut'
if role == 'aut' and elem.string:
raw = self.ENTITY_PATTERN.sub(entity_to_unicode, elem.string)
return string_to_authors(raw)
return []
def get_author_sort(self):
creators = self.metadata.findAll('dc:creator')
for elem in creators:
role = elem.get('role')
if not role:
role = elem.get('opf:role')
if role == 'aut':
fa = elem.get('file-as')
return self.ENTITY_PATTERN.sub(entity_to_unicode, fa).strip() if fa else None
return None
def get_title_sort(self):
title = self.package.find('dc:title')
if title:
if title.has_key('file-as'):
return title['file-as'].strip()
return None
def get_comments(self):
comments = self.soup.find('dc:description')
if comments and comments.string:
return self.ENTITY_PATTERN.sub(entity_to_unicode, comments.string).strip()
return None
def get_uid(self):
package = self.package
if package.has_key('unique-identifier'):
return package['unique-identifier']
def get_category(self):
category = self.soup.find('dc:type')
if category and category.string:
return self.ENTITY_PATTERN.sub(entity_to_unicode, category.string).strip()
return None
def get_publisher(self):
publisher = self.soup.find('dc:publisher')
if publisher and publisher.string:
return self.ENTITY_PATTERN.sub(entity_to_unicode, publisher.string).strip()
return None
def get_isbn(self):
for item in self.metadata.findAll('dc:identifier'):
scheme = item.get('scheme')
if not scheme:
scheme = item.get('opf:scheme')
if scheme is not None and scheme.lower() == 'isbn' and item.string:
return str(item.string).strip()
return None
def get_language(self):
item = self.metadata.find('dc:language')
if not item:
return _('Unknown')
return ''.join(item.findAll(text=True)).strip()
def get_application_id(self):
for item in self.metadata.findAll('dc:identifier'):
scheme = item.get('scheme', None)
if scheme is None:
scheme = item.get('opf:scheme', None)
if scheme in ['libprs500', 'calibre']:
return str(item.string).strip()
return None
def get_cover(self):
guide = getattr(self, 'guide', [])
if not guide:
guide = []
references = [ref for ref in guide if 'cover' in ref.type.lower()]
for candidate in ('cover', 'other.ms-coverimage-standard', 'other.ms-coverimage'):
matches = [r for r in references if r.type.lower() == candidate and r.path]
if matches:
return matches[0].path
def possible_cover_prefixes(self):
isbn, ans = [], []
for item in self.metadata.findAll('dc:identifier'):
scheme = item.get('scheme')
if not scheme:
scheme = item.get('opf:scheme')
isbn.append((scheme, item.string))
for item in isbn:
ans.append(item[1].replace('-', ''))
return ans
def get_series(self):
s = self.metadata.find('series')
if s is not None:
return str(s.string).strip()
return None
def get_series_index(self):
s = self.metadata.find('series-index')
if s and s.string:
try:
return float(str(s.string).strip())
except:
return None
return None
def get_rating(self):
s = self.metadata.find('rating')
if s and s.string:
try:
return int(str(s.string).strip())
except:
return None
return None
def get_tags(self):
ans = []
subs = self.soup.findAll('dc:subject')
for sub in subs:
val = sub.string
if val:
ans.append(val)
return [unicode(a).strip() for a in ans]
class OPFReader(OPF):
def __init__(self, stream, dir=os.getcwdu()):
manage = False
if not hasattr(stream, 'read'):
manage = True
dir = os.path.dirname(stream)
stream = open(stream, 'rb')
self.default_title = stream.name if hasattr(stream, 'name') else 'Unknown'
if hasattr(stream, 'seek'):
stream.seek(0)
self.soup = OPFSoup(stream.read())
if manage:
stream.close()
self.manifest = Manifest()
m = self.soup.find(re.compile('manifest'))
if m is not None:
self.manifest = Manifest.from_opf_manifest_element(m, dir)
self.spine = None
spine = self.soup.find(re.compile('spine'))
if spine is not None:
self.spine = Spine.from_opf_spine_element(spine, self.manifest)
self.toc = TOC(base_path=dir)
self.toc.read_from_opf(self)
guide = self.soup.find(re.compile('guide'))
if guide is not None:
self.guide = Guide.from_opf_guide(guide, dir)
self.base_dir = dir
self.cover_data = (None, None)
class OPFCreator(MetaInformation):
def __init__(self, base_path, *args, **kwargs):
'''
Initialize.
@param base_path: An absolute path to the directory in which this OPF file
will eventually be. This is used by the L{create_manifest} method
to convert paths to files into relative paths.
'''
MetaInformation.__init__(self, *args, **kwargs)
self.base_path = os.path.abspath(base_path)
if self.application_id is None:
self.application_id = str(uuid.uuid4())
if not isinstance(self.toc, TOC):
self.toc = None
if not self.authors:
self.authors = [_('Unknown')]
if self.guide is None:
self.guide = Guide()
if self.cover:
self.guide.set_cover(self.cover)
def create_manifest(self, entries):
'''
Create <manifest>
`entries`: List of (path, mime-type) If mime-type is None it is autodetected
'''
entries = map(lambda x: x if os.path.isabs(x[0]) else
(os.path.abspath(os.path.join(self.base_path, x[0])), x[1]),
entries)
self.manifest = Manifest.from_paths(entries)
self.manifest.set_basedir(self.base_path)
def create_manifest_from_files_in(self, files_and_dirs):
entries = []
def dodir(dir):
for spec in os.walk(dir):
root, files = spec[0], spec[-1]
for name in files:
path = os.path.join(root, name)
if os.path.isfile(path):
entries.append((path, None))
for i in files_and_dirs:
if os.path.isdir(i):
dodir(i)
else:
entries.append((i, None))
self.create_manifest(entries)
def create_spine(self, entries):
'''
Create the <spine> element. Must first call :method:`create_manifest`.
`entries`: List of paths
'''
entries = map(lambda x: x if os.path.isabs(x) else
os.path.abspath(os.path.join(self.base_path, x)), entries)
self.spine = Spine.from_paths(entries, self.manifest)
def set_toc(self, toc):
'''
Set the toc. You must call :method:`create_spine` before calling this
method.
:param toc: A :class:`TOC` object
'''
self.toc = toc
def create_guide(self, guide_element):
self.guide = Guide.from_opf_guide(guide_element, self.base_path)
self.guide.set_basedir(self.base_path)
def render(self, opf_stream, ncx_stream=None, ncx_manifest_entry=None):
from calibre.utils.genshi.template import MarkupTemplate
opf_template = open(P('templates/opf.xml'), 'rb').read()
template = MarkupTemplate(opf_template)
if self.manifest:
self.manifest.set_basedir(self.base_path)
if ncx_manifest_entry is not None:
if not os.path.isabs(ncx_manifest_entry):
ncx_manifest_entry = os.path.join(self.base_path, ncx_manifest_entry)
remove = [i for i in self.manifest if i.id == 'ncx']
for item in remove:
self.manifest.remove(item)
self.manifest.append(ManifestItem(ncx_manifest_entry, self.base_path))
self.manifest[-1].id = 'ncx'
self.manifest[-1].mime_type = 'application/x-dtbncx+xml'
if not self.guide:
self.guide = Guide()
if self.cover:
cover = self.cover
if not os.path.isabs(cover):
cover = os.path.abspath(os.path.join(self.base_path, cover))
self.guide.set_cover(cover)
self.guide.set_basedir(self.base_path)
opf = template.generate(__appname__=__appname__, mi=self, __version__=__version__).render('xml')
if not opf.startswith('<?xml '):
opf = '<?xml version="1.0" encoding="UTF-8"?>\n'+opf
opf_stream.write(opf)
opf_stream.flush()
toc = getattr(self, 'toc', None)
if toc is not None and ncx_stream is not None:
toc.render(ncx_stream, self.application_id)
ncx_stream.flush()

View File

@ -451,8 +451,6 @@ class OPF(object):
guide_path = XPath('descendant::*[re:match(name(), "guide", "i")]/*[re:match(name(), "reference", "i")]')
title = MetadataField('title', formatter=lambda x: re.sub(r'\s+', ' ', x))
title_sort = MetadataField('title_sort', formatter=lambda x:
re.sub(r'\s+', ' ', x), is_dc=False)
publisher = MetadataField('publisher')
language = MetadataField('language')
comments = MetadataField('description')
@ -708,6 +706,30 @@ class OPF(object):
return property(fget=fget, fset=fset)
@dynamic_property
def title_sort(self):
def fget(self):
matches = self.title_path(self.metadata)
if matches:
for match in matches:
ans = match.get('{%s}file-as'%self.NAMESPACES['opf'], None)
if not ans:
ans = match.get('file-as', None)
if ans:
return ans
def fset(self, val):
matches = self.title_path(self.metadata)
if matches:
for key in matches[0].attrib:
if key.endswith('file-as'):
matches[0].attrib.pop(key)
matches[0].set('{%s}file-as'%self.NAMESPACES['opf'], unicode(val))
return property(fget=fget, fset=fset)
@dynamic_property
def tags(self):
@ -981,11 +1003,8 @@ class OPFCreator(MetaInformation):
def render(self, opf_stream=sys.stdout, ncx_stream=None,
ncx_manifest_entry=None, encoding=None):
from calibre.utils.genshi.template import MarkupTemplate
opf_template = open(P('templates/opf.xml'), 'rb').read()
if encoding is None:
encoding = 'utf-8'
template = MarkupTemplate(opf_template)
toc = getattr(self, 'toc', None)
if self.manifest:
self.manifest.set_basedir(self.base_path)
@ -1006,12 +1025,101 @@ class OPFCreator(MetaInformation):
cover = os.path.abspath(os.path.join(self.base_path, cover))
self.guide.set_cover(cover)
self.guide.set_basedir(self.base_path)
opf = template.generate(
__appname__=__appname__, mi=self,
__version__=__version__).render('xml', encoding=encoding)
opf_stream.write('<?xml version="1.0" encoding="%s" ?>\n'
%encoding.upper())
opf_stream.write(opf)
# Actual rendering
from lxml.builder import ElementMaker
from calibre.ebooks.oeb.base import OPF2_NS, DC11_NS, CALIBRE_NS
DNS = OPF2_NS+'___xx___'
E = ElementMaker(namespace=DNS, nsmap={None:DNS})
M = ElementMaker(namespace=DNS,
nsmap={'dc':DC11_NS, 'calibre':CALIBRE_NS, 'opf':OPF2_NS})
DC = ElementMaker(namespace=DC11_NS)
def DC_ELEM(tag, text, dc_attrs={}, opf_attrs={}):
if text:
elem = getattr(DC, tag)(text, **dc_attrs)
else:
elem = getattr(DC, tag)(**dc_attrs)
for k, v in opf_attrs.items():
elem.set('{%s}%s'%(OPF2_NS, k), v)
return elem
def CAL_ELEM(name, content):
return M.meta(name=name, content=content)
metadata = M.metadata()
a = metadata.append
role = {}
if self.title_sort:
role = {'file-as':self.title_sort}
a(DC_ELEM('title', self.title if self.title else _('Unknown'),
opf_attrs=role))
for i, author in enumerate(self.authors):
fa = {'role':'aut'}
if i == 0 and self.author_sort:
fa['file-as'] = self.author_sort
a(DC_ELEM('creator', author, opf_attrs=fa))
a(DC_ELEM('contributor', '%s (%s) [%s]'%(__appname__, __version__,
'http://calibre-ebook.com'), opf_attrs={'role':'bkp',
'file-as':__appname__}))
a(DC_ELEM('identifier', str(self.application_id),
opf_attrs={'scheme':__appname__},
dc_attrs={'id':__appname__+'_id'}))
if getattr(self, 'pubdate', None) is not None:
a(DC_ELEM('date', self.pubdate.isoformat()))
a(DC_ELEM('language', self.language if self.language else 'UND'))
if self.comments:
a(DC_ELEM('description', self.comments))
if self.publisher:
a(DC_ELEM('publisher', self.publisher))
if self.isbn:
a(DC_ELEM('identifier', self.isbn, opf_attrs={'scheme':'ISBN'}))
if self.rights:
a(DC_ELEM('rights', self.rights))
if self.tags:
for tag in self.tags:
a(DC_ELEM('subject', tag))
if self.series:
a(CAL_ELEM('calibre:series', self.series))
if self.series_index is not None:
a(CAL_ELEM('calibre:series_index', self.format_series_index()))
if self.rating is not None:
a(CAL_ELEM('calibre:rating', str(self.rating)))
if self.timestamp is not None:
a(CAL_ELEM('calibre:timestamp', self.timestamp.isoformat()))
if self.publication_type is not None:
a(CAL_ELEM('calibre:publication_type', self.publication_type))
manifest = E.manifest()
if self.manifest is not None:
for ref in self.manifest:
item = E.item(id=str(ref.id), href=ref.href())
item.set('media-type', ref.mime_type)
manifest.append(item)
spine = E.spine()
if self.toc is not None:
spine.set('toc', 'ncx')
if self.spine is not None:
for ref in self.spine:
spine.append(E.itemref(idref=ref.id))
guide = E.guide()
if self.guide is not None:
for ref in self.guide:
item = E.reference(type=ref.type, href=ref.href())
if ref.title:
item.set('title', ref.title)
guide.append(item)
root = E.package(
metadata,
manifest,
spine,
guide
)
root.set('unique-identifier', __appname__+'_id')
raw = etree.tostring(root, pretty_print=True, xml_declaration=True,
encoding=encoding)
raw = raw.replace(DNS, OPF2_NS)
opf_stream.write(raw)
opf_stream.flush()
if toc is not None and ncx_stream is not None:
toc.render(ncx_stream, self.application_id)
@ -1159,12 +1267,14 @@ class OPFTest(unittest.TestCase):
<package version="2.0" xmlns="http://www.idpf.org/2007/opf" >
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title opf:file-as="Wow">A Cool &amp; &copy; &#223; Title</dc:title>
<creator opf:role="aut" file-as="Monkey">Monkey Kitchen, Next</creator>
<creator opf:role="aut" file-as="Monkey">Monkey Kitchen</creator>
<creator opf:role="aut">Next</creator>
<dc:subject>One</dc:subject><dc:subject>Two</dc:subject>
<dc:identifier scheme="ISBN">123456789</dc:identifier>
<x-metadata>
<series>A one book series</series>
</x-metadata>
<meta name="calibre:series" content="A one book series" />
<meta name="calibre:rating" content="4"/>
<meta name="calibre:publication_type" content="test"/>
<meta name="calibre:series_index" content="2.5" />
</metadata>
<manifest>
<item id="1" href="a%20%7E%20b" media-type="text/txt" />
@ -1184,7 +1294,9 @@ class OPFTest(unittest.TestCase):
self.assertEqual(opf.tags, ['One', 'Two'])
self.assertEqual(opf.isbn, '123456789')
self.assertEqual(opf.series, 'A one book series')
self.assertEqual(opf.series_index, 1)
self.assertEqual(opf.series_index, 2.5)
self.assertEqual(opf.rating, 4)
self.assertEqual(opf.publication_type, 'test')
self.assertEqual(list(opf.itermanifest())[0].get('href'), 'a ~ b')
def testWriting(self):
@ -1214,3 +1326,6 @@ def suite():
def test():
unittest.TextTestRunner(verbosity=2).run(suite())
if __name__ == '__main__':
test()

View File

@ -1,14 +1,31 @@
#!/usr/bin/env python
__license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
__copyright__ = '2010, Kovid Goyal <kovid at kovidgoyal.net>'
import os, glob, re
from urlparse import urlparse
from urllib import unquote
from uuid import uuid4
from calibre import __appname__
from lxml import etree
from lxml.builder import ElementMaker
from calibre.constants import __appname__, __version__
from calibre.ebooks.BeautifulSoup import BeautifulStoneSoup, BeautifulSoup
from calibre.ebooks.chardet import xml_to_unicode
NCX_NS = "http://www.daisy.org/z3986/2005/ncx/"
CALIBRE_NS = "http://calibre.kovidgoyal.net/2009/metadata"
NSMAP = {
None: NCX_NS,
'calibre':CALIBRE_NS
}
E = ElementMaker(namespace=NCX_NS, nsmap=NSMAP)
C = ElementMaker(namespace=CALIBRE_NS, nsmap=NSMAP)
class NCXSoup(BeautifulStoneSoup):
NESTABLE_TAGS = {'navpoint':[]}
@ -208,10 +225,46 @@ class TOC(list):
self.add_item(href, fragment, txt)
def render(self, stream, uid):
from calibre.utils.genshi.template import MarkupTemplate
ncx_template = open(P('templates/ncx.xml'), 'rb').read()
doctype = ('ncx', "-//NISO//DTD ncx 2005-1//EN", "http://www.daisy.org/z3986/2005/ncx-2005-1.dtd")
template = MarkupTemplate(ncx_template)
raw = template.generate(uid=uid, toc=self, __appname__=__appname__)
raw = raw.render(doctype=doctype)
root = E.ncx(
E.head(
E.meta(name='dtb:uid', content=str(uid)),
E.meta(name='dtb:depth', content=str(self.depth())),
E.meta(name='dtb:generator', content='%s (%s)'%(__appname__,
__version__)),
E.meta(name='dtb:totalPageCount', content='0'),
E.meta(name='dtb:maxPageNumber', content='0'),
),
E.docTitle(E.text('Table of Contents')),
)
navmap = E.navMap()
root.append(navmap)
root.set('{http://www.w3.org/XML/1998/namespace}lang', 'en')
def navpoint(parent, np):
text = np.text
if not text:
text = ''
elem = E.navPoint(
E.navLabel(E.text(re.sub(r'\s+', ' ', text))),
E.content(src=unicode(np.href)+(('#' + unicode(np.fragment))
if np.fragment else '')),
id=str(uuid4()),
playOrder=str(np.play_order)
)
au = getattr(np, 'author', None)
if au:
au = re.sub(r'\s+', ' ', au)
elem.append(C.meta(au, name='author'))
desc = getattr(np, 'description', None)
if desc:
desc = re.sub(r'\s+', ' ', desc)
elem.append(C.meta(desc, name='description'))
parent.append(elem)
for np2 in np:
navpoint(elem, np2)
for np in self:
navpoint(navmap, np)
raw = etree.tostring(root, encoding='utf-8', xml_declaration=True,
pretty_print=True)
stream.write(raw)

View File

@ -35,6 +35,8 @@ def read_metadata_(task, tdir, notification=lambda x,y:x):
if mi.cover_data:
cdata = mi.cover_data[-1]
mi.cover_data = None
if not mi.application_id:
mi.application_id = '__calibre_dummy__'
with open(os.path.join(tdir, '%s.opf'%id), 'wb') as f:
f.write(metadata_to_opf(mi))
if cdata:

View File

@ -6,9 +6,9 @@ from threading import RLock
from PyQt4.QtCore import QVariant, QFileInfo, QObject, SIGNAL, QBuffer, Qt, QSize, \
QByteArray, QTranslator, QCoreApplication, QThread, \
QEvent, QTimer, pyqtSignal
QEvent, QTimer, pyqtSignal, QDate
from PyQt4.QtGui import QFileDialog, QMessageBox, QPixmap, QFileIconProvider, \
QIcon, QTableView, QApplication, QDialog, QPushButton
QIcon, QApplication, QDialog, QPushButton
ORG_NAME = 'KovidsBrain'
APP_UID = 'libprs500'
@ -17,12 +17,14 @@ from calibre.utils.config import Config, ConfigProxy, dynamic, JSONConfig
from calibre.utils.localization import set_qt_translator
from calibre.ebooks.metadata.meta import get_metadata, metadata_from_formats
from calibre.ebooks.metadata import MetaInformation
from calibre.utils.date import UNDEFINED_DATE
gprefs = JSONConfig('gui')
NONE = QVariant() #: Null value to return from the data function of item models
UNDEFINED_QDATE = QDate(UNDEFINED_DATE)
ALL_COLUMNS = ['title', 'authors', 'size', 'timestamp', 'rating', 'publisher',
ALL_COLUMNS = ['title', 'ondevice', 'authors', 'size', 'timestamp', 'rating', 'publisher',
'tags', 'series', 'pubdate']
def _config():
@ -182,29 +184,38 @@ class MessageBox(QMessageBox):
def warning_dialog(parent, title, msg, det_msg='', show=False):
def warning_dialog(parent, title, msg, det_msg='', show=False,
show_copy_button=True):
d = MessageBox(QMessageBox.Warning, 'WARNING: '+title, msg, QMessageBox.Ok,
parent, det_msg)
d.setEscapeButton(QMessageBox.Ok)
d.setIconPixmap(QPixmap(I('dialog_warning.svg')))
if not show_copy_button:
d.cb.setVisible(False)
if show:
return d.exec_()
return d
def error_dialog(parent, title, msg, det_msg='', show=False):
def error_dialog(parent, title, msg, det_msg='', show=False,
show_copy_button=True):
d = MessageBox(QMessageBox.Critical, 'ERROR: '+title, msg, QMessageBox.Ok,
parent, det_msg)
d.setIconPixmap(QPixmap(I('dialog_error.svg')))
d.setEscapeButton(QMessageBox.Ok)
if not show_copy_button:
d.cb.setVisible(False)
if show:
return d.exec_()
return d
def question_dialog(parent, title, msg, det_msg=''):
def question_dialog(parent, title, msg, det_msg='', show_copy_button=True):
d = MessageBox(QMessageBox.Question, title, msg, QMessageBox.Yes|QMessageBox.No,
parent, det_msg)
d.setIconPixmap(QPixmap(I('dialog_information.svg')))
d.setEscapeButton(QMessageBox.No)
if not show_copy_button:
d.cb.setVisible(False)
return d.exec_() == QMessageBox.Yes
def info_dialog(parent, title, msg, det_msg='', show=False):
@ -216,24 +227,6 @@ def info_dialog(parent, title, msg, det_msg='', show=False):
return d
def qstring_to_unicode(q):
return unicode(q)
def human_readable(size):
""" Convert a size in bytes into a human readable form """
divisor, suffix = 1, "B"
if size < 1024*1024:
divisor, suffix = 1024., "KB"
elif size < 1024*1024*1024:
divisor, suffix = 1024*1024, "MB"
elif size < 1024*1024*1024*1024:
divisor, suffix = 1024*1024*1024, "GB"
size = str(float(size)/divisor)
if size.find(".") > -1:
size = size[:size.find(".")+2]
if size.endswith('.0'):
size = size[:-2]
return size + " " + suffix
class Dispatcher(QObject):
'''Convenience class to ensure that a function call always happens in the
@ -286,25 +279,6 @@ class GetMetadata(QObject):
mi = MetaInformation('', [_('Unknown')])
self.emit(SIGNAL('metadata(PyQt_PyObject, PyQt_PyObject)'), id, mi)
class TableView(QTableView):
def __init__(self, parent):
QTableView.__init__(self, parent)
self.read_settings()
def read_settings(self):
self.cw = dynamic[self.__class__.__name__+'column widths']
def write_settings(self):
dynamic[self.__class__.__name__+'column widths'] = \
tuple([int(self.columnWidth(i)) for i in range(self.model().columnCount(None))])
def restore_column_widths(self):
if self.cw and len(self.cw):
for i in range(len(self.cw)):
self.setColumnWidth(i, self.cw[i])
return True
class FileIconProvider(QFileIconProvider):
ICONS = {
@ -379,7 +353,7 @@ class FileIconProvider(QFileIconProvider):
if fileinfo.isDir():
key = 'dir'
else:
ext = qstring_to_unicode(fileinfo.completeSuffix()).lower()
ext = unicode(fileinfo.completeSuffix()).lower()
key = self.key_from_ext(ext)
return self.cached_icon(key)

View File

@ -181,6 +181,8 @@ class DBAdder(Thread):
mi.title = os.path.splitext(name)[0]
mi.title = mi.title if isinstance(mi.title, unicode) else \
mi.title.decode(preferred_encoding, 'replace')
if mi.application_id == '__calibre_dummy__':
mi.application_id = None
if self.db is not None:
if cover:
cover = open(cover, 'rb').read()

View File

@ -0,0 +1,174 @@
#!/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'
import os
from PyQt4.Qt import QWizard, QWizardPage, QIcon, QPixmap, Qt, QThread, \
pyqtSignal
from calibre.gui2 import error_dialog, choose_dir, gprefs
from calibre.constants import filesystem_encoding
from calibre.library.add_to_library import find_folders_under, \
find_books_in_folder, hash_merge_format_collections
class WizardPage(QWizardPage): # {{{
def __init__(self, db, parent):
QWizardPage.__init__(self, parent)
self.db = db
self.register = parent.register
self.setupUi(self)
self.do_init()
def do_init(self):
pass
# }}}
# Scan root folder Page {{{
from calibre.gui2.add_wizard.scan_ui import Ui_WizardPage as ScanWidget
class RecursiveFinder(QThread):
activity_changed = pyqtSignal(object, object) # description and total count
activity_iterated = pyqtSignal(object, object) # item desc, progress number
def __init__(self, parent=None):
QThread.__init__(self, parent)
self.canceled = False
self.cancel_callback = lambda : self.canceled
self.folders = set([])
self.books = []
def cancel(self, *args):
self.canceled = True
def set_params(self, root, db, one_per_folder):
self.root, self.db = root, db
self.one_per_folder = one_per_folder
def run(self):
self.activity_changed.emit(_('Searching for sub-folders'), 0)
self.folders = find_folders_under(self.root, self.db,
cancel_callback=self.cancel_callback)
if self.canceled:
return
self.activity_changed.emit(_('Searching for books'), len(self.folders))
for i, folder in enumerate(self.folders):
if self.canceled:
break
books_in_folder = find_books_in_folder(folder, self.one_per_folder,
cancel_callback=self.cancel_callback)
if self.canceled:
break
self.books.extend(books_in_folder)
self.activity_iterated.emit(folder, i)
self.activity_changed.emit(
_('Looking for duplicates based on file hash'), 0)
self.books = hash_merge_format_collections(self.books,
cancel_callback=self.cancel_callback)
class ScanPage(WizardPage, ScanWidget):
ID = 2
# }}}
# Welcome Page {{{
from calibre.gui2.add_wizard.welcome_ui import Ui_WizardPage as WelcomeWidget
class WelcomePage(WizardPage, WelcomeWidget):
ID = 1
def do_init(self):
# Root folder must be filled
self.registerField('root_folder*', self.opt_root_folder)
self.register['root_folder'] = self.get_root_folder
self.register['one_per_folder'] = self.get_one_per_folder
self.button_choose_root_folder.clicked.connect(self.choose_root_folder)
def choose_root_folder(self, *args):
x = self.get_root_folder()
if x is None:
x = '~'
x = choose_dir(self, 'add wizard choose root folder',
_('Choose root folder'), default_dir=x)
if x is not None:
self.opt_root_folder.setText(os.path.abspath(x))
def initializePage(self):
opf = gprefs.get('add wizard one per folder', True)
self.opt_one_per_folder.setChecked(opf)
self.opt_many_per_folder.setChecked(not opf)
add_dir = gprefs.get('add wizard root folder', None)
if add_dir is not None:
self.opt_root_folder.setText(add_dir)
def get_root_folder(self):
x = unicode(self.opt_root_folder.text()).strip()
if not x:
return None
return os.path.abspath(x.encode(filesystem_encoding))
def get_one_per_folder(self):
return self.opt_one_per_folder.isChecked()
def validatePage(self):
x = self.get_root_folder()
xu = x.decode(filesystem_encoding)
if x and os.access(x, os.R_OK) and os.path.isdir(x):
gprefs['add wizard root folder'] = xu
gprefs['add wizard one per folder'] = self.get_one_per_folder()
return True
error_dialog(self, _('Invalid root folder'),
xu + _('is not a valid root folder'), show=True)
return False
# }}}
class Wizard(QWizard): # {{{
def __init__(self, db, parent=None):
QWizard.__init__(self, parent)
self.setModal(True)
self.setWindowTitle(_('Add books to calibre'))
self.setWindowIcon(QIcon(I('add_book.svg')))
self.setPixmap(self.LogoPixmap, QPixmap(P('content_server/calibre.png')).scaledToHeight(80,
Qt.SmoothTransformation))
self.setPixmap(self.WatermarkPixmap,
QPixmap(I('welcome_wizard.svg')))
self.register = {}
for attr, cls in [
('welcome_page', WelcomePage),
('scan_page', ScanPage),
]:
setattr(self, attr, cls(db, self))
self.setPage(getattr(cls, 'ID'), getattr(self, attr))
# }}}
# Test Wizard {{{
if __name__ == '__main__':
from PyQt4.Qt import QApplication
from calibre.library import db
app = QApplication([])
w = Wizard(db())
w.exec_()
# }}}

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>WizardPage</class>
<widget class="QWizardPage" name="WizardPage">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>300</height>
</rect>
</property>
<property name="windowTitle">
<string>WizardPage</string>
</property>
<property name="title">
<string>Scanning root folder for books</string>
</property>
<property name="subTitle">
<string>This may take a few minutes</string>
</property>
</widget>
<resources/>
<connections/>
</ui>

View File

@ -0,0 +1,134 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>WizardPage</class>
<widget class="QWizardPage" name="WizardPage">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>704</width>
<height>468</height>
</rect>
</property>
<property name="windowTitle">
<string>WizardPage</string>
</property>
<property name="title">
<string>Choose the location to add books from</string>
</property>
<property name="subTitle">
<string>Select a folder on your hard disk</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0" colspan="3">
<widget class="QLabel" name="label">
<property name="text">
<string>&lt;p&gt;calibre can scan your computer for existing books automatically. These books will then be &lt;b&gt;copied&lt;/b&gt; into the calibre library. This wizard will help you customize the scanning and import process for your existing book collection.&lt;/p&gt;
&lt;p&gt;Choose a root folder. Books will be searched for only inside this folder and any sub-folders.&lt;/p&gt;
&lt;p&gt;Make sure that the folder you chose for your calibre library &lt;b&gt;is not&lt;/b&gt; under the root folder you choose.&lt;/p&gt;</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>&amp;Root folder:</string>
</property>
<property name="buddy">
<cstring>opt_root_folder</cstring>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="opt_root_folder">
<property name="toolTip">
<string>This folder and its sub-folders will be scanned for books to import into calibre's library</string>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QToolButton" name="button_choose_root_folder">
<property name="toolTip">
<string>Choose root folder</string>
</property>
<property name="text">
<string>...</string>
</property>
<property name="icon">
<iconset resource="../../../../resources/images.qrc">
<normaloff>:/images/document_open.svg</normaloff>:/images/document_open.svg</iconset>
</property>
</widget>
</item>
<item row="4" column="0" colspan="3">
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>Handle multiple files per book</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QRadioButton" name="opt_one_per_folder">
<property name="text">
<string>&amp;One book per folder, assumes every ebook file in a folder is the same book in a different format</string>
</property>
</widget>
</item>
<item>
<widget class="QRadioButton" name="opt_many_per_folder">
<property name="text">
<string>&amp;Multiple books per folder, assumes every ebook file is a different book</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item row="5" column="0">
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item row="3" column="1">
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item row="1" column="1">
<spacer name="verticalSpacer_3">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
<resources>
<include location="../../../../resources/images.qrc"/>
</resources>
<connections/>
</ui>

View File

@ -0,0 +1,460 @@
#!/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'
import sys
from functools import partial
from PyQt4.Qt import QComboBox, QLabel, QSpinBox, QDoubleSpinBox, QDateEdit, \
QDate, QGroupBox, QVBoxLayout, QPlainTextEdit, QSizePolicy, \
QSpacerItem, QIcon, QCheckBox, QWidget, QHBoxLayout, SIGNAL
from calibre.utils.date import qt_to_dt
from calibre.gui2.widgets import TagsLineEdit, EnComboBox
from calibre.gui2 import UNDEFINED_QDATE
from calibre.utils.config import tweaks
class Base(object):
def __init__(self, db, col_id, parent=None):
self.db, self.col_id = db, col_id
self.col_metadata = db.custom_column_num_map[col_id]
self.initial_val = None
self.setup_ui(parent)
def initialize(self, book_id):
val = self.db.get_custom(book_id, num=self.col_id, index_is_id=True)
self.initial_val = val
val = self.normalize_db_val(val)
self.setter(val)
def commit(self, book_id, notify=False):
val = self.getter()
val = self.normalize_ui_val(val)
if val != self.initial_val:
self.db.set_custom(book_id, val, num=self.col_id, notify=notify)
def normalize_db_val(self, val):
return val
def normalize_ui_val(self, val):
return val
class Bool(Base):
def setup_ui(self, parent):
self.widgets = [QLabel('&'+self.col_metadata['name']+':', parent),
QComboBox(parent)]
w = self.widgets[1]
items = [_('Yes'), _('No'), _('Undefined')]
icons = [I('ok.svg'), I('list_remove.svg'), I('blank.svg')]
if tweaks['bool_custom_columns_are_tristate'] == 'no':
items = items[:-1]
icons = icons[:-1]
for icon, text in zip(icons, items):
w.addItem(QIcon(icon), text)
def setter(self, val):
val = {None: 2, False: 1, True: 0}[val]
if tweaks['bool_custom_columns_are_tristate'] == 'no' and val == 2:
val = 1
self.widgets[1].setCurrentIndex(val)
def getter(self):
val = self.widgets[1].currentIndex()
return {2: None, 1: False, 0: True}[val]
class Int(Base):
def setup_ui(self, parent):
self.widgets = [QLabel('&'+self.col_metadata['name']+':', parent),
QSpinBox(parent)]
w = self.widgets[1]
w.setRange(-100, sys.maxint)
w.setSpecialValueText(_('Undefined'))
w.setSingleStep(1)
def setter(self, val):
if val is None:
val = self.widgets[1].minimum()
else:
val = int(val)
self.widgets[1].setValue(val)
def getter(self):
val = self.widgets[1].value()
if val == self.widgets[1].minimum():
val = None
return val
class Float(Int):
def setup_ui(self, parent):
self.widgets = [QLabel('&'+self.col_metadata['name']+':', parent),
QDoubleSpinBox(parent)]
w = self.widgets[1]
w.setRange(-100., float(sys.maxint))
w.setDecimals(2)
w.setSpecialValueText(_('Undefined'))
w.setSingleStep(1)
class Rating(Int):
def setup_ui(self, parent):
Int.setup_ui(self, parent)
w = self.widgets[1]
w.setRange(0, 5)
w.setSuffix(' '+_('star(s)'))
w.setSpecialValueText(_('Unrated'))
def setter(self, val):
if val is None:
val = 0
self.widgets[1].setValue(int(round(val/2.)))
def getter(self):
val = self.widgets[1].value()
if val == 0:
val = None
else:
val *= 2
return val
class DateEdit(QDateEdit):
def focusInEvent(self, x):
self.setSpecialValueText('')
def focusOutEvent(self, x):
self.setSpecialValueText(_('Undefined'))
class DateTime(Base):
def setup_ui(self, parent):
self.widgets = [QLabel('&'+self.col_metadata['name']+':', parent),
DateEdit(parent)]
w = self.widgets[1]
w.setDisplayFormat('dd MMM yyyy')
w.setCalendarPopup(True)
w.setMinimumDate(UNDEFINED_QDATE)
w.setSpecialValueText(_('Undefined'))
def setter(self, val):
if val is None:
val = self.widgets[1].minimumDate()
else:
val = QDate(val.year, val.month, val.day)
self.widgets[1].setDate(val)
def getter(self):
val = self.widgets[1].date()
if val == UNDEFINED_QDATE:
val = None
else:
val = qt_to_dt(val)
return val
class Comments(Base):
def setup_ui(self, parent):
self._box = QGroupBox(parent)
self._box.setTitle('&'+self.col_metadata['name'])
self._layout = QVBoxLayout()
self._tb = QPlainTextEdit(self._box)
self._tb.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
self._tb.setTabChangesFocus(True)
self._layout.addWidget(self._tb)
self._box.setLayout(self._layout)
self.widgets = [self._box]
def setter(self, val):
if val is None:
val = ''
self._tb.setPlainText(val)
def getter(self):
val = unicode(self._tb.toPlainText()).strip()
if not val:
val = None
return val
class Text(Base):
def setup_ui(self, parent):
values = self.all_values = list(self.db.all_custom(num=self.col_id))
values.sort(cmp = lambda x,y: cmp(x.lower(), y.lower()))
if self.col_metadata['is_multiple']:
w = TagsLineEdit(parent, values)
w.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred)
else:
w = EnComboBox(parent)
w.setSizeAdjustPolicy(w.AdjustToMinimumContentsLengthWithIcon)
w.setMinimumContentsLength(25)
self.widgets = [QLabel('&'+self.col_metadata['name']+':', parent),
w]
def initialize(self, book_id):
val = self.db.get_custom(book_id, num=self.col_id, index_is_id=True)
self.initial_val = val
val = self.normalize_db_val(val)
if self.col_metadata['is_multiple']:
self.setter(val)
self.widgets[1].update_tags_cache(self.all_values)
else:
idx = None
for i, c in enumerate(self.all_values):
if c == val:
idx = i
self.widgets[1].addItem(c)
self.widgets[1].setEditText('')
if idx is not None:
self.widgets[1].setCurrentIndex(idx)
def setter(self, val):
if self.col_metadata['is_multiple']:
if not val:
val = []
self.widgets[1].setText(u', '.join(val))
def getter(self):
if self.col_metadata['is_multiple']:
val = unicode(self.widgets[1].text()).strip()
ans = [x.strip() for x in val.split(',') if x.strip()]
if not ans:
ans = None
return ans
val = unicode(self.widgets[1].currentText()).strip()
if not val:
val = None
return val
widgets = {
'bool' : Bool,
'rating' : Rating,
'int': Int,
'float': Float,
'datetime': DateTime,
'text' : Text,
'comments': Comments,
}
def field_sort(y, z, x=None):
m1, m2 = x[y], x[z]
n1 = 'zzzzz' if m1['datatype'] == 'comments' else m1['name']
n2 = 'zzzzz' if m2['datatype'] == 'comments' else m2['name']
return cmp(n1.lower(), n2.lower())
def populate_single_metadata_page(left, right, db, book_id, parent=None):
x = db.custom_column_num_map
cols = list(x)
cols.sort(cmp=partial(field_sort, x=x))
ans = []
for i, col in enumerate(cols):
w = widgets[x[col]['datatype']](db, col, parent)
ans.append(w)
w.initialize(book_id)
layout = left if i%2 == 0 else right
row = layout.rowCount()
if len(w.widgets) == 1:
layout.addWidget(w.widgets[0], row, 0, 1, -1)
else:
w.widgets[0].setBuddy(w.widgets[1])
for c, widget in enumerate(w.widgets):
layout.addWidget(widget, row, c)
items = []
if len(ans) > 0:
items.append(QSpacerItem(10, 10, QSizePolicy.Minimum,
QSizePolicy.Expanding))
left.addItem(items[-1], left.rowCount(), 0, 1, 1)
left.setRowStretch(left.rowCount()-1, 100)
if len(ans) > 1:
items.append(QSpacerItem(10, 100, QSizePolicy.Minimum,
QSizePolicy.Expanding))
right.addItem(items[-1], left.rowCount(), 0, 1, 1)
right.setRowStretch(right.rowCount()-1, 100)
return ans, items
class BulkBase(Base):
def get_initial_value(self, book_ids):
values = set([])
for book_id in book_ids:
val = self.db.get_custom(book_id, num=self.col_id, index_is_id=True)
if isinstance(val, list):
val = frozenset(val)
values.add(val)
if len(values) > 1:
break
ans = None
if len(values) == 1:
ans = iter(values).next()
if isinstance(ans, frozenset):
ans = list(ans)
return ans
def process_each_book(self):
return False
def initialize(self, book_ids):
if not self.process_each_book():
self.initial_val = val = self.get_initial_value(book_ids)
val = self.normalize_db_val(val)
self.setter(val)
def commit(self, book_ids, notify=False):
if self.process_each_book():
for book_id in book_ids:
val = self.db.get_custom(book_id, num=self.col_id, index_is_id=True)
self.db.set_custom(book_id, self.getter(val), num=self.col_id, notify=notify)
else:
val = self.getter()
val = self.normalize_ui_val(val)
if val != self.initial_val:
for book_id in book_ids:
self.db.set_custom(book_id, val, num=self.col_id, notify=notify)
class BulkBool(BulkBase, Bool):
pass
class BulkInt(BulkBase, Int):
pass
class BulkFloat(BulkBase, Float):
pass
class BulkRating(BulkBase, Rating):
pass
class BulkDateTime(BulkBase, DateTime):
pass
class RemoveTags(QWidget):
def __init__(self, parent, values):
QWidget.__init__(self, parent)
layout = QHBoxLayout()
layout.setSpacing(5)
layout.setContentsMargins(0, 0, 0, 0)
self.tags_box = TagsLineEdit(parent, values)
layout.addWidget(self.tags_box, stretch = 1)
# self.tags_box.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred)
self.checkbox = QCheckBox(_('Remove all tags'), parent)
layout.addWidget(self.checkbox)
self.setLayout(layout)
self.connect(self.checkbox, SIGNAL('stateChanged(int)'), self.box_touched)
def box_touched(self, state):
if state:
self.tags_box.setText('')
self.tags_box.setEnabled(False)
else:
self.tags_box.setEnabled(True)
class BulkText(BulkBase):
def setup_ui(self, parent):
values = self.all_values = list(self.db.all_custom(num=self.col_id))
values.sort(cmp = lambda x,y: cmp(x.lower(), y.lower()))
if self.col_metadata['is_multiple']:
w = TagsLineEdit(parent, values)
w.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred)
self.widgets = [QLabel('&'+self.col_metadata['name']+': ' +
_('tags to add'), parent), w]
self.adding_widget = w
w = RemoveTags(parent, values)
self.widgets.append(QLabel('&'+self.col_metadata['name']+': ' +
_('tags to remove'), parent))
self.widgets.append(w)
self.removing_widget = w
else:
w = EnComboBox(parent)
w.setSizeAdjustPolicy(w.AdjustToMinimumContentsLengthWithIcon)
w.setMinimumContentsLength(25)
self.widgets = [QLabel('&'+self.col_metadata['name']+':', parent), w]
def initialize(self, book_ids):
if self.col_metadata['is_multiple']:
self.widgets[1].update_tags_cache(self.all_values)
else:
val = self.get_initial_value(book_ids)
self.initial_val = val = self.normalize_db_val(val)
idx = None
for i, c in enumerate(self.all_values):
if c == val:
idx = i
self.widgets[1].addItem(c)
self.widgets[1].setEditText('')
if idx is not None:
self.widgets[1].setCurrentIndex(idx)
def process_each_book(self):
return self.col_metadata['is_multiple']
def getter(self, original_value = None):
if self.col_metadata['is_multiple']:
if self.removing_widget.checkbox.isChecked():
ans = set()
else:
ans = set(original_value)
ans -= set([v.strip() for v in
unicode(self.removing_widget.tags_box.text()).split(',')])
ans |= set([v.strip() for v in
unicode(self.adding_widget.text()).split(',')])
return ans # returning a set instead of a list works, for now at least.
val = unicode(self.widgets[1].currentText()).strip()
if not val:
val = None
return val
bulk_widgets = {
'bool' : BulkBool,
'rating' : BulkRating,
'int': BulkInt,
'float': BulkFloat,
'datetime': BulkDateTime,
'text' : BulkText,
}
def populate_bulk_metadata_page(layout, db, book_ids, parent=None):
x = db.custom_column_num_map
cols = list(x)
cols.sort(cmp=partial(field_sort, x=x))
ans = []
for i, col in enumerate(cols):
dt = x[col]['datatype']
if dt == 'comments':
continue
w = bulk_widgets[dt](db, col, parent)
ans.append(w)
w.initialize(book_ids)
row = layout.rowCount()
if len(w.widgets) == 1:
layout.addWidget(w.widgets[0], row, 0, 1, -1)
else:
for c in range(0, len(w.widgets), 2):
w.widgets[c].setBuddy(w.widgets[c+1])
layout.addWidget(w.widgets[c], row, 0)
layout.addWidget(w.widgets[c+1], row, 1)
row += 1
items = []
if len(ans) > 0:
items.append(QSpacerItem(10, 10, QSizePolicy.Minimum,
QSizePolicy.Expanding))
layout.addItem(items[-1], layout.rowCount(), 0, 1, 1)
layout.setRowStretch(layout.rowCount()-1, 100)
return ans, items

View File

@ -1,7 +1,7 @@
from __future__ import with_statement
__license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
import os, traceback, Queue, time, socket, cStringIO
import os, traceback, Queue, time, socket, cStringIO, re
from threading import Thread, RLock
from itertools import repeat
from functools import partial
@ -19,12 +19,13 @@ from calibre.devices.scanner import DeviceScanner
from calibre.gui2 import config, error_dialog, Dispatcher, dynamic, \
pixmap_to_data, warning_dialog, \
question_dialog
from calibre.ebooks.metadata import authors_to_string
from calibre import preferred_encoding
from calibre.ebooks.metadata import authors_to_string, authors_to_sort_string
from calibre import preferred_encoding, prints
from calibre.utils.filenames import ascii_filename
from calibre.devices.errors import FreeSpaceError
from calibre.utils.smtp import compose_mail, sendmail, extract_email_address, \
config as email_config
from calibre.devices.folder_device.driver import FOLDER_DEVICE
class DeviceJob(BaseJob):
@ -36,6 +37,7 @@ class DeviceJob(BaseJob):
self.exception = None
self.job_manager = job_manager
self._details = _('No details available.')
self._aborted = False
def start_work(self):
self.start_time = time.time()
@ -54,7 +56,11 @@ class DeviceJob(BaseJob):
self.start_work()
try:
self.result = self.func(*self.args, **self.kwargs)
if self._aborted:
return
except (Exception, SystemExit), err:
if self._aborted:
return
self.failed = True
self._details = unicode(err) + '\n\n' + \
traceback.format_exc()
@ -62,6 +68,12 @@ class DeviceJob(BaseJob):
finally:
self.job_done()
def abort(self, err):
self._aborted = True
self.failed = True
self._details = unicode(err)
self.exception = err
@property
def log_file(self):
return cStringIO.StringIO(self._details.encode('utf-8'))
@ -69,7 +81,7 @@ class DeviceJob(BaseJob):
class DeviceManager(Thread):
def __init__(self, connected_slot, job_manager, sleep_time=2):
def __init__(self, connected_slot, job_manager, open_feedback_slot, sleep_time=2):
'''
:sleep_time: Time to sleep between device probes in secs
'''
@ -85,7 +97,10 @@ class DeviceManager(Thread):
self.current_job = None
self.scanner = DeviceScanner()
self.connected_device = None
self.ejected_devices = set([])
self.ejected_devices = set([])
self.connected_device_is_folder = False
self.folder_connection_requests = Queue.Queue(0)
self.open_feedback_slot = open_feedback_slot
def report_progress(self, *args):
pass
@ -98,8 +113,10 @@ class DeviceManager(Thread):
def device(self):
return self.connected_device
def do_connect(self, connected_devices):
def do_connect(self, connected_devices, is_folder_device):
for dev, detected_device in connected_devices:
if dev.OPEN_FEEDBACK_MESSAGE is not None:
self.open_feedback_slot(dev.OPEN_FEEDBACK_MESSAGE)
dev.reset(detected_device=detected_device,
report_progress=self.report_progress)
try:
@ -109,7 +126,8 @@ class DeviceManager(Thread):
traceback.print_exc()
continue
self.connected_device = dev
self.connected_slot(True)
self.connected_device_is_folder = is_folder_device
self.connected_slot(True, is_folder_device)
return True
return False
@ -127,7 +145,7 @@ class DeviceManager(Thread):
if self.connected_device in self.ejected_devices:
self.ejected_devices.remove(self.connected_device)
else:
self.connected_slot(False)
self.connected_slot(False, self.connected_device_is_folder)
self.connected_device = None
def detect_device(self):
@ -148,17 +166,19 @@ class DeviceManager(Thread):
if possibly_connected:
possibly_connected_devices.append((device, detected_device))
if possibly_connected_devices:
if not self.do_connect(possibly_connected_devices):
if not self.do_connect(possibly_connected_devices,
is_folder_device=False):
print 'Connect to device failed, retrying in 5 seconds...'
time.sleep(5)
if not self.do_connect(possibly_connected_devices):
if not self.do_connect(possibly_connected_devices,
is_folder_device=False):
print 'Device connect failed again, giving up'
def umount_device(self, *args):
if self.is_device_connected:
if self.is_device_connected and not self.job_manager.has_device_jobs():
self.connected_device.eject()
self.ejected_devices.add(self.connected_device)
self.connected_slot(False)
self.connected_slot(False, self.connected_device_is_folder)
def next(self):
if not self.jobs.empty():
@ -169,7 +189,23 @@ class DeviceManager(Thread):
def run(self):
while self.keep_going:
self.detect_device()
folder_path = None
while True:
try:
folder_path = self.folder_connection_requests.get_nowait()
except Queue.Empty:
break
if not folder_path or not os.access(folder_path, os.R_OK):
folder_path = None
if not self.is_device_connected and folder_path is not None:
try:
dev = FOLDER_DEVICE(folder_path)
self.do_connect([[dev, None],], is_folder_device=True)
except:
prints('Unable to open folder as device', folder_path)
traceback.print_exc()
else:
self.detect_device()
while True:
job = self.next()
if job is not None:
@ -181,7 +217,6 @@ class DeviceManager(Thread):
break
time.sleep(self.sleep_time)
def create_job(self, func, done, description, args=[], kwargs={}):
job = DeviceJob(func, done, self.job_manager,
args=args, kwargs=kwargs, description=description)
@ -207,6 +242,21 @@ class DeviceManager(Thread):
return self.create_job(self._get_device_information, done,
description=_('Get device information'))
# This will be called on the GUI thread. Because of this, we must store
# information that the scanner thread will use to do the real work.
def connect_to_folder(self, path):
self.folder_connection_requests.put(path)
# This is called on the GUI thread. No problem here, because it calls the
# device driver, telling it to tell the scanner when it passes by that the
# folder has disconnected.
def disconnect_folder(self):
if self.connected_device is not None:
if hasattr(self.connected_device, 'disconnect_from_folder'):
# As we are on the wrong thread, this call must *not* do
# anything besides set a flag that the right thread will see.
self.connected_device.disconnect_from_folder()
def _books(self):
'''Get metadata from device'''
mainlist = self.device.books(oncard=None, end_session=False)
@ -291,15 +341,17 @@ class DeviceManager(Thread):
class DeviceAction(QAction):
a_s = pyqtSignal(object)
def __init__(self, dest, delete, specific, icon_path, text, parent=None):
if delete:
text += ' ' + _('and delete from library')
QAction.__init__(self, QIcon(icon_path), text, parent)
self.dest = dest
self.delete = delete
self.specific = specific
self.connect(self, SIGNAL('triggered(bool)'),
lambda x : self.emit(SIGNAL('a_s(QAction)'), self))
self.triggered.connect(self.emit_triggered)
def emit_triggered(self, *args):
self.a_s.emit(self)
def __repr__(self):
return self.__class__.__name__ + ':%s:%s:%s'%(self.dest, self.delete,
@ -309,6 +361,8 @@ class DeviceAction(QAction):
class DeviceMenu(QMenu):
fetch_annotations = pyqtSignal()
connect_to_folder = pyqtSignal()
disconnect_from_folder = pyqtSignal()
def __init__(self, parent=None):
QMenu.__init__(self, parent)
@ -316,8 +370,9 @@ class DeviceMenu(QMenu):
self.actions = []
self._memory = []
self.set_default_menu = self.addMenu(_('Set default send to device'
' action'))
self.set_default_menu = QMenu(_('Set default send to device action'))
self.set_default_menu.setIcon(QIcon(I('config.svg')))
opts = email_config().parse()
default_account = None
if opts.accounts:
@ -341,51 +396,65 @@ class DeviceMenu(QMenu):
self.connect(action2, SIGNAL('a_s(QAction)'),
self.action_triggered)
_actions = [
basic_actions = [
('main:', False, False, I('reader.svg'),
_('Send to main memory')),
('carda:0', False, False, I('sd.svg'),
_('Send to storage card A')),
('cardb:0', False, False, I('sd.svg'),
_('Send to storage card B')),
'-----',
]
delete_actions = [
('main:', True, False, I('reader.svg'),
_('Send to main memory')),
_('Main Memory')),
('carda:0', True, False, I('sd.svg'),
_('Send to storage card A')),
_('Storage Card A')),
('cardb:0', True, False, I('sd.svg'),
_('Send to storage card B')),
'-----',
_('Storage Card B')),
]
specific_actions = [
('main:', False, True, I('reader.svg'),
_('Send specific format to main memory')),
_('Main Memory')),
('carda:0', False, True, I('sd.svg'),
_('Send specific format to storage card A')),
_('Storage Card A')),
('cardb:0', False, True, I('sd.svg'),
_('Send specific format to storage card B')),
_('Storage Card B')),
]
]
if default_account is not None:
_actions.insert(2, default_account)
_actions.insert(6, list(default_account))
_actions[6][1] = True
for round in (0, 1):
for dest, delete, specific, icon, text in _actions:
if dest == '-':
(self.set_default_menu if round else self).addSeparator()
continue
action = DeviceAction(dest, delete, specific, icon, text, self)
self._memory.append(action)
if round == 1:
action.setCheckable(True)
action.setText(action.text())
self.group.addAction(action)
self.set_default_menu.addAction(action)
else:
self.connect(action, SIGNAL('a_s(QAction)'),
self.action_triggered)
self.actions.append(action)
self.addAction(action)
for x in (basic_actions, delete_actions):
ac = list(default_account)
if x is delete_actions:
ac[1] = True
x.insert(1, tuple(ac))
for menu in (self, self.set_default_menu):
for actions, desc in (
(basic_actions, ''),
(delete_actions, _('Send and delete from library')),
(specific_actions, _('Send specific format'))
):
mdest = menu
if actions is not basic_actions:
mdest = menu.addMenu(desc)
self._memory.append(mdest)
for dest, delete, specific, icon, text in actions:
action = DeviceAction(dest, delete, specific, icon, text, self)
self._memory.append(action)
if menu is self.set_default_menu:
action.setCheckable(True)
action.setText(action.text())
self.group.addAction(action)
else:
action.a_s.connect(self.action_triggered)
self.actions.append(action)
mdest.addAction(action)
if actions is not specific_actions:
menu.addSeparator()
da = config['default_send_to_device_action']
done = False
@ -399,11 +468,24 @@ class DeviceMenu(QMenu):
action.setChecked(True)
config['default_send_to_device_action'] = repr(action)
self.connect(self.group, SIGNAL('triggered(QAction*)'),
self.change_default_action)
self.group.triggered.connect(self.change_default_action)
if opts.accounts:
self.addSeparator()
self.addMenu(self.email_to_menu)
self.addSeparator()
mitem = self.addAction(QIcon(I('document_open.svg')), _('Connect to folder'))
mitem.setEnabled(True)
mitem.triggered.connect(lambda x : self.connect_to_folder.emit())
self.connect_to_folder_action = mitem
mitem = self.addAction(QIcon(I('eject.svg')), _('Disconnect from folder'))
mitem.setEnabled(False)
mitem.triggered.connect(lambda x : self.disconnect_from_folder.emit())
self.disconnect_from_folder_action = mitem
self.addSeparator()
self.addMenu(self.set_default_menu)
self.addSeparator()
annot = self.addAction(_('Fetch annotations (experimental)'))
annot.setEnabled(False)
@ -523,7 +605,8 @@ class DeviceGUI(object):
d = ChooseFormatDialog(self, _('Choose format to send to device'),
self.device_manager.device.settings().format_map)
d.exec_()
fmt = d.format().lower()
if d.format():
fmt = d.format().lower()
dest, sub_dest = dest.split(':')
if dest in ('main', 'carda', 'cardb'):
if not self.device_connected or not self.device_manager:
@ -821,7 +904,9 @@ class DeviceGUI(object):
def sync_to_device(self, on_card, delete_from_library,
specific_format=None, send_ids=None, do_auto_convert=True):
ids = [self.library_view.model().id(r) for r in self.library_view.selectionModel().selectedRows()] if send_ids is None else send_ids
ids = [self.library_view.model().id(r) \
for r in self.library_view.selectionModel().selectedRows()] \
if send_ids is None else send_ids
if not self.device_manager or not ids or len(ids) == 0:
return
@ -842,8 +927,7 @@ class DeviceGUI(object):
ids = iter(ids)
for mi in metadata:
if mi.cover and os.access(mi.cover, os.R_OK):
mi.thumbnail = self.cover_to_thumbnail(open(mi.cover,
'rb').read())
mi.thumbnail = self.cover_to_thumbnail(open(mi.cover, 'rb').read())
imetadata = iter(metadata)
files = [getattr(f, 'name', None) for f in _files]
@ -890,7 +974,9 @@ class DeviceGUI(object):
bad.append(self.library_view.model().db.title(id, index_is_id=True))
if auto != []:
format = specific_format if specific_format in list(set(settings.format_map).intersection(set(available_output_formats()))) else None
format = specific_format if specific_format in \
list(set(settings.format_map).intersection(set(available_output_formats()))) \
else None
if not format:
for fmt in settings.format_map:
if fmt in list(set(settings.format_map).intersection(set(available_output_formats()))):
@ -971,10 +1057,137 @@ class DeviceGUI(object):
self.upload_booklists()
books_to_be_deleted = []
if memory and memory[1]:
books_to_be_deleted = memory[1]
self.library_view.model().delete_books_by_id(books_to_be_deleted)
self.set_books_in_library(self.booklists(),
reset=bool(books_to_be_deleted))
view = self.card_a_view if on_card == 'carda' else self.card_b_view if on_card == 'cardb' else self.memory_view
view.model().resort(reset=False)
view.model().research()
for f in files:
getattr(f, 'close', lambda : True)()
if memory and memory[1]:
self.library_view.model().delete_books_by_id(memory[1])
self.book_on_device(None, reset=True)
if metadata:
changed = set([])
for mi in metadata:
id_ = getattr(mi, 'application_id', None)
if id_ is not None:
changed.add(id_)
if changed:
self.library_view.model().refresh_ids(list(changed))
def book_on_device(self, id, format=None, reset=False):
loc = [None, None, None]
if reset:
self.book_db_title_cache = None
self.book_db_uuid_cache = None
return
if self.book_db_title_cache is None:
self.book_db_title_cache = []
self.book_db_uuid_cache = []
for i, l in enumerate(self.booklists()):
self.book_db_title_cache.append({})
self.book_db_uuid_cache.append(set())
for book in l:
book_title = book.title.lower() if book.title else ''
book_title = re.sub('(?u)\W|[_]', '', book_title)
if book_title not in self.book_db_title_cache[i]:
self.book_db_title_cache[i][book_title] = \
{'authors':set(), 'db_ids':set(), 'uuids':set()}
book_authors = authors_to_string(book.authors).lower()
book_authors = re.sub('(?u)\W|[_]', '', book_authors)
self.book_db_title_cache[i][book_title]['authors'].add(book_authors)
db_id = getattr(book, 'application_id', None)
if db_id is None:
db_id = book.db_id
if db_id is not None:
self.book_db_title_cache[i][book_title]['db_ids'].add(db_id)
uuid = getattr(book, 'uuid', None)
if uuid is not None:
self.book_db_uuid_cache[i].add(uuid)
mi = self.library_view.model().db.get_metadata(id, index_is_id=True)
for i, l in enumerate(self.booklists()):
if mi.uuid in self.book_db_uuid_cache[i]:
loc[i] = True
continue
db_title = re.sub('(?u)\W|[_]', '', mi.title.lower())
cache = self.book_db_title_cache[i].get(db_title, None)
if cache:
if id in cache['db_ids']:
loc[i] = True
break
if mi.authors and \
re.sub('(?u)\W|[_]', '', authors_to_string(mi.authors).lower()) \
in cache['authors']:
loc[i] = True
break
return loc
def set_books_in_library(self, booklists, reset=False):
if reset:
# First build a cache of the library, so the search isn't On**2
self.db_book_title_cache = {}
self.db_book_uuid_cache = set()
db = self.library_view.model().db
for id in db.data.iterallids():
mi = db.get_metadata(id, index_is_id=True)
title = re.sub('(?u)\W|[_]', '', mi.title.lower())
if title not in self.db_book_title_cache:
self.db_book_title_cache[title] = {'authors':{}, 'db_ids':{}}
authors = authors_to_string(mi.authors).lower() if mi.authors else ''
authors = re.sub('(?u)\W|[_]', '', authors)
self.db_book_title_cache[title]['authors'][authors] = mi
self.db_book_title_cache[title]['db_ids'][mi.application_id] = mi
self.db_book_uuid_cache.add(mi.uuid)
# Now iterate through all the books on the device, setting the
# in_library field Fastest and most accurate key is the uuid. Second is
# the application_id, which is really the db key, but as this can
# accidentally match across libraries we also verify the title. The
# db_id exists on Sony devices. Fallback is title and author match
resend_metadata = False
for booklist in booklists:
for book in booklist:
if getattr(book, 'uuid', None) in self.db_book_uuid_cache:
book.in_library = True
continue
book_title = book.title.lower() if book.title else ''
book_title = re.sub('(?u)\W|[_]', '', book_title)
book.in_library = False
d = self.db_book_title_cache.get(book_title, None)
if d is not None:
if getattr(book, 'application_id', None) in d['db_ids']:
book.in_library = True
book.smart_update(d['db_ids'][book.application_id])
resend_metadata = True
continue
if book.db_id in d['db_ids']:
book.in_library = True
book.smart_update(d['db_ids'][book.db_id])
resend_metadata = True
continue
book_authors = authors_to_string(book.authors).lower() if book.authors else ''
book_authors = re.sub('(?u)\W|[_]', '', book_authors)
if book_authors in d['authors']:
book.in_library = True
book.smart_update(d['authors'][book_authors])
resend_metadata = True
# Set author_sort if it isn't already
asort = getattr(book, 'author_sort', None)
if not asort and book.authors:
book.author_sort = authors_to_sort_string(book.authors)
resend_metadata = True
if resend_metadata:
# Correct the metadata cache on device.
if self.device_manager.is_device_connected:
self.device_manager.sync_booklists(None, booklists)

View File

@ -6,18 +6,17 @@ __docformat__ = 'restructuredtext en'
''''''
from PyQt4.QtGui import QDialog
from calibre.gui2.dialogs.comicconf_ui import Ui_Dialog
from calibre.gui2 import qstring_to_unicode
from calibre.ebooks.lrf.comic.convert_from import config, PROFILES
def set_conversion_defaults(window):
d = ComicConf(window)
d.exec_()
def get_bulk_conversion_options(window):
d = ComicConf(window, config_defaults=config(None).as_string())
if d.exec_() == QDialog.Accepted:
return d.config.parse()
def get_conversion_options(window, defaults, title, author):
if defaults is None:
defaults = config(None).as_string()
@ -26,10 +25,10 @@ def get_conversion_options(window, defaults, title, author):
if d.exec_() == QDialog.Accepted:
return d.config.parse(), d.config.src
return None, None
class ComicConf(QDialog, Ui_Dialog):
def __init__(self, window, config_defaults=None, generic=True,
title=_('Set defaults for conversion of comics (CBR/CBZ files)')):
QDialog.__init__(self, window)
@ -63,12 +62,12 @@ class ComicConf(QDialog, Ui_Dialog):
self.opt_despeckle.setChecked(opts.despeckle)
self.opt_wide.setChecked(opts.wide)
self.opt_right2left.setChecked(opts.right2left)
for opt in self.config.option_set.preferences:
g = getattr(self, 'opt_'+opt.name, False)
if opt.help and g:
g.setToolTip(opt.help)
def accept(self):
for opt in self.config.option_set.preferences:
g = getattr(self, 'opt_'+opt.name, False)
@ -78,9 +77,9 @@ class ComicConf(QDialog, Ui_Dialog):
elif hasattr(g, 'value'):
val = g.value()
elif hasattr(g, 'itemText'):
val = qstring_to_unicode(g.itemText(g.currentIndex()))
val = unicode(g.itemText(g.currentIndex()))
elif hasattr(g, 'text'):
val = qstring_to_unicode(g.text())
val = unicode(g.text())
else:
raise Exception('Bad coding')
self.config.set(opt.name, val)

View File

@ -0,0 +1,17 @@
#!/usr/bin/env python
__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
__docformat__ = 'restructuredtext en'
__license__ = 'GPL v3'
from PyQt4.Qt import QDialog
from calibre.gui2.dialogs.comments_dialog_ui import Ui_CommentsDialog
class CommentsDialog(QDialog, Ui_CommentsDialog):
def __init__(self, parent, text):
QDialog.__init__(self, parent)
Ui_CommentsDialog.__init__(self)
self.setupUi(self)
if text is not None:
self.textbox.setPlainText(text)
self.textbox.setTabChangesFocus(True)

View File

@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>CommentsDialog</class>
<widget class="QDialog" name="CommentsDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>336</width>
<height>235</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="MinimumExpanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="windowTitle">
<string>Edit Comments</string>
</property>
<widget class="QWidget" name="verticalLayoutWidget">
<property name="geometry">
<rect>
<x>10</x>
<y>10</y>
<width>311</width>
<height>211</height>
</rect>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QPlainTextEdit" name="textbox"/>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>CommentsDialog</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>229</x>
<y>211</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>234</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>CommentsDialog</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>297</x>
<y>217</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>234</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@ -1,6 +1,7 @@
__license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
import os, re, time, textwrap
import os, re, time, textwrap, copy, sys
from PyQt4.Qt import QDialog, QListWidgetItem, QIcon, \
QDesktopServices, QVBoxLayout, QLabel, QPlainTextEdit, \
@ -12,14 +13,14 @@ from PyQt4.Qt import QDialog, QListWidgetItem, QIcon, \
from calibre.constants import iswindows, isosx
from calibre.gui2.dialogs.config.config_ui import Ui_Dialog
from calibre.gui2 import qstring_to_unicode, choose_dir, error_dialog, config, \
from calibre.gui2.dialogs.config.create_custom_column import CreateCustomColumn
from calibre.gui2 import choose_dir, error_dialog, config, \
ALL_COLUMNS, NONE, info_dialog, choose_files, \
warning_dialog, ResizableDialog
warning_dialog, ResizableDialog, question_dialog
from calibre.utils.config import prefs
from calibre.gui2.library import BooksModel
from calibre.ebooks import BOOK_EXTENSIONS
from calibre.ebooks.oeb.iterator import is_supported
from calibre.library import server_config
from calibre.library.server import server_config
from calibre.customize.ui import initialized_plugins, is_disabled, enable_plugin, \
disable_plugin, customize_plugin, \
plugin_customization, add_plugin, \
@ -90,7 +91,6 @@ class ConfigTabs(QTabWidget):
widget.commit(save_defaults=True)
return True
class PluginModel(QAbstractItemModel):
def __init__(self, *args):
@ -110,6 +110,9 @@ class PluginModel(QAbstractItemModel):
self._data[plugin.type].append(plugin)
self.categories = sorted(self._data.keys())
for plugins in self._data.values():
plugins.sort(cmp=lambda x, y: cmp(x.name.lower(), y.name.lower()))
def index(self, row, column, parent):
if not self.hasIndex(row, column, parent):
return QModelIndex()
@ -328,14 +331,17 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
def category_current_changed(self, n, p):
self.stackedWidget.setCurrentIndex(n.row())
def __init__(self, window, db, server=None):
ResizableDialog.__init__(self, window)
def __init__(self, parent, library_view, server=None):
ResizableDialog.__init__(self, parent)
self.ICON_SIZES = {0:QSize(48, 48), 1:QSize(32,32), 2:QSize(24,24)}
self._category_model = CategoryModel()
self.category_view.currentChanged = self.category_current_changed
self.category_view.setModel(self._category_model)
self.db = db
self.parent = parent
self.library_view = library_view
self.model = library_view.model()
self.db = self.model.db
self.server = server
path = prefs['library_path']
self.location.setText(path if path else '')
@ -359,18 +365,28 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
self.roman_numerals.setChecked(rn)
self.new_version_notification.setChecked(config['new_version_notification'])
column_map = config['column_map']
for col in column_map + [i for i in ALL_COLUMNS if i not in column_map]:
try:
item = QListWidgetItem(BooksModel.headers[col], self.columns)
except KeyError:
continue
# Set up columns
colmap = list(self.model.column_map)
state = self.library_view.get_state()
hidden_cols = state['hidden_columns']
positions = state['column_positions']
colmap.sort(cmp=lambda x,y: cmp(positions[x], positions[y]))
self.custcols = copy.deepcopy(self.db.field_metadata.get_custom_field_metadata())
for col in colmap:
item = QListWidgetItem(self.model.headers[col], self.columns)
item.setData(Qt.UserRole, QVariant(col))
item.setFlags(Qt.ItemIsEnabled|Qt.ItemIsUserCheckable|Qt.ItemIsSelectable)
item.setCheckState(Qt.Checked if col in column_map else Qt.Unchecked)
self.connect(self.column_up, SIGNAL('clicked()'), self.up_column)
self.connect(self.column_down, SIGNAL('clicked()'), self.down_column)
flags = Qt.ItemIsEnabled|Qt.ItemIsSelectable
if col != 'ondevice':
flags |= Qt.ItemIsUserCheckable
item.setFlags(flags)
if col != 'ondevice':
item.setCheckState(Qt.Unchecked if col in hidden_cols else
Qt.Checked)
self.column_up.clicked.connect(self.up_column)
self.column_down.clicked.connect(self.down_column)
self.del_custcol_button.clicked.connect(self.del_custcol)
self.add_custcol_button.clicked.connect(self.add_custcol)
self.edit_custcol_button.clicked.connect(self.edit_custcol)
icons = config['toolbar_icon_size']
self.toolbar_button_size.setCurrentIndex(0 if icons == self.ICON_SIZES[0] else 1 if icons == self.ICON_SIZES[1] else 2)
@ -401,7 +417,6 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
for item in items:
self.language.addItem(item[1], QVariant(item[0]))
exts = set([])
for ext in BOOK_EXTENSIONS:
ext = ext.lower()
@ -635,6 +650,7 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
self.input_order.insertItem(idx+1, self.input_order.takeItem(idx))
self.input_order.setCurrentRow(idx+1)
# Column settings {{{
def up_column(self):
idx = self.columns.currentRow()
if idx > 0:
@ -647,6 +663,77 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
self.columns.insertItem(idx+1, self.columns.takeItem(idx))
self.columns.setCurrentRow(idx+1)
def del_custcol(self):
idx = self.columns.currentRow()
if idx < 0:
return error_dialog(self, '', _('You must select a column to delete it'),
show=True)
col = unicode(self.columns.item(idx).data(Qt.UserRole).toString())
if col not in self.custcols:
return error_dialog(self, '',
_('The selected column is not a custom column'), show=True)
if not question_dialog(self, _('Are you sure?'),
_('Do you really want to delete column %s and all its data?') %
self.custcols[col]['name'], show_copy_button=False):
return
self.columns.item(idx).setCheckState(False)
self.columns.takeItem(idx)
self.custcols[col]['*deleteme'] = True
return
def add_custcol(self):
CreateCustomColumn(self, False, self.model.orig_headers, ALL_COLUMNS)
def edit_custcol(self):
CreateCustomColumn(self, True, self.model.orig_headers, ALL_COLUMNS)
def apply_custom_column_changes(self):
config_cols = [unicode(self.columns.item(i).data(Qt.UserRole).toString())\
for i in range(self.columns.count())]
if not config_cols:
config_cols = ['title']
removed_cols = set(self.model.column_map) - set(config_cols)
hidden_cols = set([unicode(self.columns.item(i).data(Qt.UserRole).toString())\
for i in range(self.columns.count()) \
if self.columns.item(i).checkState()==Qt.Unchecked])
hidden_cols = hidden_cols.union(removed_cols) # Hide removed cols
hidden_cols = list(hidden_cols.intersection(set(self.model.column_map)))
if 'ondevice' in hidden_cols:
hidden_cols.remove('ondevice')
def col_pos(x, y):
xidx = config_cols.index(x) if x in config_cols else sys.maxint
yidx = config_cols.index(y) if y in config_cols else sys.maxint
return cmp(xidx, yidx)
positions = {}
for i, col in enumerate((sorted(self.model.column_map, cmp=col_pos))):
positions[col] = i
state = {'hidden_columns': hidden_cols, 'column_positions':positions}
self.library_view.apply_state(state)
self.library_view.save_state()
must_restart = False
for c in self.custcols:
if self.custcols[c]['colnum'] is None:
self.db.create_custom_column(
label=self.custcols[c]['label'],
name=self.custcols[c]['name'],
datatype=self.custcols[c]['datatype'],
is_multiple=self.custcols[c]['is_multiple'],
display = self.custcols[c]['display'])
must_restart = True
elif '*deleteme' in self.custcols[c]:
self.db.delete_custom_column(label=self.custcols[c]['label'])
must_restart = True
elif '*edited' in self.custcols[c]:
cc = self.custcols[c]
self.db.set_custom_column_metadata(cc['colnum'], name=cc['name'],
label=cc['label'],
display = self.custcols[c]['display'])
if '*must_restart' in self.custcols[c]:
must_restart = True
return must_restart
# }}}
def view_server_logs(self):
from calibre.library.server import log_access_file, log_error_file
d = QDialog(self)
@ -683,7 +770,7 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
def start_server(self):
self.set_server_options()
from calibre.library.server import start_threaded_server
from calibre.library.server.main import start_threaded_server
self.server = start_threaded_server(self.db, server_config().parse())
while not self.server.is_running and self.server.exception is None:
time.sleep(1)
@ -696,7 +783,7 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
self.stop.setEnabled(True)
def stop_server(self):
from calibre.library.server import stop_threaded_server
from calibre.library.server.main import stop_threaded_server
stop_threaded_server(self.server)
self.server = None
self.start.setEnabled(True)
@ -716,7 +803,6 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
if dir:
self.location.setText(dir)
def accept(self):
mcs = unicode(self.max_cover_size.text()).strip()
if not re.match(r'\d+x\d+', mcs):
@ -734,17 +820,15 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
wl += 1
config['worker_limit'] = wl
config['use_roman_numerals_for_series_number'] = bool(self.roman_numerals.isChecked())
config['new_version_notification'] = bool(self.new_version_notification.isChecked())
prefs['network_timeout'] = int(self.timeout.value())
path = qstring_to_unicode(self.location.text())
path = unicode(self.location.text())
input_cols = [unicode(self.input_order.item(i).data(Qt.UserRole).toString()) for i in range(self.input_order.count())]
prefs['input_format_order'] = input_cols
cols = [unicode(self.columns.item(i).data(Qt.UserRole).toString()) for i in range(self.columns.count()) if self.columns.item(i).checkState()==Qt.Checked]
if not cols:
cols = ['title']
config['column_map'] = cols
must_restart = self.apply_custom_column_changes()
config['toolbar_icon_size'] = self.ICON_SIZES[self.toolbar_button_size.currentIndex()]
config['show_text_in_toolbar'] = bool(self.show_toolbar_text.isChecked())
config['separate_cover_flow'] = bool(self.separate_cover_flow.isChecked())
@ -785,6 +869,12 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
d.exec_()
else:
self.database_location = os.path.abspath(path)
if must_restart:
warning_dialog(self, _('Must restart'),
_('The changes you made require that Calibre be '
'restarted. Please restart as soon as practical.'),
show=True, show_copy_button=False)
self.parent.must_restart_before_config = True
QDialog.accept(self)
class VacThread(QThread):

View File

@ -498,6 +498,87 @@
</property>
</spacer>
</item>
<item>
<widget class="QToolButton" name="del_custcol_button">
<property name="toolTip">
<string>Remove a user-defined column</string>
</property>
<property name="text">
<string>...</string>
</property>
<property name="icon">
<iconset resource="../../../../../resources/images.qrc">
<normaloff>:/images/minus.svg</normaloff>:/images/minus.svg</iconset>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QToolButton" name="add_custcol_button">
<property name="text">
<string>...</string>
</property>
<property name="toolTip">
<string>Add a user-defined column</string>
</property>
<property name="icon">
<iconset resource="../../../../../resources/images.qrc">
<normaloff>:/images/plus.svg</normaloff>:/images/plus.svg</iconset>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QToolButton" name="edit_custcol_button">
<property name="toolTip">
<string>Edit settings of a user-defined column</string>
</property>
<property name="text">
<string>...</string>
</property>
<property name="icon">
<iconset resource="../../../../../resources/images.qrc">
<normaloff>:/images/edit_input.svg</normaloff>:/images/edit_input.svg</iconset>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QToolButton" name="column_down">
<property name="text">

View File

@ -0,0 +1,164 @@
__license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal <kovid at kovidgoyal.net>'
'''Dialog to create a new custom column'''
from functools import partial
from PyQt4.QtCore import SIGNAL
from PyQt4.Qt import QDialog, Qt, QListWidgetItem, QVariant
from calibre.gui2.dialogs.config.create_custom_column_ui import Ui_QCreateCustomColumn
from calibre.gui2 import error_dialog
class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn):
column_types = {
0:{'datatype':'text',
'text':_('Text, column shown in the tag browser'),
'is_multiple':False},
1:{'datatype':'*text',
'text':_('Comma separated text, like tags, shown in the tag browser'),
'is_multiple':True},
2:{'datatype':'comments',
'text':_('Long text, like comments, not shown in the tag browser'),
'is_multiple':False},
3:{'datatype':'datetime',
'text':_('Date'), 'is_multiple':False},
4:{'datatype':'float',
'text':_('Floating point numbers'), 'is_multiple':False},
5:{'datatype':'int',
'text':_('Integers'), 'is_multiple':False},
6:{'datatype':'rating',
'text':_('Ratings, shown with stars'),
'is_multiple':False},
7:{'datatype':'bool',
'text':_('Yes/No'), 'is_multiple':False},
}
def __init__(self, parent, editing, standard_colheads, standard_colnames):
QDialog.__init__(self, parent)
Ui_QCreateCustomColumn.__init__(self)
self.setupUi(self)
self.simple_error = partial(error_dialog, self, show=True,
show_copy_button=False)
self.connect(self.button_box, SIGNAL("accepted()"), self.accept)
self.connect(self.button_box, SIGNAL("rejected()"), self.reject)
self.parent = parent
self.editing_col = editing
self.standard_colheads = standard_colheads
self.standard_colnames = standard_colnames
for t in self.column_types:
self.column_type_box.addItem(self.column_types[t]['text'])
self.column_type_box.currentIndexChanged.connect(self.datatype_changed)
if not self.editing_col:
self.datatype_changed()
self.exec_()
return
idx = parent.columns.currentRow()
if idx < 0:
self.simple_error(_('No column selected'),
_('No column has been selected'))
return
col = unicode(parent.columns.item(idx).data(Qt.UserRole).toString())
if col not in parent.custcols:
self.simple_error('', _('Selected column is not a user-defined column'))
return
c = parent.custcols[col]
self.column_name_box.setText(c['label'])
self.column_heading_box.setText(c['name'])
ct = c['datatype'] if not c['is_multiple'] else '*text'
self.orig_column_number = c['colnum']
self.orig_column_name = col
column_numbers = dict(map(lambda x:(self.column_types[x]['datatype'], x), self.column_types))
self.column_type_box.setCurrentIndex(column_numbers[ct])
self.column_type_box.setEnabled(False)
if ct == 'datetime':
if c['display'].get('date_format', None):
self.date_format_box.setText(c['display'].get('date_format', ''))
self.datatype_changed()
self.exec_()
def datatype_changed(self, *args):
try:
col_type = self.column_types[self.column_type_box.currentIndex()]['datatype']
except:
col_type = None
df_visible = col_type == 'datetime'
for x in ('box', 'default_label', 'label'):
getattr(self, 'date_format_'+x).setVisible(df_visible)
def accept(self):
col = unicode(self.column_name_box.text()).lower()
if not col:
return self.simple_error('', _('No lookup name was provided'))
if not col.isalnum() or not col[0].isalpha():
return self.simple_error('', _('The label must contain only letters and digits, and start with a letter'))
col_heading = unicode(self.column_heading_box.text())
col_type = self.column_types[self.column_type_box.currentIndex()]['datatype']
if col_type == '*text':
col_type='text'
is_multiple = True
else:
is_multiple = False
if not col_heading:
return self.simple_error('', _('No column heading was provided'))
bad_col = False
if col in self.parent.custcols:
if not self.editing_col or self.parent.custcols[col]['colnum'] != self.orig_column_number:
bad_col = True
if bad_col:
return self.simple_error('', _('The lookup name %s is already used')%col)
bad_head = False
for t in self.parent.custcols:
if self.parent.custcols[t]['name'] == col_heading:
if not self.editing_col or self.parent.custcols[t]['colnum'] != self.orig_column_number:
bad_head = True
for t in self.standard_colheads:
if self.standard_colheads[t] == col_heading:
bad_head = True
if bad_head:
return self.simple_error('', _('The heading %s is already used')%col_heading)
if ':' in col or ' ' in col or col.lower() != col:
return self.simple_error('', _('The lookup name must be lower case and cannot contain ":"s or spaces'))
date_format = {}
if col_type == 'datetime':
if self.date_format_box.text():
date_format = {'date_format':unicode(self.date_format_box.text())}
else:
date_format = {'date_format': None}
key = self.parent.db.field_metadata.custom_field_prefix+col
if not self.editing_col:
self.parent.db.field_metadata
self.parent.custcols[key] = {
'label':col,
'name':col_heading,
'datatype':col_type,
'editable':True,
'display':date_format,
'normalized':None,
'colnum':None,
'is_multiple':is_multiple,
}
item = QListWidgetItem(col_heading, self.parent.columns)
item.setData(Qt.UserRole, QVariant(key))
item.setFlags(Qt.ItemIsEnabled|Qt.ItemIsUserCheckable|Qt.ItemIsSelectable)
item.setCheckState(Qt.Checked)
else:
idx = self.parent.columns.currentRow()
item = self.parent.columns.item(idx)
item.setData(Qt.UserRole, QVariant(key))
item.setText(col_heading)
self.parent.custcols[self.orig_column_name]['label'] = col
self.parent.custcols[self.orig_column_name]['name'] = col_heading
self.parent.custcols[self.orig_column_name]['display'].update(date_format)
self.parent.custcols[self.orig_column_name]['*edited'] = True
self.parent.custcols[self.orig_column_name]['*must_restart'] = True
QDialog.accept(self)
def reject(self):
QDialog.reject(self)

View File

@ -0,0 +1,191 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>QCreateCustomColumn</class>
<widget class="QDialog" name="QCreateCustomColumn">
<property name="windowModality">
<enum>Qt::ApplicationModal</enum>
</property>
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>528</width>
<height>199</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="windowTitle">
<string>Create or edit custom columns</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QGridLayout" name="gridLayout_2" rowstretch="0,0,0,0">
<property name="sizeConstraint">
<enum>QLayout::SetDefaultConstraint</enum>
</property>
<property name="margin">
<number>5</number>
</property>
<item row="2" column="0">
<layout class="QGridLayout" name="gridLayout">
<property name="margin">
<number>0</number>
</property>
<item row="0" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>&amp;Lookup name</string>
</property>
<property name="buddy">
<cstring>column_name_box</cstring>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Column &amp;heading</string>
</property>
<property name="buddy">
<cstring>column_heading_box</cstring>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="column_name_box">
<property name="minimumSize">
<size>
<width>20</width>
<height>0</height>
</size>
</property>
<property name="toolTip">
<string>Used for searching the column. Must contain only digits and lower case letters.</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="column_heading_box">
<property name="toolTip">
<string>Column heading in the library view and category name in the tag browser</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
<string>Column &amp;type</string>
</property>
<property name="buddy">
<cstring>column_type_box</cstring>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QComboBox" name="column_type_box">
<property name="sizePolicy">
<sizepolicy hsizetype="Maximum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>70</width>
<height>0</height>
</size>
</property>
<property name="toolTip">
<string>What kind of information will be kept in the column.</string>
</property>
</widget>
</item>
<item row="4" column="1">
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<widget class="QLineEdit" name="date_format_box">
<property name="sizePolicy">
<sizepolicy hsizetype="Maximum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="toolTip">
<string>&lt;p&gt;Date format. Use 1-4 'd's for day, 1-4 'M's for month, and 2 or 4 'y's for year.&lt;/p&gt;
&lt;p&gt;For example:
&lt;ul&gt;
&lt;li&gt; ddd, d MMM yyyy gives Mon, 5 Jan 2010&lt;li&gt;
&lt;li&gt;dd MMMM yy gives 05 January 10&lt;/li&gt;
&lt;/ul&gt; </string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="date_format_default_label">
<property name="toolTip">
<string>Use MMM yyyy for month + year, yyyy for year only</string>
</property>
<property name="text">
<string>Default: dd MMM yyyy.</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="4" column="0">
<widget class="QLabel" name="date_format_label">
<property name="text">
<string>Format for &amp;dates</string>
</property>
<property name="buddy">
<cstring>date_format_box</cstring>
</property>
</widget>
</item>
</layout>
</item>
<item row="3" column="0">
<widget class="QDialogButtonBox" name="button_box">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
<property name="centerButtons">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_6">
<property name="font">
<font>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>Create or edit custom columns</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<tabstops>
<tabstop>column_name_box</tabstop>
<tabstop>column_heading_box</tabstop>
<tabstop>column_type_box</tabstop>
<tabstop>date_format_box</tabstop>
<tabstop>button_box</tabstop>
</tabstops>
<resources/>
<connections/>
</ui>

View File

@ -14,12 +14,12 @@
<string>Active Jobs</string>
</property>
<property name="windowIcon">
<iconset resource="../../../work/calibre/resources/images.qrc">
<iconset resource="../../../../resources/images.qrc">
<normaloff>:/images/jobs.svg</normaloff>:/images/jobs.svg</iconset>
</property>
<layout class="QVBoxLayout">
<item>
<widget class="JobsView" name="jobs_view">
<widget class="QTableView" name="jobs_view">
<property name="contextMenuPolicy">
<enum>Qt::NoContextMenu</enum>
</property>
@ -66,15 +66,8 @@
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>JobsView</class>
<extends>QTableView</extends>
<header>widgets.h</header>
</customwidget>
</customwidgets>
<resources>
<include location="../../../work/calibre/resources/images.qrc"/>
<include location="../../../../resources/images.qrc"/>
</resources>
<connections/>
</ui>

View File

@ -4,12 +4,13 @@ __copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
'''Dialog to edit metadata in bulk'''
from PyQt4.QtCore import SIGNAL, QObject
from PyQt4.QtGui import QDialog
from PyQt4.QtGui import QDialog, QGridLayout
from calibre.gui2.dialogs.metadata_bulk_ui import Ui_MetadataBulkDialog
from calibre.gui2.dialogs.tag_editor import TagEditor
from calibre.ebooks.metadata import string_to_authors, authors_to_sort_string, \
authors_to_string
from calibre.gui2.custom_column_widgets import populate_bulk_metadata_page
class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
@ -19,7 +20,8 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
self.setupUi(self)
self.db = db
self.ids = [db.id(r) for r in rows]
self.groupBox.setTitle(_('Editing meta information for %d books') %
self.box_title.setText('<p>' +
_('Editing meta information for <b>%d books</b>') %
len(rows))
self.write_series = False
self.changed = False
@ -38,9 +40,27 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
QObject.connect(self.series, SIGNAL('currentIndexChanged(int)'), self.series_changed)
QObject.connect(self.series, SIGNAL('editTextChanged(QString)'), self.series_changed)
QObject.connect(self.tag_editor_button, SIGNAL('clicked()'), self.tag_editor)
if len(db.custom_column_label_map) == 0:
self.central_widget.tabBar().setVisible(False)
else:
self.create_custom_column_editors()
self.exec_()
def create_custom_column_editors(self):
w = self.central_widget.widget(1)
layout = QGridLayout()
self.custom_column_widgets, self.__cc_spacers = populate_bulk_metadata_page(
layout, self.db, self.ids, w)
w.setLayout(layout)
self.__custom_col_layouts = [layout]
ans = self.custom_column_widgets
for i in range(len(ans)-1):
w.setTabOrder(ans[i].widgets[-1], ans[i+1].widgets[1])
for c in range(2, len(ans[i].widgets), 2):
w.setTabOrder(ans[i].widgets[c-1], ans[i].widgets[c+1])
def initialize_combos(self):
self.initalize_authors()
self.initialize_series()
@ -133,7 +153,13 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
new_authors = string_to_authors(title)
self.db.set_authors(id, new_authors, notify=False)
if self.remove_conversion_settings.isChecked():
self.db.delete_conversion_options(id, 'PIPE')
self.changed = True
for w in getattr(self, 'custom_column_widgets', []):
w.commit(self.ids)
def series_changed(self):
self.write_series = True

View File

@ -6,8 +6,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>495</width>
<height>468</height>
<width>526</width>
<height>499</height>
</rect>
</property>
<property name="windowTitle">
@ -18,6 +18,16 @@
<normaloff>:/images/edit_input.svg</normaloff>:/images/edit_input.svg</iconset>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="box_title">
<property name="text">
<string/>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<layout class="QVBoxLayout">
<property name="spacing">
@ -27,239 +37,261 @@
<number>0</number>
</property>
<item>
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>Meta information</string>
<widget class="QTabWidget" name="central_widget">
<property name="currentIndex">
<number>0</number>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>&amp;Author(s): </string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<property name="buddy">
<cstring>authors</cstring>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QCheckBox" name="auto_author_sort">
<property name="text">
<string>A&amp;utomatically set author sort</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_8">
<property name="text">
<string>Author s&amp;ort: </string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<property name="buddy">
<cstring>author_sort</cstring>
</property>
</widget>
</item>
<item row="2" column="1" colspan="2">
<widget class="EnLineEdit" name="author_sort">
<property name="toolTip">
<string>Specify how the author(s) of this book should be sorted. For example Charles Dickens should be sorted as Dickens, Charles.</string>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label_6">
<property name="text">
<string>&amp;Rating:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<property name="buddy">
<cstring>rating</cstring>
</property>
</widget>
</item>
<item row="3" column="1" colspan="2">
<widget class="QSpinBox" name="rating">
<property name="toolTip">
<string>Rating of this book. 0-5 stars</string>
</property>
<property name="whatsThis">
<string>Rating of this book. 0-5 stars</string>
</property>
<property name="buttonSymbols">
<enum>QAbstractSpinBox::PlusMinus</enum>
</property>
<property name="specialValueText">
<string>No change</string>
</property>
<property name="suffix">
<string> stars</string>
</property>
<property name="minimum">
<number>-1</number>
</property>
<property name="maximum">
<number>5</number>
</property>
<property name="value">
<number>-1</number>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
<string>&amp;Publisher: </string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<property name="buddy">
<cstring>publisher</cstring>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="EnComboBox" name="publisher">
<property name="editable">
<bool>true</bool>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QLabel" name="label_4">
<property name="text">
<string>Add ta&amp;gs: </string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<property name="buddy">
<cstring>tags</cstring>
</property>
</widget>
</item>
<item row="5" column="1">
<widget class="TagsLineEdit" name="tags">
<property name="toolTip">
<string>Tags categorize the book. This is particularly useful while searching. &lt;br&gt;&lt;br&gt;They can be any words or phrases, separated by commas.</string>
</property>
</widget>
</item>
<item row="5" column="2">
<widget class="QToolButton" name="tag_editor_button">
<property name="toolTip">
<string>Open Tag Editor</string>
</property>
<property name="text">
<string>Open Tag Editor</string>
</property>
<property name="icon">
<iconset resource="../../../../resources/images.qrc">
<normaloff>:/images/chapters.svg</normaloff>:/images/chapters.svg</iconset>
</property>
</widget>
</item>
<item row="6" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>&amp;Remove tags:</string>
</property>
<property name="buddy">
<cstring>remove_tags</cstring>
</property>
</widget>
</item>
<item row="6" column="1" colspan="2">
<widget class="TagsLineEdit" name="remove_tags">
<property name="toolTip">
<string>Comma separated list of tags to remove from the books. </string>
</property>
</widget>
</item>
<item row="7" column="0">
<widget class="QLabel" name="label_7">
<property name="text">
<string>&amp;Series:</string>
</property>
<property name="textFormat">
<enum>Qt::PlainText</enum>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<property name="buddy">
<cstring>series</cstring>
</property>
</widget>
</item>
<item row="7" column="1">
<widget class="EnComboBox" name="series">
<property name="toolTip">
<string>List of known series. You can add new series.</string>
</property>
<property name="whatsThis">
<string>List of known series. You can add new series.</string>
</property>
<property name="editable">
<bool>true</bool>
</property>
<property name="insertPolicy">
<enum>QComboBox::InsertAlphabetically</enum>
</property>
<property name="sizeAdjustPolicy">
<enum>QComboBox::AdjustToContents</enum>
</property>
</widget>
</item>
<item row="9" column="0">
<widget class="QLabel" name="label_5">
<property name="text">
<string>Remove &amp;format:</string>
</property>
<property name="buddy">
<cstring>remove_format</cstring>
</property>
</widget>
</item>
<item row="9" column="1">
<widget class="QComboBox" name="remove_format"/>
</item>
<item row="0" column="1">
<widget class="EnComboBox" name="authors">
<property name="editable">
<bool>true</bool>
</property>
</widget>
</item>
<item row="10" column="0" colspan="2">
<widget class="QCheckBox" name="swap_title_and_author">
<property name="text">
<string>&amp;Swap title and author</string>
</property>
</widget>
</item>
<item row="8" column="1">
<widget class="QCheckBox" name="autonumber_series">
<property name="toolTip">
<string>Selected books will be automatically numbered,
<widget class="QWidget" name="tabWidgetPage1">
<attribute name="title">
<string>&amp;Basic metadata</string>
</attribute>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>&amp;Author(s): </string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<property name="buddy">
<cstring>authors</cstring>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QCheckBox" name="auto_author_sort">
<property name="text">
<string>A&amp;utomatically set author sort</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_8">
<property name="text">
<string>Author s&amp;ort: </string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<property name="buddy">
<cstring>author_sort</cstring>
</property>
</widget>
</item>
<item row="2" column="1" colspan="2">
<widget class="EnLineEdit" name="author_sort">
<property name="toolTip">
<string>Specify how the author(s) of this book should be sorted. For example Charles Dickens should be sorted as Dickens, Charles.</string>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label_6">
<property name="text">
<string>&amp;Rating:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<property name="buddy">
<cstring>rating</cstring>
</property>
</widget>
</item>
<item row="3" column="1" colspan="2">
<widget class="QSpinBox" name="rating">
<property name="toolTip">
<string>Rating of this book. 0-5 stars</string>
</property>
<property name="whatsThis">
<string>Rating of this book. 0-5 stars</string>
</property>
<property name="buttonSymbols">
<enum>QAbstractSpinBox::PlusMinus</enum>
</property>
<property name="specialValueText">
<string>No change</string>
</property>
<property name="suffix">
<string> stars</string>
</property>
<property name="minimum">
<number>-1</number>
</property>
<property name="maximum">
<number>5</number>
</property>
<property name="value">
<number>-1</number>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
<string>&amp;Publisher: </string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<property name="buddy">
<cstring>publisher</cstring>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="EnComboBox" name="publisher">
<property name="editable">
<bool>true</bool>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QLabel" name="label_4">
<property name="text">
<string>Add ta&amp;gs: </string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<property name="buddy">
<cstring>tags</cstring>
</property>
</widget>
</item>
<item row="5" column="1">
<widget class="TagsLineEdit" name="tags">
<property name="toolTip">
<string>Tags categorize the book. This is particularly useful while searching. &lt;br&gt;&lt;br&gt;They can be any words or phrases, separated by commas.</string>
</property>
</widget>
</item>
<item row="5" column="2">
<widget class="QToolButton" name="tag_editor_button">
<property name="toolTip">
<string>Open Tag Editor</string>
</property>
<property name="text">
<string>Open Tag Editor</string>
</property>
<property name="icon">
<iconset resource="../../../../resources/images.qrc">
<normaloff>:/images/chapters.svg</normaloff>:/images/chapters.svg</iconset>
</property>
</widget>
</item>
<item row="6" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>&amp;Remove tags:</string>
</property>
<property name="buddy">
<cstring>remove_tags</cstring>
</property>
</widget>
</item>
<item row="6" column="1" colspan="2">
<widget class="TagsLineEdit" name="remove_tags">
<property name="toolTip">
<string>Comma separated list of tags to remove from the books. </string>
</property>
</widget>
</item>
<item row="7" column="0">
<widget class="QLabel" name="label_7">
<property name="text">
<string>&amp;Series:</string>
</property>
<property name="textFormat">
<enum>Qt::PlainText</enum>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<property name="buddy">
<cstring>series</cstring>
</property>
</widget>
</item>
<item row="7" column="1">
<widget class="EnComboBox" name="series">
<property name="toolTip">
<string>List of known series. You can add new series.</string>
</property>
<property name="whatsThis">
<string>List of known series. You can add new series.</string>
</property>
<property name="editable">
<bool>true</bool>
</property>
<property name="insertPolicy">
<enum>QComboBox::InsertAlphabetically</enum>
</property>
<property name="sizeAdjustPolicy">
<enum>QComboBox::AdjustToContents</enum>
</property>
</widget>
</item>
<item row="9" column="0">
<widget class="QLabel" name="label_5">
<property name="text">
<string>Remove &amp;format:</string>
</property>
<property name="buddy">
<cstring>remove_format</cstring>
</property>
</widget>
</item>
<item row="9" column="1">
<widget class="QComboBox" name="remove_format"/>
</item>
<item row="0" column="1">
<widget class="EnComboBox" name="authors">
<property name="editable">
<bool>true</bool>
</property>
</widget>
</item>
<item row="11" column="0" colspan="2">
<widget class="QCheckBox" name="swap_title_and_author">
<property name="text">
<string>&amp;Swap title and author</string>
</property>
</widget>
</item>
<item row="8" column="1">
<widget class="QCheckBox" name="autonumber_series">
<property name="toolTip">
<string>Selected books will be automatically numbered,
in the order you selected them.
So if you selected Book A and then Book B,
Book A will have series number 1 and Book B series number 2.</string>
</property>
<property name="text">
<string>Automatically number books in this series</string>
</property>
</widget>
</item>
</layout>
</property>
<property name="text">
<string>Automatically number books in this series</string>
</property>
</widget>
</item>
<item row="10" column="0" colspan="2">
<widget class="QCheckBox" name="remove_conversion_settings">
<property name="toolTip">
<string>Remove stored conversion settings for the selected books.
Future conversion of these books will use the default settings.</string>
</property>
<property name="text">
<string>Remove &amp;stored conversion settings for the selected books</string>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab">
<attribute name="title">
<string>&amp;Custom metadata</string>
</attribute>
</widget>
</widget>
</item>
</layout>
@ -344,21 +376,5 @@ Book A will have series number 1 and Book B series number 2.</string>
</hint>
</hints>
</connection>
<connection>
<sender>auto_author_sort</sender>
<signal>toggled(bool)</signal>
<receiver>author_sort</receiver>
<slot>setDisabled(bool)</slot>
<hints>
<hint type="sourcelabel">
<x>240</x>
<y>95</y>
</hint>
<hint type="destinationlabel">
<x>240</x>
<y>113</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@ -11,10 +11,11 @@ import re
import time
import traceback
import sip
from PyQt4.Qt import SIGNAL, QObject, QCoreApplication, Qt, QTimer, QThread, QDate, \
QPixmap, QListWidgetItem, QDialog
QPixmap, QListWidgetItem, QDialog, QHBoxLayout, QGridLayout
from calibre.gui2 import qstring_to_unicode, error_dialog, file_icon_provider, \
from calibre.gui2 import error_dialog, file_icon_provider, \
choose_files, choose_images, ResizableDialog, \
warning_dialog
from calibre.gui2.dialogs.metadata_single_ui import Ui_MetadataSingleDialog
@ -31,6 +32,7 @@ from calibre.utils.config import prefs, tweaks
from calibre.utils.date import qt_to_dt
from calibre.customize.ui import run_plugins_on_import, get_isbndb_key
from calibre.gui2.dialogs.config.social import SocialMetadata
from calibre.gui2.custom_column_widgets import populate_single_metadata_page
class CoverFetcher(QThread):
@ -311,6 +313,9 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog):
self.cpixmap = None
self.cover.setAcceptDrops(True)
self.pubdate.setMinimumDate(QDate(100,1,1))
pubdate_format = tweaks['gui_pubdate_display_format']
if pubdate_format is not None:
self.pubdate.setDisplayFormat(pubdate_format)
self.date.setMinimumDate(QDate(100,1,1))
self.connect(self.cover, SIGNAL('cover_changed(PyQt_PyObject)'), self.cover_dropped)
@ -405,6 +410,30 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog):
self.cover.setPixmap(pm)
self.cover_data = cover
self.original_series_name = unicode(self.series.text()).strip()
if len(db.custom_column_label_map) == 0:
self.central_widget.tabBar().setVisible(False)
else:
self.create_custom_column_editors()
def create_custom_column_editors(self):
w = self.central_widget.widget(1)
top_layout = QHBoxLayout()
top_layout.setSpacing(20)
left_layout = QGridLayout()
right_layout = QGridLayout()
top_layout.addLayout(left_layout)
self.custom_column_widgets, self.__cc_spacers = populate_single_metadata_page(
left_layout, right_layout, self.db, self.id, w)
top_layout.addLayout(right_layout)
sip.delete(w.layout())
w.setLayout(top_layout)
self.__custom_col_layouts = [top_layout, left_layout, right_layout]
ans = self.custom_column_widgets
for i in range(len(ans)-1):
w.setTabOrder(ans[i].widgets[-1], ans[i+1].widgets[-1])
def validate_isbn(self, isbn):
isbn = unicode(isbn).strip()
@ -559,12 +588,12 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog):
def fetch_metadata(self):
isbn = re.sub(r'[^0-9a-zA-Z]', '', unicode(self.isbn.text()))
title = qstring_to_unicode(self.title.text())
title = unicode(self.title.text())
try:
author = string_to_authors(unicode(self.authors.text()))[0]
except:
author = ''
publisher = qstring_to_unicode(self.publisher.currentText())
publisher = unicode(self.publisher.currentText())
if isbn or title or author or publisher:
d = FetchMetadata(self, isbn, title, author, publisher, self.timeout)
self._fetch_metadata_scope = d
@ -630,12 +659,12 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog):
def remove_unused_series(self):
self.db.remove_unused_series()
idx = qstring_to_unicode(self.series.currentText())
idx = unicode(self.series.currentText())
self.series.clear()
self.initialize_series()
if idx:
for i in range(self.series.count()):
if qstring_to_unicode(self.series.itemText(i)) == idx:
if unicode(self.series.itemText(i)) == idx:
self.series.setCurrentIndex(i)
break
@ -655,7 +684,7 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog):
self.db.set_isbn(self.id,
re.sub(r'[^0-9a-zA-Z]', '', unicode(self.isbn.text())), notify=False)
self.db.set_rating(self.id, 2*self.rating.value(), notify=False)
self.db.set_publisher(self.id, qstring_to_unicode(self.publisher.currentText()), notify=False)
self.db.set_publisher(self.id, unicode(self.publisher.currentText()), notify=False)
self.db.set_tags(self.id, [x.strip() for x in
unicode(self.tags.text()).split(',')], notify=False)
self.db.set_series(self.id,
@ -675,6 +704,8 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog):
self.db.set_cover(self.id, self.cover_data)
else:
self.db.remove_cover(self.id)
for w in getattr(self, 'custom_column_widgets', []):
w.commit(self.id)
except IOError, err:
if err.errno == 13: # Permission denied
fname = err.filename if err.filename else 'file'

File diff suppressed because it is too large Load Diff

View File

@ -5,38 +5,38 @@ from PyQt4.QtGui import QDialog, QLineEdit
from PyQt4.QtCore import SIGNAL, Qt
from calibre.gui2.dialogs.password_ui import Ui_Dialog
from calibre.gui2 import qstring_to_unicode, dynamic
from calibre.gui2 import dynamic
class PasswordDialog(QDialog, Ui_Dialog):
def __init__(self, window, name, msg):
QDialog.__init__(self, window)
Ui_Dialog.__init__(self)
self.setupUi(self)
self.cfg_key = re.sub(r'[^0-9a-zA-Z]', '_', name)
un = dynamic[self.cfg_key+'__un']
pw = dynamic[self.cfg_key+'__pw']
if not un: un = ''
if not pw: pw = ''
self.gui_username.setText(un)
self.gui_password.setText(pw)
self.sname = name
self.sname = name
self.msg.setText(msg)
self.connect(self.show_password, SIGNAL('stateChanged(int)'), self.toggle_password)
def toggle_password(self, state):
if state == Qt.Unchecked:
self.gui_password.setEchoMode(QLineEdit.Password)
else:
self.gui_password.setEchoMode(QLineEdit.Normal)
def username(self):
return qstring_to_unicode(self.gui_username.text())
return unicode(self.gui_username.text())
def password(self):
return qstring_to_unicode(self.gui_password.text())
return unicode(self.gui_password.text())
def accept(self):
dynamic.set(self.cfg_key+'__un', unicode(self.gui_username.text()))
dynamic.set(self.cfg_key+'__pw', unicode(self.gui_password.text()))

View File

@ -32,8 +32,7 @@ class SchedulerDialog(QDialog, Ui_Dialog):
self.search.setMinimumContentsLength(25)
self.search.initialize('scheduler_search_history')
self.recipe_box.layout().insertWidget(0, self.search)
self.connect(self.search, SIGNAL('search(PyQt_PyObject,PyQt_PyObject)'),
self.recipe_model.search)
self.search.search.connect(self.recipe_model.search)
self.connect(self.recipe_model, SIGNAL('searched(PyQt_PyObject)'),
self.search.search_done)
self.connect(self.recipe_model, SIGNAL('searched(PyQt_PyObject)'),

View File

@ -4,7 +4,6 @@ import re
from PyQt4.QtGui import QDialog
from calibre.gui2.dialogs.search_ui import Ui_Dialog
from calibre.gui2 import qstring_to_unicode
from calibre.library.caches import CONTAINS_MATCH, EQUALS_MATCH
class SearchDialog(QDialog, Ui_Dialog):
@ -48,11 +47,11 @@ class SearchDialog(QDialog, Ui_Dialog):
return ans
def token(self):
txt = qstring_to_unicode(self.text.text()).strip()
txt = unicode(self.text.text()).strip()
if txt:
if self.negate.isChecked():
txt = '!'+txt
tok = self.FIELDS[qstring_to_unicode(self.field.currentText())]+txt
tok = self.FIELDS[unicode(self.field.currentText())]+txt
if re.search(r'\s', tok):
tok = '"%s"'%tok
return tok

View File

@ -0,0 +1,197 @@
__license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
from PyQt4.QtCore import SIGNAL, Qt
from PyQt4.QtGui import QDialog, QIcon, QListWidgetItem
from calibre.gui2.dialogs.tag_categories_ui import Ui_TagCategories
from calibre.utils.config import prefs
from calibre.gui2.dialogs.confirm_delete import confirm
from calibre.constants import islinux
class Item:
def __init__(self, name, label, index, icon, exists):
self.name = name
self.label = label
self.index = index
self.icon = icon
self.exists = exists
def __str__(self):
return 'name=%s, label=%s, index=%s, exists='%(self.name, self.label, self.index, self.exists)
class TagCategories(QDialog, Ui_TagCategories):
category_labels_orig = ['', 'authors', 'series', 'publishers', 'tags']
def __init__(self, window, db, index=None):
QDialog.__init__(self, window)
Ui_TagCategories.__init__(self)
self.setupUi(self)
self.db = db
self.index = index
self.applied_items = []
cc_icon = QIcon(I('column.svg'))
self.category_labels = self.category_labels_orig[:]
category_icons = [None, QIcon(I('user_profile.svg')), QIcon(I('series.svg')),
QIcon(I('publisher.png')), QIcon(I('tags.svg'))]
category_values = [None,
lambda: [n.replace('|', ',') for (id, n) in self.db.all_authors()],
lambda: [n for (id, n) in self.db.all_series()],
lambda: [n for (id, n) in self.db.all_publishers()],
lambda: self.db.all_tags()
]
category_names = ['', _('Authors'), _('Series'), _('Publishers'), _('Tags')]
cc_map = self.db.custom_column_label_map
for cc in cc_map:
if cc_map[cc]['datatype'] == 'text':
self.category_labels.append(db.field_metadata.label_to_key(cc))
category_icons.append(cc_icon)
category_values.append(lambda col=cc: self.db.all_custom(label=col))
category_names.append(cc_map[cc]['name'])
self.all_items = []
self.all_items_dict = {}
for idx,label in enumerate(self.category_labels):
if idx == 0:
continue
for n in category_values[idx]():
t = Item(name=n, label=label, index=len(self.all_items),icon=category_icons[idx], exists=True)
self.all_items.append(t)
self.all_items_dict[label+':'+n] = t
self.categories = dict.copy(prefs['user_categories'])
if self.categories is None:
self.categories = {}
for cat in self.categories:
for item,l in enumerate(self.categories[cat]):
key = ':'.join([l[1], l[0]])
t = self.all_items_dict.get(key, None)
if l[1] in self.category_labels:
if t is None:
t = Item(name=l[0], label=l[1], index=len(self.all_items),
icon=category_icons[self.category_labels.index(l[1])], exists=False)
self.all_items.append(t)
self.all_items_dict[key] = t
l[2] = t.index
else:
# remove any references to a category that no longer exists
del self.categories[cat][item]
self.all_items_sorted = sorted(self.all_items, cmp=lambda x,y: cmp(x.name.lower(), y.name.lower()))
self.display_filtered_categories(0)
for v in category_names:
self.category_filter_box.addItem(v)
self.current_cat_name = None
self.connect(self.apply_button, SIGNAL('clicked()'), self.apply_tags)
self.connect(self.unapply_button, SIGNAL('clicked()'), self.unapply_tags)
self.connect(self.add_category_button, SIGNAL('clicked()'), self.add_category)
self.connect(self.category_box, SIGNAL('currentIndexChanged(int)'), self.select_category)
self.connect(self.category_filter_box, SIGNAL('currentIndexChanged(int)'), self.display_filtered_categories)
self.connect(self.delete_category_button, SIGNAL('clicked()'), self.del_category)
if islinux:
self.available_items_box.itemDoubleClicked.connect(self.apply_tags)
else:
self.connect(self.available_items_box, SIGNAL('itemActivated(QListWidgetItem*)'), self.apply_tags)
self.connect(self.applied_items_box, SIGNAL('itemActivated(QListWidgetItem*)'), self.unapply_tags)
self.populate_category_list()
return
self.select_category(0)
def make_list_widget(self, item):
n = item.name if item.exists else item.name + _(' (not on any book)')
w = QListWidgetItem(item.icon, n)
w.setData(Qt.UserRole, item.index)
return w
def display_filtered_categories(self, idx):
idx = idx if idx is not None else self.category_filter_box.currentIndex()
self.available_items_box.clear()
self.applied_items_box.clear()
for item in self.all_items_sorted:
if idx == 0 or item.label == self.category_labels[idx]:
if item.index not in self.applied_items and item.exists:
self.available_items_box.addItem(self.make_list_widget(item))
for index in self.applied_items:
self.applied_items_box.addItem(self.make_list_widget(self.all_items[index]))
def apply_tags(self, node=None):
if self.current_cat_name is None:
return
nodes = self.available_items_box.selectedItems() if node is None else [node]
for node in nodes:
index = self.all_items[node.data(Qt.UserRole).toPyObject()].index
if index not in self.applied_items:
self.applied_items.append(index)
self.applied_items.sort(cmp=lambda x, y:cmp(self.all_items[x].name.lower(), self.all_items[y].name.lower()))
self.display_filtered_categories(None)
def unapply_tags(self, node=None):
nodes = self.applied_items_box.selectedItems() if node is None else [node]
for node in nodes:
index = self.all_items[node.data(Qt.UserRole).toPyObject()].index
self.applied_items.remove(index)
self.display_filtered_categories(None)
def add_category(self):
self.save_category()
cat_name = unicode(self.input_box.text()).strip()
if cat_name == '':
return False
if cat_name not in self.categories:
self.category_box.clear()
self.current_cat_name = cat_name
self.categories[cat_name] = []
self.applied_items = []
self.populate_category_list()
self.category_box.setCurrentIndex(self.category_box.findText(cat_name))
else:
self.select_category(self.category_box.findText(cat_name))
return True
def del_category(self):
if self.current_cat_name is not None:
if not confirm('<p>'+_('The current tag category will be '
'<b>permanently deleted</b>. Are you sure?')
+'</p>', 'tag_category_delete', self):
return
del self.categories[self.current_cat_name]
self.current_cat_name = None
self.category_box.removeItem(self.category_box.currentIndex())
def select_category(self, idx):
self.save_category()
s = self.category_box.itemText(idx)
if s:
self.current_cat_name = unicode(s)
else:
self.current_cat_name = None
if self.current_cat_name:
self.applied_items = [cat[2] for cat in self.categories.get(self.current_cat_name, [])]
else:
self.applied_items = []
self.display_filtered_categories(None)
def accept(self):
self.save_category()
prefs['user_categories'] = self.categories
QDialog.accept(self)
def save_category(self):
if self.current_cat_name is not None:
l = []
for index in self.applied_items:
item = self.all_items[index]
l.append([item.name, item.label, item.index])
self.categories[self.current_cat_name] = l
def populate_category_list(self):
for n in sorted(self.categories.keys(), cmp=lambda x,y: cmp(x.lower(), y.lower())):
self.category_box.addItem(n)

View File

@ -0,0 +1,385 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>TagCategories</class>
<widget class="QDialog" name="TagCategories">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>588</width>
<height>482</height>
</rect>
</property>
<property name="windowTitle">
<string>Tag Editor</string>
</property>
<property name="windowIcon">
<iconset>
<normaloff>:/images/chapters.svg</normaloff>:/images/chapters.svg</iconset>
</property>
<layout class="QGridLayout">
<item row="1" column="0">
<layout class="QVBoxLayout">
<item>
<layout class="QHBoxLayout">
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>A&amp;vailable items</string>
</property>
<property name="buddy">
<cstring>available_items_box</cstring>
</property>
</widget>
</item>
<item>
<spacer>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout">
<item>
<widget class="QListWidget" name="available_items_box">
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::MultiSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
<item row="1" column="1">
<layout class="QVBoxLayout">
<item>
<spacer>
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QToolButton" name="apply_button">
<property name="toolTip">
<string>Apply tags to current tag category</string>
</property>
<property name="text">
<string>...</string>
</property>
<property name="icon">
<iconset>
<normaloff>:/images/forward.svg</normaloff>:/images/forward.svg</iconset>
</property>
</widget>
</item>
<item>
<spacer>
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item row="1" column="2">
<layout class="QVBoxLayout">
<item>
<layout class="QHBoxLayout">
<item>
<widget class="QLabel" name="label_2">
<property name="text">
<string>A&amp;pplied items</string>
</property>
<property name="buddy">
<cstring>applied_items_box</cstring>
</property>
</widget>
</item>
<item>
<spacer>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<widget class="QListWidget" name="applied_items_box">
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::MultiSelection</enum>
</property>
</widget>
</item>
</layout>
</item>
<item row="1" column="3">
<layout class="QVBoxLayout">
<item>
<spacer>
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QToolButton" name="unapply_button">
<property name="toolTip">
<string>Unapply (remove) tag from current tag category</string>
</property>
<property name="text">
<string>...</string>
</property>
<property name="icon">
<iconset>
<normaloff>:/images/list_remove.svg</normaloff>:/images/list_remove.svg</iconset>
</property>
</widget>
</item>
<item>
<spacer>
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item row="3" column="0" colspan="4">
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
<property name="centerButtons">
<bool>true</bool>
</property>
</widget>
</item>
<item row="0" column="0" colspan="4">
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QLabel" name="label_3">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>100</width>
<height>0</height>
</size>
</property>
<property name="text">
<string>Category name: </string>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set>
</property>
<property name="buddy">
<cstring>category_box</cstring>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QComboBox" name="category_box">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>160</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>145</width>
<height>0</height>
</size>
</property>
<property name="toolTip">
<string>Select a category to edit</string>
</property>
<property name="editable">
<bool>false</bool>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QToolButton" name="delete_category_button">
<property name="toolTip">
<string>Delete this selected tag category</string>
</property>
<property name="text">
<string>...</string>
</property>
<property name="icon">
<iconset>
<normaloff>:/images/minus.svg</normaloff>:/images/minus.svg</iconset>
</property>
</widget>
</item>
<item row="0" column="3">
<spacer>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="0" column="4">
<widget class="QLineEdit" name="input_box">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>60</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="toolTip">
<string>Enter a new category name. Select the kind before adding it.</string>
</property>
</widget>
</item>
<item row="0" column="5">
<widget class="QToolButton" name="add_category_button">
<property name="toolTip">
<string>Add the new category</string>
</property>
<property name="text">
<string>...</string>
</property>
<property name="icon">
<iconset>
<normaloff>:/images/plus.svg</normaloff>:/images/plus.svg</iconset>
</property>
</widget>
</item>
<item row="1" column="5">
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_5">
<property name="text">
<string>Category filter: </string>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="category_filter_box">
<property name="toolTip">
<string>Select the content kind of the new category</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources>
<include location="../../../../../calibre_datesearch/resources/images"/>
</resources>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>TagCategories</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>TagCategories</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@ -4,12 +4,14 @@ from PyQt4.QtCore import SIGNAL, Qt
from PyQt4.QtGui import QDialog
from calibre.gui2.dialogs.tag_editor_ui import Ui_TagEditor
from calibre.gui2 import qstring_to_unicode
from calibre.gui2 import question_dialog, error_dialog
from calibre.constants import islinux
class TagEditor(QDialog, Ui_TagEditor):
def tag_cmp(self, x, y):
return cmp(x.lower(), y.lower())
def __init__(self, window, db, index=None):
QDialog.__init__(self, window)
Ui_TagEditor.__init__(self)
@ -23,7 +25,7 @@ class TagEditor(QDialog, Ui_TagEditor):
tags = []
if tags:
tags = [tag.strip() for tag in tags.split(',') if tag.strip()]
tags.sort()
tags.sort(cmp=self.tag_cmp)
for tag in tags:
self.applied_tags.addItem(tag)
else:
@ -33,7 +35,7 @@ class TagEditor(QDialog, Ui_TagEditor):
all_tags = [tag for tag in self.db.all_tags()]
all_tags = list(set(all_tags))
all_tags.sort()
all_tags.sort(cmp=self.tag_cmp)
for tag in all_tags:
if tag not in tags:
self.available_tags.addItem(tag)
@ -57,30 +59,30 @@ class TagEditor(QDialog, Ui_TagEditor):
error_dialog(self, 'No tags selected', 'You must select at least one tag from the list of Available tags.').exec_()
return
for item in items:
if self.db.is_tag_used(qstring_to_unicode(item.text())):
if self.db.is_tag_used(unicode(item.text())):
confirms.append(item)
else:
deletes.append(item)
if confirms:
ct = ', '.join([qstring_to_unicode(item.text()) for item in confirms])
ct = ', '.join([unicode(item.text()) for item in confirms])
if question_dialog(self, _('Are your sure?'),
'<p>'+_('The following tags are used by one or more books. '
'Are you certain you want to delete them?')+'<br>'+ct):
deletes += confirms
for item in deletes:
self.db.delete_tag(qstring_to_unicode(item.text()))
self.db.delete_tag(unicode(item.text()))
self.available_tags.takeItem(self.available_tags.row(item))
def apply_tags(self, item=None):
items = self.available_tags.selectedItems() if item is None else [item]
for item in items:
tag = qstring_to_unicode(item.text())
tag = unicode(item.text())
self.tags.append(tag)
self.available_tags.takeItem(self.available_tags.row(item))
self.tags.sort()
self.tags.sort(cmp=self.tag_cmp)
self.applied_tags.clear()
for tag in self.tags:
self.applied_tags.addItem(tag)
@ -90,19 +92,24 @@ class TagEditor(QDialog, Ui_TagEditor):
def unapply_tags(self, item=None):
items = self.applied_tags.selectedItems() if item is None else [item]
for item in items:
tag = qstring_to_unicode(item.text())
tag = unicode(item.text())
self.tags.remove(tag)
self.available_tags.addItem(tag)
self.tags.sort()
self.tags.sort(cmp=self.tag_cmp)
self.applied_tags.clear()
for tag in self.tags:
self.applied_tags.addItem(tag)
self.available_tags.sortItems()
items = [unicode(self.available_tags.item(x).text()) for x in
range(self.available_tags.count())]
items.sort(cmp=self.tag_cmp)
self.available_tags.clear()
for item in items:
self.available_tags.addItem(item)
def add_tag(self):
tags = qstring_to_unicode(self.add_tag_input.text()).split(',')
tags = unicode(self.add_tag_input.text()).split(',')
for tag in tags:
tag = tag.strip()
for item in self.available_tags.findItems(tag, Qt.MatchFixedString):
@ -110,7 +117,7 @@ class TagEditor(QDialog, Ui_TagEditor):
if tag not in self.tags:
self.tags.append(tag)
self.tags.sort()
self.tags.sort(cmp=self.tag_cmp)
self.applied_tags.clear()
for tag in self.tags:
self.applied_tags.addItem(tag)

View File

@ -9,7 +9,7 @@ from PyQt4.Qt import SIGNAL, QUrl, QDesktopServices, QAbstractListModel, Qt, \
from calibre.web.feeds.recipes import compile_recipe
from calibre.web.feeds.news import AutomaticNewsRecipe
from calibre.gui2.dialogs.user_profiles_ui import Ui_Dialog
from calibre.gui2 import qstring_to_unicode, error_dialog, question_dialog, \
from calibre.gui2 import error_dialog, question_dialog, \
choose_files, ResizableDialog, NONE
from calibre.gui2.widgets import PythonHighlighter
from calibre.ptempfile import PersistentTemporaryFile
@ -162,19 +162,19 @@ class UserProfiles(ResizableDialog, Ui_Dialog):
else:
self.stacks.setCurrentIndex(1)
self.toggle_mode_button.setText(_('Switch to Basic mode'))
if not qstring_to_unicode(self.source_code.toPlainText()).strip():
if not unicode(self.source_code.toPlainText()).strip():
src = self.options_to_profile()[0].replace('AutomaticNewsRecipe', 'BasicNewsRecipe')
self.source_code.setPlainText(src.replace('BasicUserRecipe', 'AdvancedUserRecipe'))
self.highlighter = PythonHighlighter(self.source_code.document())
def add_feed(self, *args):
title = qstring_to_unicode(self.feed_title.text()).strip()
title = unicode(self.feed_title.text()).strip()
if not title:
error_dialog(self, _('Feed must have a title'),
_('The feed must have a title')).exec_()
return
url = qstring_to_unicode(self.feed_url.text()).strip()
url = unicode(self.feed_url.text()).strip()
if not url:
error_dialog(self, _('Feed must have a URL'),
_('The feed %s must have a URL')%title).exec_()
@ -190,7 +190,7 @@ class UserProfiles(ResizableDialog, Ui_Dialog):
def options_to_profile(self):
classname = 'BasicUserRecipe'+str(int(time.time()))
title = qstring_to_unicode(self.profile_title.text()).strip()
title = unicode(self.profile_title.text()).strip()
if not title:
title = classname
self.profile_title.setText(title)
@ -229,7 +229,7 @@ class %(classname)s(%(base_class)s):
return
profile = src
else:
src = qstring_to_unicode(self.source_code.toPlainText())
src = unicode(self.source_code.toPlainText())
try:
title = compile_recipe(src).title
except Exception, err:

View File

@ -15,10 +15,11 @@ from PyQt4.Qt import QAbstractTableModel, QVariant, QModelIndex, Qt, \
from calibre.utils.ipc.server import Server
from calibre.utils.ipc.job import ParallelJob
from calibre.gui2 import Dispatcher, error_dialog, NONE, config
from calibre.gui2 import Dispatcher, error_dialog, NONE, config, gprefs
from calibre.gui2.device import DeviceJob
from calibre.gui2.dialogs.jobs_ui import Ui_JobsDialog
from calibre import __appname__
from calibre.gui2.dialogs.job_view_ui import Ui_Dialog
class JobManager(QAbstractTableModel):
@ -243,7 +244,32 @@ class ProgressBarDelegate(QAbstractItemDelegate):
opts.text = QString(_('Unavailable') if percent == 0 else '%d%%'%percent)
QApplication.style().drawControl(QStyle.CE_ProgressBar, opts, painter)
class DetailView(QDialog, Ui_Dialog):
def __init__(self, parent, job):
QDialog.__init__(self, parent)
self.setupUi(self)
self.setWindowTitle(job.description)
self.job = job
self.next_pos = 0
self.update()
self.timer = QTimer(self)
self.timer.timeout.connect(self.update)
self.timer.start(1000)
def update(self):
f = self.job.log_file
f.seek(self.next_pos)
more = f.read()
self.next_pos = f.tell()
if more:
self.log.appendPlainText(more.decode('utf-8', 'replace'))
class JobsDialog(QDialog, Ui_JobsDialog):
def __init__(self, window, model):
QDialog.__init__(self, window)
Ui_JobsDialog.__init__(self)
@ -252,8 +278,6 @@ class JobsDialog(QDialog, Ui_JobsDialog):
self.model = model
self.setWindowModality(Qt.NonModal)
self.setWindowTitle(__appname__ + _(' - Jobs'))
self.connect(self.jobs_view.model(), SIGNAL('modelReset()'),
self.jobs_view.resizeColumnsToContents)
self.connect(self.kill_button, SIGNAL('clicked()'),
self.kill_job)
self.connect(self.details_button, SIGNAL('clicked()'),
@ -264,7 +288,21 @@ class JobsDialog(QDialog, Ui_JobsDialog):
self.jobs_view.model().kill_job)
self.pb_delegate = ProgressBarDelegate(self)
self.jobs_view.setItemDelegateForColumn(2, self.pb_delegate)
self.jobs_view.doubleClicked.connect(self.show_job_details)
self.jobs_view.horizontalHeader().setMovable(True)
state = gprefs.get('jobs view column layout', None)
if state is not None:
try:
self.jobs_view.horizontalHeader().restoreState(bytes(state))
except:
pass
def show_job_details(self, index):
row = index.row()
job = self.jobs_view.model().row_to_job(row)
d = DetailView(self, job)
d.exec_()
d.timer.stop()
def kill_job(self):
for index in self.jobs_view.selectedIndexes():
@ -281,5 +319,9 @@ class JobsDialog(QDialog, Ui_JobsDialog):
self.model.kill_all_jobs()
def closeEvent(self, e):
self.jobs_view.write_settings()
try:
state = bytearray(self.jobs_view.horizontalHeader().saveState())
gprefs['jobs view column layout'] = state
except:
pass
e.accept()

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,10 @@
#!/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 PyQt4.Qt import Qt
DEFAULT_SORT = ('timestamp', Qt.DescendingOrder)

View File

@ -0,0 +1,308 @@
#!/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'
import sys
from math import cos, sin, pi
from PyQt4.Qt import QColor, Qt, QModelIndex, QSize, \
QPainterPath, QLinearGradient, QBrush, \
QPen, QStyle, QPainter, QStyleOptionViewItemV4, \
QIcon, QDoubleSpinBox, QVariant, QSpinBox, \
QStyledItemDelegate, QCompleter, \
QComboBox
from calibre.gui2 import UNDEFINED_QDATE
from calibre.gui2.widgets import EnLineEdit, TagsLineEdit
from calibre.utils.date import now, format_date
from calibre.utils.config import tweaks
from calibre.gui2.dialogs.comments_dialog import CommentsDialog
class RatingDelegate(QStyledItemDelegate): # {{{
COLOR = QColor("blue")
SIZE = 16
PEN = QPen(COLOR, 1, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
def __init__(self, parent):
QStyledItemDelegate.__init__(self, parent)
self._parent = parent
self.dummy = QModelIndex()
self.star_path = QPainterPath()
self.star_path.moveTo(90, 50)
for i in range(1, 5):
self.star_path.lineTo(50 + 40 * cos(0.8 * i * pi), \
50 + 40 * sin(0.8 * i * pi))
self.star_path.closeSubpath()
self.star_path.setFillRule(Qt.WindingFill)
gradient = QLinearGradient(0, 0, 0, 100)
gradient.setColorAt(0.0, self.COLOR)
gradient.setColorAt(1.0, self.COLOR)
self.brush = QBrush(gradient)
self.factor = self.SIZE/100.
def sizeHint(self, option, index):
#num = index.model().data(index, Qt.DisplayRole).toInt()[0]
return QSize(5*(self.SIZE), self.SIZE+4)
def paint(self, painter, option, index):
style = self._parent.style()
option = QStyleOptionViewItemV4(option)
self.initStyleOption(option, self.dummy)
num = index.model().data(index, Qt.DisplayRole).toInt()[0]
def draw_star():
painter.save()
painter.scale(self.factor, self.factor)
painter.translate(50.0, 50.0)
painter.rotate(-20)
painter.translate(-50.0, -50.0)
painter.drawPath(self.star_path)
painter.restore()
painter.save()
if hasattr(QStyle, 'CE_ItemViewItem'):
style.drawControl(QStyle.CE_ItemViewItem, option,
painter, self._parent)
elif option.state & QStyle.State_Selected:
painter.fillRect(option.rect, option.palette.highlight())
try:
painter.setRenderHint(QPainter.Antialiasing)
painter.setClipRect(option.rect)
y = option.rect.center().y()-self.SIZE/2.
x = option.rect.left()
painter.setPen(self.PEN)
painter.setBrush(self.brush)
painter.translate(x, y)
i = 0
while i < num:
draw_star()
painter.translate(self.SIZE, 0)
i += 1
except:
import traceback
traceback.print_exc()
painter.restore()
def createEditor(self, parent, option, index):
sb = QStyledItemDelegate.createEditor(self, parent, option, index)
sb.setMinimum(0)
sb.setMaximum(5)
return sb
# }}}
class DateDelegate(QStyledItemDelegate): # {{{
def displayText(self, val, locale):
d = val.toDate()
if d == UNDEFINED_QDATE:
return ''
return format_date(d.toPyDate(), 'dd MMM yyyy')
def createEditor(self, parent, option, index):
qde = QStyledItemDelegate.createEditor(self, parent, option, index)
stdformat = unicode(qde.displayFormat())
if 'yyyy' not in stdformat:
stdformat = stdformat.replace('yy', 'yyyy')
qde.setDisplayFormat(stdformat)
qde.setMinimumDate(UNDEFINED_QDATE)
qde.setSpecialValueText(_('Undefined'))
qde.setCalendarPopup(True)
return qde
# }}}
class PubDateDelegate(QStyledItemDelegate): # {{{
def displayText(self, val, locale):
d = val.toDate()
if d == UNDEFINED_QDATE:
return ''
format = tweaks['gui_pubdate_display_format']
if format is None:
format = 'MMM yyyy'
return format_date(d.toPyDate(), format)
def createEditor(self, parent, option, index):
qde = QStyledItemDelegate.createEditor(self, parent, option, index)
qde.setDisplayFormat('MM yyyy')
qde.setMinimumDate(UNDEFINED_QDATE)
qde.setSpecialValueText(_('Undefined'))
qde.setCalendarPopup(True)
return qde
# }}}
class TextDelegate(QStyledItemDelegate): # {{{
def __init__(self, parent):
'''
Delegate for text data. If auto_complete_function needs to return a list
of text items to auto-complete with. The funciton is None no
auto-complete will be used.
'''
QStyledItemDelegate.__init__(self, parent)
self.auto_complete_function = None
def set_auto_complete_function(self, f):
self.auto_complete_function = f
def createEditor(self, parent, option, index):
editor = EnLineEdit(parent)
if self.auto_complete_function:
complete_items = [i[1] for i in self.auto_complete_function()]
completer = QCompleter(complete_items, self)
completer.setCaseSensitivity(Qt.CaseInsensitive)
completer.setCompletionMode(QCompleter.InlineCompletion)
editor.setCompleter(completer)
return editor
#}}}
class TagsDelegate(QStyledItemDelegate): # {{{
def __init__(self, parent):
QStyledItemDelegate.__init__(self, parent)
self.db = None
def set_database(self, db):
self.db = db
def createEditor(self, parent, option, index):
if self.db:
col = index.model().column_map[index.column()]
if not index.model().is_custom_column(col):
editor = TagsLineEdit(parent, self.db.all_tags())
else:
editor = TagsLineEdit(parent,
sorted(list(self.db.all_custom(label=self.db.field_metadata.key_to_label(col)))))
return editor
else:
editor = EnLineEdit(parent)
return editor
# }}}
class CcDateDelegate(QStyledItemDelegate): # {{{
'''
Delegate for custom columns dates. Because this delegate stores the
format as an instance variable, a new instance must be created for each
column. This differs from all the other delegates.
'''
def set_format(self, format):
if not format:
self.format = 'dd MMM yyyy'
else:
self.format = format
def displayText(self, val, locale):
d = val.toDate()
if d == UNDEFINED_QDATE:
return ''
return format_date(d.toPyDate(), self.format)
def createEditor(self, parent, option, index):
qde = QStyledItemDelegate.createEditor(self, parent, option, index)
qde.setDisplayFormat(self.format)
qde.setMinimumDate(UNDEFINED_QDATE)
qde.setSpecialValueText(_('Undefined'))
qde.setCalendarPopup(True)
return qde
def setEditorData(self, editor, index):
m = index.model()
# db col is not named for the field, but for the table number. To get it,
# gui column -> column label -> table number -> db column
val = m.db.data[index.row()][m.custom_columns[m.column_map[index.column()]]['rec_index']]
if val is None:
val = now()
editor.setDate(val)
def setModelData(self, editor, model, index):
val = editor.date()
if val == UNDEFINED_QDATE:
val = None
model.setData(index, QVariant(val), Qt.EditRole)
# }}}
class CcTextDelegate(QStyledItemDelegate): # {{{
'''
Delegate for text/int/float data.
'''
def createEditor(self, parent, option, index):
m = index.model()
col = m.column_map[index.column()]
typ = m.custom_columns[col]['datatype']
if typ == 'int':
editor = QSpinBox(parent)
editor.setRange(-100, sys.maxint)
editor.setSpecialValueText(_('Undefined'))
editor.setSingleStep(1)
elif typ == 'float':
editor = QDoubleSpinBox(parent)
editor.setSpecialValueText(_('Undefined'))
editor.setRange(-100., float(sys.maxint))
editor.setDecimals(2)
else:
editor = EnLineEdit(parent)
complete_items = sorted(list(m.db.all_custom(label=m.db.field_metadata.key_to_label(col))))
completer = QCompleter(complete_items, self)
completer.setCaseSensitivity(Qt.CaseInsensitive)
completer.setCompletionMode(QCompleter.PopupCompletion)
editor.setCompleter(completer)
return editor
# }}}
class CcCommentsDelegate(QStyledItemDelegate): # {{{
'''
Delegate for comments data.
'''
def createEditor(self, parent, option, index):
m = index.model()
col = m.column_map[index.column()]
text = m.db.data[index.row()][m.custom_columns[col]['rec_index']]
editor = CommentsDialog(parent, text)
d = editor.exec_()
if d:
m.setData(index, QVariant(editor.textbox.toPlainText()), Qt.EditRole)
return None
def setModelData(self, editor, model, index):
model.setData(index, QVariant(editor.textbox.toPlainText()), Qt.EditRole)
# }}}
class CcBoolDelegate(QStyledItemDelegate): # {{{
def __init__(self, parent):
'''
Delegate for custom_column bool data.
'''
QStyledItemDelegate.__init__(self, parent)
def createEditor(self, parent, option, index):
editor = QComboBox(parent)
items = [_('Y'), _('N'), ' ']
icons = [I('ok.svg'), I('list_remove.svg'), I('blank.svg')]
if tweaks['bool_custom_columns_are_tristate'] == 'no':
items = items[:-1]
icons = icons[:-1]
for icon, text in zip(icons, items):
editor.addItem(QIcon(icon), text)
return editor
def setModelData(self, editor, model, index):
val = {0:True, 1:False, 2:None}[editor.currentIndex()]
model.setData(index, QVariant(val), Qt.EditRole)
def setEditorData(self, editor, index):
m = index.model()
val = m.db.data[index.row()][m.custom_columns[m.column_map[index.column()]]['rec_index']]
if tweaks['bool_custom_columns_are_tristate'] == 'no':
val = 1 if not val else 0
else:
val = 2 if val is None else 1 if not val else 0
editor.setCurrentIndex(val)
# }}}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,475 @@
#!/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'
import os
from functools import partial
from PyQt4.Qt import QTableView, Qt, QAbstractItemView, QMenu, pyqtSignal, \
QModelIndex
from calibre.gui2.library.delegates import RatingDelegate, PubDateDelegate, \
TextDelegate, DateDelegate, TagsDelegate, CcTextDelegate, \
CcBoolDelegate, CcCommentsDelegate, CcDateDelegate
from calibre.gui2.library.models import BooksModel, DeviceBooksModel
from calibre.utils.config import tweaks
from calibre.gui2 import error_dialog, gprefs
from calibre.gui2.library import DEFAULT_SORT
class BooksView(QTableView): # {{{
files_dropped = pyqtSignal(object)
def __init__(self, parent, modelcls=BooksModel):
QTableView.__init__(self, parent)
self.rating_delegate = RatingDelegate(self)
self.timestamp_delegate = DateDelegate(self)
self.pubdate_delegate = PubDateDelegate(self)
self.tags_delegate = TagsDelegate(self)
self.authors_delegate = TextDelegate(self)
self.series_delegate = TextDelegate(self)
self.publisher_delegate = TextDelegate(self)
self.text_delegate = TextDelegate(self)
self.cc_text_delegate = CcTextDelegate(self)
self.cc_bool_delegate = CcBoolDelegate(self)
self.cc_comments_delegate = CcCommentsDelegate(self)
self.display_parent = parent
self._model = modelcls(self)
self.setModel(self._model)
self.setSelectionBehavior(QAbstractItemView.SelectRows)
self.setSortingEnabled(True)
self.selectionModel().currentRowChanged.connect(self._model.current_changed)
# {{{ Column Header setup
self.was_restored = False
self.column_header = self.horizontalHeader()
self.column_header.setMovable(True)
self.column_header.sectionMoved.connect(self.save_state)
self.column_header.setContextMenuPolicy(Qt.CustomContextMenu)
self.column_header.customContextMenuRequested.connect(self.show_column_header_context_menu)
# }}}
self._model.database_changed.connect(self.database_changed)
hv = self.verticalHeader()
hv.setClickable(True)
hv.setCursor(Qt.PointingHandCursor)
self.selected_ids = []
self._model.about_to_be_sorted.connect(self.about_to_be_sorted)
self._model.sorting_done.connect(self.sorting_done)
# Column Header Context Menu {{{
def column_header_context_handler(self, action=None, column=None):
if not action or not column:
return
try:
idx = self.column_map.index(column)
except:
return
h = self.column_header
if action == 'hide':
h.setSectionHidden(idx, True)
elif action == 'show':
h.setSectionHidden(idx, False)
elif action == 'ascending':
self.sortByColumn(idx, Qt.AscendingOrder)
elif action == 'descending':
self.sortByColumn(idx, Qt.DescendingOrder)
elif action == 'defaults':
self.apply_state(self.get_default_state())
elif action.startswith('align_'):
alignment = action.partition('_')[-1]
self._model.change_alignment(column, alignment)
self.save_state()
def show_column_header_context_menu(self, pos):
idx = self.column_header.logicalIndexAt(pos)
if idx > -1 and idx < len(self.column_map):
col = self.column_map[idx]
name = unicode(self.model().headerData(idx, Qt.Horizontal,
Qt.DisplayRole).toString())
self.column_header_context_menu = QMenu(self)
if col != 'ondevice':
self.column_header_context_menu.addAction(_('Hide column %s') %
name,
partial(self.column_header_context_handler, action='hide',
column=col))
m = self.column_header_context_menu.addMenu(
_('Sort on %s') % name)
a = m.addAction(_('Ascending'),
partial(self.column_header_context_handler,
action='ascending', column=col))
d = m.addAction(_('Descending'),
partial(self.column_header_context_handler,
action='descending', column=col))
if self._model.sorted_on[0] == col:
ac = a if self._model.sorted_on[1] == Qt.AscendingOrder else d
ac.setCheckable(True)
ac.setChecked(True)
if col not in ('ondevice', 'rating', 'inlibrary') and \
(not self.model().is_custom_column(col) or \
self.model().custom_columns[col]['datatype'] not in ('bool',
'rating')):
m = self.column_header_context_menu.addMenu(
_('Change text alignment for %s') % name)
al = self._model.alignment_map.get(col, 'left')
for x, t in (('left', _('Left')), ('right', _('Right')), ('center',
_('Center'))):
a = m.addAction(t,
partial(self.column_header_context_handler,
action='align_'+x, column=col))
if al == x:
a.setCheckable(True)
a.setChecked(True)
hidden_cols = [self.column_map[i] for i in
range(self.column_header.count()) if
self.column_header.isSectionHidden(i)]
try:
hidden_cols.remove('ondevice')
except:
pass
if hidden_cols:
self.column_header_context_menu.addSeparator()
m = self.column_header_context_menu.addMenu(_('Show column'))
for col in hidden_cols:
hidx = self.column_map.index(col)
name = unicode(self.model().headerData(hidx, Qt.Horizontal,
Qt.DisplayRole).toString())
m.addAction(name,
partial(self.column_header_context_handler,
action='show', column=col))
self.column_header_context_menu.addSeparator()
self.column_header_context_menu.addAction(
_('Restore default layout'),
partial(self.column_header_context_handler,
action='defaults', column=col))
self.column_header_context_menu.popup(self.column_header.mapToGlobal(pos))
# }}}
# Sorting {{{
def about_to_be_sorted(self, idc):
selected_rows = [r.row() for r in self.selectionModel().selectedRows()]
self.selected_ids = [idc(r) for r in selected_rows]
def sorting_done(self, indexc):
if self.selected_ids:
indices = [self.model().index(indexc(i), 0) for i in
self.selected_ids]
sm = self.selectionModel()
for idx in indices:
sm.select(idx, sm.Select|sm.Rows)
self.selected_ids = []
# }}}
# Ondevice column {{{
def set_ondevice_column_visibility(self):
m = self._model
self.column_header.setSectionHidden(m.column_map.index('ondevice'),
not m.device_connected)
def set_device_connected(self, is_connected):
self._model.set_device_connected(is_connected)
self.set_ondevice_column_visibility()
# }}}
# Save/Restore State {{{
def get_state(self):
h = self.column_header
cm = self.column_map
state = {}
state['hidden_columns'] = [cm[i] for i in range(h.count())
if h.isSectionHidden(i) and cm[i] != 'ondevice']
state['sort_history'] = \
self.cleanup_sort_history(self.model().sort_history)
state['column_positions'] = {}
state['column_sizes'] = {}
state['column_alignment'] = self._model.alignment_map
for i in range(h.count()):
name = cm[i]
state['column_positions'][name] = h.visualIndex(i)
if name != 'ondevice':
state['column_sizes'][name] = h.sectionSize(i)
return state
def save_state(self):
# Only save if we have been initialized (set_database called)
if len(self.column_map) > 0 and self.was_restored:
state = self.get_state()
name = unicode(self.objectName())
if name:
gprefs.set(name + ' books view state', state)
def cleanup_sort_history(self, sort_history):
history = []
for col, order in sort_history:
if col in self.column_map and (not history or history[0][0] != col):
history.append([col, order])
return history
def apply_sort_history(self, saved_history):
if not saved_history:
return
for col, order in reversed(self.cleanup_sort_history(saved_history)[:3]):
self.sortByColumn(self.column_map.index(col), order)
def apply_state(self, state):
h = self.column_header
cmap = {}
hidden = state.get('hidden_columns', [])
for i, c in enumerate(self.column_map):
cmap[c] = i
if c != 'ondevice':
h.setSectionHidden(i, c in hidden)
positions = state.get('column_positions', {})
pmap = {}
for col, pos in positions.items():
if col in cmap:
pmap[pos] = col
for pos in sorted(pmap.keys()):
col = pmap[pos]
idx = cmap[col]
current_pos = h.visualIndex(idx)
if current_pos != pos:
h.moveSection(current_pos, pos)
sizes = state.get('column_sizes', {})
for col, size in sizes.items():
if col in cmap:
sz = sizes[col]
if sz < 3:
sz = h.sectionSizeHint(cmap[col])
h.resizeSection(cmap[col], sz)
self.apply_sort_history(state.get('sort_history', None))
for col, alignment in state.get('column_alignment', {}).items():
self._model.change_alignment(col, alignment)
def get_default_state(self):
old_state = {
'hidden_columns': [],
'sort_history':[DEFAULT_SORT],
'column_positions': {},
'column_sizes': {},
'column_alignment': {
'size':'center',
'timestamp':'center',
'pubdate':'center'},
}
h = self.column_header
cm = self.column_map
for i in range(h.count()):
name = cm[i]
old_state['column_positions'][name] = i
if name != 'ondevice':
old_state['column_sizes'][name] = \
max(self.sizeHintForColumn(i), h.sectionSizeHint(i))
if name == 'timestamp':
old_state['column_sizes'][name] += 12
return old_state
def restore_state(self):
name = unicode(self.objectName())
old_state = None
if name:
old_state = gprefs.get(name + ' books view state', None)
if old_state is None:
old_state = self.get_default_state()
if tweaks['sort_columns_at_startup'] is not None:
old_state['sort_history'] = tweaks['sort_columns_at_startup']
self.apply_state(old_state)
# Resize all rows to have the correct height
if self.model().rowCount(QModelIndex()) > 0:
self.resizeRowToContents(0)
self.verticalHeader().setDefaultSectionSize(self.rowHeight(0))
self.was_restored = True
# }}}
# Initialization/Delegate Setup {{{
def set_database(self, db):
self.save_state()
self._model.set_database(db)
self.tags_delegate.set_database(db)
self.authors_delegate.set_auto_complete_function(db.all_authors)
self.series_delegate.set_auto_complete_function(db.all_series)
self.publisher_delegate.set_auto_complete_function(db.all_publishers)
def database_changed(self, db):
for i in range(self.model().columnCount(None)):
if self.itemDelegateForColumn(i) in (self.rating_delegate,
self.timestamp_delegate, self.pubdate_delegate):
self.setItemDelegateForColumn(i, self.itemDelegate())
cm = self.column_map
for colhead in cm:
if self._model.is_custom_column(colhead):
cc = self._model.custom_columns[colhead]
if cc['datatype'] == 'datetime':
delegate = CcDateDelegate(self)
delegate.set_format(cc['display'].get('date_format',''))
self.setItemDelegateForColumn(cm.index(colhead), delegate)
elif cc['datatype'] == 'comments':
self.setItemDelegateForColumn(cm.index(colhead), self.cc_comments_delegate)
elif cc['datatype'] == 'text':
if cc['is_multiple']:
self.setItemDelegateForColumn(cm.index(colhead), self.tags_delegate)
else:
self.setItemDelegateForColumn(cm.index(colhead), self.cc_text_delegate)
elif cc['datatype'] in ('int', 'float'):
self.setItemDelegateForColumn(cm.index(colhead), self.cc_text_delegate)
elif cc['datatype'] == 'bool':
self.setItemDelegateForColumn(cm.index(colhead), self.cc_bool_delegate)
elif cc['datatype'] == 'rating':
self.setItemDelegateForColumn(cm.index(colhead), self.rating_delegate)
else:
dattr = colhead+'_delegate'
delegate = colhead if hasattr(self, dattr) else 'text'
self.setItemDelegateForColumn(cm.index(colhead), getattr(self,
delegate+'_delegate'))
self.restore_state()
self.set_ondevice_column_visibility()
#}}}
# Context Menu {{{
def set_context_menu(self, edit_metadata, send_to_device, convert, view,
save, open_folder, book_details, delete, similar_menu=None):
self.setContextMenuPolicy(Qt.DefaultContextMenu)
self.context_menu = QMenu(self)
if edit_metadata is not None:
self.context_menu.addAction(edit_metadata)
if send_to_device is not None:
self.context_menu.addAction(send_to_device)
if convert is not None:
self.context_menu.addAction(convert)
self.context_menu.addAction(view)
self.context_menu.addAction(save)
if open_folder is not None:
self.context_menu.addAction(open_folder)
if delete is not None:
self.context_menu.addAction(delete)
if book_details is not None:
self.context_menu.addAction(book_details)
if similar_menu is not None:
self.context_menu.addMenu(similar_menu)
def contextMenuEvent(self, event):
self.context_menu.popup(event.globalPos())
event.accept()
# }}}
# Drag 'n Drop {{{
@classmethod
def paths_from_event(cls, event):
'''
Accept a drop event and return a list of paths that can be read from
and represent files with extensions.
'''
if event.mimeData().hasFormat('text/uri-list'):
urls = [unicode(u.toLocalFile()) for u in event.mimeData().urls()]
return [u for u in urls if os.path.splitext(u)[1] and os.access(u, os.R_OK)]
def dragEnterEvent(self, event):
if int(event.possibleActions() & Qt.CopyAction) + \
int(event.possibleActions() & Qt.MoveAction) == 0:
return
paths = self.paths_from_event(event)
if paths:
event.acceptProposedAction()
def dragMoveEvent(self, event):
event.acceptProposedAction()
def dropEvent(self, event):
paths = self.paths_from_event(event)
event.setDropAction(Qt.CopyAction)
event.accept()
self.files_dropped.emit(paths)
# }}}
@property
def column_map(self):
return self._model.column_map
def scrollContentsBy(self, dx, dy):
# Needed as Qt bug causes headerview to not always update when scrolling
QTableView.scrollContentsBy(self, dx, dy)
if dy != 0:
self.column_header.update()
def close(self):
self._model.close()
def set_editable(self, editable):
self._model.set_editable(editable)
def connect_to_search_box(self, sb, search_done):
sb.search.connect(self._model.search)
self._search_done = search_done
self._model.searched.connect(self.search_done)
def connect_to_restriction_set(self, tv):
# must be synchronous (not queued)
tv.restriction_set.connect(self._model.set_search_restriction)
def connect_to_book_display(self, bd):
self._model.new_bookdisplay_data.connect(bd)
def search_done(self, ok):
self._search_done(self, ok)
def row_count(self):
return self._model.count()
# }}}
class DeviceBooksView(BooksView): # {{{
def __init__(self, parent):
BooksView.__init__(self, parent, DeviceBooksModel)
self.columns_resized = False
self.resize_on_select = False
self.rating_delegate = None
for i in range(10):
self.setItemDelegateForColumn(i, TextDelegate(self))
self.setDragDropMode(self.NoDragDrop)
self.setAcceptDrops(False)
def set_database(self, db):
self._model.set_database(db)
self.restore_state()
def resizeColumnsToContents(self):
QTableView.resizeColumnsToContents(self)
self.columns_resized = True
def connect_dirtied_signal(self, slot):
self._model.booklist_dirtied.connect(slot)
def dropEvent(self, *args):
error_dialog(self, _('Not allowed'),
_('Dropping onto a device is not supported. First add the book to the calibre library.')).exec_()
# }}}

View File

@ -81,7 +81,7 @@ class Main(MainWindow, Ui_MainWindow):
self.search = SearchBox2(self)
self.search.initialize('lrf_viewer_search_history')
self.search_action = self.tool_bar.addWidget(self.search)
QObject.connect(self.search, SIGNAL('search(PyQt_PyObject, PyQt_PyObject)'), self.find)
self.search.search.connect(self.find)
self.action_next_page.setShortcuts([QKeySequence.MoveToNextPage, QKeySequence(Qt.Key_Space)])
self.action_previous_page.setShortcuts([QKeySequence.MoveToPreviousPage, QKeySequence(Qt.Key_Backspace)])

View File

@ -9,7 +9,6 @@ from PyQt4.QtGui import QFont, QColor, QPixmap, QGraphicsPixmapItem, \
from calibre.ebooks.lrf.fonts import FONT_MAP
from calibre.ebooks.BeautifulSoup import Tag
from calibre.ebooks.hyphenate import hyphenate_word
from calibre.gui2 import qstring_to_unicode
WEIGHT_MAP = lambda wt : int((wt/10.)-1)
NULL = lambda a, b: a
@ -527,12 +526,12 @@ class Line(QGraphicsItem):
while True:
word = words.next()
word.highlight = False
if tokens[0] in qstring_to_unicode(word.string).lower():
if tokens[0] in unicode(word.string).lower():
matches.append(word)
for c in range(1, len(tokens)):
word = words.next()
print tokens[c], word.string
if tokens[c] not in qstring_to_unicode(word.string):
if tokens[c] not in unicode(word.string):
return None
matches.append(word)
for w in matches:
@ -556,7 +555,7 @@ class Line(QGraphicsItem):
if isinstance(tok, (int, float)):
s += ' '
elif isinstance(tok, Word):
s += qstring_to_unicode(tok.string)
s += unicode(tok.string)
return s
def __str__(self):

View File

@ -28,8 +28,8 @@
<normaloff>:/images/library.png</normaloff>:/images/library.png</iconset>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<widget class="LocationView" name="location_view">
@ -149,14 +149,38 @@
</item>
</layout>
</item>
<item row="1" column="0">
<layout class="QHBoxLayout">
<item>
<layout class="QHBoxLayout" name="hl234">
<property name="spacing">
<number>6</number>
</property>
<property name="margin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="restriction_label">
<property name="text">
<string>&amp;Restrict to:</string>
</property>
<property name="buddy">
<cstring>search_restriction</cstring>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="search_restriction">
<property name="toolTip">
<string>Books display will be restricted to those matching the selected saved search</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="search_count">
<property name="text">
<string>set in ui.py</string>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="advanced_search_button">
<property name="toolTip">
@ -206,13 +230,6 @@
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="search_count">
<property name="text">
<string>set in ui.py</string>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="clear_button">
<property name="toolTip">
@ -287,218 +304,255 @@
</item>
</layout>
</item>
<item row="2" column="0">
<widget class="QStackedWidget" name="stack">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>100</horstretch>
<verstretch>100</verstretch>
</sizepolicy>
</property>
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="library">
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="TagsView" name="tags_view">
<property name="tabKeyNavigation">
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="Splitter" name="vertical_splitter">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>100</verstretch>
</sizepolicy>
</property>
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<widget class="QStackedWidget" name="stack">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>100</horstretch>
<verstretch>100</verstretch>
</sizepolicy>
</property>
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="library">
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="Splitter" name="horizontal_splitter">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<widget class="QWidget" name="layoutWidget">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="TagsView" name="tags_view">
<property name="tabKeyNavigation">
<bool>true</bool>
</property>
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="animated">
<bool>true</bool>
</property>
<property name="headerHidden">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="popularity">
<property name="text">
<string>Sort by &amp;popularity</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="tag_match">
<property name="currentIndex">
<number>0</number>
</property>
<item>
<property name="text">
<string>Match any</string>
</property>
</item>
<item>
<property name="text">
<string>Match all</string>
</property>
</item>
</widget>
</item>
<item>
<widget class="QPushButton" name="edit_categories">
<property name="toolTip">
<string>Create, edit, and delete user categories</string>
</property>
<property name="text">
<string>Manage &amp;user categories</string>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="BooksView" name="library_view">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>100</horstretch>
<verstretch>10</verstretch>
</sizepolicy>
</property>
<property name="acceptDrops">
<bool>true</bool>
</property>
<property name="dragEnabled">
<bool>true</bool>
</property>
<property name="dragDropOverwriteMode">
<bool>false</bool>
</property>
<property name="dragDropMode">
<enum>QAbstractItemView::DragDrop</enum>
</property>
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="animated">
<bool>true</bool>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="headerHidden">
<bool>true</bool>
<property name="showGrid">
<bool>false</bool>
</property>
<property name="wordWrap">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="popularity">
<property name="text">
<string>Sort by &amp;popularity</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="tag_match">
<property name="currentIndex">
<number>0</number>
</property>
<item>
<property name="text">
<string>Match any</string>
</property>
</item>
<item>
<property name="text">
<string>Match all</string>
</property>
</item>
</widget>
</item>
</layout>
</item>
<item>
<widget class="BooksView" name="library_view">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>100</horstretch>
<verstretch>10</verstretch>
</sizepolicy>
</property>
<property name="acceptDrops">
<bool>true</bool>
</property>
<property name="dragEnabled">
<bool>true</bool>
</property>
<property name="dragDropOverwriteMode">
<bool>false</bool>
</property>
<property name="dragDropMode">
<enum>QAbstractItemView::DragDrop</enum>
</property>
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="showGrid">
<bool>false</bool>
</property>
<property name="wordWrap">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<widget class="QWidget" name="main_memory">
<layout class="QGridLayout">
<item row="0" column="0">
<widget class="DeviceBooksView" name="memory_view">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>100</horstretch>
<verstretch>10</verstretch>
</sizepolicy>
</property>
<property name="acceptDrops">
<bool>true</bool>
</property>
<property name="dragEnabled">
<bool>true</bool>
</property>
<property name="dragDropOverwriteMode">
<bool>false</bool>
</property>
<property name="dragDropMode">
<enum>QAbstractItemView::DragDrop</enum>
</property>
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="showGrid">
<bool>false</bool>
</property>
<property name="wordWrap">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="card_a_memory">
<layout class="QGridLayout">
<item row="0" column="0">
<widget class="DeviceBooksView" name="card_a_view">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<horstretch>10</horstretch>
<verstretch>10</verstretch>
</sizepolicy>
</property>
<property name="acceptDrops">
<bool>true</bool>
</property>
<property name="dragEnabled">
<bool>true</bool>
</property>
<property name="dragDropOverwriteMode">
<bool>false</bool>
</property>
<property name="dragDropMode">
<enum>QAbstractItemView::DragDrop</enum>
</property>
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="showGrid">
<bool>false</bool>
</property>
<property name="wordWrap">
<bool>false</bool>
</property>
<widget class="QWidget" name="main_memory">
<layout class="QGridLayout">
<item row="0" column="0">
<widget class="DeviceBooksView" name="memory_view">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>100</horstretch>
<verstretch>10</verstretch>
</sizepolicy>
</property>
<property name="acceptDrops">
<bool>true</bool>
</property>
<property name="dragEnabled">
<bool>true</bool>
</property>
<property name="dragDropOverwriteMode">
<bool>false</bool>
</property>
<property name="dragDropMode">
<enum>QAbstractItemView::DragDrop</enum>
</property>
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="showGrid">
<bool>false</bool>
</property>
<property name="wordWrap">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="card_b_memory">
<layout class="QGridLayout">
<item row="0" column="0">
<widget class="DeviceBooksView" name="card_b_view">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<horstretch>10</horstretch>
<verstretch>10</verstretch>
</sizepolicy>
</property>
<property name="acceptDrops">
<bool>true</bool>
</property>
<property name="dragEnabled">
<bool>true</bool>
</property>
<property name="dragDropOverwriteMode">
<bool>false</bool>
</property>
<property name="dragDropMode">
<enum>QAbstractItemView::DragDrop</enum>
</property>
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="showGrid">
<bool>false</bool>
</property>
<property name="wordWrap">
<bool>false</bool>
</property>
<widget class="QWidget" name="card_a_memory">
<layout class="QGridLayout">
<item row="0" column="0">
<widget class="DeviceBooksView" name="card_a_view">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<horstretch>10</horstretch>
<verstretch>10</verstretch>
</sizepolicy>
</property>
<property name="acceptDrops">
<bool>true</bool>
</property>
<property name="dragEnabled">
<bool>true</bool>
</property>
<property name="dragDropOverwriteMode">
<bool>false</bool>
</property>
<property name="dragDropMode">
<enum>QAbstractItemView::DragDrop</enum>
</property>
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="showGrid">
<bool>false</bool>
</property>
<property name="wordWrap">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
</widget>
<widget class="QWidget" name="card_b_memory">
<layout class="QGridLayout">
<item row="0" column="0">
<widget class="DeviceBooksView" name="card_b_view">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<horstretch>10</horstretch>
<verstretch>10</verstretch>
</sizepolicy>
</property>
<property name="acceptDrops">
<bool>true</bool>
</property>
<property name="dragEnabled">
<bool>true</bool>
</property>
<property name="dragDropOverwriteMode">
<bool>false</bool>
</property>
<property name="dragDropMode">
<enum>QAbstractItemView::DragDrop</enum>
</property>
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="showGrid">
<bool>false</bool>
</property>
<property name="wordWrap">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
<widget class="StatusBar" name="status_bar" native="true"/>
</widget>
</item>
<item>
<widget class="SideBar" name="sidebar" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
@ -545,11 +599,6 @@
<addaction name="separator"/>
<addaction name="action_preferences"/>
</widget>
<widget class="QStatusBar" name="statusBar">
<property name="mouseTracking">
<bool>true</bool>
</property>
</widget>
<action name="action_add">
<property name="icon">
<iconset resource="../../../resources/images.qrc">
@ -744,7 +793,7 @@
<customwidget>
<class>BooksView</class>
<extends>QTableView</extends>
<header>library.h</header>
<header>calibre/gui2/library/views.h</header>
</customwidget>
<customwidget>
<class>LocationView</class>
@ -754,7 +803,7 @@
<customwidget>
<class>DeviceBooksView</class>
<extends>QTableView</extends>
<header>library.h</header>
<header>calibre/gui2/library/views.h</header>
</customwidget>
<customwidget>
<class>TagsView</class>
@ -771,6 +820,24 @@
<extends>QComboBox</extends>
<header>calibre.gui2.search_box</header>
</customwidget>
<customwidget>
<class>StatusBar</class>
<extends>QWidget</extends>
<header>calibre/gui2/status.h</header>
<container>1</container>
</customwidget>
<customwidget>
<class>Splitter</class>
<extends>QSplitter</extends>
<header>calibre/gui2/widgets.h</header>
<container>1</container>
</customwidget>
<customwidget>
<class>SideBar</class>
<extends>QWidget</extends>
<header>calibre/gui2/sidebar.h</header>
<container>1</container>
</customwidget>
</customwidgets>
<resources>
<include location="../../../resources/images.qrc"/>

View File

@ -6,10 +6,12 @@ __license__ = 'GPL v3'
__copyright__ = '2009, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'
from PyQt4.Qt import QComboBox, SIGNAL, Qt, QLineEdit, QStringList, pyqtSlot
from PyQt4.Qt import QComboBox, Qt, QLineEdit, QStringList, pyqtSlot, \
pyqtSignal, SIGNAL
from PyQt4.QtGui import QCompleter
from calibre.gui2 import config
from calibre.gui2.dialogs.confirm_delete import confirm
class SearchLineEdit(QLineEdit):
@ -55,6 +57,8 @@ class SearchBox2(QComboBox):
INTERVAL = 1500 #: Time to wait before emitting search signal
MAX_COUNT = 25
search = pyqtSignal(object, object)
def __init__(self, parent=None):
QComboBox.__init__(self, parent)
self.normal_background = 'rgb(255, 255, 255, 0%)'
@ -72,7 +76,8 @@ class SearchBox2(QComboBox):
self.setInsertPolicy(self.NoInsert)
self.setMaxCount(self.MAX_COUNT)
self.setSizeAdjustPolicy(self.AdjustToMinimumContentsLengthWithIcon)
self.setMinimumContentsLength(50)
self.setMinimumContentsLength(25)
self._in_a_search = False
def initialize(self, opt_name, colorize=False,
help_text=_('Search')):
@ -92,6 +97,7 @@ class SearchBox2(QComboBox):
self.help_state = False
def clear_to_help(self):
self._in_a_search = False
self.setEditText(self.help_text)
self.line_edit.home(False)
self.help_state = True
@ -105,11 +111,12 @@ class SearchBox2(QComboBox):
def clear(self):
self.clear_to_help()
self.emit(SIGNAL('search(PyQt_PyObject, PyQt_PyObject)'), '', False)
self.search.emit('', False)
def search_done(self, ok):
if not unicode(self.currentText()).strip():
return self.clear_to_help()
self._in_a_search = ok
col = 'rgba(0,255,0,20%)' if ok else 'rgb(255,0,0,20%)'
if not self.colorize:
col = self.normal_background
@ -128,13 +135,12 @@ class SearchBox2(QComboBox):
def text_edited_slot(self, text):
if self.as_you_type:
text = unicode(text)
self.prev_text = text
self.timer = self.startTimer(self.__class__.INTERVAL)
def timerEvent(self, event):
self.killTimer(event.timerId())
if event.timerId() == self.timer:
self.timer = None
self.do_search()
@property
@ -151,7 +157,7 @@ class SearchBox2(QComboBox):
self.help_state = False
refinement = text.startswith(self.prev_search) and ':' not in text
self.prev_search = text
self.emit(SIGNAL('search(PyQt_PyObject, PyQt_PyObject)'), text, refinement)
self.search.emit(text, refinement)
idx = self.findText(text, Qt.MatchFixedString)
self.block_signals(True)
@ -183,13 +189,18 @@ class SearchBox2(QComboBox):
def set_search_string(self, txt):
self.normalize_state()
self.setEditText(txt)
self.emit(SIGNAL('search(PyQt_PyObject, PyQt_PyObject)'), txt, False)
if self.timer is not None: # Turn off any timers that got started in setEditText
self.killTimer(self.timer)
self.timer = None
self.search.emit(txt, False)
self.line_edit.end(False)
self.initial_state = False
def search_as_you_type(self, enabled):
self.as_you_type = enabled
def in_a_search(self):
return self._in_a_search
class SavedSearchBox(QComboBox):
@ -233,7 +244,6 @@ class SavedSearchBox(QComboBox):
self.clear_to_help()
def normalize_state(self):
#print 'in normalize_state'
self.setEditText('')
self.line_edit.setStyleSheet(
'QLineEdit { color: black; background-color: %s; }' %
@ -241,7 +251,6 @@ class SavedSearchBox(QComboBox):
self.help_state = False
def clear_to_help(self):
#print 'in clear_to_help'
self.setToolTip(self.tool_tip_text)
self.initialize_saved_search_names()
self.setEditText(self.help_text)
@ -252,12 +261,10 @@ class SavedSearchBox(QComboBox):
self.normal_background)
def focus_out(self, event):
#print 'in focus_out'
if self.currentText() == '':
self.clear_to_help()
def key_pressed(self, event):
#print 'in key_pressed'
if self.help_state:
self.normalize_state()
@ -266,7 +273,6 @@ class SavedSearchBox(QComboBox):
self.normalize_state()
def saved_search_selected (self, qname):
#print 'in saved_search_selected'
qname = unicode(qname)
if qname is None or not qname.strip():
return
@ -276,7 +282,6 @@ class SavedSearchBox(QComboBox):
self.setToolTip(self.saved_searches.lookup(qname))
def initialize_saved_search_names(self):
#print 'in initialize_saved_search_names'
self.clear()
qnames = self.saved_searches.names()
self.addItems(qnames)
@ -284,18 +289,23 @@ class SavedSearchBox(QComboBox):
# SIGNALed from the main UI
def delete_search_button_clicked(self):
#print 'in delete_search_button_clicked'
if not confirm('<p>'+_('The selected search will be '
'<b>permanently deleted</b>. Are you sure?')
+'</p>', 'saved_search_delete', self):
return
idx = self.currentIndex
if idx < 0:
return
ss = self.saved_searches.lookup(unicode(self.currentText()))
if ss is None:
return
self.saved_searches.delete(unicode(self.currentText()))
self.clear_to_help()
self.search_box.set_search_string('')
self.search_box.clear_to_help()
self.emit(SIGNAL('changed()'))
# SIGNALed from the main UI
def save_search_button_clicked(self):
#print 'in save_search_button_clicked'
name = unicode(self.currentText())
if self.help_state or not name.strip():
name = unicode(self.search_box.text()).replace('"', '')
@ -312,10 +322,7 @@ class SavedSearchBox(QComboBox):
# SIGNALed from the main UI
def copy_search_button_clicked (self):
#print 'in copy_search_button_clicked'
idx = self.currentIndex();
if idx < 0:
return
self.search_box.set_search_string(self.saved_searches.lookup(unicode(self.currentText())))

240
src/calibre/gui2/sidebar.py Normal file
View File

@ -0,0 +1,240 @@
#!/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'
import re
from functools import partial
from PyQt4.Qt import QToolBar, Qt, QIcon, QSizePolicy, QWidget, \
QFrame, QVBoxLayout, QLabel, QSize, QCoreApplication, QToolButton
from calibre.gui2.progress_indicator import ProgressIndicator
from calibre.gui2 import dynamic
class JobsButton(QFrame):
def __init__(self, parent):
QFrame.__init__(self, parent)
self.setLayout(QVBoxLayout())
self.pi = ProgressIndicator(self)
self.layout().addWidget(self.pi)
self.jobs = QLabel('<b>'+_('Jobs:')+' 0')
self.jobs.setAlignment(Qt.AlignHCenter|Qt.AlignBottom)
self.layout().addWidget(self.jobs)
self.layout().setAlignment(self.jobs, Qt.AlignHCenter)
self.jobs.setMargin(0)
self.layout().setMargin(0)
self.jobs.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
self.setCursor(Qt.PointingHandCursor)
self.setToolTip(_('Click to see list of active jobs.'))
def initialize(self, jobs_dialog):
self.jobs_dialog = jobs_dialog
def mouseReleaseEvent(self, event):
if self.jobs_dialog.isVisible():
self.jobs_dialog.hide()
else:
self.jobs_dialog.show()
@property
def is_running(self):
return self.pi.isAnimated()
def start(self):
self.pi.startAnimation()
def stop(self):
self.pi.stopAnimation()
class Jobs(ProgressIndicator):
def initialize(self, jobs_dialog):
self.jobs_dialog = jobs_dialog
def mouseClickEvent(self, event):
if self.jobs_dialog.isVisible():
self.jobs_dialog.jobs_view.write_settings()
self.jobs_dialog.hide()
else:
self.jobs_dialog.jobs_view.read_settings()
self.jobs_dialog.show()
self.jobs_dialog.jobs_view.restore_column_widths()
@property
def is_running(self):
return self.isAnimated()
def start(self):
self.startAnimation()
def stop(self):
self.stopAnimation()
class SideBar(QToolBar):
toggle_texts = {
'book_info' : (_('Show Book Details'), _('Hide Book Details')),
'tag_browser' : (_('Show Tag Browser'), _('Hide Tag Browser')),
'cover_browser': (_('Show Cover Browser'), _('Hide Cover Browser')),
}
toggle_icons = {
'book_info' : 'book.svg',
'tag_browser' : 'tags.svg',
'cover_browser': 'cover_flow.svg',
}
def __init__(self, parent=None):
QToolBar.__init__(self, _('Side bar'), parent)
self.setOrientation(Qt.Vertical)
self.setMovable(False)
self.setFloatable(False)
self.setToolButtonStyle(Qt.ToolButtonIconOnly)
self.setIconSize(QSize(48, 48))
for ac in ('book_info', 'tag_browser', 'cover_browser'):
action = self.addAction(QIcon(I(self.toggle_icons[ac])),
self.toggle_texts[ac][1], getattr(self, '_toggle_'+ac))
setattr(self, 'action_toggle_'+ac, action)
w = self.widgetForAction(action)
w.setCheckable(True)
setattr(self, 'show_'+ac, partial(getattr(self, '_toggle_'+ac),
show=True))
setattr(self, 'hide_'+ac, partial(getattr(self, '_toggle_'+ac),
show=False))
self.spacer = QWidget(self)
self.spacer.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Expanding)
self.addWidget(self.spacer)
self.jobs_button = JobsButton(self)
self.addWidget(self.jobs_button)
self.show_cover_browser = partial(self._toggle_cover_browser, show=True)
self.hide_cover_browser = partial(self._toggle_cover_browser,
show=False)
for ch in self.children():
if isinstance(ch, QToolButton):
ch.setCursor(Qt.PointingHandCursor)
def initialize(self, jobs_dialog, cover_browser, toggle_cover_browser,
cover_browser_error, vertical_splitter, horizontal_splitter):
self.jobs_button.initialize(jobs_dialog)
self.cover_browser, self.do_toggle_cover_browser = cover_browser, \
toggle_cover_browser
if self.cover_browser is None:
self.action_toggle_cover_browser.setEnabled(False)
self.action_toggle_cover_browser.setText(
_('Cover browser could not be loaded: ') + cover_browser_error)
else:
self.cover_browser.stop.connect(self.hide_cover_browser)
self._toggle_cover_browser(dynamic.get('cover_flow_visible', False))
self.horizontal_splitter = horizontal_splitter
self.vertical_splitter = vertical_splitter
tb_state = dynamic.get('tag_browser_state', None)
if tb_state is not None:
self.horizontal_splitter.restoreState(tb_state)
tb_last_open_state = dynamic.get('tag_browser_last_open_state', None)
if tb_last_open_state is not None and \
not self.horizontal_splitter.is_side_index_hidden:
self.horizontal_splitter.restoreState(tb_last_open_state)
bi_state = dynamic.get('book_info_state', None)
if bi_state is not None:
self.vertical_splitter.restoreState(bi_state)
bi_last_open_state = dynamic.get('book_info_last_open_state', None)
if bi_last_open_state is not None and \
not self.vertical_splitter.is_side_index_hidden:
self.vertical_splitter.restoreState(bi_last_open_state)
self.horizontal_splitter.initialize(name='tag_browser')
self.vertical_splitter.initialize(name='book_info')
self.view_status_changed('book_info', not
self.vertical_splitter.is_side_index_hidden)
self.view_status_changed('tag_browser', not
self.horizontal_splitter.is_side_index_hidden)
self.vertical_splitter.state_changed.connect(partial(self.view_status_changed,
'book_info'), type=Qt.QueuedConnection)
self.horizontal_splitter.state_changed.connect(partial(self.view_status_changed,
'tag_browser'), type=Qt.QueuedConnection)
def view_status_changed(self, name, visible):
action = getattr(self, 'action_toggle_'+name)
texts = self.toggle_texts[name]
action.setText(texts[int(visible)])
w = self.widgetForAction(action)
w.setCheckable(True)
w.setChecked(visible)
def location_changed(self, location):
is_lib = location == 'library'
for ac in ('cover_browser', 'tag_browser'):
ac = getattr(self, 'action_toggle_'+ac)
ac.setEnabled(is_lib)
self.widgetForAction(ac).setVisible(is_lib)
def save_state(self):
dynamic.set('cover_flow_visible', self.is_cover_browser_visible)
dynamic.set('tag_browser_state',
str(self.horizontal_splitter.saveState()))
dynamic.set('book_info_state',
str(self.vertical_splitter.saveState()))
@property
def is_cover_browser_visible(self):
return self.cover_browser is not None and self.cover_browser.isVisible()
def _toggle_cover_browser(self, show=None):
if show is None:
show = not self.is_cover_browser_visible
self.do_toggle_cover_browser(show)
self.view_status_changed('cover_browser', show)
def external_cover_flow_finished(self, *args):
self.view_status_changed('cover_browser', False)
def _toggle_tag_browser(self, show=None):
self.horizontal_splitter.toggle_side_index()
def _toggle_book_info(self, show=None):
self.vertical_splitter.toggle_side_index()
def jobs(self):
src = unicode(self.jobs_button.jobs.text())
return int(re.search(r'\d+', src).group())
def job_added(self, nnum):
jobs = self.jobs_button.jobs
src = unicode(jobs.text())
num = self.jobs()
text = src.replace(str(num), str(nnum))
jobs.setText(text)
self.jobs_button.start()
def job_done(self, nnum):
jobs = self.jobs_button.jobs
src = unicode(jobs.text())
num = self.jobs()
text = src.replace(str(num), str(nnum))
jobs.setText(text)
if nnum == 0:
self.no_more_jobs()
def no_more_jobs(self):
if self.jobs_button.is_running:
self.jobs_button.stop()
QCoreApplication.instance().alert(self, 5000)

View File

@ -1,14 +1,14 @@
__license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
import os, re, collections
import os, collections
from PyQt4.QtGui import QStatusBar, QLabel, QWidget, QHBoxLayout, QPixmap, \
QVBoxLayout, QSizePolicy, QToolButton, QIcon, QScrollArea, QFrame
from PyQt4.QtCore import Qt, QSize, SIGNAL, QCoreApplication
QSizePolicy, QScrollArea
from PyQt4.QtCore import Qt, QSize, pyqtSignal
from calibre import fit_image, preferred_encoding, isosx
from calibre.gui2 import qstring_to_unicode, config
from calibre.gui2 import config
from calibre.gui2.widgets import IMAGE_EXTENSIONS
from calibre.gui2.progress_indicator import ProgressIndicator
from calibre.gui2.notify import get_notifier
from calibre.ebooks import BOOK_EXTENSIONS
from calibre.library.comments import comments_to_html
@ -16,6 +16,7 @@ from calibre.library.comments import comments_to_html
class BookInfoDisplay(QWidget):
DROPABBLE_EXTENSIONS = IMAGE_EXTENSIONS+BOOK_EXTENSIONS
files_dropped = pyqtSignal(object, object)
@classmethod
def paths_from_event(cls, event):
@ -39,8 +40,7 @@ class BookInfoDisplay(QWidget):
def dropEvent(self, event):
paths = self.paths_from_event(event)
event.setDropAction(Qt.CopyAction)
self.emit(SIGNAL('files_dropped(PyQt_PyObject, PyQt_PyObject)'), event,
paths)
self.files_dropped.emit(event, paths)
def dragMoveEvent(self, event):
event.acceptProposedAction()
@ -48,38 +48,47 @@ class BookInfoDisplay(QWidget):
class BookCoverDisplay(QLabel):
WIDTH = 81
HEIGHT = 108
def __init__(self, coverpath=I('book.svg')):
QLabel.__init__(self)
self.default_pixmap = QPixmap(coverpath).scaled(self.__class__.WIDTH,
self.__class__.HEIGHT,
self.setMaximumWidth(81)
self.setMaximumHeight(108)
self.default_pixmap = QPixmap(coverpath).scaled(self.maximumWidth(),
self.maximumHeight(),
Qt.IgnoreAspectRatio,
Qt.SmoothTransformation)
self.setScaledContents(True)
self.setMaximumHeight(self.HEIGHT)
self.statusbar_height = 120
self.setPixmap(self.default_pixmap)
def setPixmap(self, pixmap):
width, height = fit_image(pixmap.width(), pixmap.height(),
self.WIDTH, self.HEIGHT)[1:]
def do_layout(self):
pixmap = self.pixmap()
pwidth, pheight = pixmap.width(), pixmap.height()
width, height = fit_image(pwidth, pheight,
pwidth, self.statusbar_height-12)[1:]
self.setMaximumHeight(height)
self.setMaximumWidth(width)
QLabel.setPixmap(self, pixmap)
try:
aspect_ratio = pixmap.width()/float(pixmap.height())
aspect_ratio = pwidth/float(pheight)
except ZeroDivisionError:
aspect_ratio = 1
self.setMaximumWidth(int(aspect_ratio*self.HEIGHT))
self.setMaximumWidth(int(aspect_ratio*self.maximumHeight()))
def setPixmap(self, pixmap):
QLabel.setPixmap(self, pixmap)
self.do_layout()
def sizeHint(self):
return QSize(self.__class__.WIDTH, self.__class__.HEIGHT)
return QSize(self.maximumWidth(), self.maximumHeight())
def relayout(self, statusbar_size):
self.statusbar_height = statusbar_size.height()
self.do_layout()
class BookDataDisplay(QLabel):
mr = pyqtSignal(int)
def __init__(self):
QLabel.__init__(self)
self.setText('')
@ -87,14 +96,17 @@ class BookInfoDisplay(QWidget):
self.setSizePolicy(QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding))
def mouseReleaseEvent(self, ev):
self.emit(SIGNAL('mr(int)'), 1)
self.mr.emit(1)
WEIGHTS = collections.defaultdict(lambda : 100)
WEIGHTS[_('Path')] = 0
WEIGHTS[_('Formats')] = 1
WEIGHTS[_('Comments')] = 4
WEIGHTS[_('Series')] = 2
WEIGHTS[_('Tags')] = 3
WEIGHTS[_('Collections')] = 2
WEIGHTS[_('Series')] = 3
WEIGHTS[_('Tags')] = 4
WEIGHTS[_('Comments')] = 5
show_book_info = pyqtSignal()
def __init__(self, clear_message):
QWidget.__init__(self)
@ -106,14 +118,14 @@ class BookInfoDisplay(QWidget):
self.cover_display = BookInfoDisplay.BookCoverDisplay()
self._layout.addWidget(self.cover_display)
self.book_data = BookInfoDisplay.BookDataDisplay()
self.connect(self.book_data, SIGNAL('mr(int)'), self.mouseReleaseEvent)
self.book_data.mr.connect(self.mouseReleaseEvent)
self._layout.addWidget(self.book_data)
self.data = {}
self.setVisible(False)
self._layout.setAlignment(self.cover_display, Qt.AlignTop|Qt.AlignLeft)
def mouseReleaseEvent(self, ev):
self.emit(SIGNAL('show_book_info()'))
self.show_book_info.emit()
def show_data(self, data):
if data.has_key('cover'):
@ -121,7 +133,7 @@ class BookInfoDisplay(QWidget):
else:
self.cover_display.setPixmap(self.cover_display.default_pixmap)
rows = u''
rows, comments = [], ''
self.book_data.setText('')
self.data = data.copy()
keys = data.keys()
@ -135,119 +147,50 @@ class BookInfoDisplay(QWidget):
if isinstance(txt, str):
txt = txt.decode(preferred_encoding, 'replace')
if key == _('Comments'):
txt = comments_to_html(txt)
rows += u'<tr><td><b>%s:</b></td><td>%s</td></tr>'%(key, txt)
self.book_data.setText(u'<table>'+rows+u'</table>')
comments = comments_to_html(txt)
else:
rows.append((key, txt))
rows = '\n'.join([u'<tr><td valign="top"><b>%s:</b></td><td valign="top">%s</td></tr>'%(k,t) for
k, t in rows])
if comments:
comments = '<b>Comments:</b>'+comments
left_pane = u'<table>%s</table>'%rows
right_pane = u'<div>%s</div>'%comments
self.book_data.setText(u'<table><tr><td valign="top" '
'style="padding-right:2em">%s</td><td valign="top">%s</td></tr></table>'
% (left_pane, right_pane))
self.clear_message()
self.book_data.updateGeometry()
self.updateGeometry()
self.setVisible(True)
class MovieButton(QFrame):
def __init__(self, jobs_dialog):
QFrame.__init__(self)
self.setLayout(QVBoxLayout())
self.pi = ProgressIndicator(self)
self.layout().addWidget(self.pi)
self.jobs = QLabel('<b>'+_('Jobs:')+' 0')
self.jobs.setAlignment(Qt.AlignHCenter|Qt.AlignBottom)
self.layout().addWidget(self.jobs)
self.layout().setAlignment(self.jobs, Qt.AlignHCenter)
self.jobs.setMargin(0)
self.layout().setMargin(0)
self.jobs.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
self.jobs_dialog = jobs_dialog
self.setCursor(Qt.PointingHandCursor)
self.setToolTip(_('Click to see list of active jobs.'))
self.jobs_dialog.jobs_view.restore_column_widths()
def mouseReleaseEvent(self, event):
if self.jobs_dialog.isVisible():
self.jobs_dialog.jobs_view.write_settings()
self.jobs_dialog.hide()
else:
self.jobs_dialog.jobs_view.read_settings()
self.jobs_dialog.show()
self.jobs_dialog.jobs_view.restore_column_widths()
@property
def is_running(self):
return self.pi.isAnimated()
def start(self):
self.pi.startAnimation()
def stop(self):
self.pi.stopAnimation()
class CoverFlowButton(QToolButton):
def __init__(self, parent=None):
QToolButton.__init__(self, parent)
self.setIconSize(QSize(80, 80))
self.setIcon(QIcon(I('cover_flow.svg')))
self.setCheckable(True)
self.setChecked(False)
self.setAutoRaise(True)
self.setSizePolicy(QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding))
self.connect(self, SIGNAL('toggled(bool)'), self.adjust_tooltip)
self.adjust_tooltip(False)
self.setCursor(Qt.PointingHandCursor)
def adjust_tooltip(self, on):
tt = _('Click to turn off Cover Browsing') if on else _('Click to browse books by their covers')
self.setToolTip(tt)
def disable(self, reason):
self.setDisabled(True)
self.setToolTip(_('<p>Browsing books by their covers is disabled.<br>Import of pictureflow module failed:<br>')+reason)
class TagViewButton(QToolButton):
def __init__(self, parent=None):
QToolButton.__init__(self, parent)
self.setIconSize(QSize(80, 80))
self.setIcon(QIcon(I('tags.svg')))
self.setToolTip(_('Click to browse books by tags'))
self.setSizePolicy(QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding))
self.setCursor(Qt.PointingHandCursor)
self.setCheckable(True)
self.setChecked(False)
self.setAutoRaise(True)
class StatusBar(QStatusBar):
def __init__(self, jobs_dialog, systray=None):
QStatusBar.__init__(self)
resized = pyqtSignal(object)
files_dropped = pyqtSignal(object, object)
show_book_info = pyqtSignal()
def initialize(self, systray=None):
self.systray = systray
self.notifier = get_notifier(systray)
self.movie_button = MovieButton(jobs_dialog)
self.cover_flow_button = CoverFlowButton()
self.tag_view_button = TagViewButton()
self.addPermanentWidget(self.cover_flow_button)
self.addPermanentWidget(self.tag_view_button)
self.addPermanentWidget(self.movie_button)
self.book_info = BookInfoDisplay(self.clearMessage)
self.book_info.setAcceptDrops(True)
self.scroll_area = QScrollArea()
self.scroll_area.setWidget(self.book_info)
self.scroll_area.setMaximumHeight(120)
self.scroll_area.setWidgetResizable(True)
self.connect(self.book_info, SIGNAL('show_book_info()'), self.show_book_info)
self.connect(self.book_info,
SIGNAL('files_dropped(PyQt_PyObject,PyQt_PyObject)'),
self.files_dropped, Qt.QueuedConnection)
self.book_info.show_book_info.connect(self.show_book_info.emit,
type=Qt.QueuedConnection)
self.book_info.files_dropped.connect(self.files_dropped.emit,
type=Qt.QueuedConnection)
self.addWidget(self.scroll_area, 100)
self.setMinimumHeight(120)
self.setMaximumHeight(120)
self.resized.connect(self.book_info.cover_display.relayout)
self.book_info.cover_display.relayout(self.size())
def files_dropped(self, event, paths):
self.emit(SIGNAL('files_dropped(PyQt_PyObject, PyQt_PyObject)'), event,
paths)
def resizeEvent(self, ev):
self.resized.emit(self.size())
def reset_info(self):
self.book_info.show_data({})
@ -263,33 +206,4 @@ class StatusBar(QStatusBar):
self.notifier(msg)
return ret
def jobs(self):
src = qstring_to_unicode(self.movie_button.jobs.text())
return int(re.search(r'\d+', src).group())
def show_book_info(self):
self.emit(SIGNAL('show_book_info()'))
def job_added(self, nnum):
jobs = self.movie_button.jobs
src = qstring_to_unicode(jobs.text())
num = self.jobs()
text = src.replace(str(num), str(nnum))
jobs.setText(text)
self.movie_button.start()
def job_done(self, nnum):
jobs = self.movie_button.jobs
src = qstring_to_unicode(jobs.text())
num = self.jobs()
text = src.replace(str(num), str(nnum))
jobs.setText(text)
if nnum == 0:
self.no_more_jobs()
def no_more_jobs(self):
if self.movie_button.is_running:
self.movie_button.stop()
QCoreApplication.instance().alert(self, 5000)

View File

@ -10,15 +10,18 @@ Browsing book collection by tags.
from itertools import izip
from PyQt4.Qt import Qt, QTreeView, QApplication, pyqtSignal, \
QFont, SIGNAL, QSize, QIcon, QPoint, \
QFont, QSize, QIcon, QPoint, \
QAbstractItemModel, QVariant, QModelIndex
from calibre.gui2 import config, NONE
from calibre.utils.config import prefs
from calibre.library.field_metadata import TagsIcons
from calibre.utils.search_query_parser import saved_searches
from calibre.library.database2 import Tag
class TagsView(QTreeView):
class TagsView(QTreeView): # {{{
need_refresh = pyqtSignal()
need_refresh = pyqtSignal()
restriction_set = pyqtSignal(object)
tags_marked = pyqtSignal(object, object)
def __init__(self, *args):
QTreeView.__init__(self, *args)
@ -27,16 +30,24 @@ class TagsView(QTreeView):
self.setIconSize(QSize(30, 30))
self.tag_match = None
def set_database(self, db, tag_match, popularity):
def set_database(self, db, tag_match, popularity, restriction):
self._model = TagsModel(db, parent=self)
self.popularity = popularity
self.restriction = restriction
self.tag_match = tag_match
self.db = db
self.setModel(self._model)
self.connect(self, SIGNAL('clicked(QModelIndex)'), self.toggle)
self.clicked.connect(self.toggle)
self.popularity.setChecked(config['sort_by_popularity'])
self.connect(self.popularity, SIGNAL('stateChanged(int)'), self.sort_changed)
self.popularity.stateChanged.connect(self.sort_changed)
self.restriction.activated[str].connect(self.search_restriction_set)
self.need_refresh.connect(self.recount, type=Qt.QueuedConnection)
db.add_listener(self.database_changed)
self.saved_searches_changed(recount=False)
def create_tag_category(self, name, tag_list):
self._model.create_tag_category(name, tag_list)
self.recount()
def database_changed(self, event, ids):
self.need_refresh.emit()
@ -48,17 +59,42 @@ class TagsView(QTreeView):
def sort_changed(self, state):
config.set('sort_by_popularity', state == Qt.Checked)
self.model().refresh()
# self.search_restriction_set()
def search_restriction_set(self, s):
self.clear()
if len(s) == 0:
self.search_restriction = ''
else:
self.search_restriction = 'search:"%s"' % unicode(s).strip()
self.model().set_search_restriction(self.search_restriction)
self.restriction_set.emit(self.search_restriction)
self.recount() # Must happen after the emission of the restriction_set signal
self.tags_marked.emit(self._model.tokens(), self.match_all)
def toggle(self, index):
modifiers = int(QApplication.keyboardModifiers())
exclusive = modifiers not in (Qt.CTRL, Qt.SHIFT)
if self._model.toggle(index, exclusive):
self.emit(SIGNAL('tags_marked(PyQt_PyObject, PyQt_PyObject)'),
self._model.tokens(), self.match_all)
self.tags_marked.emit(self._model.tokens(), self.match_all)
def clear(self):
self.model().clear_state()
def saved_searches_changed(self, recount=True):
p = prefs['saved_searches'].keys()
p.sort()
t = self.restriction.currentText()
self.restriction.clear() # rebuild the restrictions combobox using current saved searches
self.restriction.addItem('')
for s in p:
self.restriction.addItem(s)
if t in p: # redo the current restriction, if there was one
self.restriction.setCurrentIndex(self.restriction.findText(t))
self.search_restriction_set(t)
if recount:
self.recount()
def recount(self, *args):
ci = self.currentIndex()
if not ci.isValid():
@ -74,13 +110,23 @@ class TagsView(QTreeView):
self.setCurrentIndex(idx)
self.scrollTo(idx, QTreeView.PositionAtCenter)
class TagTreeItem(object):
'''
If the number of user categories changed, or if custom columns have come or gone,
we must rebuild the model. Reason: it is much easier to do that than to reconstruct
the browser tree.
'''
def set_new_model(self):
self._model = TagsModel(self.db, parent=self)
self.setModel(self._model)
# }}}
class TagTreeItem(object): # {{{
CATEGORY = 0
TAG = 1
ROOT = 2
def __init__(self, data=None, tag=None, category_icon=None, icon_map=None, parent=None):
def __init__(self, data=None, category_icon=None, icon_map=None, parent=None, tooltip=None):
self.parent = parent
self.children = []
if self.parent is not None:
@ -96,13 +142,15 @@ class TagTreeItem(object):
self.bold_font.setBold(True)
self.bold_font = QVariant(self.bold_font)
elif self.type == self.TAG:
self.tag, self.icon_map = data, list(map(QVariant, icon_map))
icon_map[0] = data.icon
self.tag, self.icon_state_map = data, list(map(QVariant, icon_map))
self.tooltip = tooltip
def __str__(self):
if self.type == self.ROOT:
return 'ROOT'
if self.type == self.CATEGORY:
return 'CATEGORY:'+self.name+':%d'%len(self.children)
return 'CATEGORY:'+str(QVariant.toString(self.name))+':%d'%len(self.children)
return 'TAG:'+self.tag.name
def row(self):
@ -123,11 +171,13 @@ class TagTreeItem(object):
def category_data(self, role):
if role == Qt.DisplayRole:
return self.name
return QVariant(self.py_name + ' [%d]'%len(self.children))
if role == Qt.DecorationRole:
return self.icon
if role == Qt.FontRole:
return self.bold_font
if role == Qt.ToolTipRole and self.tooltip is not None:
return QVariant(self.tooltip)
return NONE
def tag_data(self, role):
@ -137,8 +187,8 @@ class TagTreeItem(object):
else:
return QVariant('[%d] %s'%(self.tag.count, self.tag.name))
if role == Qt.DecorationRole:
return self.icon_map[self.tag.state]
if role == Qt.ToolTipRole and self.tag.tooltip:
return self.icon_state_map[self.tag.state]
if role == Qt.ToolTipRole and self.tag.tooltip is not None:
return QVariant(self.tag.tooltip)
return NONE
@ -146,40 +196,81 @@ class TagTreeItem(object):
if self.type == self.TAG:
self.tag.state = (self.tag.state + 1)%3
# }}}
class TagsModel(QAbstractItemModel):
categories = [_('Authors'), _('Series'), _('Formats'), _('Publishers'), _('News'), _('Tags'), _('Searches')]
row_map = ['author', 'series', 'format', 'publisher', 'news', 'tag', 'search']
class TagsModel(QAbstractItemModel): # {{{
def __init__(self, db, parent=None):
QAbstractItemModel.__init__(self, parent)
self.cmap = tuple(map(QIcon, [I('user_profile.svg'),
I('series.svg'), I('book.svg'), I('publisher.png'),
I('news.svg'), I('tags.svg'), I('search.svg')]))
self.icon_map = [QIcon(), QIcon(I('plus.svg')),
QIcon(I('minus.svg'))]
# must do this here because 'QPixmap: Must construct a QApplication
# before a QPaintDevice'. The ':' in front avoids polluting either the
# user-defined categories (':' at end) or columns namespaces (no ':').
self.category_icon_map = TagsIcons({
'authors' : QIcon(I('user_profile.svg')),
'series' : QIcon(I('series.svg')),
'formats' : QIcon(I('book.svg')),
'publisher' : QIcon(I('publisher.png')),
'rating' : QIcon(I('star.png')),
'news' : QIcon(I('news.svg')),
'tags' : QIcon(I('tags.svg')),
':custom' : QIcon(I('column.svg')),
':user' : QIcon(I('drawer.svg')),
'search' : QIcon(I('search.svg'))})
self.icon_state_map = [None, QIcon(I('plus.svg')), QIcon(I('minus.svg'))]
self.db = db
self.search_restriction = ''
self.ignore_next_search = 0
# Reconstruct the user categories, putting them into metadata
tb_cats = self.db.field_metadata
for k in tb_cats.keys():
if tb_cats[k]['kind'] in ['user', 'search']:
del tb_cats[k]
for user_cat in sorted(prefs['user_categories'].keys()):
cat_name = user_cat+':' # add the ':' to avoid name collision
tb_cats.add_user_category(label=cat_name, name=user_cat)
if len(saved_searches.names()):
tb_cats.add_search_category(label='search', name=_('Searches'))
data = self.get_node_tree(config['sort_by_popularity'])
self.root_item = TagTreeItem()
data = self.db.get_categories(config['sort_by_popularity'])
data['search'] = self.get_search_nodes()
for i, r in enumerate(self.row_map):
if self.db.field_metadata[r]['kind'] != 'user':
tt = _('The lookup/search name is "{0}"').format(r)
else:
tt = ''
c = TagTreeItem(parent=self.root_item,
data=self.categories[i], category_icon=self.cmap[i])
data=self.categories[i],
category_icon=self.category_icon_map[r],
tooltip=tt)
for tag in data[r]:
TagTreeItem(parent=c, data=tag, icon_map=self.icon_map)
TagTreeItem(parent=c, data=tag, icon_map=self.icon_state_map)
def set_search_restriction(self, s):
self.search_restriction = s
def get_search_nodes(self):
l = []
for i in saved_searches.names():
l.append(Tag(i, tooltip=saved_searches.lookup(i)))
return l
def get_node_tree(self, sort):
self.row_map = []
self.categories = []
if len(self.search_restriction):
data = self.db.get_categories(sort_on_count=sort, icon_map=self.category_icon_map,
ids=self.db.search(self.search_restriction, return_matches=True))
else:
data = self.db.get_categories(sort_on_count=sort, icon_map=self.category_icon_map)
tb_categories = self.db.field_metadata
for category in tb_categories:
if category in data: # They should always be there, but ...
self.row_map.append(category)
self.categories.append(tb_categories[category]['name'])
return data
def refresh(self):
data = self.db.get_categories(config['sort_by_popularity'])
data['search'] = self.get_search_nodes()
data = self.get_node_tree(config['sort_by_popularity']) # get category data
for i, r in enumerate(self.row_map):
category = self.root_item.children[i]
names = [t.tag.name for t in category.children]
@ -194,10 +285,8 @@ class TagsModel(QAbstractItemModel):
if len(data[r]) > 0:
self.beginInsertRows(category_index, 0, len(data[r])-1)
for tag in data[r]:
if r == 'author':
tag.name = tag.name.replace('|', ',')
tag.state = state_map.get(tag.name, 0)
t = TagTreeItem(parent=category, data=tag, icon_map=self.icon_map)
t = TagTreeItem(parent=category, data=tag, icon_map=self.icon_state_map)
self.endInsertRows()
def columnCount(self, parent):
@ -273,18 +362,20 @@ class TagsModel(QAbstractItemModel):
return len(parent_item.children)
def reset_all_states(self, except_=None):
update_list = []
for i in xrange(self.rowCount(QModelIndex())):
category_index = self.index(i, 0, QModelIndex())
for j in xrange(self.rowCount(category_index)):
tag_index = self.index(j, 0, category_index)
tag_item = tag_index.internalPointer()
if tag_item is except_:
continue
tag = tag_item.tag
if tag.state != 0:
if tag is except_:
self.dataChanged.emit(tag_index, tag_index)
continue
if tag.state != 0 or tag in update_list:
tag.state = 0
self.emit(SIGNAL('dataChanged(QModelIndex,QModelIndex)'),
tag_index, tag_index)
update_list.append(tag)
self.dataChanged.emit(tag_index, tag_index)
def clear_state(self):
self.reset_all_states()
@ -299,24 +390,35 @@ class TagsModel(QAbstractItemModel):
if not index.isValid(): return False
item = index.internalPointer()
if item.type == TagTreeItem.TAG:
if exclusive:
self.reset_all_states(except_=item)
item.toggle()
if exclusive:
self.reset_all_states(except_=item.tag)
self.ignore_next_search = 2
self.emit(SIGNAL('dataChanged(QModelIndex,QModelIndex)'), index, index)
self.dataChanged.emit(index, index)
return True
return False
def tokens(self):
ans = []
tags_seen = set()
for i, key in enumerate(self.row_map):
if key.endswith(':'): # User category, so skip it. The tag will be marked in its real category
continue
category_item = self.root_item.children[i]
for tag_item in category_item.children:
tag = tag_item.tag
category = key if key != 'news' else 'tag'
if tag.state > 0:
prefix = ' not ' if tag.state == 2 else ''
ans.append('%s%s:"=%s"'%(prefix, category, tag.name))
category = key if key != 'news' else 'tag'
if tag.name[0] == u'\u2605': # char is a star. Assume rating
ans.append('%s%s:%s'%(prefix, category, len(tag.name)))
else:
if category == 'tags':
if tag.name in tags_seen:
continue
tags_seen.add(tag.name)
ans.append('%s%s:"=%s"'%(prefix, category, tag.name))
return ans
# }}}

View File

@ -30,6 +30,7 @@ from calibre.ptempfile import PersistentTemporaryFile
from calibre.utils.config import prefs, dynamic
from calibre.utils.ipc.server import Server
from calibre.utils.search_query_parser import saved_searches
from calibre.devices.errors import UserFeedback
from calibre.gui2 import warning_dialog, choose_files, error_dialog, \
question_dialog,\
pixmap_to_data, choose_dir, \
@ -45,7 +46,6 @@ from calibre.gui2.update import CheckForUpdates
from calibre.gui2.main_window import MainWindow
from calibre.gui2.main_ui import Ui_MainWindow
from calibre.gui2.device import DeviceManager, DeviceMenu, DeviceGUI, Emailer
from calibre.gui2.status import StatusBar
from calibre.gui2.jobs import JobManager, JobsDialog
from calibre.gui2.dialogs.metadata_single import MetadataSingleDialog
from calibre.gui2.dialogs.metadata_bulk import MetadataBulkDialog
@ -60,6 +60,7 @@ from calibre.ebooks.BeautifulSoup import BeautifulSoup, Tag, NavigableString
from calibre.library.database2 import LibraryDatabase2
from calibre.library.caches import CoverCache
from calibre.gui2.dialogs.confirm_delete import confirm
from calibre.gui2.dialogs.tag_categories import TagCategories
class SaveMenu(QMenu):
@ -125,10 +126,13 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.default_thumbnail = (pixmap.width(), pixmap.height(),
pixmap_to_data(pixmap))
self.last_time = datetime.datetime.now()
def __init__(self, library_path, db, listener, opts, actions, parent=None):
self.last_time = datetime.datetime.now()
self.preferences_action, self.quit_action = actions
self.library_path = library_path
self.spare_servers = []
self.must_restart_before_config = False
MainWindow.__init__(self, opts, parent)
# Initialize fontconfig in a separate thread as this can be a lengthy
# process if run for the first time on this machine
@ -144,6 +148,9 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.setupUi(self)
self.setWindowTitle(__appname__)
self.restriction_count_of_books_in_view = 0
self.restriction_count_of_books_in_library = 0
self.restriction_in_effect = False
self.search.initialize('main_search_history', colorize=True,
help_text=_('Search (For Advanced Search click the button to the left)'))
self.connect(self.clear_button, SIGNAL('clicked()'), self.search_clear)
@ -177,7 +184,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
_('Error communicating with device'), ' ')
self.device_error_dialog.setModal(Qt.NonModal)
self.tb_wrapper = textwrap.TextWrapper(width=40)
self.device_connected = False
self.device_connected = None
self.viewers = collections.deque()
self.content_server = None
self.system_tray_icon = SystemTrayIcon(QIcon(I('library.png')), self)
@ -227,7 +234,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
####################### Setup device detection ########################
self.device_manager = DeviceManager(Dispatcher(self.device_detected),
self.job_manager)
self.job_manager, Dispatcher(self.status_bar.showMessage))
self.device_manager.start()
@ -254,20 +261,14 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.device_info = ' '
if not opts.no_update_check:
self.update_checker = CheckForUpdates(self)
QObject.connect(self.update_checker,
SIGNAL('update_found(PyQt_PyObject)'), self.update_found)
self.update_checker.start(2000)
self.update_checker.update_found.connect(self.update_found,
type=Qt.QueuedConnection)
self.update_checker.start()
####################### Status Bar #####################
self.status_bar = StatusBar(self.jobs_dialog, self.system_tray_icon)
self.setStatusBar(self.status_bar)
QObject.connect(self.job_manager, SIGNAL('job_added(int)'),
self.status_bar.job_added, Qt.QueuedConnection)
QObject.connect(self.job_manager, SIGNAL('job_done(int)'),
self.status_bar.job_done, Qt.QueuedConnection)
QObject.connect(self.status_bar, SIGNAL('show_book_info()'),
self.show_book_info)
QObject.connect(self.status_bar, SIGNAL('files_dropped(PyQt_PyObject,PyQt_PyObject)'),
self.files_dropped_on_book)
self.status_bar.initialize(self.system_tray_icon)
self.status_bar.show_book_info.connect(self.show_book_info)
self.status_bar.files_dropped.connect(self.files_dropped_on_book)
####################### Setup Toolbar #####################
md = QMenu()
md.addAction(_('Edit metadata individually'))
@ -348,6 +349,10 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.save_menu.addAction(_('Save to disk in a single directory'))
self.save_menu.addAction(_('Save only %s format to disk')%
prefs['output_format'].upper())
self.save_menu.addAction(
_('Save only %s format to disk in a single directory')%
prefs['output_format'].upper())
self.save_sub_menu = SaveMenu(self)
self.save_menu.addMenu(self.save_sub_menu)
self.connect(self.save_sub_menu, SIGNAL('save_fmt(PyQt_PyObject)'),
@ -376,6 +381,8 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.save_to_single_dir)
QObject.connect(self.save_menu.actions()[2], SIGNAL("triggered(bool)"),
self.save_single_format_to_disk)
QObject.connect(self.save_menu.actions()[3], SIGNAL("triggered(bool)"),
self.save_single_fmt_to_single_dir)
QObject.connect(self.action_view, SIGNAL("triggered(bool)"),
self.view_book)
QObject.connect(self.view_menu.actions()[0],
@ -458,6 +465,10 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
QObject.connect(self.advanced_search_button, SIGNAL('clicked(bool)'),
self.do_advanced_search)
for ch in self.tool_bar.children():
if isinstance(ch, QToolButton):
ch.setCursor(Qt.PointingHandCursor)
####################### Library view ########################
similar_menu = QMenu(_('Similar books...'))
similar_menu.addAction(self.action_books_by_same_author)
@ -497,14 +508,14 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.card_b_view.set_context_menu(None, None, None,
self.action_view, self.action_save, None, None, self.action_del)
QObject.connect(self.library_view,
SIGNAL('files_dropped(PyQt_PyObject)'),
self.files_dropped, Qt.QueuedConnection)
self.library_view.files_dropped.connect(self.files_dropped, type=Qt.QueuedConnection)
for func, args in [
('connect_to_search_box', (self.search,
self.search_done)),
('connect_to_book_display',
(self.status_bar.book_info.show_data,)),
('connect_to_restriction_set',
(self.tags_view,)),
]:
for view in (self.library_view, self.memory_view, self.card_a_view, self.card_b_view):
getattr(view, func)(*args)
@ -517,37 +528,28 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
if self.system_tray_icon.isVisible() and opts.start_in_tray:
self.hide_windows()
self.stack.setCurrentIndex(0)
self.book_on_device(None, reset=True)
db.set_book_on_device_func(self.book_on_device)
self.library_view.set_database(db)
self.library_view.model().set_book_on_device_func(self.book_on_device)
prefs['library_path'] = self.library_path
self.library_view.sortByColumn(*dynamic.get('sort_column',
('timestamp', Qt.DescendingOrder)))
if not self.library_view.restore_column_widths():
self.library_view.resizeColumnsToContents()
self.search.setFocus(Qt.OtherFocusReason)
self.cover_cache = CoverCache(self.library_path)
self.cover_cache.start()
self.library_view.model().cover_cache = self.cover_cache
self.tags_view.setVisible(False)
self.tag_match.setVisible(False)
self.popularity.setVisible(False)
self.tags_view.set_database(db, self.tag_match, self.popularity)
self.connect(self.tags_view,
SIGNAL('tags_marked(PyQt_PyObject, PyQt_PyObject)'),
self.search.search_from_tags)
self.connect(self.tags_view,
SIGNAL('tags_marked(PyQt_PyObject, PyQt_PyObject)'),
self.saved_search.clear_to_help)
self.connect(self.status_bar.tag_view_button,
SIGNAL('toggled(bool)'), self.toggle_tags_view)
self.connect(self.search,
SIGNAL('search(PyQt_PyObject, PyQt_PyObject)'),
self.tags_view.model().reinit)
self.connect(self.library_view.model(),
SIGNAL('count_changed(int)'), self.location_view.count_changed)
self.connect(self.library_view.model(), SIGNAL('count_changed(int)'),
self.tags_view.recount, Qt.QueuedConnection)
self.connect(self.search, SIGNAL('cleared()'), self.tags_view_clear)
self.connect(self.saved_search, SIGNAL('changed()'), self.tags_view.recount, Qt.QueuedConnection)
self.connect(self.edit_categories, SIGNAL('clicked()'), self.do_edit_categories)
self.tags_view.set_database(db, self.tag_match, self.popularity, self.search_restriction)
self.tags_view.tags_marked.connect(self.search.search_from_tags)
for x in (self.saved_search.clear_to_help, self.mark_restriction_set):
self.tags_view.restriction_set.connect(x)
self.tags_view.tags_marked.connect(self.saved_search.clear_to_help)
self.search.search.connect(self.tags_view.model().reinit)
for x in (self.location_view.count_changed, self.tags_view.recount,
self.restriction_count_changed):
self.library_view.model().count_changed_signal.connect(x)
self.connect(self.search, SIGNAL('cleared()'), self.search_box_cleared)
self.connect(self.saved_search, SIGNAL('changed()'), self.tags_view.saved_searches_changed, Qt.QueuedConnection)
if not gprefs.get('quick_start_guide_added', False):
from calibre.ebooks.metadata import MetaInformation
mi = MetaInformation(_('Calibre Quick Start Guide'), ['John Schember'])
@ -562,6 +564,14 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.db_images.reset()
self.library_view.model().count_changed()
self.location_view.model().database_changed(self.library_view.model().db)
self.library_view.model().database_changed.connect(self.location_view.model().database_changed,
type=Qt.QueuedConnection)
########################### Tags Browser ##############################
self.search_restriction.setSizeAdjustPolicy(self.search_restriction.AdjustToMinimumContentsLengthWithIcon)
self.search_restriction.setMinimumContentsLength(10)
########################### Cover Flow ################################
self.cover_flow = None
@ -581,26 +591,31 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
if not config['separate_cover_flow']:
self.library.layout().addWidget(self.cover_flow)
self.cover_flow.currentChanged.connect(self.sync_listview_to_cf)
self.connect(self.status_bar.cover_flow_button,
SIGNAL('toggled(bool)'), self.toggle_cover_flow)
self.connect(self.cover_flow, SIGNAL('stop()'),
self.status_bar.cover_flow_button.toggle)
self.library_view.selectionModel().currentRowChanged.connect(
self.sync_cf_to_listview)
self.db_images = DatabaseImages(self.library_view.model())
self.cover_flow.setImages(self.db_images)
else:
self.status_bar.cover_flow_button.disable(pictureflowerror)
self._calculated_available_height = min(max_available_height()-15,
self.height())
self.resize(self.width(), self._calculated_available_height)
self.search.setMaximumWidth(self.width()-150)
####################### Side Bar ###############################
self.sidebar.initialize(self.jobs_dialog, self.cover_flow,
self.toggle_cover_flow, pictureflowerror,
self.vertical_splitter, self.horizontal_splitter)
QObject.connect(self.job_manager, SIGNAL('job_added(int)'),
self.sidebar.job_added, Qt.QueuedConnection)
QObject.connect(self.job_manager, SIGNAL('job_done(int)'),
self.sidebar.job_done, Qt.QueuedConnection)
if config['autolaunch_server']:
from calibre.library.server import start_threaded_server
from calibre.library import server_config
from calibre.library.server.main import start_threaded_server
from calibre.library.server import server_config
self.content_server = start_threaded_server(
db, server_config().parse())
self.test_server_timer = QTimer.singleShot(10000, self.test_server)
@ -624,26 +639,29 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.location_view.setCurrentIndex(self.location_view.model().index(0))
if self.cover_flow is not None and dynamic.get('cover_flow_visible', False):
self.status_bar.cover_flow_button.toggle()
if dynamic.get('tag_view_visible', False):
self.status_bar.tag_view_button.toggle()
self._add_filesystem_book = Dispatcher(self.__add_filesystem_book)
v = self.library_view
if v.model().rowCount(None) > 1:
v.resizeRowToContents(0)
height = v.rowHeight(0)
self.library_view.verticalHeader().setDefaultSectionSize(height)
self.keyboard_interrupt.connect(self.quit, type=Qt.QueuedConnection)
def do_edit_categories(self):
d = TagCategories(self, self.library_view.model().db)
d.exec_()
if d.result() == d.Accepted:
self.tags_view.set_new_model()
self.tags_view.recount()
def resizeEvent(self, ev):
MainWindow.resizeEvent(self, ev)
self.search.setMaximumWidth(self.width()-150)
def connect_to_folder(self):
dir = choose_dir(self, 'Select Device Folder',
_('Select folder to open as device'))
if dir is not None:
self.device_manager.connect_to_folder(dir)
def disconnect_from_folder(self):
self.device_manager.disconnect_folder()
def _sync_action_triggered(self, *args):
m = getattr(self, '_sync_menu', None)
if m is not None:
@ -656,6 +674,17 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
SIGNAL('sync(PyQt_PyObject, PyQt_PyObject, PyQt_PyObject)'),
self.dispatch_sync_event)
self._sync_menu.fetch_annotations.connect(self.fetch_annotations)
self._sync_menu.connect_to_folder.connect(self.connect_to_folder)
self._sync_menu.disconnect_from_folder.connect(self.disconnect_from_folder)
if self.device_connected:
self._sync_menu.connect_to_folder_action.setEnabled(False)
if self.device_connected == 'folder':
self._sync_menu.disconnect_from_folder_action.setEnabled(True)
else:
self._sync_menu.disconnect_from_folder_action.setEnabled(False)
else:
self._sync_menu.connect_to_folder_action.setEnabled(True)
self._sync_menu.disconnect_from_folder_action.setEnabled(False)
def add_spare_server(self, *args):
self.spare_servers.append(Server(limit=int(config['worker_limit']/2.0)))
@ -728,11 +757,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
if search:
self.search.set_search_string(join.join(search))
def uncheck_cover_button(self, *args):
self.status_bar.cover_flow_button.setChecked(False)
def toggle_cover_flow(self, show):
if config['separate_cover_flow']:
if show:
@ -748,8 +772,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.cover_flow.setFocus(Qt.OtherFocusReason)
self.library_view.scrollTo(self.library_view.currentIndex())
d.show()
self.connect(d, SIGNAL('finished(int)'),
self.uncheck_cover_button)
d.finished.connect(self.sidebar.external_cover_flow_finished)
self.cf_dialog = d
self.cover_flow_sync_timer.start(500)
else:
@ -771,8 +794,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.library_view.currentIndex())
self.cover_flow.setVisible(True)
self.cover_flow.setFocus(Qt.OtherFocusReason)
#self.status_bar.book_info.book_data.setMaximumHeight(100)
#self.status_bar.setMaximumHeight(120)
self.library_view.scrollTo(self.library_view.currentIndex())
self.cover_flow_sync_timer.start(500)
else:
@ -783,33 +804,61 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
sm = self.library_view.selectionModel()
sm.select(idx, sm.ClearAndSelect|sm.Rows)
self.library_view.setCurrentIndex(idx)
#self.status_bar.book_info.book_data.setMaximumHeight(1000)
#self.resize(self.width(), self._calculated_available_height)
#self.setMaximumHeight(available_height())
def toggle_tags_view(self, show):
if show:
self.tags_view.setVisible(True)
self.tag_match.setVisible(True)
self.popularity.setVisible(True)
self.tags_view.setFocus(Qt.OtherFocusReason)
else:
self.tags_view.setVisible(False)
self.tag_match.setVisible(False)
self.popularity.setVisible(False)
def tags_view_clear(self):
self.search_count.setText(_("(all books)"))
'''
Handling of the count of books in a restricted view requires that
we capture the count after the initial restriction search. To so this,
we require that the restriction_set signal be issued before the search signal,
so that when the search_done happens and the count is displayed,
we can grab the count. This works because the search box is cleared
when a restriction is set, so that first search will find all books.
Adding and deleting books creates another complexity. When added, they are
displayed regardless of whether they match the restriction. However, if they
do not, they are removed at the next search. The counts must take this
behavior into effect.
'''
def restriction_count_changed(self, c):
self.restriction_count_of_books_in_view += c - self.restriction_count_of_books_in_library
self.restriction_count_of_books_in_library = c
if self.restriction_in_effect:
self.set_number_of_books_shown(compute_count=False)
def mark_restriction_set(self, r):
self.restriction_in_effect = False if r is None or not r else True
def set_number_of_books_shown(self, compute_count):
if self.current_view() == self.library_view and self.restriction_in_effect:
if compute_count:
self.restriction_count_of_books_in_view = self.current_view().row_count()
t = _("({0} of {1})").format(self.current_view().row_count(),
self.restriction_count_of_books_in_view)
self.search_count.setStyleSheet('QLabel { border-radius: 8px; background-color: yellow; }')
else: # No restriction or not library view
if not self.search.in_a_search():
t = _("(all books)")
else:
t = _("({0} of all)").format(self.current_view().row_count())
self.search_count.setStyleSheet(
'QLabel { background-color: transparent; }')
self.search_count.setText(t)
def search_box_cleared(self):
self.set_number_of_books_shown(compute_count=True)
self.tags_view.clear()
self.saved_search.clear_to_help()
def search_clear(self):
self.search_count.setText(_("(all books)"))
self.set_number_of_books_shown(compute_count=True)
self.search.clear()
def search_done(self, view, ok):
if view is self.current_view():
self.search_count.setText(_("(%d found)") % self.current_view().row_count())
self.search.search_done(ok)
self.set_number_of_books_shown(compute_count=False)
def sync_cf_to_listview(self, current, previous):
if self.cover_flow_sync_flag and self.cover_flow.isVisible() and \
@ -881,7 +930,8 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
def save_device_view_settings(self):
model = self.location_view.model()
self.memory_view.write_settings()
return
#self.memory_view.write_settings()
for x in range(model.rowCount()):
if x > 1:
if model.location_for_row(x) == 'carda':
@ -889,11 +939,14 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
elif model.location_for_row(x) == 'cardb':
self.card_b_view.write_settings()
def device_detected(self, connected):
def device_detected(self, connected, is_folder_device):
'''
Called when a device is connected to the computer.
'''
if connected:
self._sync_menu.connect_to_folder_action.setEnabled(False)
if is_folder_device:
self._sync_menu.disconnect_from_folder_action.setEnabled(True)
self.device_manager.get_device_information(\
Dispatcher(self.info_read))
self.set_default_thumbnail(\
@ -901,15 +954,18 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.status_bar.showMessage(_('Device: ')+\
self.device_manager.device.__class__.get_gui_name()+\
_(' detected.'), 3000)
self.device_connected = True
self.device_connected = 'device' if not is_folder_device else 'folder'
self._sync_menu.enable_device_actions(True,
self.device_manager.device.card_prefix(),
self.device_manager.device)
self.location_view.model().device_connected(self.device_manager.device)
self.eject_action.setEnabled(True)
self.refresh_ondevice_info (device_connected = True, reset_only = True)
else:
self._sync_menu.connect_to_folder_action.setEnabled(True)
self._sync_menu.disconnect_from_folder_action.setEnabled(False)
self.save_device_view_settings()
self.device_connected = False
self.device_connected = None
self._sync_menu.enable_device_actions(False)
self.location_view.model().update_devices()
self.vanity.setText(self.vanity_template%\
@ -919,6 +975,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.status_bar.reset_info()
self.location_view.setCurrentIndex(self.location_view.model().index(0))
self.eject_action.setEnabled(False)
self.refresh_ondevice_info (device_connected = False)
def info_read(self, job):
'''
@ -951,6 +1008,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
else:
self.device_job_exception(job)
return
self.set_books_in_library(job.result, reset=True)
mainlist, cardalist, cardblist = job.result
self.memory_view.set_database(mainlist)
self.memory_view.set_editable(self.device_manager.device.CAN_SET_METADATA)
@ -958,14 +1016,17 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.card_a_view.set_editable(self.device_manager.device.CAN_SET_METADATA)
self.card_b_view.set_database(cardblist)
self.card_b_view.set_editable(self.device_manager.device.CAN_SET_METADATA)
for view in (self.memory_view, self.card_a_view, self.card_b_view):
view.sortByColumn(3, Qt.DescendingOrder)
view.read_settings()
if not view.restore_column_widths():
view.resizeColumnsToContents()
view.resize_on_select = not view.isVisible()
self.sync_news()
self.sync_catalogs()
self.refresh_ondevice_info(device_connected = True)
############################################################################
### Force the library view to refresh, taking into consideration books information
def refresh_ondevice_info(self, device_connected, reset_only = False):
self.book_on_device(None, reset=True)
if reset_only:
return
self.library_view.set_device_connected(device_connected)
############################################################################
######################### Fetch annotations ################################
@ -1447,6 +1508,11 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
sm = view.selectionModel()
sm.select(ci, sm.Select)
else:
if not confirm('<p>'+_('The selected books will be '
'<b>permanently deleted</b> '
'from your device. Are you sure?')
+'</p>', 'library_delete_books', self):
return
if self.stack.currentIndex() == 1:
view = self.memory_view
elif self.stack.currentIndex() == 2:
@ -1727,6 +1793,10 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
def save_to_single_dir(self, checked):
self.save_to_disk(checked, True)
def save_single_fmt_to_single_dir(self, *args):
self.save_to_disk(False, single_dir=True,
single_format=prefs['output_format'])
def save_to_disk(self, checked, single_dir=False, single_format=None):
rows = self.current_view().selectionModel().selectedRows()
if not rows or len(rows) == 0:
@ -2069,14 +2139,25 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
format = d.format()
self.view_format(row, format)
def _view_check(self, num, max_=3):
if num <= max_:
return True
return question_dialog(self, _('Multiple Books Selected'),
_('You are attempting to open %d books. Opening too many '
'books at once can be slow and have a negative effect on the '
'responsiveness of your computer. Once started the process '
'cannot be stopped until complete. Do you wish to continue?'
) % num)
def view_folder(self, *args):
rows = self.current_view().selectionModel().selectedRows()
if self.current_view() is self.library_view:
if not rows or len(rows) == 0:
d = error_dialog(self, _('Cannot open folder'),
_('No book selected'))
d.exec_()
return
if not rows or len(rows) == 0:
d = error_dialog(self, _('Cannot open folder'),
_('No book selected'))
d.exec_()
return
if not self._view_check(len(rows)):
return
for row in rows:
path = self.library_view.model().db.abspath(row.row())
QDesktopServices.openUrl(QUrl.fromLocalFile(path))
@ -2094,14 +2175,8 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self._launch_viewer()
return
if len(rows) >= 3:
if not question_dialog(self, _('Multiple Books Selected'),
_('You are attempting to open %d books. Opening too many '
'books at once can be slow and have a negative effect on the '
'responsiveness of your computer. Once started the process '
'cannot be stopped until complete. Do you wish to continue?'
)% len(rows)):
return
if not self._view_check(len(rows)):
return
if self.current_view() is self.library_view:
for row in rows:
@ -2157,8 +2232,14 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
_('Cannot configure while there are running jobs.'))
d.exec_()
return
d = ConfigDialog(self, self.library_view.model().db,
if self.must_restart_before_config:
d = error_dialog(self, _('Cannot configure'),
_('Cannot configure before calibre is restarted.'))
d.exec_()
return
d = ConfigDialog(self, self.library_view,
server=self.content_server)
d.exec_()
self.content_server = d.server
if d.result() == d.Accepted:
@ -2171,24 +2252,28 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.save_menu.actions()[2].setText(
_('Save only %s format to disk')%
prefs['output_format'].upper())
self.library_view.model().read_config()
self.save_menu.actions()[3].setText(
_('Save only %s format to disk in a single directory')%
prefs['output_format'].upper())
self.tags_view.set_new_model() # in case columns changed
self.tags_view.recount()
self.create_device_menu()
if not patheq(self.library_path, d.database_location):
newloc = d.database_location
move_library(self.library_path, newloc, self,
self.library_moved)
def library_moved(self, newloc):
if newloc is None: return
db = LibraryDatabase2(newloc)
self.book_on_device(None, reset=True)
db.set_book_on_device_func(self.book_on_device)
self.library_view.set_database(db)
self.library_view.model().set_book_on_device_func(self.book_on_device)
self.status_bar.clearMessage()
self.search.clear_to_help()
self.status_bar.reset_info()
self.library_view.sortByColumn(3, Qt.DescendingOrder)
self.library_view.model().count_changed()
############################################################################
@ -2214,15 +2299,8 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
'''
page = 0 if location == 'library' else 1 if location == 'main' else 2 if location == 'carda' else 3
self.stack.setCurrentIndex(page)
view = self.memory_view if page == 1 else \
self.card_a_view if page == 2 else \
self.card_b_view if page == 3 else None
if view:
if view.resize_on_select:
if not view.restore_column_widths():
view.resizeColumnsToContents()
view.resize_on_select = False
self.status_bar.reset_info()
self.sidebar.location_changed(location)
if location == 'library':
self.action_edit.setEnabled(True)
self.action_merge.setEnabled(True)
@ -2230,8 +2308,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.view_menu.actions()[1].setEnabled(True)
self.action_open_containing_folder.setEnabled(True)
self.action_sync.setEnabled(True)
self.status_bar.tag_view_button.setEnabled(True)
self.status_bar.cover_flow_button.setEnabled(True)
self.search_restriction.setEnabled(True)
for action in list(self.delete_menu.actions())[1:]:
action.setEnabled(True)
else:
@ -2241,16 +2318,24 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.view_menu.actions()[1].setEnabled(False)
self.action_open_containing_folder.setEnabled(False)
self.action_sync.setEnabled(False)
self.status_bar.tag_view_button.setEnabled(False)
self.status_bar.cover_flow_button.setEnabled(False)
self.search_restriction.setEnabled(False)
for action in list(self.delete_menu.actions())[1:]:
action.setEnabled(False)
self.set_number_of_books_shown(compute_count=False)
def device_job_exception(self, job):
'''
Handle exceptions in threaded device jobs.
'''
if isinstance(getattr(job, 'exception', None), UserFeedback):
ex = job.exception
func = {UserFeedback.ERROR:error_dialog,
UserFeedback.WARNING:warning_dialog,
UserFeedback.INFO:info_dialog}[ex.level]
return func(self, _('Failed'), ex.msg, det_msg=ex.details if
ex.details else '', show=True)
try:
if 'Could not read 32 bytes on the control bus.' in \
unicode(job.details):
@ -2326,12 +2411,11 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
def write_settings(self):
config.set('main_window_geometry', self.saveGeometry())
dynamic.set('sort_column', self.library_view.model().sorted_on)
dynamic.set('tag_view_visible', self.tags_view.isVisible())
dynamic.set('cover_flow_visible', self.cover_flow.isVisible())
self.library_view.write_settings()
if self.device_connected:
self.save_device_view_settings()
dynamic.set('sort_history', self.library_view.model().sort_history)
self.sidebar.save_state()
for view in ('library_view', 'memory_view', 'card_a_view',
'card_b_view'):
getattr(self, view).save_state()
def restart(self):
self.quit(restart=True)
@ -2400,7 +2484,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
if write_settings:
self.write_settings()
self.check_messages_timer.stop()
self.update_checker.stop()
self.update_checker.terminate()
self.listener.close()
self.job_manager.server.close()
while self.spare_servers:

View File

@ -3,7 +3,7 @@ __copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
import traceback
from PyQt4.QtCore import QObject, SIGNAL, QTimer
from PyQt4.QtCore import QThread, pyqtSignal
import mechanize
from calibre.constants import __version__, iswindows, isosx
@ -12,31 +12,27 @@ from calibre.utils.config import prefs
URL = 'http://status.calibre-ebook.com/latest'
class CheckForUpdates(QObject):
class CheckForUpdates(QThread):
update_found = pyqtSignal(object)
INTERVAL = 24*60*60
def __init__(self, parent):
QObject.__init__(self, parent)
self.timer = QTimer(self)
self.first = True
self.connect(self.timer, SIGNAL('timeout()'), self)
self.start = self.timer.start
self.stop = self.timer.stop
QThread.__init__(self, parent)
def __call__(self):
if self.first:
self.timer.setInterval(1000*24*60*60)
self.first = False
try:
br = browser()
req = mechanize.Request(URL)
req.add_header('CALIBRE_VERSION', __version__)
req.add_header('CALIBRE_OS',
'win' if iswindows else 'osx' if isosx else 'oth')
req.add_header('CALIBRE_INSTALL_UUID', prefs['installation_uuid'])
version = br.open(req).read().strip()
if version and version != __version__:
self.emit(SIGNAL('update_found(PyQt_PyObject)'), version)
except:
traceback.print_exc()
def run(self):
while True:
try:
br = browser()
req = mechanize.Request(URL)
req.add_header('CALIBRE_VERSION', __version__)
req.add_header('CALIBRE_OS',
'win' if iswindows else 'osx' if isosx else 'oth')
req.add_header('CALIBRE_INSTALL_UUID', prefs['installation_uuid'])
version = br.open(req).read().strip()
if version and version != __version__:
self.update_found.emit(version)
except:
traceback.print_exc()
self.sleep(self.INTERVAL)

View File

@ -9,7 +9,7 @@ from PyQt4.Qt import Qt, QDialog, QAbstractTableModel, QVariant, SIGNAL, \
QModelIndex, QInputDialog, QLineEdit, QFileDialog
from calibre.gui2.viewer.bookmarkmanager_ui import Ui_BookmarkManager
from calibre.gui2 import NONE, qstring_to_unicode
from calibre.gui2 import NONE
class BookmarkManager(QDialog, Ui_BookmarkManager):
def __init__(self, parent, bookmarks):
@ -111,7 +111,7 @@ class BookmarkTableModel(QAbstractTableModel):
def setData(self, index, value, role):
if role == Qt.EditRole:
self.bookmarks[index.row()] = (qstring_to_unicode(value.toString()).strip(), self.bookmarks[index.row()][1])
self.bookmarks[index.row()] = (unicode(value.toString()).strip(), self.bookmarks[index.row()][1])
self.emit(SIGNAL("dataChanged(QModelIndex, QModelIndex)"), index, index)
return True
return False

View File

@ -244,7 +244,7 @@ class EbookViewer(MainWindow, Ui_EbookViewer):
self.pos.editingFinished.connect(self.goto_page_num)
self.connect(self.vertical_scrollbar, SIGNAL('valueChanged(int)'),
lambda x: self.goto_page(x/100.))
self.connect(self.search, SIGNAL('search(PyQt_PyObject, PyQt_PyObject)'), self.find)
self.search.search.connect(self.find)
self.connect(self.toc, SIGNAL('clicked(QModelIndex)'), self.toc_clicked)
self.connect(self.reference, SIGNAL('goto(PyQt_PyObject)'), self.goto)

View File

@ -7,22 +7,22 @@ import re, os, traceback
from PyQt4.Qt import QListView, QIcon, QFont, QLabel, QListWidget, \
QListWidgetItem, QTextCharFormat, QApplication, \
QSyntaxHighlighter, QCursor, QColor, QWidget, \
QPixmap, QPalette, QTimer, QDialog, \
QAbstractListModel, QVariant, Qt, SIGNAL, \
QRegExp, QSettings, QSize, QModelIndex, \
QPixmap, QPalette, QSplitterHandle, \
QAbstractListModel, QVariant, Qt, SIGNAL, pyqtSignal, \
QRegExp, QSettings, QSize, QModelIndex, QSplitter, \
QAbstractButton, QPainter, QLineEdit, QComboBox, \
QMenu, QStringListModel, QCompleter, QStringList
from calibre.gui2 import human_readable, NONE, TableView, \
qstring_to_unicode, error_dialog, pixmap_to_data
from calibre.gui2.dialogs.job_view_ui import Ui_Dialog
from calibre.gui2 import NONE, error_dialog, pixmap_to_data, dynamic
from calibre.gui2.filename_pattern_ui import Ui_Form
from calibre import fit_image
from calibre import fit_image, human_readable
from calibre.utils.fonts import fontconfig
from calibre.ebooks import BOOK_EXTENSIONS
from calibre.ebooks.metadata.meta import metadata_from_filename
from calibre.utils.config import prefs, XMLConfig
from calibre.gui2.progress_indicator import ProgressIndicator as _ProgressIndicator
from calibre.constants import filesystem_encoding
history = XMLConfig('history')
@ -71,7 +71,7 @@ class FilenamePattern(QWidget, Ui_Form):
error_dialog(self, _('Invalid regular expression'),
_('Invalid regular expression: %s')%err).exec_()
return
mi = metadata_from_filename(qstring_to_unicode(self.filename.text()), pat)
mi = metadata_from_filename(unicode(self.filename.text()), pat)
if mi.title:
self.title.setText(mi.title)
else:
@ -95,7 +95,7 @@ class FilenamePattern(QWidget, Ui_Form):
def pattern(self):
pat = qstring_to_unicode(self.re.text())
pat = unicode(self.re.text())
return re.compile(pat)
def commit(self):
@ -157,7 +157,7 @@ class ImageView(QLabel):
and represent files with extensions.
'''
if event.mimeData().hasFormat('text/uri-list'):
urls = [qstring_to_unicode(u.toLocalFile()) for u in event.mimeData().urls()]
urls = [unicode(u.toLocalFile()) for u in event.mimeData().urls()]
urls = [u for u in urls if os.path.splitext(u)[1] and os.access(u, os.R_OK)]
return [u for u in urls if os.path.splitext(u)[1][1:].lower() in cls.DROPABBLE_EXTENSIONS]
@ -230,13 +230,22 @@ class LocationModel(QAbstractListModel):
self.free = [-1, -1, -1]
self.count = 0
self.highlight_row = 0
self.library_tooltip = _('Click to see the books available on your computer')
self.tooltips = [
_('Click to see the books available on your computer'),
self.library_tooltip,
_('Click to see the books in the main memory of your reader'),
_('Click to see the books on storage card A in your reader'),
_('Click to see the books on storage card B in your reader')
]
def database_changed(self, db):
lp = db.library_path
if not isinstance(lp, unicode):
lp = lp.decode(filesystem_encoding, 'replace')
self.tooltips[0] = self.library_tooltip + '\n\n' + \
_('Books located at') + ' ' + lp
self.dataChanged.emit(self.index(0), self.index(0))
def rowCount(self, *args):
return 1 + len([i for i in self.free if i >= 0])
@ -389,41 +398,6 @@ class EjectButton(QAbstractButton):
painter.drawPixmap(0, 0, image)
class DetailView(QDialog, Ui_Dialog):
def __init__(self, parent, job):
QDialog.__init__(self, parent)
self.setupUi(self)
self.setWindowTitle(job.description)
self.job = job
self.next_pos = 0
self.update()
self.timer = QTimer(self)
self.connect(self.timer, SIGNAL('timeout()'), self.update)
self.timer.start(1000)
def update(self):
f = self.job.log_file
f.seek(self.next_pos)
more = f.read()
self.next_pos = f.tell()
if more:
self.log.appendPlainText(more.decode('utf-8', 'replace'))
class JobsView(TableView):
def __init__(self, parent):
TableView.__init__(self, parent)
self.connect(self, SIGNAL('doubleClicked(QModelIndex)'), self.show_details)
def show_details(self, index):
row = index.row()
job = self.model().row_to_job(row)
d = DetailView(self, job)
d.exec_()
d.timer.stop()
class FontFamilyModel(QAbstractListModel):
@ -520,7 +494,7 @@ class BasicList(QListWidget):
class LineEditECM(object):
'''
Extend the contenxt menu of a QLineEdit to include more actions.
Extend the context menu of a QLineEdit to include more actions.
'''
def contextMenuEvent(self, event):
@ -620,13 +594,13 @@ class TagsLineEdit(EnLineEdit):
self.completer.update_tags_cache(tags)
def text_changed(self, text):
all_text = qstring_to_unicode(text)
all_text = unicode(text)
text = all_text[:self.cursorPosition()]
prefix = text.split(',')[-1].strip()
text_tags = []
for t in all_text.split(self.separator):
t1 = qstring_to_unicode(t).strip()
t1 = unicode(t).strip()
if t1 != '':
text_tags.append(t)
text_tags = list(set(text_tags))
@ -636,8 +610,8 @@ class TagsLineEdit(EnLineEdit):
def complete_text(self, text):
cursor_pos = self.cursorPosition()
before_text = qstring_to_unicode(self.text())[:cursor_pos]
after_text = qstring_to_unicode(self.text())[cursor_pos:]
before_text = unicode(self.text())[:cursor_pos]
after_text = unicode(self.text())[cursor_pos:]
prefix_len = len(before_text.split(',')[-1].strip())
self.setText('%s%s%s %s' % (before_text[:cursor_pos - prefix_len],
text, self.separator, after_text))
@ -649,7 +623,7 @@ class EnComboBox(QComboBox):
'''
Enhanced QComboBox.
Includes an extended content menu.
Includes an extended context menu.
'''
def __init__(self, *args):
@ -941,3 +915,90 @@ class PythonHighlighter(QSyntaxHighlighter):
QApplication.setOverrideCursor(QCursor(Qt.WaitCursor))
QSyntaxHighlighter.rehighlight(self)
QApplication.restoreOverrideCursor()
class SplitterHandle(QSplitterHandle):
double_clicked = pyqtSignal(object)
def __init__(self, orientation, splitter):
QSplitterHandle.__init__(self, orientation, splitter)
splitter.splitterMoved.connect(self.splitter_moved,
type=Qt.QueuedConnection)
self.double_clicked.connect(splitter.double_clicked,
type=Qt.QueuedConnection)
self.highlight = False
def splitter_moved(self, *args):
oh = self.highlight
self.highlight = 0 in self.splitter().sizes()
if oh != self.highlight:
self.update()
def paintEvent(self, ev):
QSplitterHandle.paintEvent(self, ev)
if self.highlight:
painter = QPainter(self)
painter.setClipRect(ev.rect())
painter.fillRect(self.rect(), Qt.yellow)
def mouseDoubleClickEvent(self, ev):
self.double_clicked.emit(self)
class Splitter(QSplitter):
state_changed = pyqtSignal(object)
def __init__(self, *args):
QSplitter.__init__(self, *args)
self.splitterMoved.connect(self.splitter_moved, type=Qt.QueuedConnection)
def createHandle(self):
return SplitterHandle(self.orientation(), self)
def initialize(self, name=None):
if name is not None:
self._name = name
for i in range(self.count()):
h = self.handle(i)
if h is not None:
h.splitter_moved()
self.state_changed.emit(not self.is_side_index_hidden)
def splitter_moved(self, *args):
self.state_changed.emit(not self.is_side_index_hidden)
@property
def side_index(self):
return 0 if self.orientation() == Qt.Horizontal else 1
@property
def is_side_index_hidden(self):
sizes = list(self.sizes())
return sizes[self.side_index] == 0
def toggle_side_index(self):
self.double_clicked(None)
def double_clicked(self, handle):
visible = not self.is_side_index_hidden
sizes = list(self.sizes())
if 0 in sizes:
idx = sizes.index(0)
sizes[idx] = 80
else:
sizes[self.side_index] = 0
if visible:
dynamic.set(self._name + '_last_open_state', str(self.saveState()))
self.setSizes(sizes)
else:
state = dynamic.get(self._name+ '_last_open_state', None)
if state is not None:
self.restoreState(state)
else:
self.setSizes(sizes)
self.initialize()

View File

@ -78,18 +78,12 @@ class KindleDX(Kindle):
name = 'Kindle DX'
id = 'kindledx'
class Sony500(Device):
class Sony505(Device):
output_profile = 'sony'
name = 'SONY PRS 500'
output_format = 'LRF'
manufacturer = 'SONY'
id = 'prs500'
class Sony505(Sony500):
name = 'All other SONY devices'
output_format = 'EPUB'
name = 'SONY Reader 6" and Touch Edition'
manufacturer = 'SONY'
id = 'prs505'
class Kobo(Device):
@ -344,7 +338,7 @@ class StanzaPage(QWizardPage, StanzaUI):
p = self.set_port()
if p is not None:
from calibre.library import server_config
from calibre.library.server import server_config
c = server_config()
c.set('port', p)

View File

@ -1,31 +1,6 @@
__license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
''' Code to manage ebook library'''
from calibre.utils.config import Config, StringConfig
def server_config(defaults=None):
desc=_('Settings to control the calibre content server')
c = Config('server', desc) if defaults is None else StringConfig(defaults, desc)
c.add_opt('port', ['-p', '--port'], default=8080,
help=_('The port on which to listen. Default is %default'))
c.add_opt('timeout', ['-t', '--timeout'], default=120,
help=_('The server timeout in seconds. Default is %default'))
c.add_opt('thread_pool', ['--thread-pool'], default=30,
help=_('The max number of worker threads to use. Default is %default'))
c.add_opt('password', ['--password'], default=None,
help=_('Set a password to restrict access. By default access is unrestricted.'))
c.add_opt('username', ['--username'], default='calibre',
help=_('Username for access. By default, it is: %default'))
c.add_opt('develop', ['--develop'], default=False,
help='Development mode. Server automatically restarts on file changes and serves code files (html, css, js) from the file system instead of calibre\'s resource system.')
c.add_opt('max_cover', ['--max-cover'], default='600x800',
help=_('The maximum size for displayed covers. Default is %default.'))
c.add_opt('max_opds_items', ['--max-opds-items'], default=30,
help=_('The maximum number of matches to return per OPDS query. '
'This affects Stanza, WordPlayer, etc. integration.'))
return c
def db():
from calibre.library.database2 import LibraryDatabase2

View File

@ -0,0 +1,178 @@
#!/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'
import os
from hashlib import sha1
from calibre.constants import filesystem_encoding
from calibre.ebooks import BOOK_EXTENSIONS
def find_folders_under(root, db, add_root=True, # {{{
follow_links=False, cancel_callback=lambda : False):
'''
Find all folders under the specified root path, ignoring any folders under
the library path of db
root must be a bytestring in filesystem_encoding
If follow_links is True, follow symbolic links. WARNING; this can lead to
infinite recursion.
cancel_callback must be a no argument callable that returns True to cancel
the search
'''
assert not isinstance(root, unicode) # root must be in filesystem encoding
lp = db.library_path
if isinstance(lp, unicode):
try:
lp = lp.encode(filesystem_encoding)
except:
lp = None
if lp:
lp = os.path.abspath(lp)
root = os.path.abspath(root)
ans = set([])
for dirpath, dirnames, __ in os.walk(root, topdown=True, followlinks=follow_links):
if cancel_callback():
break
for x in list(dirnames):
path = os.path.join(dirpath, x)
if lp and path.startswith(lp):
dirnames.remove(x)
if lp and dirpath.startswith(lp):
continue
ans.add(dirpath)
if not add_root:
ans.remove(root)
return ans
# }}}
class FormatCollection(object): # {{{
def __init__(self, parent_folder, formats):
self.path_map = {}
for x in set(formats):
fmt = os.path.splitext(x)[1].lower()
if fmt:
fmt = fmt[1:]
self.path_map[fmt] = x
self.parent_folder = None
self.hash_map = {}
for fmt, path in self.format_map.items():
self.hash_map[fmt] = self.hash_of_file(path)
def hash_of_file(self, path):
with open(path, 'rb') as f:
return sha1(f.read()).digest()
@property
def hashes(self):
return frozenset(self.formats.values())
@property
def is_empty(self):
return len(self) == 0
def __iter__(self):
for x in self.path_map:
yield x
def __len__(self):
return len(self.path_map)
def remove(self, fmt):
self.hash_map.pop(fmt, None)
self.path_map.pop(fmt, None)
def matches(self, other):
if not self.hashes.intersection(other.hashes):
return False
for fmt in self:
if self.hash_map[fmt] != other.hash_map.get(fmt, False):
return False
return True
def merge(self, other):
for fmt in list(other):
self.path_map[fmt] = other.path_map[fmt]
self.hash_map[fmt] = other.hash_map[fmt]
other.remove(fmt)
# }}}
def books_in_folder(folder, one_per_folder, # {{{
cancel_callback=lambda : False):
assert not isinstance(folder, unicode)
dirpath = os.path.abspath(folder)
if one_per_folder:
formats = set([])
for path in os.listdir(dirpath):
if cancel_callback():
return []
path = os.path.abspath(os.path.join(dirpath, path))
if os.path.isdir(path) or not os.access(path, os.R_OK):
continue
ext = os.path.splitext(path)[1]
if not ext:
continue
ext = ext[1:].lower()
if ext not in BOOK_EXTENSIONS and ext != 'opf':
continue
formats.add(path)
return [FormatCollection(folder, formats)]
else:
books = {}
for path in os.listdir(dirpath):
if cancel_callback():
return
path = os.path.abspath(os.path.join(dirpath, path))
if os.path.isdir(path) or not os.access(path, os.R_OK):
continue
ext = os.path.splitext(path)[1]
if not ext:
continue
ext = ext[1:].lower()
if ext not in BOOK_EXTENSIONS:
continue
key = os.path.splitext(path)[0]
if not books.has_key(key):
books[key] = set([])
books[key].add(path)
return [FormatCollection(folder, x) for x in books.values() if x]
# }}}
def hash_merge_format_collections(collections, cancel_callback=lambda:False):
ans = []
collections = list(collections)
l = len(collections)
for i in range(l):
if cancel_callback():
return collections
one = collections[i]
if one.is_empty:
continue
for j in range(i+1, l):
if cancel_callback():
return collections
two = collections[j]
if two.is_empty:
continue
if one.matches(two):
one.merge(two)
ans.append(one)
return ans

View File

@ -8,12 +8,16 @@ __docformat__ = 'restructuredtext en'
import collections, glob, os, re, itertools, functools
from itertools import repeat
from datetime import timedelta
from PyQt4.QtCore import QThread, QReadWriteLock
from PyQt4.QtGui import QImage
from calibre.utils.config import tweaks
from calibre.utils.date import parse_date, now, UNDEFINED_DATE
from calibre.utils.search_query_parser import SearchQueryParser
from calibre.utils.date import parse_date
from calibre.utils.pyparsing import ParseException
# from calibre.library.field_metadata import FieldMetadata
class CoverCache(QThread):
@ -146,8 +150,41 @@ class ResultCache(SearchQueryParser):
'''
Stores sorted and filtered metadata in memory.
'''
def __init__(self, FIELD_MAP, field_metadata):
self.FIELD_MAP = FIELD_MAP
self._map = self._map_filtered = self._data = []
self.first_sort = True
self.search_restriction = ''
self.field_metadata = field_metadata
self.all_search_locations = field_metadata.get_search_terms()
SearchQueryParser.__init__(self, self.all_search_locations)
self.build_date_relop_dict()
self.build_numeric_relop_dict()
def build_relop_dict(self):
def __getitem__(self, row):
return self._data[self._map_filtered[row]]
def __len__(self):
return len(self._map_filtered)
def __iter__(self):
for id in self._map_filtered:
yield self._data[id]
def iterall(self):
for x in self._data:
if x is not None:
yield x
def iterallids(self):
idx = self.FIELD_MAP['id']
for x in self.iterall():
yield x[idx]
def universal_set(self):
return set([i[0] for i in self._data if i is not None])
def build_date_relop_dict(self):
'''
Because the database dates have time in them, we can't use direct
comparisons even when field_count == 3. The query has time = 0, but
@ -191,55 +228,134 @@ class ResultCache(SearchQueryParser):
def relop_le(db, query, field_count):
return not relop_gt(db, query, field_count)
self.search_relops = {'=':[1, relop_eq], '>':[1, relop_gt], '<':[1, relop_lt], \
'!=':[2, relop_ne], '>=':[2, relop_ge], '<=':[2, relop_le]}
self.date_search_relops = {
'=' :[1, relop_eq],
'>' :[1, relop_gt],
'<' :[1, relop_lt],
'!=':[2, relop_ne],
'>=':[2, relop_ge],
'<=':[2, relop_le]
}
def __init__(self, FIELD_MAP):
self.FIELD_MAP = FIELD_MAP
self._map = self._map_filtered = self._data = []
self.first_sort = True
SearchQueryParser.__init__(self)
self.build_relop_dict()
def get_dates_matches(self, location, query):
matches = set([])
if len(query) < 2:
return matches
relop = None
for k in self.date_search_relops.keys():
if query.startswith(k):
(p, relop) = self.date_search_relops[k]
query = query[p:]
if relop is None:
(p, relop) = self.date_search_relops['=']
def __getitem__(self, row):
return self._data[self._map_filtered[row]]
if location == 'date':
location = 'timestamp'
loc = self.field_metadata[location]['rec_index']
def __len__(self):
return len(self._map_filtered)
if query == _('today'):
qd = now()
field_count = 3
elif query == _('yesterday'):
qd = now() - timedelta(1)
field_count = 3
elif query == _('thismonth'):
qd = now()
field_count = 2
elif query.endswith(_('daysago')):
num = query[0:-len(_('daysago'))]
try:
qd = now() - timedelta(int(num))
except:
raise ParseException(query, len(query), 'Number conversion error', self)
field_count = 3
else:
try:
qd = parse_date(query)
except:
raise ParseException(query, len(query), 'Date conversion error', self)
if '-' in query:
field_count = query.count('-') + 1
else:
field_count = query.count('/') + 1
for item in self._data:
if item is None or item[loc] is None: continue
if relop(item[loc], qd, field_count):
matches.add(item[0])
return matches
def __iter__(self):
for id in self._map_filtered:
yield self._data[id]
def build_numeric_relop_dict(self):
self.numeric_search_relops = {
'=':[1, lambda r, q: r == q],
'>':[1, lambda r, q: r > q],
'<':[1, lambda r, q: r < q],
'!=':[2, lambda r, q: r != q],
'>=':[2, lambda r, q: r >= q],
'<=':[2, lambda r, q: r <= q]
}
def universal_set(self):
return set([i[0] for i in self._data if i is not None])
def get_numeric_matches(self, location, query):
matches = set([])
if len(query) == 0:
return matches
if query == 'false':
query = '0'
elif query == 'true':
query = '>0'
relop = None
for k in self.numeric_search_relops.keys():
if query.startswith(k):
(p, relop) = self.numeric_search_relops[k]
query = query[p:]
if relop is None:
(p, relop) = self.numeric_search_relops['=']
loc = self.field_metadata[location]['rec_index']
dt = self.field_metadata[location]['datatype']
if dt == 'int':
cast = (lambda x: int (x))
adjust = lambda x: x
elif dt == 'rating':
cast = (lambda x: int (x))
adjust = lambda x: x/2
elif dt == 'float':
cast = lambda x : float (x)
adjust = lambda x: x
try:
q = cast(query)
except:
return matches
for item in self._data:
if item is None:
continue
if not item[loc]:
i = 0
else:
i = adjust(item[loc])
if relop(i, q):
matches.add(item[0])
return matches
def get_matches(self, location, query):
matches = set([])
if query and query.strip():
location = location.lower().strip()
# get metadata key associated with the search term. Eliminates
# dealing with plurals and other aliases
location = self.field_metadata.search_term_to_key(location.lower().strip())
### take care of dates special case
if location in ('pubdate', 'date'):
if len(query) < 2:
return matches
relop = None
for k in self.search_relops.keys():
if query.startswith(k):
(p, relop) = self.search_relops[k]
query = query[p:]
if relop is None:
return matches
loc = self.FIELD_MAP[{'date':'timestamp', 'pubdate':'pubdate'}[location]]
qd = parse_date(query)
field_count = query.count('-') + 1
for item in self._data:
if item is None: continue
if relop(item[loc], qd, field_count):
matches.add(item[0])
return matches
# take care of dates special case
if location in self.field_metadata and \
self.field_metadata[location]['datatype'] == 'datetime':
return self.get_dates_matches(location, query.lower())
### everything else
# take care of numbers special case
if location in self.field_metadata and \
self.field_metadata[location]['datatype'] in ('rating', 'int', 'float'):
return self.get_numeric_matches(location, query.lower())
# everything else, or 'all' matches
matchkind = CONTAINS_MATCH
if (len(query) > 1):
if query.startswith('\\'):
@ -250,42 +366,76 @@ class ResultCache(SearchQueryParser):
elif query.startswith('~'):
matchkind = REGEXP_MATCH
query = query[1:]
if matchkind != REGEXP_MATCH: ### leave case in regexps because it can be significant e.g. \S \W \D
if matchkind != REGEXP_MATCH:
# leave case in regexps because it can be significant e.g. \S \W \D
query = query.lower()
if not isinstance(query, unicode):
query = query.decode('utf-8')
if location in ('tag', 'author', 'format', 'comment'):
location += 's'
all = ('title', 'authors', 'publisher', 'tags', 'comments', 'series', 'formats', 'isbn', 'rating', 'cover')
MAP = {}
for x in all:
MAP[x] = self.FIELD_MAP[x]
EXCLUDE_FIELDS = [MAP['rating'], MAP['cover']]
SPLITABLE_FIELDS = [MAP['authors'], MAP['tags'], MAP['formats']]
location = [location] if location != 'all' else list(MAP.keys())
for i, loc in enumerate(location):
location[i] = MAP[loc]
db_col = {}
exclude_fields = [] # fields to not check when matching against text.
col_datatype = []
is_multiple_cols = {}
for x in range(len(self.FIELD_MAP)):
col_datatype.append('')
for x in self.field_metadata:
if len(self.field_metadata[x]['search_terms']):
db_col[x] = self.field_metadata[x]['rec_index']
if self.field_metadata[x]['datatype'] not in ['text', 'comments']:
exclude_fields.append(db_col[x])
col_datatype[db_col[x]] = self.field_metadata[x]['datatype']
is_multiple_cols[db_col[x]] = self.field_metadata[x]['is_multiple']
try:
rating_query = int(query) * 2
except:
rating_query = None
for loc in location:
if loc == MAP['authors']:
q = query.replace(',', '|'); ### DB stores authors with commas changed to bars, so change query
location = [location] if location != 'all' else list(db_col.keys())
for i, loc in enumerate(location):
location[i] = db_col[loc]
# get the tweak here so that the string lookup and compare aren't in the loop
bools_are_tristate = tweaks['bool_custom_columns_are_tristate'] == 'yes'
for loc in location: # location is now an array of field indices
if loc == db_col['authors']:
### DB stores authors with commas changed to bars, so change query
q = query.replace(',', '|');
else:
q = query
for item in self._data:
if item is None: continue
if col_datatype[loc] == 'bool': # complexity caused by the two-/three-value tweak
v = item[loc]
if not bools_are_tristate:
if v is None or not v: # item is None or set to false
if q in [_('no'), _('unchecked'), 'false']:
matches.add(item[0])
else: # item is explicitly set to true
if q in [_('yes'), _('checked'), 'true']:
matches.add(item[0])
else:
if v is None:
if q in [_('empty'), _('blank'), 'false']:
matches.add(item[0])
elif not v: # is not None and false
if q in [_('no'), _('unchecked'), 'true']:
matches.add(item[0])
else: # item is not None and true
if q in [_('yes'), _('checked'), 'true']:
matches.add(item[0])
continue
if not item[loc]:
if query == 'false':
if isinstance(item[loc], basestring):
if item[loc].strip() != '':
continue
if q == 'false':
matches.add(item[0])
continue
continue ### item is empty. No possible matches below
continue # item is empty. No possible matches below
if q == 'false': # Field has something in it, so a false query does not match
continue
if q == 'true':
if isinstance(item[loc], basestring):
@ -293,14 +443,32 @@ class ResultCache(SearchQueryParser):
continue
matches.add(item[0])
continue
if rating_query and loc == MAP['rating'] and rating_query == int(item[loc]):
matches.add(item[0])
if col_datatype[loc] == 'rating': # get here if 'all' query
if rating_query and rating_query == int(item[loc]):
matches.add(item[0])
continue
if loc not in EXCLUDE_FIELDS:
if loc in SPLITABLE_FIELDS:
vals = item[loc].split(',') ### check individual tags/authors/formats, not the long string
try: # a conversion below might fail
# relationals are not supported in 'all' queries
if col_datatype[loc] == 'float':
if float(query) == item[loc]:
matches.add(item[0])
continue
if col_datatype[loc] == 'int':
if int(query) == item[loc]:
matches.add(item[0])
continue
except:
# A conversion threw an exception. Because of the type,
# no further match is possible
continue
if loc not in exclude_fields: # time for text matching
if is_multiple_cols[loc] is not None:
vals = item[loc].split(is_multiple_cols[loc])
else:
vals = [item[loc]] ### make into list to make _match happy
vals = [item[loc]] ### make into list to make _match happy
if _match(q, vals, matchkind):
matches.add(item[0])
continue
@ -342,9 +510,9 @@ class ResultCache(SearchQueryParser):
'''
for id in ids:
try:
self._data[id] = db.conn.get('SELECT * from meta2 WHERE id=?',
(id,))[0]
self._data[id] = db.conn.get('SELECT * from meta2 WHERE id=?', (id,))[0]
self._data[id].append(db.has_cover(id, index_is_id=True))
self._data[id].append(db.book_on_device_string(id))
except IndexError:
return None
try:
@ -360,6 +528,7 @@ class ResultCache(SearchQueryParser):
for id in ids:
self._data[id] = db.conn.get('SELECT * from meta2 WHERE id=?', (id,))[0]
self._data[id].append(db.has_cover(id, index_is_id=True))
self._data[id].append(db.book_on_device_string(id))
self._map[0:0] = ids
self._map_filtered[0:0] = ids
@ -372,6 +541,12 @@ class ResultCache(SearchQueryParser):
def count(self):
return len(self._map)
def refresh_ondevice(self, db):
ondevice_col = self.FIELD_MAP['ondevice']
for item in self._data:
if item is not None:
item[ondevice_col] = db.book_on_device_string(item[0])
def refresh(self, db, field=None, ascending=True):
temp = db.conn.get('SELECT * FROM meta2')
self._data = list(itertools.repeat(None, temp[-1][0]+2)) if temp else []
@ -380,18 +555,21 @@ class ResultCache(SearchQueryParser):
for item in self._data:
if item is not None:
item.append(db.has_cover(item[0], index_is_id=True))
item.append(db.book_on_device_string(item[0]))
self._map = [i[0] for i in self._data if i is not None]
if field is not None:
self.sort(field, ascending)
self._map_filtered = list(self._map)
def seriescmp(self, x, y):
sidx = self.FIELD_MAP['series']
try:
ans = cmp(self._data[x][9].lower(), self._data[y][9].lower())
ans = cmp(self._data[x][sidx].lower(), self._data[y][sidx].lower())
except AttributeError: # Some entries may be None
ans = cmp(self._data[x][9], self._data[y][9])
ans = cmp(self._data[x][sidx], self._data[y][sidx])
if ans != 0: return ans
return cmp(self._data[x][10], self._data[y][10])
sidx = self.FIELD_MAP['series_index']
return cmp(self._data[x][sidx], self._data[y][sidx])
def cmp(self, loc, x, y, asstr=True, subsort=False):
try:
@ -399,6 +577,14 @@ class ResultCache(SearchQueryParser):
asstr else cmp(self._data[x][loc], self._data[y][loc])
except AttributeError: # Some entries may be None
ans = cmp(self._data[x][loc], self._data[y][loc])
except TypeError: ## raised when a datetime is None
x = self._data[x][loc]
if x is None:
x = UNDEFINED_DATE
y = self._data[y][loc]
if y is None:
y = UNDEFINED_DATE
return cmp(x, y)
if subsort and ans == 0:
return cmp(self._data[x][11].lower(), self._data[y][11].lower())
return ans
@ -410,21 +596,40 @@ class ResultCache(SearchQueryParser):
if field == 'date': field = 'timestamp'
elif field == 'title': field = 'sort'
elif field == 'authors': field = 'author_sort'
as_string = field not in ('size', 'rating', 'timestamp')
if self.field_metadata[field]['is_custom']:
as_string = self.field_metadata[field]['datatype'] in ('comments', 'text')
field = self.field_metadata[field]['colnum']
if self.first_sort:
subsort = True
self.first_sort = False
fcmp = self.seriescmp if field == 'series' else \
functools.partial(self.cmp, self.FIELD_MAP[field], subsort=subsort,
asstr=field not in ('size', 'rating', 'timestamp'))
asstr=as_string)
self._map.sort(cmp=fcmp, reverse=not ascending)
self._map_filtered = [id for id in self._map if id in self._map_filtered]
def search(self, query):
def search(self, query, return_matches=False,
ignore_search_restriction=False):
q = ''
if not query or not query.strip():
if not ignore_search_restriction:
q = self.search_restriction
else:
q = query
if not ignore_search_restriction:
q = u'%s (%s)' % (self.search_restriction, query)
if not q:
if return_matches:
return list(self._map) # when return_matches, do not update the maps!
self._map_filtered = list(self._map)
return
matches = sorted(self.parse(query))
self._map_filtered = [id for id in self._map if id in matches]
matches = sorted(self.parse(q))
ans = [id for id in self._map if id in matches]
if return_matches:
return ans
self._map_filtered = ans
def set_search_restriction(self, s):
self.search_restriction = s

View File

@ -127,120 +127,60 @@ class CSV_XML(CatalogPlugin):
elif self.fmt == 'xml':
from lxml import etree
from lxml.builder import E
from calibre.utils.genshi.template import MarkupTemplate
root = E.calibredb()
for r in data:
record = E.record()
root.append(record)
PY_NAMESPACE = "http://genshi.edgewall.org/"
PY = "{%s}" % PY_NAMESPACE
NSMAP = {'py' : PY_NAMESPACE}
root = etree.Element('calibredb', nsmap=NSMAP)
py_for = etree.SubElement(root, PY + 'for', each="record in data")
record = etree.SubElement(py_for, 'record')
for field in ('id', 'uuid', 'title', 'publisher', 'rating', 'size',
'isbn'):
if field in fields:
val = r[field]
if val is None:
continue
if not isinstance(val, (str, unicode)):
val = unicode(val)
item = getattr(E, field)(val)
record.append(item)
if 'id' in fields:
record_child = etree.SubElement(record, 'id')
record_child.set(PY + "if", "record['id']")
record_child.text = "${record['id']}"
if 'authors' in fields:
aus = E.authors(sort=r['author_sort'])
for au in r['authors']:
aus.append(E.author(au))
record.append(aus)
if 'uuid' in fields:
record_child = etree.SubElement(record, 'uuid')
record_child.set(PY + "if", "record['uuid']")
record_child.text = "${record['uuid']}"
for field in ('timestamp', 'pubdate'):
if field in fields:
record.append(getattr(E, field)(r[field].isoformat()))
if 'title' in fields:
record_child = etree.SubElement(record, 'title')
record_child.set(PY + "if", "record['title']")
record_child.text = "${record['title']}"
if 'tags' in fields and r['tags']:
tags = E.tags()
for tag in r['tags']:
tags.append(E.tag(tag))
record.append(tags)
if 'authors' in fields:
record_child = etree.SubElement(record, 'authors', sort="${record['author_sort']}")
record_subchild = etree.SubElement(record_child, PY + 'for', each="author in record['authors']")
record_subsubchild = etree.SubElement(record_subchild, 'author')
record_subsubchild.text = '$author'
if 'comments' in fields and r['comments']:
record.append(E.comments(r['comments']))
if 'publisher' in fields:
record_child = etree.SubElement(record, 'publisher')
record_child.set(PY + "if", "record['publisher']")
record_child.text = "${record['publisher']}"
if 'series' in fields and r['series']:
record.append(E.series(r['series'],
index=str(r['series_index'])))
if 'rating' in fields:
record_child = etree.SubElement(record, 'rating')
record_child.set(PY + "if", "record['rating']")
record_child.text = "${record['rating']}"
if 'cover' in fields and r['cover']:
record.append(E.cover(r['cover'].replace(os.sep, '/')))
if 'date' in fields:
record_child = etree.SubElement(record, 'date')
record_child.set(PY + "if", "record['date']")
record_child.text = "${record['date'].isoformat()}"
if 'formats' in fields and r['formats']:
fmt = E.formats()
for f in r['formats']:
fmt.append(E.format(f.replace(os.sep, '/')))
record.append(fmt)
if 'pubdate' in fields:
record_child = etree.SubElement(record, 'pubdate')
record_child.set(PY + "if", "record['pubdate']")
record_child.text = "${record['pubdate'].isoformat()}"
with open(path_to_output, 'w') as f:
f.write(etree.tostring(root, encoding='utf-8',
xml_declaration=True, pretty_print=True))
if 'size' in fields:
record_child = etree.SubElement(record, 'size')
record_child.set(PY + "if", "record['size']")
record_child.text = "${record['size']}"
if 'tags' in fields:
# <tags py:if="record['tags']">
# <py:for each="tag in record['tags']">
# <tag>$tag</tag>
# </py:for>
# </tags>
record_child = etree.SubElement(record, 'tags')
record_child.set(PY + "if", "record['tags']")
record_subchild = etree.SubElement(record_child, PY + 'for', each="tag in record['tags']")
record_subsubchild = etree.SubElement(record_subchild, 'tag')
record_subsubchild.text = '$tag'
if 'comments' in fields:
record_child = etree.SubElement(record, 'comments')
record_child.set(PY + "if", "record['comments']")
record_child.text = "${record['comments']}"
if 'series' in fields:
# <series py:if="record['series']" index="${record['series_index']}">
# ${record['series']}
# </series>
record_child = etree.SubElement(record, 'series')
record_child.set(PY + "if", "record['series']")
record_child.set('index', "${record['series_index']}")
record_child.text = "${record['series']}"
if 'isbn' in fields:
record_child = etree.SubElement(record, 'isbn')
record_child.set(PY + "if", "record['isbn']")
record_child.text = "${record['isbn']}"
if 'cover' in fields:
# <cover py:if="record['cover']">
# ${record['cover'].replace(os.sep, '/')}
# </cover>
record_child = etree.SubElement(record, 'cover')
record_child.set(PY + "if", "record['cover']")
record_child.text = "${record['cover']}"
if 'formats' in fields:
# <formats py:if="record['formats']">
# <py:for each="path in record['formats']">
# <format>${path.replace(os.sep, '/')}</format>
# </py:for>
# </formats>
record_child = etree.SubElement(record, 'formats')
record_child.set(PY + "if", "record['formats']")
record_subchild = etree.SubElement(record_child, PY + 'for', each="path in record['formats']")
record_subsubchild = etree.SubElement(record_subchild, 'format')
record_subsubchild.text = "${path.replace(os.sep, '/')}"
outfile = open(path_to_output, 'w')
template = MarkupTemplate(etree.tostring(root, xml_declaration=True,
encoding="UTF-8", pretty_print=True))
outfile.write(template.generate(data=data, os=os).render('xml'))
outfile.close()
return None
class EPUB_MOBI(CatalogPlugin):
'ePub catalog generator'

View File

@ -9,99 +9,18 @@ Command line interface to the calibre database.
import sys, os, cStringIO
from textwrap import TextWrapper
from urllib import quote
from calibre import terminal_controller, preferred_encoding, prints
from calibre.utils.config import OptionParser, prefs
from calibre.ebooks.metadata.meta import get_metadata
from calibre.library.database2 import LibraryDatabase2
from calibre.ebooks.metadata.opf2 import OPFCreator, OPF
from calibre.utils.genshi.template import MarkupTemplate
from calibre.utils.date import isoformat
FIELDS = set(['title', 'authors', 'author_sort', 'publisher', 'rating',
'timestamp', 'size', 'tags', 'comments', 'series', 'series_index',
'formats', 'isbn', 'uuid', 'pubdate', 'cover'])
XML_TEMPLATE = '''\
<?xml version="1.0" encoding="UTF-8"?>
<calibredb xmlns:py="http://genshi.edgewall.org/">
<py:for each="record in data">
<record>
<id>${record['id']}</id>
<uuid>${record['uuid']}</uuid>
<title>${record['title']}</title>
<authors sort="${record['author_sort']}">
<py:for each="author in record['authors']">
<author>$author</author>
</py:for>
</authors>
<publisher>${record['publisher']}</publisher>
<rating>${record['rating']}</rating>
<date>${record['timestamp'].isoformat()}</date>
<pubdate>${record['pubdate'].isoformat()}</pubdate>
<size>${record['size']}</size>
<tags py:if="record['tags']">
<py:for each="tag in record['tags']">
<tag>$tag</tag>
</py:for>
</tags>
<comments>${record['comments']}</comments>
<series py:if="record['series']" index="${record['series_index']}">${record['series']}</series>
<isbn>${record['isbn']}</isbn>
<cover py:if="record['cover']">${record['cover'].replace(os.sep, '/')}</cover>
<formats py:if="record['formats']">
<py:for each="path in record['formats']">
<format>${path.replace(os.sep, '/')}</format>
</py:for>
</formats>
</record>
</py:for>
</calibredb>
'''
STANZA_TEMPLATE='''\
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:py="http://genshi.edgewall.org/">
<title>calibre Library</title>
<author>
<name>calibre</name>
<uri>http://calibre-ebook.com</uri>
</author>
<id>$id</id>
<updated>${updated.isoformat()}</updated>
<subtitle>
${subtitle}
</subtitle>
<py:for each="record in data">
<entry>
<title>${record['title']}</title>
<id>urn:calibre:${record['uuid']}</id>
<author><name>${record['author_sort']}</name></author>
<updated>${record['timestamp'].isoformat()}</updated>
<link type="application/epub+zip" href="${quote(record['fmt_epub'].replace(sep, '/'))}"/>
<link py:if="record['cover']" rel="x-stanza-cover-image" type="image/png" href="${quote(record['cover'].replace(sep, '/'))}"/>
<link py:if="record['cover']" rel="x-stanza-cover-image-thumbnail" type="image/png" href="${quote(record['cover'].replace(sep, '/'))}"/>
<content type="xhtml">
<div xmlns="http://www.w3.org/1999/xhtml">
<py:for each="f in ('authors', 'publisher', 'rating', 'tags', 'series', 'isbn')">
<py:if test="record[f]">
${f.capitalize()}:${unicode(', '.join(record[f]) if f=='tags' else record[f])}
<py:if test="f =='series'"># ${str(record['series_index'])}</py:if>
<br/>
</py:if>
</py:for>
<py:if test="record['comments']">
<br/>
${record['comments']}
</py:if>
</div>
</content>
</entry>
</py:for>
</feed>
'''
def send_message(msg=''):
prints('Notifying calibre of the change')
from calibre.utils.ipc import RC
@ -130,81 +49,67 @@ def get_db(dbpath, options):
return LibraryDatabase2(dbpath)
def do_list(db, fields, afields, sort_by, ascending, search_text, line_width, separator,
prefix, output_format, subtitle='Books in the calibre database'):
prefix, subtitle='Books in the calibre database'):
if sort_by:
db.sort(sort_by, ascending)
if search_text:
db.search(search_text)
authors_to_string = output_format in ['stanza', 'text']
data = db.get_data_as_dict(prefix, authors_as_string=authors_to_string)
data = db.get_data_as_dict(prefix, authors_as_string=True)
fields = ['id'] + fields
title_fields = fields
fields = [db.custom_column_label_map[x[1:]]['num'] if x[0]=='*'
else x for x in fields]
if output_format == 'text':
for f in data:
fmts = [x for x in f['formats'] if x is not None]
f['formats'] = u'[%s]'%u','.join(fmts)
widths = list(map(lambda x : 0, fields))
for record in data:
for f in record.keys():
if hasattr(record[f], 'isoformat'):
record[f] = isoformat(record[f], as_utc=False)
else:
record[f] = unicode(record[f])
record[f] = record[f].replace('\n', ' ')
for i in data:
for j, field in enumerate(fields):
widths[j] = max(widths[j], len(unicode(i[field])))
screen_width = terminal_controller.COLS if line_width < 0 else line_width
if not screen_width:
screen_width = 80
field_width = screen_width//len(fields)
base_widths = map(lambda x: min(x+1, field_width), widths)
for f in data:
fmts = [x for x in f['formats'] if x is not None]
f['formats'] = u'[%s]'%u','.join(fmts)
widths = list(map(lambda x : 0, fields))
for record in data:
for f in record.keys():
if hasattr(record[f], 'isoformat'):
record[f] = isoformat(record[f], as_utc=False)
else:
record[f] = unicode(record[f])
record[f] = record[f].replace('\n', ' ')
for i in data:
for j, field in enumerate(fields):
widths[j] = max(widths[j], len(unicode(i[field])))
while sum(base_widths) < screen_width:
adjusted = False
for i in range(len(widths)):
if base_widths[i] < widths[i]:
base_widths[i] += min(screen_width-sum(base_widths), widths[i]-base_widths[i])
adjusted = True
break
if not adjusted:
screen_width = terminal_controller.COLS if line_width < 0 else line_width
if not screen_width:
screen_width = 80
field_width = screen_width//len(fields)
base_widths = map(lambda x: min(x+1, field_width), widths)
while sum(base_widths) < screen_width:
adjusted = False
for i in range(len(widths)):
if base_widths[i] < widths[i]:
base_widths[i] += min(screen_width-sum(base_widths), widths[i]-base_widths[i])
adjusted = True
break
if not adjusted:
break
widths = list(base_widths)
titles = map(lambda x, y: '%-*s%s'%(x-len(separator), y, separator),
widths, title_fields)
print terminal_controller.GREEN + ''.join(titles)+terminal_controller.NORMAL
widths = list(base_widths)
titles = map(lambda x, y: '%-*s%s'%(x-len(separator), y, separator),
widths, title_fields)
print terminal_controller.GREEN + ''.join(titles)+terminal_controller.NORMAL
wrappers = map(lambda x: TextWrapper(x-1), widths)
o = cStringIO.StringIO()
wrappers = map(lambda x: TextWrapper(x-1), widths)
o = cStringIO.StringIO()
for record in data:
text = [wrappers[i].wrap(unicode(record[field]).encode('utf-8')) for i, field in enumerate(fields)]
lines = max(map(len, text))
for l in range(lines):
for i, field in enumerate(text):
ft = text[i][l] if l < len(text[i]) else ''
filler = '%*s'%(widths[i]-len(ft)-1, '')
o.write(ft)
o.write(filler+separator)
print >>o
return o.getvalue()
elif output_format == 'xml':
template = MarkupTemplate(XML_TEMPLATE)
return template.generate(data=data, os=os).render('xml')
elif output_format == 'stanza':
data = [i for i in data if i.has_key('fmt_epub')]
for x in data:
if isinstance(x['fmt_epub'], unicode):
x['fmt_epub'] = x['fmt_epub'].encode('utf-8')
if isinstance(x['cover'], unicode):
x['cover'] = x['cover'].encode('utf-8')
template = MarkupTemplate(STANZA_TEMPLATE)
return template.generate(id="urn:calibre:main", data=data, subtitle=subtitle,
sep=os.sep, quote=quote, updated=db.last_modified()).render('xml')
for record in data:
text = [wrappers[i].wrap(unicode(record[field]).encode('utf-8')) for i, field in enumerate(fields)]
lines = max(map(len, text))
for l in range(lines):
for i, field in enumerate(text):
ft = text[i][l] if l < len(text[i]) else ''
filler = '%*s'%(widths[i]-len(ft)-1, '')
o.write(ft)
o.write(filler+separator)
print >>o
return o.getvalue()
def list_option_parser(db=None):
fields = set(FIELDS)
@ -236,9 +141,6 @@ List the books available in the calibre database.
help=_('The maximum width of a single line in the output. Defaults to detecting screen size.'))
parser.add_option('--separator', default=' ', help=_('The string used to separate fields. Default is a space.'))
parser.add_option('--prefix', default=None, help=_('The prefix for all file paths. Default is the absolute path to the library folder.'))
of = ['text', 'xml', 'stanza']
parser.add_option('--output-format', choices=of, default='text',
help=_('The format in which to output the data. Available choices: %s. Defaults is text.')%of)
return parser
@ -272,7 +174,7 @@ def command_list(args, dbpath):
return 1
print do_list(db, fields, afields, opts.sort_by, opts.ascending, opts.search, opts.line_width, opts.separator,
opts.prefix, opts.output_format)
opts.prefix)
return 0

View File

@ -45,6 +45,7 @@ class CustomColumns(object):
DROP TRIGGER IF EXISTS fkc_insert_{table};
DROP TRIGGER IF EXISTS fkc_delete_{table};
DROP VIEW IF EXISTS tag_browser_{table};
DROP VIEW IF EXISTS tag_browser_filtered_{table};
DROP TABLE IF EXISTS {table};
DROP TABLE IF EXISTS {lt};
'''.format(table=table, lt=lt)
@ -137,7 +138,25 @@ class CustomColumns(object):
'comments': lambda x,d: adapt_text(x, {'is_multiple':False}),
'datetime' : adapt_datetime,
'text':adapt_text
}
}
# Create Tag Browser categories for custom columns
for k in sorted(self.custom_column_label_map.keys()):
v = self.custom_column_label_map[k]
if v['normalized']:
is_category = True
else:
is_category = False
if v['is_multiple']:
is_m = '|'
else:
is_m = None
tn = 'custom_column_{0}'.format(v['num'])
self.field_metadata.add_custom_field(label=v['label'],
table=tn, column='value', datatype=v['datatype'],
colnum=v['num'], name=v['name'], display=v['display'],
is_multiple=is_m, is_category=is_category,
is_editable=v['editable'])
def get_custom(self, idx, label=None, num=None, index_is_id=False):
if label is not None:
@ -190,7 +209,7 @@ class CustomColumns(object):
(label, num))
changed = True
if is_editable is not None:
self.conn.execute('UPDATE custom_columns SET is_editable=? WHERE id=?',
self.conn.execute('UPDATE custom_columns SET editable=? WHERE id=?',
(bool(is_editable), num))
self.custom_column_num_map[num]['is_editable'] = bool(is_editable)
changed = True
@ -243,7 +262,7 @@ class CustomColumns(object):
'SELECT id FROM %s WHERE value=?'%table, (ex,), all=False)
if ex != x:
self.conn.execute(
'UPDATE %s SET value=? WHERE id=?', (x, xid))
'UPDATE %s SET value=? WHERE id=?'%table, (x, xid))
else:
xid = self.conn.execute(
'INSERT INTO %s(value) VALUES(?)'%table, (x,)).lastrowid
@ -396,6 +415,13 @@ class CustomColumns(object):
(SELECT COUNT(id) FROM {lt} WHERE value={table}.id) count
FROM {table};
CREATE VIEW tag_browser_filtered_{table} AS SELECT
id,
value,
(SELECT COUNT({lt}.id) FROM {lt} WHERE value={table}.id AND
books_list_filter(book)) count
FROM {table};
'''.format(lt=lt, table=table),
]

View File

@ -1070,6 +1070,9 @@ ALTER TABLE books ADD COLUMN isbn TEXT DEFAULT "" COLLATE NOCASE;
return [ (i[0], i[1]) for i in \
self.conn.get('SELECT id, name FROM tags')]
def all_titles(self):
return [ (i[0], i[1]) for i in \
self.conn.get('SELECT id, title FROM books')]
def conversion_options(self, id, format):
data = self.conn.get('SELECT data FROM conversion_options WHERE book=? AND format=?', (id, format.upper()), all=False)

View File

@ -20,6 +20,7 @@ from PyQt4.QtGui import QImage
from calibre.ebooks.metadata import title_sort
from calibre.library.database import LibraryDatabase
from calibre.library.field_metadata import FieldMetadata, TagsIcons
from calibre.library.schema_upgrades import SchemaUpgrade
from calibre.library.caches import ResultCache
from calibre.library.custom_columns import CustomColumns
@ -33,8 +34,11 @@ from calibre.customize.ui import run_plugins_on_import
from calibre.utils.filenames import ascii_filename
from calibre.utils.date import utcnow, now as nowf, utcfromtimestamp
from calibre.utils.config import prefs
from calibre.utils.search_query_parser import saved_searches
from calibre.ebooks import BOOK_EXTENSIONS, check_ebook_format
if iswindows:
import calibre.utils.winshell as winshell
@ -56,16 +60,15 @@ def delete_tree(path, permanent=False):
copyfile = os.link if hasattr(os, 'link') else shutil.copyfile
class Tag(object):
def __init__(self, name, id=None, count=0, state=0, tooltip=None):
def __init__(self, name, id=None, count=0, state=0, tooltip=None, icon=None):
self.name = name
self.id = id
self.count = count
self.state = state
self.tooltip = tooltip
self.icon = icon
def __unicode__(self):
return u'%s:%s:%s:%s:%s'%(self.name, self.count, self.id, self.state, self.tooltip)
@ -107,8 +110,13 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
self.conn = connect(self.dbpath, self.row_factory)
if self.user_version == 0:
self.initialize_database()
# remember to add any filter to the connect method in sqlite.py as well
# so that various code taht connects directly will not complain about
# missing functions
self.books_list_filter = self.conn.create_dynamic_filter('books_list_filter')
def __init__(self, library_path, row_factory=False):
self.field_metadata = FieldMetadata()
if not os.path.exists(library_path):
os.makedirs(library_path)
self.listeners = set([])
@ -119,6 +127,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
self.dbpath)
if isinstance(self.dbpath, unicode):
self.dbpath = self.dbpath.encode(filesystem_encoding)
self.connect()
self.is_case_sensitive = not iswindows and not isosx and \
not os.path.exists(self.dbpath.replace('metadata.db', 'MeTAdAtA.dB'))
@ -126,6 +135,30 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
self.initialize_dynamic()
def initialize_dynamic(self):
self.conn.executescript(u'''
CREATE TEMP VIEW IF NOT EXISTS tag_browser_news AS SELECT DISTINCT
id,
name,
(SELECT COUNT(books_tags_link.id) FROM books_tags_link WHERE tag=x.id) count
FROM tags as x WHERE name!="{0}" AND id IN
(SELECT DISTINCT tag FROM books_tags_link WHERE book IN
(SELECT DISTINCT book FROM books_tags_link WHERE tag IN
(SELECT id FROM tags WHERE name="{0}")));
'''.format(_('News')))
self.conn.executescript(u'''
CREATE TEMP VIEW IF NOT EXISTS tag_browser_filtered_news AS SELECT DISTINCT
id,
name,
(SELECT COUNT(books_tags_link.id) FROM books_tags_link WHERE tag=x.id and books_list_filter(book)) count
FROM tags as x WHERE name!="{0}" AND id IN
(SELECT DISTINCT tag FROM books_tags_link WHERE book IN
(SELECT DISTINCT book FROM books_tags_link WHERE tag IN
(SELECT id FROM tags WHERE name="{0}")));
'''.format(_('News')))
self.conn.commit()
CustomColumns.__init__(self)
template = '''\
(SELECT {query} FROM books_{table}_link AS link INNER JOIN
@ -135,13 +168,13 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
columns = ['id', 'title',
# col table link_col query
('authors', 'authors', 'author', 'sortconcat(link.id, name)'),
('publisher', 'publishers', 'publisher', 'name'),
('rating', 'ratings', 'rating', 'ratings.rating'),
'timestamp',
'(SELECT MAX(uncompressed_size) FROM data WHERE book=books.id) size',
('rating', 'ratings', 'rating', 'ratings.rating'),
('tags', 'tags', 'tag', 'group_concat(name)'),
'(SELECT text FROM comments WHERE book=books.id) comments',
('series', 'series', 'series', 'name'),
('publisher', 'publishers', 'publisher', 'name'),
'series_index',
'sort',
'author_sort',
@ -162,19 +195,31 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
lines.append(line)
custom_map = self.custom_columns_in_meta()
# custom col labels are numbers (the id in the custom_columns table)
custom_cols = list(sorted(custom_map.keys()))
lines.extend([custom_map[x] for x in custom_cols])
self.FIELD_MAP = {'id':0, 'title':1, 'authors':2, 'publisher':3, 'rating':4, 'timestamp':5,
'size':6, 'tags':7, 'comments':8, 'series':9, 'series_index':10,
self.FIELD_MAP = {'id':0, 'title':1, 'authors':2, 'timestamp':3,
'size':4, 'rating':5, 'tags':6, 'comments':7, 'series':8,
'publisher':9, 'series_index':10,
'sort':11, 'author_sort':12, 'formats':13, 'isbn':14, 'path':15,
'lccn':16, 'pubdate':17, 'flags':18, 'uuid':19}
for k,v in self.FIELD_MAP.iteritems():
self.field_metadata.set_field_record_index(k, v, prefer_custom=False)
base = max(self.FIELD_MAP.values())
for col in custom_cols:
self.FIELD_MAP[col] = base = base+1
self.field_metadata.set_field_record_index(
self.custom_column_num_map[col]['label'],
base,
prefer_custom=True)
self.FIELD_MAP['cover'] = base+1
self.field_metadata.set_field_record_index('cover', base+1, prefer_custom=False)
self.FIELD_MAP['ondevice'] = base+2
self.field_metadata.set_field_record_index('ondevice', base+2, prefer_custom=False)
script = '''
DROP VIEW IF EXISTS meta2;
@ -186,7 +231,20 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
self.conn.executescript(script)
self.conn.commit()
self.data = ResultCache(self.FIELD_MAP)
# Reconstruct the user categories, putting them into field_metadata
# Assumption is that someone else will fix them if they change.
tb_cats = self.field_metadata
for k in tb_cats.keys():
if tb_cats[k]['kind'] in ['user', 'search']:
del tb_cats[k]
for user_cat in sorted(prefs['user_categories'].keys()):
cat_name = user_cat+':' # add the ':' to avoid name collision
tb_cats.add_user_category(label=cat_name, name=user_cat)
if len(saved_searches.names()):
tb_cats.add_search_category(label='search', name=_('Searches'))
self.book_on_device_func = None
self.data = ResultCache(self.FIELD_MAP, self.field_metadata)
self.search = self.data.search
self.refresh = functools.partial(self.data.refresh, self)
self.sort = self.data.sort
@ -196,6 +254,8 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
self.has_id = self.data.has_id
self.count = self.data.count
self.refresh_ondevice = functools.partial(self.data.refresh_ondevice, self)
self.refresh()
self.last_update_check = self.last_modified()
@ -421,6 +481,27 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
im = PILImage.open(f)
im.convert('RGB').save(path, 'JPEG')
def book_on_device(self, id):
if callable(self.book_on_device_func):
return self.book_on_device_func(id)
return None
def book_on_device_string(self, id):
loc = []
on = self.book_on_device(id)
if on is not None:
m, a, b = on
if m is not None:
loc.append(_('Main'))
if a is not None:
loc.append(_('Card A'))
if b is not None:
loc.append(_('Card B'))
return ', '.join(loc)
def set_book_on_device_func(self, func):
self.book_on_device_func = func
def all_formats(self):
formats = self.conn.get('SELECT DISTINCT format from data')
if not formats:
@ -576,42 +657,144 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
def get_recipe(self, id):
return self.conn.get('SELECT script FROM feeds WHERE id=?', (id,), all=False)
def get_categories(self, sort_on_count=False):
self.conn.executescript(u'''
CREATE TEMP VIEW IF NOT EXISTS tag_browser_news AS SELECT DISTINCT
id,
name,
(SELECT COUNT(id) FROM books_tags_link WHERE tag=x.id) count
FROM tags as x WHERE name!="{0}" AND id IN
(SELECT DISTINCT tag FROM books_tags_link WHERE book IN
(SELECT DISTINCT book FROM books_tags_link WHERE tag IN
(SELECT id FROM tags WHERE name="{0}")));
'''.format(_('News')))
self.conn.commit()
def get_books_for_category(self, category, id_):
ans = set([])
if category not in self.field_metadata:
return ans
field = self.field_metadata[category]
ans = self.conn.get(
'SELECT book FROM books_{tn}_link WHERE {col}=?'.format(
tn=field['table'], col=field['link_column']), (id_,))
return set(x[0] for x in ans)
def get_categories(self, sort_on_count=False, ids=None, icon_map=None):
self.books_list_filter.change([] if not ids else ids)
categories = {}
for x in ('tags', 'series', 'news', 'publishers', 'authors'):
query = 'SELECT id,name,count FROM tag_browser_'+x
if icon_map is not None and type(icon_map) != TagsIcons:
raise TypeError('icon_map passed to get_categories must be of type TagIcons')
tb_cats = self.field_metadata
#### First, build the standard and custom-column categories ####
for category in tb_cats.keys():
cat = tb_cats[category]
if not cat['is_category'] or cat['kind'] in ['user', 'search']:
continue
tn = cat['table']
categories[category] = [] #reserve the position in the ordered list
if tn is None: # Nothing to do for the moment
continue
cn = cat['column']
if ids is None:
query = 'SELECT id, {0}, count FROM tag_browser_{1}'.format(cn, tn)
else:
query = 'SELECT id, {0}, count FROM tag_browser_filtered_{1}'.format(cn, tn)
if sort_on_count:
query += ' ORDER BY count DESC'
else:
query += ' ORDER BY name ASC'
query += ' ORDER BY {0} ASC'.format(cn)
data = self.conn.get(query)
category = x if x in ('series', 'news') else x[:-1]
categories[category] = [Tag(r[1], count=r[2], id=r[0]) for r in data]
categories['format'] = []
# icon_map is not None if get_categories is to store an icon and
# possibly a tooltip in the tag structure.
icon, tooltip = None, ''
label = tb_cats.key_to_label(category)
if icon_map:
if not tb_cats.is_custom_field(category):
if category in icon_map:
icon = icon_map[label]
else:
icon = icon_map[':custom']
icon_map[category] = icon
tooltip = self.custom_column_label_map[label]['name']
datatype = cat['datatype']
if datatype == 'rating':
item_not_zero_func = (lambda x: x[1] > 0 and x[2] > 0)
formatter = (lambda x:u'\u2605'*int(round(x/2.)))
elif category == 'authors':
item_not_zero_func = (lambda x: x[2] > 0)
# Clean up the authors strings to human-readable form
formatter = (lambda x: x.replace('|', ','))
else:
item_not_zero_func = (lambda x: x[2] > 0)
formatter = (lambda x:unicode(x))
categories[category] = [Tag(formatter(r[1]), count=r[2], id=r[0],
icon=icon, tooltip = tooltip)
for r in data if item_not_zero_func(r)]
# We delayed computing the standard formats category because it does not
# use a view, but is computed dynamically
categories['formats'] = []
icon = None
if icon_map and 'formats' in icon_map:
icon = icon_map['formats']
for fmt in self.conn.get('SELECT DISTINCT format FROM data'):
fmt = fmt[0]
count = self.conn.get('SELECT COUNT(id) FROM data WHERE format="%s"'%fmt,
all=False)
categories['format'].append(Tag(fmt, count=count))
if ids is not None:
count = self.conn.get('''SELECT COUNT(id)
FROM data
WHERE format="%s" AND
books_list_filter(book)'''%fmt,
all=False)
else:
count = self.conn.get('''SELECT COUNT(id)
FROM data
WHERE format="%s"'''%fmt,
all=False)
if count > 0:
categories['formats'].append(Tag(fmt, count=count, icon=icon))
if sort_on_count:
categories['format'].sort(cmp=lambda x,y:cmp(x.count, y.count),
categories['formats'].sort(cmp=lambda x,y:cmp(x.count, y.count),
reverse=True)
else:
categories['format'].sort(cmp=lambda x,y:cmp(x.name, y.name))
categories['formats'].sort(cmp=lambda x,y:cmp(x.name, y.name))
#### Now do the user-defined categories. ####
user_categories = prefs['user_categories']
# We want to use same node in the user category as in the source
# category. To do that, we need to find the original Tag node. There is
# a time/space tradeoff here. By converting the tags into a map, we can
# do the verification in the category loop much faster, at the cost of
# temporarily duplicating the categories lists.
taglist = {}
for c in categories.keys():
taglist[c] = dict(map(lambda t:(t.name, t), categories[c]))
for user_cat in sorted(user_categories.keys()):
items = []
for (name,label,ign) in user_categories[user_cat]:
if label in taglist and name in taglist[label]:
items.append(taglist[label][name])
# else: do nothing, to not include nodes w zero counts
if len(items):
cat_name = user_cat+':' # add the ':' to avoid name collision
# Not a problem if we accumulate entries in the icon map
if icon_map is not None:
icon_map[cat_name] = icon_map[':user']
if sort_on_count:
categories[cat_name] = \
sorted(items, cmp=(lambda x, y: cmp(y.count, x.count)))
else:
categories[cat_name] = \
sorted(items, cmp=(lambda x, y: cmp(x.name.lower(), y.name.lower())))
#### Finally, the saved searches category ####
items = []
icon = None
if icon_map and 'search' in icon_map:
icon = icon_map['search']
for srch in saved_searches.names():
items.append(Tag(srch, tooltip=saved_searches.lookup(srch), icon=icon))
if len(items):
if icon_map is not None:
icon_map['search'] = icon_map['search']
categories['search'] = items
return categories

View File

@ -0,0 +1,454 @@
'''
Created on 25 May 2010
@author: charles
'''
from calibre.utils.ordered_dict import OrderedDict
class TagsIcons(dict):
'''
If the client wants icons to be in the tag structure, this class must be
instantiated and filled in with real icons. If this class is instantiated
and passed to get_categories, All items must be given a value not None
'''
category_icons = ['authors', 'series', 'formats', 'publisher', 'rating',
'news', 'tags', ':custom', ':user', 'search',]
def __init__(self, icon_dict):
for a in self.category_icons:
if a not in icon_dict:
raise ValueError('Missing category icon [%s]'%a)
self[a] = icon_dict[a]
class FieldMetadata(dict):
'''
key: the key to the dictionary is:
- for standard fields, the metadata field name.
- for custom fields, the metadata field name prefixed by '#'
This is done to create two 'namespaces' so the names don't clash
label: the actual column label. No prefixing.
datatype: the type of the information in the field. Valid values are float,
int, rating, bool, comments, datetime, text.
is_multiple: valid for the text datatype. If None, the field is to be
treated as a single term. If not None, it contains a string, and the field
is assumed to contain a list of terms separated by that string
kind == standard: is a db field.
kind == category: standard tag category that isn't a field. see news.
kind == user: user-defined tag category.
kind == search: saved-searches category.
is_category: is a tag browser category. If true, then:
table: name of the db table used to construct item list
column: name of the column in the normalized table to join on
link_column: name of the column in the connection table to join on
If these are None, then the category constructor must know how
to build the item list (e.g., formats).
The order below is the order that the categories will
appear in the tags pane.
name: the text that is to be used when displaying the field. Column headings
in the GUI, etc.
search_terms: the terms that can be used to identify the field when
searching. They can be thought of as aliases for metadata keys, but are only
valid when passed to search().
is_custom: the field has been added by the user.
rec_index: the index of the field in the db metadata record.
'''
_field_metadata = [
('authors', {'table':'authors',
'column':'name',
'link_column':'author',
'datatype':'text',
'is_multiple':',',
'kind':'field',
'name':_('Authors'),
'search_terms':['authors', 'author'],
'is_custom':False,
'is_category':True}),
('series', {'table':'series',
'column':'name',
'link_column':'series',
'datatype':'text',
'is_multiple':None,
'kind':'field',
'name':_('Series'),
'search_terms':['series'],
'is_custom':False,
'is_category':True}),
('formats', {'table':None,
'column':None,
'datatype':'text',
'is_multiple':',',
'kind':'field',
'name':_('Formats'),
'search_terms':['formats', 'format'],
'is_custom':False,
'is_category':True}),
('publisher', {'table':'publishers',
'column':'name',
'link_column':'publisher',
'datatype':'text',
'is_multiple':None,
'kind':'field',
'name':_('Publishers'),
'search_terms':['publisher'],
'is_custom':False,
'is_category':True}),
('rating', {'table':'ratings',
'column':'rating',
'link_column':'rating',
'datatype':'rating',
'is_multiple':None,
'kind':'field',
'name':_('Ratings'),
'search_terms':['rating'],
'is_custom':False,
'is_category':True}),
('news', {'table':'news',
'column':'name',
'datatype':None,
'is_multiple':None,
'kind':'category',
'name':_('News'),
'search_terms':[],
'is_custom':False,
'is_category':True}),
('tags', {'table':'tags',
'column':'name',
'link_column': 'tag',
'datatype':'text',
'is_multiple':',',
'kind':'field',
'name':_('Tags'),
'search_terms':['tags', 'tag'],
'is_custom':False,
'is_category':True}),
('author_sort',{'table':None,
'column':None,
'datatype':'text',
'is_multiple':None,
'kind':'field',
'name':None,
'search_terms':[],
'is_custom':False,
'is_category':False}),
('comments', {'table':None,
'column':None,
'datatype':'text',
'is_multiple':None,
'kind':'field',
'name':None,
'search_terms':['comments', 'comment'],
'is_custom':False, 'is_category':False}),
('cover', {'table':None,
'column':None,
'datatype':None,
'is_multiple':None,
'kind':'field',
'name':None,
'search_terms':['cover'],
'is_custom':False,
'is_category':False}),
('flags', {'table':None,
'column':None,
'datatype':'text',
'is_multiple':None,
'kind':'field',
'name':None,
'search_terms':[],
'is_custom':False,
'is_category':False}),
('id', {'table':None,
'column':None,
'datatype':'int',
'is_multiple':None,
'kind':'field',
'name':None,
'search_terms':[],
'is_custom':False,
'is_category':False}),
('isbn', {'table':None,
'column':None,
'datatype':'text',
'is_multiple':None,
'kind':'field',
'name':None,
'search_terms':['isbn'],
'is_custom':False,
'is_category':False}),
('lccn', {'table':None,
'column':None,
'datatype':'text',
'is_multiple':None,
'kind':'field',
'name':None,
'search_terms':[],
'is_custom':False,
'is_category':False}),
('ondevice', {'table':None,
'column':None,
'datatype':'bool',
'is_multiple':None,
'kind':'field',
'name':None,
'search_terms':[],
'is_custom':False,
'is_category':False}),
('path', {'table':None,
'column':None,
'datatype':'text',
'is_multiple':None,
'kind':'field',
'name':None,
'search_terms':[],
'is_custom':False,
'is_category':False}),
('pubdate', {'table':None,
'column':None,
'datatype':'datetime',
'is_multiple':None,
'kind':'field',
'name':None,
'search_terms':['pubdate'],
'is_custom':False,
'is_category':False}),
('series_index',{'table':None,
'column':None,
'datatype':'float',
'is_multiple':None,
'kind':'field',
'name':None,
'search_terms':[],
'is_custom':False,
'is_category':False}),
('sort', {'table':None,
'column':None,
'datatype':'text',
'is_multiple':None,
'kind':'field',
'name':None,
'search_terms':[],
'is_custom':False,
'is_category':False}),
('size', {'table':None,
'column':None,
'datatype':'float',
'is_multiple':None,
'kind':'field',
'name':None,
'search_terms':[],
'is_custom':False,
'is_category':False}),
('timestamp', {'table':None,
'column':None,
'datatype':'datetime',
'is_multiple':None,
'kind':'field',
'name':None,
'search_terms':['date'],
'is_custom':False,
'is_category':False}),
('title', {'table':None,
'column':None,
'datatype':'text',
'is_multiple':None,
'kind':'field',
'name':None,
'search_terms':['title'],
'is_custom':False,
'is_category':False}),
('uuid', {'table':None,
'column':None,
'datatype':'text',
'is_multiple':None,
'kind':'field',
'name':None,
'search_terms':[],
'is_custom':False,
'is_category':False}),
]
# search labels that are not db columns
search_items = [ 'all',
# 'date',
'search',
]
def __init__(self):
self._tb_cats = OrderedDict()
self._search_term_map = {}
self.custom_label_to_key_map = {}
for k,v in self._field_metadata:
self._tb_cats[k] = v
self._tb_cats[k]['label'] = k
self._tb_cats[k]['display'] = {}
self._tb_cats[k]['is_editable'] = True
self._add_search_terms_to_map(k, self._tb_cats[k]['search_terms'])
self.custom_field_prefix = '#'
self.get = self._tb_cats.get
def __getitem__(self, key):
return self._tb_cats[key]
def __setitem__(self, key, val):
raise AttributeError('Assigning to this object is forbidden')
def __delitem__(self, key):
del self._tb_cats[key]
def __iter__(self):
for key in self._tb_cats:
yield key
def __contains__(self, key):
return self.has_key(key)
def has_key(self, key):
return key in self._tb_cats
def keys(self):
return self._tb_cats.keys()
def iterkeys(self):
for key in self._tb_cats:
yield key
def itervalues(self):
return self._tb_cats.itervalues()
def values(self):
return self._tb_cats.values()
def iteritems(self):
for key in self._tb_cats:
yield (key, self._tb_cats[key])
def items(self):
return list(self.iteritems())
def is_custom_field(self, key):
return key.startswith(self.custom_field_prefix)
def key_to_label(self, key):
if 'label' not in self._tb_cats[key]:
return key
return self._tb_cats[key]['label']
def label_to_key(self, label, prefer_custom=False):
if prefer_custom:
if label in self.custom_label_to_key_map:
return self.custom_label_to_key_map[label]
if 'label' in self._tb_cats:
return label
if not prefer_custom:
if label in self.custom_label_to_key_map:
return self.custom_label_to_key_map[label]
raise ValueError('Unknown key [%s]'%(label))
def get_custom_fields(self):
return [l for l in self._tb_cats if self._tb_cats[l]['is_custom']]
def get_custom_field_metadata(self):
l = {}
for k in self._tb_cats:
if self._tb_cats[k]['is_custom']:
l[k] = self._tb_cats[k]
return l
def add_custom_field(self, label, table, column, datatype, colnum, name,
display, is_editable, is_multiple, is_category):
key = self.custom_field_prefix + label
if key in self._tb_cats:
raise ValueError('Duplicate custom field [%s]'%(label))
self._tb_cats[key] = {'table':table, 'column':column,
'datatype':datatype, 'is_multiple':is_multiple,
'kind':'field', 'name':name,
'search_terms':[key], 'label':label,
'colnum':colnum, 'display':display,
'is_custom':True, 'is_category':is_category,
'link_column':'value',
'is_editable': is_editable,}
self._add_search_terms_to_map(key, [key])
self.custom_label_to_key_map[label] = key
def add_user_category(self, label, name):
if label in self._tb_cats:
raise ValueError('Duplicate user field [%s]'%(label))
self._tb_cats[label] = {'table':None, 'column':None,
'datatype':None, 'is_multiple':None,
'kind':'user', 'name':name,
'search_terms':[], 'is_custom':False,
'is_category':True}
def add_search_category(self, label, name):
if label in self._tb_cats:
raise ValueError('Duplicate user field [%s]'%(label))
self._tb_cats[label] = {'table':None, 'column':None,
'datatype':None, 'is_multiple':None,
'kind':'search', 'name':name,
'search_terms':[], 'is_custom':False,
'is_category':True}
def set_field_record_index(self, label, index, prefer_custom=False):
if prefer_custom:
key = self.custom_field_prefix+label
if key not in self._tb_cats:
key = label
else:
if label in self._tb_cats:
key = label
else:
key = self.custom_field_prefix+label
self._tb_cats[key]['rec_index'] = index # let the exception fly ...
# DEFAULT_LOCATIONS = frozenset([
# 'all',
# 'author', # compatibility
# 'authors',
# 'comment', # compatibility
# 'comments',
# 'cover',
# 'date',
# 'format', # compatibility
# 'formats',
# 'isbn',
# 'ondevice',
# 'pubdate',
# 'publisher',
# 'search',
# 'series',
# 'rating',
# 'tag', # compatibility
# 'tags',
# 'title',
# ])
def get_search_terms(self):
s_keys = []
for v in self._tb_cats.itervalues():
map((lambda x:s_keys.append(x)), v['search_terms'])
for v in self.search_items:
s_keys.append(v)
# if set(s_keys) != self.DEFAULT_LOCATIONS:
# print 'search labels and default_locations do not match:'
# print set(s_keys) ^ self.DEFAULT_LOCATIONS
return s_keys
def _add_search_terms_to_map(self, key, terms):
if terms is not None:
for t in terms:
self._search_term_map[t] = key
def search_term_to_key(self, term):
if term in self._search_term_map:
return self._search_term_map[term]
return term

View File

@ -269,3 +269,26 @@ class SchemaUpgrade(object):
CREATE INDEX IF NOT EXISTS formats_idx ON data (format);
''')
def upgrade_version_10(self):
'Add restricted Tag Browser views'
def create_tag_browser_view(table_name, column_name, view_column_name):
script = ('''
DROP VIEW IF EXISTS tag_browser_{tn};
CREATE VIEW tag_browser_{tn} AS SELECT
id,
{vcn},
(SELECT COUNT(id) FROM books_{tn}_link WHERE {cn}={tn}.id) count
FROM {tn};
DROP VIEW IF EXISTS tag_browser_filtered_{tn};
CREATE VIEW tag_browser_filtered_{tn} AS SELECT
id,
{vcn},
(SELECT COUNT(books_{tn}_link.id) FROM books_{tn}_link WHERE
{cn}={tn}.id AND books_list_filter(book)) count
FROM {tn};
'''.format(tn=table_name, cn=column_name, vcn=view_column_name))
self.conn.executescript(script)
for field in self.field_metadata.itervalues():
if field['is_category'] and not field['is_custom'] and 'link_column' in field:
create_tag_browser_view(field['table'], field['link_column'], field['column'])

Some files were not shown because too many files have changed in this diff Show More