Move sources into src folder so I can try Pydev/Eclipse
42
src/libprs500/__init__.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
## Copyright (C) 2006 Kovid Goyal kovid@kovidgoyal.net
|
||||||
|
## This program is free software; you can redistribute it and/or modify
|
||||||
|
## it under the terms of the GNU General Public License as published by
|
||||||
|
## the Free Software Foundation; either version 2 of the License, or
|
||||||
|
## (at your option) any later version.
|
||||||
|
##
|
||||||
|
## This program is distributed in the hope that it will be useful,
|
||||||
|
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
## GNU General Public License for more details.
|
||||||
|
##
|
||||||
|
## You should have received a copy of the GNU General Public License along
|
||||||
|
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
"""
|
||||||
|
This package provides an interface to the SONY Reader PRS-500 over USB.
|
||||||
|
|
||||||
|
The public interface of libprs500 is in L{libprs500.communicate}. To use it
|
||||||
|
>>> from libprs500.communicate import PRS500Device
|
||||||
|
>>> dev = PRS500Device()
|
||||||
|
>>> dev.get_device_information()
|
||||||
|
('Sony Reader', 'PRS-500/U', '1.0.00.21081', 'application/x-bbeb-book')
|
||||||
|
|
||||||
|
There is also a script L{prs500} that provides a command-line interface to
|
||||||
|
libprs500. See the script
|
||||||
|
for more usage examples. A GUI is available via the command prs500-gui.
|
||||||
|
|
||||||
|
The packet structure used by the SONY Reader USB protocol is defined
|
||||||
|
in the module L{prstypes}. The communication logic
|
||||||
|
is defined in the module L{communicate}.
|
||||||
|
|
||||||
|
This package requires U{PyUSB<http://pyusb.berlios.de/>}.
|
||||||
|
In order to use it as a non-root user on Linux, you should have
|
||||||
|
the following rule in C{/etc/udev/rules.d/90-local.rules} ::
|
||||||
|
BUS=="usb", SYSFS{idProduct}=="029b", SYSFS{idVendor}=="054c",
|
||||||
|
MODE="660", GROUP="plugdev"
|
||||||
|
You may have to adjust the GROUP and the location of the rules file to
|
||||||
|
suit your distribution.
|
||||||
|
"""
|
||||||
|
__version__ = "0.3.0b2"
|
||||||
|
__docformat__ = "epytext"
|
||||||
|
__author__ = "Kovid Goyal <kovid@kovidgoyal.net>"
|
211
src/libprs500/books.py
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
## Copyright (C) 2006 Kovid Goyal kovid@kovidgoyal.net
|
||||||
|
## This program is free software; you can redistribute it and/or modify
|
||||||
|
## it under the terms of the GNU General Public License as published by
|
||||||
|
## the Free Software Foundation; either version 2 of the License, or
|
||||||
|
## (at your option) any later version.
|
||||||
|
##
|
||||||
|
## This program is distributed in the hope that it will be useful,
|
||||||
|
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
## GNU General Public License for more details.
|
||||||
|
##
|
||||||
|
## You should have received a copy of the GNU General Public License along
|
||||||
|
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
"""
|
||||||
|
This module contains the logic for dealing with XML book lists found
|
||||||
|
in the reader cache.
|
||||||
|
"""
|
||||||
|
from xml.dom.ext import PrettyPrint
|
||||||
|
import xml.dom.minidom as dom
|
||||||
|
from base64 import b64decode as decode
|
||||||
|
from base64 import b64encode as encode
|
||||||
|
import time
|
||||||
|
|
||||||
|
MIME_MAP = { \
|
||||||
|
"lrf":"application/x-sony-bbeb", \
|
||||||
|
"rtf":"application/rtf", \
|
||||||
|
"pdf":"application/pdf", \
|
||||||
|
"txt":"text/plain" \
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
obj.elem.setAttribute(self.attr, str(val))
|
||||||
|
|
||||||
|
class Book(object):
|
||||||
|
""" Provides a view onto the XML element that represents a book """
|
||||||
|
title = book_metadata_field("title")
|
||||||
|
author = book_metadata_field("author", \
|
||||||
|
formatter=lambda x: x if 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=int)
|
||||||
|
# When setting this attribute you must use an epoch
|
||||||
|
datetime = book_metadata_field("date", \
|
||||||
|
formatter=lambda x: time.strptime(x, "%a, %d %b %Y %H:%M:%S %Z"),
|
||||||
|
setter=lambda x: time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(x)))
|
||||||
|
|
||||||
|
@apply
|
||||||
|
def thumbnail():
|
||||||
|
doc = \
|
||||||
|
"""
|
||||||
|
The thumbnail. Should be a height 68 image.
|
||||||
|
Setting is not supported.
|
||||||
|
"""
|
||||||
|
def fget(self):
|
||||||
|
th = self.elem.getElementsByTagName(self.prefix + "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)
|
||||||
|
|
||||||
|
@apply
|
||||||
|
def path():
|
||||||
|
doc = """ Absolute path to book on device. Setting not supported. """
|
||||||
|
def fget(self):
|
||||||
|
return self.root + self.rpath
|
||||||
|
return property(fget=fget, doc=doc)
|
||||||
|
|
||||||
|
def __init__(self, node, prefix="xs1:", root="/Data/media/"):
|
||||||
|
self.elem = node
|
||||||
|
self.prefix = prefix
|
||||||
|
self.root = root
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return self.title + " by " + self.author+ " at " + self.path
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.__repr__()
|
||||||
|
|
||||||
|
|
||||||
|
def fix_ids(media, cache):
|
||||||
|
"""
|
||||||
|
Update ids in media, cache to be consistent with their
|
||||||
|
current structure
|
||||||
|
"""
|
||||||
|
cid = 0
|
||||||
|
for child in media.root.childNodes:
|
||||||
|
if child.nodeType == child.ELEMENT_NODE and \
|
||||||
|
child.hasAttribute("id"):
|
||||||
|
child.setAttribute("id", str(cid))
|
||||||
|
cid += 1
|
||||||
|
mmaxid = cid - 1
|
||||||
|
cid = mmaxid + 2
|
||||||
|
if len(cache):
|
||||||
|
for child in cache.root.childNodes:
|
||||||
|
if child.nodeType == child.ELEMENT_NODE and \
|
||||||
|
child.hasAttribute("sourceid"):
|
||||||
|
child.setAttribute("sourceid", str(mmaxid+1))
|
||||||
|
child.setAttribute("id", str(cid))
|
||||||
|
cid += 1
|
||||||
|
media.document.documentElement.setAttribute("nextID", str(cid))
|
||||||
|
|
||||||
|
class BookList(list):
|
||||||
|
"""
|
||||||
|
A list of L{Book}s. Created from an XML file. Can write list
|
||||||
|
to an XML file.
|
||||||
|
"""
|
||||||
|
__getslice__ = None
|
||||||
|
__setslice__ = None
|
||||||
|
|
||||||
|
def __init__(self, prefix="xs1:", root="/Data/media/", sfile=None):
|
||||||
|
list.__init__(self)
|
||||||
|
if sfile:
|
||||||
|
self.prefix = prefix
|
||||||
|
self.proot = root
|
||||||
|
sfile.seek(0)
|
||||||
|
self.document = dom.parse(sfile)
|
||||||
|
# The root element containing all records
|
||||||
|
self.root = self.document.documentElement
|
||||||
|
if prefix == "xs1:":
|
||||||
|
self.root = self.root.getElementsByTagName("records")[0]
|
||||||
|
for book in self.document.getElementsByTagName(self.prefix + "text"):
|
||||||
|
self.append(Book(book, root=root, prefix=prefix))
|
||||||
|
|
||||||
|
def max_id(self):
|
||||||
|
""" Highest id in underlying XML file """
|
||||||
|
cid = -1
|
||||||
|
for child in self.root.childNodes:
|
||||||
|
if child.nodeType == child.ELEMENT_NODE and \
|
||||||
|
child.hasAttribute("id"):
|
||||||
|
c = int(child.getAttribute("id"))
|
||||||
|
if c > cid:
|
||||||
|
cid = c
|
||||||
|
return cid
|
||||||
|
|
||||||
|
def has_id(self, cid):
|
||||||
|
"""
|
||||||
|
Check if a book with id C{ == cid} exists already.
|
||||||
|
This *does not* check if id exists in the underlying XML file
|
||||||
|
"""
|
||||||
|
ans = False
|
||||||
|
for book in self:
|
||||||
|
if book.id == cid:
|
||||||
|
ans = True
|
||||||
|
break
|
||||||
|
return ans
|
||||||
|
|
||||||
|
def delete_book(self, cid):
|
||||||
|
""" Remove DOM node corresponding to book with C{id == cid}."""
|
||||||
|
node = None
|
||||||
|
for book in self:
|
||||||
|
if book.id == cid:
|
||||||
|
node = book
|
||||||
|
self.remove(book)
|
||||||
|
break
|
||||||
|
node.elem.parentNode.removeChild(node.elem)
|
||||||
|
node.elem.unlink()
|
||||||
|
|
||||||
|
def add_book(self, info, name, size, ctime):
|
||||||
|
""" Add a node into DOM tree representing a book """
|
||||||
|
node = self.document.createElement(self.prefix + "text")
|
||||||
|
mime = MIME_MAP[name[name.rfind(".")+1:]]
|
||||||
|
cid = self.max_id()+1
|
||||||
|
sourceid = str(self[0].sourceid) if len(self) else "1"
|
||||||
|
attrs = { "title":info["title"], "author":info["authors"], \
|
||||||
|
"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])
|
||||||
|
w, h, data = info["cover"]
|
||||||
|
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.appendChild(node)
|
||||||
|
book = Book(node, root=self.proot, prefix=self.prefix)
|
||||||
|
book.datetime = ctime
|
||||||
|
self.append(book)
|
||||||
|
|
||||||
|
def write(self, stream):
|
||||||
|
""" Write XML representation of DOM tree to C{stream} """
|
||||||
|
PrettyPrint(self.document, stream)
|
21
src/libprs500/cli/__init__.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
## Copyright (C) 2006 Kovid Goyal kovid@kovidgoyal.net
|
||||||
|
## This program is free software; you can redistribute it and/or modify
|
||||||
|
## it under the terms of the GNU General Public License as published by
|
||||||
|
## the Free Software Foundation; either version 2 of the License, or
|
||||||
|
## (at your option) any later version.
|
||||||
|
##
|
||||||
|
## This program is distributed in the hope that it will be useful,
|
||||||
|
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
## GNU General Public License for more details.
|
||||||
|
##
|
||||||
|
## You should have received a copy of the GNU General Public License along
|
||||||
|
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
"""
|
||||||
|
Provides a command-line interface to the SONY Reader PRS-500.
|
||||||
|
|
||||||
|
For usage information run the script.
|
||||||
|
"""
|
||||||
|
__docformat__ = "epytext"
|
||||||
|
__author__ = "Kovid Goyal <kovid@kovidgoyal.net>"
|
304
src/libprs500/cli/main.py
Executable file
@ -0,0 +1,304 @@
|
|||||||
|
## Copyright (C) 2006 Kovid Goyal kovid@kovidgoyal.net
|
||||||
|
## This program is free software; you can redistribute it and/or modify
|
||||||
|
## it under the terms of the GNU General Public License as published by
|
||||||
|
## the Free Software Foundation; either version 2 of the License, or
|
||||||
|
## (at your option) any later version.
|
||||||
|
##
|
||||||
|
## This program is distributed in the hope that it will be useful,
|
||||||
|
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
## GNU General Public License for more details.
|
||||||
|
##
|
||||||
|
## You should have received a copy of the GNU General Public License along
|
||||||
|
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
"""
|
||||||
|
Provides a command-line and optional graphical interface to the SONY Reader PRS-500.
|
||||||
|
|
||||||
|
For usage information run the script.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import StringIO, sys, time, os
|
||||||
|
from optparse import OptionParser
|
||||||
|
|
||||||
|
from libprs500 import __version__ as VERSION
|
||||||
|
from libprs500.communicate import PRS500Device
|
||||||
|
from terminfo import TerminalController
|
||||||
|
from libprs500.errors import ArgumentError, DeviceError
|
||||||
|
|
||||||
|
|
||||||
|
MINIMUM_COL_WIDTH = 12 #: Minimum width of columns in ls output
|
||||||
|
|
||||||
|
def human_readable(size):
|
||||||
|
""" Convert a size in bytes into a human readle form """
|
||||||
|
if size < 1024: divisor, suffix = 1, ""
|
||||||
|
elif size < 1024*1024: divisor, suffix = 1024., "K"
|
||||||
|
elif size < 1024*1024*1024: divisor, suffix = 1024*1024, "M"
|
||||||
|
elif size < 1024*1024*1024*1024: divisor, suffix = 1024*1024, "G"
|
||||||
|
size = str(size/divisor)
|
||||||
|
if size.find(".") > -1: size = size[:size.find(".")+2]
|
||||||
|
return size + suffix
|
||||||
|
|
||||||
|
class FileFormatter(object):
|
||||||
|
def __init__(self, file, term):
|
||||||
|
self.term = term
|
||||||
|
self.is_dir = file.is_dir
|
||||||
|
self.is_readonly = file.is_readonly
|
||||||
|
self.size = file.size
|
||||||
|
self.ctime = file.ctime
|
||||||
|
self.wtime = file.wtime
|
||||||
|
self.name = file.name
|
||||||
|
self.path = file.path
|
||||||
|
|
||||||
|
@apply
|
||||||
|
def mode_string():
|
||||||
|
doc=""" The mode string for this file. There are only two modes read-only and read-write """
|
||||||
|
def fget(self):
|
||||||
|
mode, x = "-", "-"
|
||||||
|
if self.is_dir: mode, x = "d", "x"
|
||||||
|
if self.is_readonly: mode += "r-"+x+"r-"+x+"r-"+x
|
||||||
|
else: mode += "rw"+x+"rw"+x+"rw"+x
|
||||||
|
return mode
|
||||||
|
return property(**locals())
|
||||||
|
|
||||||
|
@apply
|
||||||
|
def name_in_color():
|
||||||
|
doc=""" The name in ANSI text. Directories are blue, ebooks are green """
|
||||||
|
def fget(self):
|
||||||
|
cname = self.name
|
||||||
|
blue, green, normal = "", "", ""
|
||||||
|
if self.term: blue, green, normal = self.term.BLUE, self.term.GREEN, self.term.NORMAL
|
||||||
|
if self.is_dir: cname = blue + self.name + normal
|
||||||
|
else:
|
||||||
|
ext = self.name[self.name.rfind("."):]
|
||||||
|
if ext in (".pdf", ".rtf", ".lrf", ".lrx", ".txt"): cname = green + self.name + normal
|
||||||
|
return cname
|
||||||
|
return property(**locals())
|
||||||
|
|
||||||
|
@apply
|
||||||
|
def human_readable_size():
|
||||||
|
doc=""" File size in human readable form """
|
||||||
|
def fget(self):
|
||||||
|
human_readable(self.size)
|
||||||
|
return property(**locals())
|
||||||
|
|
||||||
|
@apply
|
||||||
|
def modification_time():
|
||||||
|
doc=""" Last modified time in the Linux ls -l format """
|
||||||
|
def fget(self):
|
||||||
|
return time.strftime("%Y-%m-%d %H:%M", time.localtime(self.wtime))
|
||||||
|
return property(**locals())
|
||||||
|
|
||||||
|
@apply
|
||||||
|
def creation_time():
|
||||||
|
doc=""" Last modified time in the Linux ls -l format """
|
||||||
|
def fget(self):
|
||||||
|
return time.strftime("%Y-%m-%d %H:%M", time.localtime(self.ctime))
|
||||||
|
return property(**locals())
|
||||||
|
|
||||||
|
def info(dev):
|
||||||
|
info = dev.get_device_information()
|
||||||
|
print "Device name: ", info[0]
|
||||||
|
print "Device version: ", info[1]
|
||||||
|
print "Software version:", info[2]
|
||||||
|
print "Mime type: ", info[3]
|
||||||
|
|
||||||
|
def ls(dev, path, term, recurse=False, color=False, human_readable_size=False, ll=False, cols=0):
|
||||||
|
def col_split(l, cols): # split list l into columns
|
||||||
|
rows = len(l) / cols
|
||||||
|
if len(l) % cols:
|
||||||
|
rows += 1
|
||||||
|
m = []
|
||||||
|
for i in range(rows):
|
||||||
|
m.append(l[i::rows])
|
||||||
|
return m
|
||||||
|
|
||||||
|
def row_widths(table): # Calculate widths for each column in the row-wise table
|
||||||
|
tcols = len(table[0])
|
||||||
|
rowwidths = [ 0 for i in range(tcols) ]
|
||||||
|
for row in table:
|
||||||
|
c = 0
|
||||||
|
for item in row:
|
||||||
|
rowwidths[c] = len(item) if len(item) > rowwidths[c] else rowwidths[c]
|
||||||
|
c += 1
|
||||||
|
return rowwidths
|
||||||
|
|
||||||
|
output = StringIO.StringIO()
|
||||||
|
if path.endswith("/"): path = path[:-1]
|
||||||
|
dirs = dev.list(path, recurse)
|
||||||
|
for dir in dirs:
|
||||||
|
if recurse: print >>output, dir[0] + ":"
|
||||||
|
lsoutput, lscoloutput = [], []
|
||||||
|
files = dir[1]
|
||||||
|
maxlen = 0
|
||||||
|
if ll: # Calculate column width for size column
|
||||||
|
for file in files:
|
||||||
|
size = len(str(file.size))
|
||||||
|
if human_readable_size:
|
||||||
|
file = FileFormatter(file, term)
|
||||||
|
size = len(file.human_readable_size)
|
||||||
|
if size > maxlen: maxlen = size
|
||||||
|
for file in files:
|
||||||
|
file = FileFormatter(file, term)
|
||||||
|
name = file.name
|
||||||
|
lsoutput.append(name)
|
||||||
|
if color: name = file.name_in_color
|
||||||
|
lscoloutput.append(name)
|
||||||
|
if ll:
|
||||||
|
size = str(file.size)
|
||||||
|
if human_readable_size: size = file.human_readable_size
|
||||||
|
print >>output, file.mode_string, ("%"+str(maxlen)+"s")%size, file.modification_time, name
|
||||||
|
if not ll and len(lsoutput) > 0:
|
||||||
|
trytable = []
|
||||||
|
for colwidth in range(MINIMUM_COL_WIDTH, cols):
|
||||||
|
trycols = int(cols/colwidth)
|
||||||
|
trytable = col_split(lsoutput, trycols)
|
||||||
|
works = True
|
||||||
|
for row in trytable:
|
||||||
|
row_break = False
|
||||||
|
for item in row:
|
||||||
|
if len(item) > colwidth - 1:
|
||||||
|
works, row_break = False, True
|
||||||
|
break
|
||||||
|
if row_break: break
|
||||||
|
if works: break
|
||||||
|
rowwidths = row_widths(trytable)
|
||||||
|
trytablecol = col_split(lscoloutput, len(trytable[0]))
|
||||||
|
for r in range(len(trytable)):
|
||||||
|
for c in range(len(trytable[r])):
|
||||||
|
padding = rowwidths[c] - len(trytable[r][c])
|
||||||
|
print >>output, trytablecol[r][c], "".ljust(padding),
|
||||||
|
print >>output
|
||||||
|
print >>output
|
||||||
|
listing = output.getvalue().rstrip()+ "\n"
|
||||||
|
output.close()
|
||||||
|
return listing
|
||||||
|
|
||||||
|
def main():
|
||||||
|
term = TerminalController()
|
||||||
|
cols = term.COLS
|
||||||
|
|
||||||
|
parser = OptionParser(usage="usage: %prog [options] command args\n\ncommand is one of: info, books, df, ls, cp, mkdir, touch, cat, rm\n\n"+
|
||||||
|
"For help on a particular command: %prog command", version="libprs500 version: " + VERSION)
|
||||||
|
parser.add_option("--log-packets", help="print out packet stream to stdout. "+\
|
||||||
|
"The numbers in the left column are byte offsets that allow the packet size to be read off easily.",
|
||||||
|
dest="log_packets", action="store_true", default=False)
|
||||||
|
parser.remove_option("-h")
|
||||||
|
parser.disable_interspersed_args() # Allow unrecognized options
|
||||||
|
options, args = parser.parse_args()
|
||||||
|
|
||||||
|
if len(args) < 1:
|
||||||
|
parser.print_help()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
command = args[0]
|
||||||
|
args = args[1:]
|
||||||
|
dev = PRS500Device(log_packets=options.log_packets)
|
||||||
|
try:
|
||||||
|
if command == "df":
|
||||||
|
total = dev.total_space(end_session=False)
|
||||||
|
free = dev.free_space()
|
||||||
|
where = ("Memory", "Stick", "Card")
|
||||||
|
print "Filesystem\tSize \tUsed \tAvail \tUse%"
|
||||||
|
for i in range(3):
|
||||||
|
print "%-10s\t%s\t%s\t%s\t%s"%(where[i], human_readable(total[i]), human_readable(total[i]-free[i]), human_readable(free[i]),\
|
||||||
|
str(0 if total[i]==0 else int(100*(total[i]-free[i])/(total[i]*1.)))+"%")
|
||||||
|
elif command == "books":
|
||||||
|
print "Books in main memory:"
|
||||||
|
for book in dev.books(): print book
|
||||||
|
print "\nBooks on storage card:"
|
||||||
|
for book in dev.books(oncard=True): print book
|
||||||
|
elif command == "mkdir":
|
||||||
|
parser = OptionParser(usage="usage: %prog mkdir [options] path\nCreate a directory on the device\n\npath must begin with /,a:/ or b:/")
|
||||||
|
if len(args) != 1:
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
dev.mkdir(args[0])
|
||||||
|
elif command == "ls":
|
||||||
|
parser = OptionParser(usage="usage: %prog ls [options] path\nList files on the device\n\npath must begin with /,a:/ or b:/")
|
||||||
|
parser.add_option("--color", help="show ls output in color", dest="color", action="store_true", default=False)
|
||||||
|
parser.add_option("-l", help="In addition to the name of each file, print the file type, permissions, and timestamp (the modification time, in the local timezone). Times are local.", dest="ll", action="store_true", default=False)
|
||||||
|
parser.add_option("-R", help="Recursively list subdirectories encountered. /dev and /proc are omitted", dest="recurse", action="store_true", default=False)
|
||||||
|
parser.remove_option("-h")
|
||||||
|
parser.add_option("-h", "--human-readable", help="show sizes in human readable format", dest="hrs", action="store_true", default=False)
|
||||||
|
options, args = parser.parse_args(args)
|
||||||
|
if len(args) != 1:
|
||||||
|
parser.print_help()
|
||||||
|
return 1
|
||||||
|
print ls(dev, args[0], term, color=options.color, recurse=options.recurse, ll=options.ll, human_readable_size=options.hrs, cols=cols),
|
||||||
|
elif command == "info":
|
||||||
|
info(dev)
|
||||||
|
elif command == "cp":
|
||||||
|
usage="usage: %prog cp [options] source destination\nCopy files to/from the device\n\n"+\
|
||||||
|
"One of source or destination must be a path on the device. \n\nDevice paths have the form\n"+\
|
||||||
|
"prs500:mountpoint/my/path\n"+\
|
||||||
|
"where mountpoint is one of /, a: or b:\n\n"+\
|
||||||
|
"source must point to a file for which you have read permissions\n"+\
|
||||||
|
"destination must point to a file or directory for which you have write permissions"
|
||||||
|
parser = OptionParser(usage=usage)
|
||||||
|
options, args = parser.parse_args(args)
|
||||||
|
if len(args) != 2:
|
||||||
|
parser.print_help()
|
||||||
|
return 1
|
||||||
|
if args[0].startswith("prs500:"):
|
||||||
|
outfile = args[1]
|
||||||
|
path = args[0][7:]
|
||||||
|
if path.endswith("/"): path = path[:-1]
|
||||||
|
if os.path.isdir(outfile):
|
||||||
|
outfile = os.path.join(outfile, path[path.rfind("/")+1:])
|
||||||
|
try:
|
||||||
|
outfile = open(outfile, "w")
|
||||||
|
except IOError, e:
|
||||||
|
print >> sys.stderr, e
|
||||||
|
parser.print_help()
|
||||||
|
return 1
|
||||||
|
dev.get_file(path, outfile)
|
||||||
|
outfile.close()
|
||||||
|
elif args[1].startswith("prs500:"):
|
||||||
|
try:
|
||||||
|
infile = open(args[0], "r")
|
||||||
|
except IOError, e:
|
||||||
|
print >> sys.stderr, e
|
||||||
|
parser.print_help()
|
||||||
|
return 1
|
||||||
|
dev.put_file(infile, args[1][7:])
|
||||||
|
infile.close()
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
return 1
|
||||||
|
elif command == "cat":
|
||||||
|
outfile = sys.stdout
|
||||||
|
parser = OptionParser(usage="usage: %prog cat path\nShow file on the device\n\npath should point to a file on the device and must begin with /,a:/ or b:/")
|
||||||
|
options, args = parser.parse_args(args)
|
||||||
|
if len(args) != 1:
|
||||||
|
parser.print_help()
|
||||||
|
return 1
|
||||||
|
if args[0].endswith("/"): path = args[0][:-1]
|
||||||
|
else: path = args[0]
|
||||||
|
outfile = sys.stdout
|
||||||
|
dev.get_file(path, outfile)
|
||||||
|
elif command == "rm":
|
||||||
|
parser = OptionParser(usage="usage: %prog rm path\nDelete files from the device\n\npath should point to a file or empty directory on the device "+\
|
||||||
|
"and must begin with /,a:/ or b:/\n\n"+\
|
||||||
|
"rm will DELETE the file. Be very CAREFUL")
|
||||||
|
options, args = parser.parse_args(args)
|
||||||
|
if len(args) != 1:
|
||||||
|
parser.print_help()
|
||||||
|
return 1
|
||||||
|
dev.rm(args[0])
|
||||||
|
elif command == "touch":
|
||||||
|
parser = OptionParser(usage="usage: %prog touch path\nCreate an empty file on the device\n\npath should point to a file on the device and must begin with /,a:/ or b:/\n\n"+
|
||||||
|
"Unfortunately, I cant figure out how to update file times on the device, so if path already exists, touch does nothing" )
|
||||||
|
options, args = parser.parse_args(args)
|
||||||
|
if len(args) != 1:
|
||||||
|
parser.print_help()
|
||||||
|
return 1
|
||||||
|
dev.touch(args[0])
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
if dev.handle: dev.close()
|
||||||
|
return 1
|
||||||
|
except (ArgumentError, DeviceError), e:
|
||||||
|
print >>sys.stderr, e
|
||||||
|
return 1
|
||||||
|
return 0
|
208
src/libprs500/cli/terminfo.py
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
## Copyright (C) 2006 Kovid Goyal kovid@kovidgoyal.net
|
||||||
|
## This program is free software; you can redistribute it and/or modify
|
||||||
|
## it under the terms of the GNU General Public License as published by
|
||||||
|
## the Free Software Foundation; either version 2 of the License, or
|
||||||
|
## (at your option) any later version.
|
||||||
|
##
|
||||||
|
## This program is distributed in the hope that it will be useful,
|
||||||
|
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
## GNU General Public License for more details.
|
||||||
|
##
|
||||||
|
## You should have received a copy of the GNU General Public License along
|
||||||
|
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
import sys, re
|
||||||
|
|
||||||
|
""" Get information about the terminal we are running in """
|
||||||
|
|
||||||
|
class TerminalController:
|
||||||
|
"""
|
||||||
|
A class that can be used to portably generate formatted output to
|
||||||
|
a terminal.
|
||||||
|
|
||||||
|
`TerminalController` defines a set of instance variables whose
|
||||||
|
values are initialized to the control sequence necessary to
|
||||||
|
perform a given action. These can be simply included in normal
|
||||||
|
output to the terminal:
|
||||||
|
|
||||||
|
>>> term = TerminalController()
|
||||||
|
>>> print 'This is '+term.GREEN+'green'+term.NORMAL
|
||||||
|
|
||||||
|
Alternatively, the `render()` method can used, which replaces
|
||||||
|
'${action}' with the string required to perform 'action':
|
||||||
|
|
||||||
|
>>> term = TerminalController()
|
||||||
|
>>> print term.render('This is ${GREEN}green${NORMAL}')
|
||||||
|
|
||||||
|
If the terminal doesn't support a given action, then the value of
|
||||||
|
the corresponding instance variable will be set to ''. As a
|
||||||
|
result, the above code will still work on terminals that do not
|
||||||
|
support color, except that their output will not be colored.
|
||||||
|
Also, this means that you can test whether the terminal supports a
|
||||||
|
given action by simply testing the truth value of the
|
||||||
|
corresponding instance variable:
|
||||||
|
|
||||||
|
>>> term = TerminalController()
|
||||||
|
>>> if term.CLEAR_SCREEN:
|
||||||
|
... print 'This terminal supports clearning the screen.'
|
||||||
|
|
||||||
|
Finally, if the width and height of the terminal are known, then
|
||||||
|
they will be stored in the `COLS` and `LINES` attributes.
|
||||||
|
"""
|
||||||
|
# Cursor movement:
|
||||||
|
BOL = '' #: Move the cursor to the beginning of the line
|
||||||
|
UP = '' #: Move the cursor up one line
|
||||||
|
DOWN = '' #: Move the cursor down one line
|
||||||
|
LEFT = '' #: Move the cursor left one char
|
||||||
|
RIGHT = '' #: Move the cursor right one char
|
||||||
|
|
||||||
|
# Deletion:
|
||||||
|
CLEAR_SCREEN = '' #: Clear the screen and move to home position
|
||||||
|
CLEAR_EOL = '' #: Clear to the end of the line.
|
||||||
|
CLEAR_BOL = '' #: Clear to the beginning of the line.
|
||||||
|
CLEAR_EOS = '' #: Clear to the end of the screen
|
||||||
|
|
||||||
|
# Output modes:
|
||||||
|
BOLD = '' #: Turn on bold mode
|
||||||
|
BLINK = '' #: Turn on blink mode
|
||||||
|
DIM = '' #: Turn on half-bright mode
|
||||||
|
REVERSE = '' #: Turn on reverse-video mode
|
||||||
|
NORMAL = '' #: Turn off all modes
|
||||||
|
|
||||||
|
# Cursor display:
|
||||||
|
HIDE_CURSOR = '' #: Make the cursor invisible
|
||||||
|
SHOW_CURSOR = '' #: Make the cursor visible
|
||||||
|
|
||||||
|
# Terminal size:
|
||||||
|
COLS = None #: Width of the terminal (None for unknown)
|
||||||
|
LINES = None #: Height of the terminal (None for unknown)
|
||||||
|
|
||||||
|
# Foreground colors:
|
||||||
|
BLACK = BLUE = GREEN = CYAN = RED = MAGENTA = YELLOW = WHITE = ''
|
||||||
|
|
||||||
|
# Background colors:
|
||||||
|
BG_BLACK = BG_BLUE = BG_GREEN = BG_CYAN = ''
|
||||||
|
BG_RED = BG_MAGENTA = BG_YELLOW = BG_WHITE = ''
|
||||||
|
|
||||||
|
_STRING_CAPABILITIES = """
|
||||||
|
BOL=cr UP=cuu1 DOWN=cud1 LEFT=cub1 RIGHT=cuf1
|
||||||
|
CLEAR_SCREEN=clear CLEAR_EOL=el CLEAR_BOL=el1 CLEAR_EOS=ed BOLD=bold
|
||||||
|
BLINK=blink DIM=dim REVERSE=rev UNDERLINE=smul NORMAL=sgr0
|
||||||
|
HIDE_CURSOR=cinvis SHOW_CURSOR=cnorm""".split()
|
||||||
|
_COLORS = """BLACK BLUE GREEN CYAN RED MAGENTA YELLOW WHITE""".split()
|
||||||
|
_ANSICOLORS = "BLACK RED GREEN YELLOW BLUE MAGENTA CYAN WHITE".split()
|
||||||
|
|
||||||
|
def __init__(self, term_stream=sys.stdout):
|
||||||
|
"""
|
||||||
|
Create a `TerminalController` and initialize its attributes
|
||||||
|
with appropriate values for the current terminal.
|
||||||
|
`term_stream` is the stream that will be used for terminal
|
||||||
|
output; if this stream is not a tty, then the terminal is
|
||||||
|
assumed to be a dumb terminal (i.e., have no capabilities).
|
||||||
|
"""
|
||||||
|
# Curses isn't available on all platforms
|
||||||
|
try: import curses
|
||||||
|
except: return
|
||||||
|
|
||||||
|
# If the stream isn't a tty, then assume it has no capabilities.
|
||||||
|
if not term_stream.isatty(): return
|
||||||
|
|
||||||
|
# Check the terminal type. If we fail, then assume that the
|
||||||
|
# terminal has no capabilities.
|
||||||
|
try: curses.setupterm()
|
||||||
|
except: return
|
||||||
|
|
||||||
|
# Look up numeric capabilities.
|
||||||
|
self.COLS = curses.tigetnum('cols')
|
||||||
|
self.LINES = curses.tigetnum('lines')
|
||||||
|
|
||||||
|
# Look up string capabilities.
|
||||||
|
for capability in self._STRING_CAPABILITIES:
|
||||||
|
(attrib, cap_name) = capability.split('=')
|
||||||
|
setattr(self, attrib, self._tigetstr(cap_name) or '')
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
set_fg = self._tigetstr('setf')
|
||||||
|
if set_fg:
|
||||||
|
for i,color in zip(range(len(self._COLORS)), self._COLORS):
|
||||||
|
setattr(self, color, curses.tparm(set_fg, i) or '')
|
||||||
|
set_fg_ansi = self._tigetstr('setaf')
|
||||||
|
if set_fg_ansi:
|
||||||
|
for i,color in zip(range(len(self._ANSICOLORS)), self._ANSICOLORS):
|
||||||
|
setattr(self, color, curses.tparm(set_fg_ansi, i) or '')
|
||||||
|
set_bg = self._tigetstr('setb')
|
||||||
|
if set_bg:
|
||||||
|
for i,color in zip(range(len(self._COLORS)), self._COLORS):
|
||||||
|
setattr(self, 'BG_'+color, curses.tparm(set_bg, i) or '')
|
||||||
|
set_bg_ansi = self._tigetstr('setab')
|
||||||
|
if set_bg_ansi:
|
||||||
|
for i,color in zip(range(len(self._ANSICOLORS)), self._ANSICOLORS):
|
||||||
|
setattr(self, 'BG_'+color, curses.tparm(set_bg_ansi, i) or '')
|
||||||
|
|
||||||
|
def _tigetstr(self, cap_name):
|
||||||
|
# String capabilities can include "delays" of the form "$<2>".
|
||||||
|
# For any modern terminal, we should be able to just ignore
|
||||||
|
# these, so strip them out.
|
||||||
|
import curses
|
||||||
|
cap = curses.tigetstr(cap_name) or ''
|
||||||
|
return re.sub(r'\$<\d+>[/*]?', '', cap)
|
||||||
|
|
||||||
|
def render(self, template):
|
||||||
|
"""
|
||||||
|
Replace each $-substitutions in the given template string with
|
||||||
|
the corresponding terminal control string (if it's defined) or
|
||||||
|
'' (if it's not).
|
||||||
|
"""
|
||||||
|
return re.sub(r'\$\$|\${\w+}', self._render_sub, template)
|
||||||
|
|
||||||
|
def _render_sub(self, match):
|
||||||
|
s = match.group()
|
||||||
|
if s == '$$': return s
|
||||||
|
else: return getattr(self, s[2:-1])
|
||||||
|
|
||||||
|
#######################################################################
|
||||||
|
# Example use case: progress bar
|
||||||
|
#######################################################################
|
||||||
|
|
||||||
|
class ProgressBar:
|
||||||
|
"""
|
||||||
|
A 3-line progress bar, which looks like::
|
||||||
|
|
||||||
|
Header
|
||||||
|
20% [===========----------------------------------]
|
||||||
|
progress message
|
||||||
|
|
||||||
|
The progress bar is colored, if the terminal supports color
|
||||||
|
output; and adjusts to the width of the terminal.
|
||||||
|
"""
|
||||||
|
BAR = '%3d%% ${GREEN}[${BOLD}%s%s${NORMAL}${GREEN}]${NORMAL}\n'
|
||||||
|
HEADER = '${BOLD}${CYAN}%s${NORMAL}\n\n'
|
||||||
|
|
||||||
|
def __init__(self, term, header):
|
||||||
|
self.term = term
|
||||||
|
if not (self.term.CLEAR_EOL and self.term.UP and self.term.BOL):
|
||||||
|
raise ValueError("Terminal isn't capable enough -- you "
|
||||||
|
"should use a simpler progress dispaly.")
|
||||||
|
self.width = self.term.COLS or 75
|
||||||
|
self.bar = term.render(self.BAR)
|
||||||
|
self.header = self.term.render(self.HEADER % header.center(self.width))
|
||||||
|
self.cleared = 1 #: true if we haven't drawn the bar yet.
|
||||||
|
self.update(0, '')
|
||||||
|
|
||||||
|
def update(self, percent, message):
|
||||||
|
if self.cleared:
|
||||||
|
sys.stdout.write(self.header)
|
||||||
|
self.cleared = 0
|
||||||
|
n = int((self.width-10)*percent)
|
||||||
|
sys.stdout.write(
|
||||||
|
self.term.BOL + self.term.UP + self.term.CLEAR_EOL +
|
||||||
|
(self.bar % (100*percent, '='*n, '-'*(self.width-10-n))) +
|
||||||
|
self.term.CLEAR_EOL + message.center(self.width))
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
if not self.cleared:
|
||||||
|
sys.stdout.write(self.term.BOL + self.term.CLEAR_EOL +
|
||||||
|
self.term.UP + self.term.CLEAR_EOL +
|
||||||
|
self.term.UP + self.term.CLEAR_EOL)
|
||||||
|
self.cleared = 1
|
847
src/libprs500/communicate.py
Executable file
@ -0,0 +1,847 @@
|
|||||||
|
## Copyright (C) 2006 Kovid Goyal kovid@kovidgoyal.net
|
||||||
|
## This program is free software; you can redistribute it and/or modify
|
||||||
|
## it under the terms of the GNU General Public License as published by
|
||||||
|
## the Free Software Foundation; either version 2 of the License, or
|
||||||
|
## (at your option) any later version.
|
||||||
|
##
|
||||||
|
## This program is distributed in the hope that it will be useful,
|
||||||
|
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
## GNU General Public License for more details.
|
||||||
|
##
|
||||||
|
## You should have received a copy of the GNU General Public License along
|
||||||
|
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
|
||||||
|
### End point description for PRS-500 procductId=667
|
||||||
|
### Endpoint Descriptor:
|
||||||
|
### bLength 7
|
||||||
|
### bDescriptorType 5
|
||||||
|
### bEndpointAddress 0x81 EP 1 IN
|
||||||
|
### bmAttributes 2
|
||||||
|
### Transfer Type Bulk
|
||||||
|
### Synch Type None
|
||||||
|
### Usage Type Data
|
||||||
|
### wMaxPacketSize 0x0040 1x 64 bytes
|
||||||
|
### bInterval 0
|
||||||
|
### Endpoint Descriptor:
|
||||||
|
### bLength 7
|
||||||
|
### bDescriptorType 5
|
||||||
|
### bEndpointAddress 0x02 EP 2 OUT
|
||||||
|
### bmAttributes 2
|
||||||
|
### Transfer Type Bulk
|
||||||
|
### Synch Type None
|
||||||
|
### Usage Type Data
|
||||||
|
### wMaxPacketSize 0x0040 1x 64 bytes
|
||||||
|
### bInterval 0
|
||||||
|
###
|
||||||
|
###
|
||||||
|
### Endpoint 0x81 is device->host and endpoint 0x02 is host->device.
|
||||||
|
### You can establish Stream pipes to/from these endpoints for Bulk transfers.
|
||||||
|
### Has two configurations 1 is the USB charging config 2 is the self-powered
|
||||||
|
### config. I think config management is automatic. Endpoints are the same
|
||||||
|
"""
|
||||||
|
Contains the logic for communication with the device (a SONY PRS-500).
|
||||||
|
|
||||||
|
The public interface of class L{PRS500Device} defines the
|
||||||
|
methods for performing various tasks.
|
||||||
|
"""
|
||||||
|
import usb
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from tempfile import TemporaryFile
|
||||||
|
from array import array
|
||||||
|
|
||||||
|
from libprs500.prstypes import *
|
||||||
|
from libprs500.errors import *
|
||||||
|
from libprs500.books import BookList, fix_ids
|
||||||
|
from libprs500 import __author__ as AUTHOR
|
||||||
|
|
||||||
|
MINIMUM_COL_WIDTH = 12 #: Minimum width of columns in ls output
|
||||||
|
|
||||||
|
# Protocol versions libprs500 has been tested with
|
||||||
|
KNOWN_USB_PROTOCOL_VERSIONS = [0x3030303030303130L]
|
||||||
|
|
||||||
|
class Device(object):
|
||||||
|
""" Contains specific device independent methods """
|
||||||
|
_packet_number = 0 #: Keep track of the packet number for packet tracing
|
||||||
|
|
||||||
|
def log_packet(self, packet, header, stream=sys.stderr):
|
||||||
|
"""
|
||||||
|
Log C{packet} to stream C{stream}.
|
||||||
|
Header should be a small word describing the type of packet.
|
||||||
|
"""
|
||||||
|
self._packet_number += 1
|
||||||
|
print >> stream, str(self._packet_number), header, "Type:", \
|
||||||
|
packet.__class__.__name__
|
||||||
|
print >> stream, packet
|
||||||
|
print >> stream, "--"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def validate_response(cls, res, _type=0x00, number=0x00):
|
||||||
|
"""
|
||||||
|
Raise a ProtocolError if the type and number of C{res}
|
||||||
|
is not the same as C{type} and C{number}.
|
||||||
|
"""
|
||||||
|
if _type != res.type or number != res.rnumber:
|
||||||
|
raise ProtocolError("Inavlid response.\ntype: expected=" + \
|
||||||
|
hex(_type)+" actual=" + hex(res.type) + \
|
||||||
|
"\nrnumber: expected=" + hex(number) + \
|
||||||
|
" actual="+hex(res.rnumber))
|
||||||
|
|
||||||
|
|
||||||
|
class File(object):
|
||||||
|
"""
|
||||||
|
Wrapper that allows easy access to all information about files/directories
|
||||||
|
"""
|
||||||
|
def __init__(self, _file):
|
||||||
|
self.is_dir = _file[1].is_dir #: True if self is a directory
|
||||||
|
self.is_readonly = _file[1].is_readonly #: True if self is readonly
|
||||||
|
self.size = _file[1].file_size #: Size in bytes of self
|
||||||
|
self.ctime = _file[1].ctime #: Creation time of self as a epoch
|
||||||
|
self.wtime = _file[1].wtime #: Creation time of self as an epoch
|
||||||
|
path = _file[0]
|
||||||
|
if path.endswith("/"):
|
||||||
|
path = path[:-1]
|
||||||
|
self.path = path #: Path to self
|
||||||
|
self.name = path[path.rfind("/")+1:].rstrip() #: Name of self
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
""" Return path to self """
|
||||||
|
return "File:" + self.path
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceDescriptor:
|
||||||
|
"""
|
||||||
|
Describes a USB device.
|
||||||
|
|
||||||
|
A description is composed of the Vendor Id, Product Id and Interface Id.
|
||||||
|
See the U{USB spec<http://www.usb.org/developers/docs/usb_20_05122006.zip>}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, vendor_id, product_id, interface_id) :
|
||||||
|
self.vendor_id = vendor_id
|
||||||
|
self.product_id = product_id
|
||||||
|
self.interface_id = interface_id
|
||||||
|
|
||||||
|
def get_device(self) :
|
||||||
|
"""
|
||||||
|
Return the device corresponding to the device descriptor if it is
|
||||||
|
available on a USB bus. Otherwise, return None. Note that the
|
||||||
|
returned device has yet to be claimed or opened.
|
||||||
|
"""
|
||||||
|
buses = usb.busses()
|
||||||
|
for bus in buses :
|
||||||
|
for device in bus.devices :
|
||||||
|
if device.idVendor == self.vendor_id :
|
||||||
|
if device.idProduct == self.product_id :
|
||||||
|
return device
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class PRS500Device(Device):
|
||||||
|
|
||||||
|
"""
|
||||||
|
Contains the logic for performing various tasks on the reader.
|
||||||
|
|
||||||
|
The implemented tasks are:
|
||||||
|
0. Getting information about the device
|
||||||
|
1. Getting a file from the device
|
||||||
|
2. Listing of directories. See the C{list} method.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SONY_VENDOR_ID = 0x054c #: SONY Vendor Id
|
||||||
|
PRS500_PRODUCT_ID = 0x029b #: Product Id for the PRS-500
|
||||||
|
PRS500_INTERFACE_ID = 0 #: The interface we use to talk to the device
|
||||||
|
PRS500_BULK_IN_EP = 0x81 #: Endpoint for Bulk reads
|
||||||
|
PRS500_BULK_OUT_EP = 0x02 #: Endpoint for Bulk writes
|
||||||
|
# Location of media.xml file on device
|
||||||
|
MEDIA_XML = "/Data/database/cache/media.xml"
|
||||||
|
# Location of cache.xml on storage card in device
|
||||||
|
CACHE_XML = "/Sony Reader/database/cache.xml"
|
||||||
|
# Ordered list of supported formats
|
||||||
|
FORMATS = ["lrf", "rtf", "pdf", "txt"]
|
||||||
|
# Height for thumbnails of books/images on the device
|
||||||
|
THUMBNAIL_HEIGHT = 68
|
||||||
|
|
||||||
|
device_descriptor = DeviceDescriptor(SONY_VENDOR_ID, \
|
||||||
|
PRS500_PRODUCT_ID, PRS500_INTERFACE_ID)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def signature(cls):
|
||||||
|
""" Return a two element tuple (vendor id, product id) """
|
||||||
|
return (cls.SONY_VENDOR_ID, cls.PRS500_PRODUCT_ID )
|
||||||
|
|
||||||
|
def safe(func):
|
||||||
|
"""
|
||||||
|
Decorator that wraps a call to C{func} to ensure that
|
||||||
|
exceptions are handled correctly. It also calls L{open} to claim
|
||||||
|
the interface and initialize the Reader if needed.
|
||||||
|
|
||||||
|
As a convenience, C{safe} automatically sends the a
|
||||||
|
L{EndSession} after calling func, unless func has
|
||||||
|
a keyword argument named C{end_session} set to C{False}.
|
||||||
|
|
||||||
|
An L{ArgumentError} will cause the L{EndSession} command to
|
||||||
|
be sent to the device, unless end_session is set to C{False}.
|
||||||
|
An L{usb.USBError} will cause the library to release control of the
|
||||||
|
USB interface via a call to L{close}.
|
||||||
|
"""
|
||||||
|
def run_session(*args, **kwargs):
|
||||||
|
dev = args[0]
|
||||||
|
res = None
|
||||||
|
try:
|
||||||
|
if not dev.handle:
|
||||||
|
dev.open()
|
||||||
|
res = func(*args, **kwargs)
|
||||||
|
except ArgumentError:
|
||||||
|
if not kwargs.has_key("end_session") or kwargs["end_session"]:
|
||||||
|
dev.send_validated_command(EndSession())
|
||||||
|
raise
|
||||||
|
except usb.USBError, err:
|
||||||
|
if "No such device" in str(err):
|
||||||
|
raise DeviceError()
|
||||||
|
elif "Connection timed out" in str(err):
|
||||||
|
dev.close()
|
||||||
|
raise TimeoutError(func.__name__)
|
||||||
|
elif "Protocol error" in str(err):
|
||||||
|
dev.close()
|
||||||
|
raise ProtocolError("There was an unknown error in the"+\
|
||||||
|
" protocol. Contact " + AUTHOR)
|
||||||
|
dev.close()
|
||||||
|
raise
|
||||||
|
if not kwargs.has_key("end_session") or kwargs["end_session"]:
|
||||||
|
dev.send_validated_command(EndSession())
|
||||||
|
return res
|
||||||
|
|
||||||
|
return run_session
|
||||||
|
|
||||||
|
def __init__(self, log_packets=False, report_progress=None) :
|
||||||
|
"""
|
||||||
|
@param log_packets: If true the packet stream to/from the device is logged
|
||||||
|
@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
|
||||||
|
"""
|
||||||
|
Device.__init__(self)
|
||||||
|
# The actual device (PyUSB object)
|
||||||
|
self.device = self.device_descriptor.get_device()
|
||||||
|
# Handle that is used to communicate with device. Setup in L{open}
|
||||||
|
self.handle = None
|
||||||
|
self.log_packets = log_packets
|
||||||
|
self.report_progress = report_progress
|
||||||
|
|
||||||
|
def reconnect(self):
|
||||||
|
""" Only recreates the device node and deleted the connection handle """
|
||||||
|
self.device = self.device_descriptor.get_device()
|
||||||
|
self.handle = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_connected(cls):
|
||||||
|
"""
|
||||||
|
This method checks to see whether the device is physically connected.
|
||||||
|
It does not return any information about the validity of the
|
||||||
|
software connection. You may need to call L{reconnect} if you keep
|
||||||
|
getting L{DeviceError}.
|
||||||
|
"""
|
||||||
|
return cls.device_descriptor.get_device() != None
|
||||||
|
|
||||||
|
|
||||||
|
def open(self) :
|
||||||
|
"""
|
||||||
|
Claim an interface on the device for communication.
|
||||||
|
Requires write privileges to the device file.
|
||||||
|
Also initialize the device.
|
||||||
|
See the source code for the sequenceof initialization commands.
|
||||||
|
|
||||||
|
@todo: Implement unlocking of the device
|
||||||
|
"""
|
||||||
|
self.device = self.device_descriptor.get_device()
|
||||||
|
if not self.device:
|
||||||
|
raise DeviceError()
|
||||||
|
try:
|
||||||
|
self.handle = self.device.open()
|
||||||
|
self.handle.claimInterface(self.device_descriptor.interface_id)
|
||||||
|
except usb.USBError, err:
|
||||||
|
print >> sys.stderr, err
|
||||||
|
raise DeviceBusy()
|
||||||
|
# Large timeout as device may still be initializing
|
||||||
|
res = self.send_validated_command(GetUSBProtocolVersion(), timeout=20000)
|
||||||
|
if res.code != 0:
|
||||||
|
raise ProtocolError("Unable to get USB Protocol version.")
|
||||||
|
version = self._bulk_read(24, data_type=USBProtocolVersion)[0].version
|
||||||
|
if version not in KNOWN_USB_PROTOCOL_VERSIONS:
|
||||||
|
print >> sys.stderr, "WARNING: Usb protocol version " + \
|
||||||
|
hex(version) + " is unknown"
|
||||||
|
res = self.send_validated_command(SetBulkSize(size=0x028000))
|
||||||
|
if res.code != 0:
|
||||||
|
raise ProtocolError("Unable to set bulk size.")
|
||||||
|
self.send_validated_command(UnlockDevice(key=0x312d))
|
||||||
|
if res.code != 0:
|
||||||
|
raise ProtocolError("Unlocking of device not implemented. Remove locking and retry.")
|
||||||
|
res = self.send_validated_command(SetTime())
|
||||||
|
if res.code != 0:
|
||||||
|
raise ProtocolError("Could not set time on device")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
""" Release device interface """
|
||||||
|
try:
|
||||||
|
self.handle.reset()
|
||||||
|
self.handle.releaseInterface()
|
||||||
|
except Exception, err:
|
||||||
|
print >> sys.stderr, err
|
||||||
|
self.handle, self.device = None, None
|
||||||
|
|
||||||
|
def _send_command(self, command, response_type=Response, timeout=1000):
|
||||||
|
"""
|
||||||
|
Send L{command<Command>} to device and return its L{response<Response>}.
|
||||||
|
|
||||||
|
@param command: an object of type Command or one of its derived classes
|
||||||
|
@param response_type: an object of type 'type'. The return packet
|
||||||
|
from the device is returned as an object of type response_type.
|
||||||
|
@param timeout: The time to wait for a response from the
|
||||||
|
device, in milliseconds. If there is no response, a L{usb.USBError} is raised.
|
||||||
|
"""
|
||||||
|
if self.log_packets:
|
||||||
|
self.log_packet(command, "Command")
|
||||||
|
bytes_sent = self.handle.controlMsg(0x40, 0x80, command)
|
||||||
|
if bytes_sent != len(command):
|
||||||
|
raise ControlError(desc="Could not send control request to device\n"\
|
||||||
|
+ str(command))
|
||||||
|
response = response_type(self.handle.controlMsg(0xc0, 0x81, \
|
||||||
|
Response.SIZE, timeout=timeout))
|
||||||
|
if self.log_packets:
|
||||||
|
self.log_packet(response, "Response")
|
||||||
|
return response
|
||||||
|
|
||||||
|
def send_validated_command(self, command, cnumber=None, \
|
||||||
|
response_type=Response, timeout=1000):
|
||||||
|
"""
|
||||||
|
Wrapper around L{_send_command} that checks if the
|
||||||
|
C{Response.rnumber == cnumber or
|
||||||
|
command.number if cnumber==None}. Also check that
|
||||||
|
C{Response.type == Command.type}.
|
||||||
|
"""
|
||||||
|
if cnumber == None:
|
||||||
|
cnumber = command.number
|
||||||
|
res = self._send_command(command, response_type=response_type, \
|
||||||
|
timeout=timeout)
|
||||||
|
self.validate_response(res, _type=command.type, number=cnumber)
|
||||||
|
return res
|
||||||
|
|
||||||
|
def _bulk_write(self, data, packet_size=0x1000):
|
||||||
|
"""
|
||||||
|
Send data to device via a bulk transfer.
|
||||||
|
@type data: Any listable type supporting __getslice__
|
||||||
|
@param packet_size: Size of packets to be sent to device.
|
||||||
|
C{data} is broken up into packets to be sent to device.
|
||||||
|
"""
|
||||||
|
def bulk_write_packet(packet):
|
||||||
|
self.handle.bulkWrite(PRS500Device.PRS500_BULK_OUT_EP, packet)
|
||||||
|
if self.log_packets:
|
||||||
|
self.log_packet(Answer(packet), "Answer h->d")
|
||||||
|
|
||||||
|
bytes_left = len(data)
|
||||||
|
if bytes_left + 16 <= packet_size:
|
||||||
|
packet_size = bytes_left +16
|
||||||
|
first_packet = Answer(bytes_left+16)
|
||||||
|
first_packet[16:] = data
|
||||||
|
first_packet.length = len(data)
|
||||||
|
else:
|
||||||
|
first_packet = Answer(packet_size)
|
||||||
|
first_packet[16:] = data[0:packet_size-16]
|
||||||
|
first_packet.length = packet_size-16
|
||||||
|
first_packet.number = 0x10005
|
||||||
|
bulk_write_packet(first_packet)
|
||||||
|
pos = first_packet.length
|
||||||
|
bytes_left -= first_packet.length
|
||||||
|
while bytes_left > 0:
|
||||||
|
endpos = pos + packet_size if pos + packet_size <= len(data) \
|
||||||
|
else len(data)
|
||||||
|
bulk_write_packet(data[pos:endpos])
|
||||||
|
bytes_left -= endpos - pos
|
||||||
|
pos = endpos
|
||||||
|
res = Response(self.handle.controlMsg(0xc0, 0x81, Response.SIZE, \
|
||||||
|
timeout=5000))
|
||||||
|
if self.log_packets:
|
||||||
|
self.log_packet(res, "Response")
|
||||||
|
if res.rnumber != 0x10005 or res.code != 0:
|
||||||
|
raise ProtocolError("Sending via Bulk Transfer failed with response:\n"\
|
||||||
|
+str(res))
|
||||||
|
if res.data_size != len(data):
|
||||||
|
raise ProtocolError("Unable to transfer all data to device. "+\
|
||||||
|
"Response packet:\n"\
|
||||||
|
+str(res))
|
||||||
|
|
||||||
|
|
||||||
|
def _bulk_read(self, bytes, command_number=0x00, packet_size=4096, \
|
||||||
|
data_type=Answer):
|
||||||
|
"""
|
||||||
|
Read in C{bytes} bytes via a bulk transfer in
|
||||||
|
packets of size S{<=} C{packet_size}
|
||||||
|
@param data_type: an object of type type.
|
||||||
|
The data packet is returned as an object of type C{data_type}.
|
||||||
|
@return: A list of packets read from the device.
|
||||||
|
Each packet is of type data_type
|
||||||
|
"""
|
||||||
|
def bulk_read_packet(data_type=Answer, size=0x1000):
|
||||||
|
data = data_type(self.handle.bulkRead(PRS500Device.PRS500_BULK_IN_EP, \
|
||||||
|
size))
|
||||||
|
if self.log_packets:
|
||||||
|
self.log_packet(data, "Answer d->h")
|
||||||
|
return data
|
||||||
|
|
||||||
|
bytes_left = bytes
|
||||||
|
packets = []
|
||||||
|
while bytes_left > 0:
|
||||||
|
if packet_size > bytes_left:
|
||||||
|
packet_size = bytes_left
|
||||||
|
packet = bulk_read_packet(data_type=data_type, size=packet_size)
|
||||||
|
bytes_left -= len(packet)
|
||||||
|
packets.append(packet)
|
||||||
|
self.send_validated_command(\
|
||||||
|
AcknowledgeBulkRead(packets[0].number), \
|
||||||
|
cnumber=command_number)
|
||||||
|
return packets
|
||||||
|
|
||||||
|
@safe
|
||||||
|
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)
|
||||||
|
"""
|
||||||
|
size = self.send_validated_command(DeviceInfoQuery()).data[2] + 16
|
||||||
|
data = self._bulk_read(size, command_number=\
|
||||||
|
DeviceInfoQuery.NUMBER, data_type=DeviceInfo)[0]
|
||||||
|
return (data.device_name, data.device_version, \
|
||||||
|
data.software_version, data.mime_type)
|
||||||
|
|
||||||
|
@safe
|
||||||
|
def path_properties(self, path, end_session=True):
|
||||||
|
"""
|
||||||
|
Send command asking device for properties of C{path}.
|
||||||
|
Return L{FileProperties}.
|
||||||
|
"""
|
||||||
|
res = self.send_validated_command(PathQuery(path), \
|
||||||
|
response_type=ListResponse)
|
||||||
|
data = self._bulk_read(0x28, data_type=FileProperties, \
|
||||||
|
command_number=PathQuery.NUMBER)[0]
|
||||||
|
if path.endswith("/"):
|
||||||
|
path = path[:-1]
|
||||||
|
if res.path_not_found :
|
||||||
|
raise PathError(path + " does not exist on device")
|
||||||
|
if res.is_invalid:
|
||||||
|
raise PathError(path + " is not a valid path")
|
||||||
|
if res.is_unmounted:
|
||||||
|
raise PathError(path + " is not mounted")
|
||||||
|
if res.code not in (0, PathResponseCodes.IS_FILE):
|
||||||
|
raise PathError(path + " has an unknown error. Code: " + \
|
||||||
|
hex(res.code))
|
||||||
|
return data
|
||||||
|
|
||||||
|
@safe
|
||||||
|
def get_file(self, path, outfile, end_session=True):
|
||||||
|
"""
|
||||||
|
Read the file at path on the device and write it to outfile.
|
||||||
|
|
||||||
|
The data is fetched in chunks of size S{<=} 32K. Each chunk is
|
||||||
|
made of packets of size S{<=} 4K. See L{FileOpen},
|
||||||
|
L{FileRead} and L{FileClose} for details on the command packets used.
|
||||||
|
|
||||||
|
@param outfile: file object like C{sys.stdout} or the result of an C{open} call
|
||||||
|
"""
|
||||||
|
if path.endswith("/"):
|
||||||
|
path = path[:-1] # We only copy files
|
||||||
|
_file = self.path_properties(path, end_session=False)
|
||||||
|
if _file.is_dir:
|
||||||
|
raise PathError("Cannot read as " + path + " is a directory")
|
||||||
|
bytes = _file.file_size
|
||||||
|
res = self.send_validated_command(FileOpen(path))
|
||||||
|
if res.code != 0:
|
||||||
|
raise PathError("Unable to open " + path + \
|
||||||
|
" for reading. Response code: " + hex(res.code))
|
||||||
|
_id = self._bulk_read(20, data_type=IdAnswer, \
|
||||||
|
command_number=FileOpen.NUMBER)[0].id
|
||||||
|
bytes_left, chunk_size, pos = bytes, 0x8000, 0
|
||||||
|
while bytes_left > 0:
|
||||||
|
if chunk_size > bytes_left:
|
||||||
|
chunk_size = bytes_left
|
||||||
|
res = self.send_validated_command(FileIO(_id, pos, chunk_size))
|
||||||
|
if res.code != 0:
|
||||||
|
self.send_validated_command(FileClose(id))
|
||||||
|
raise ProtocolError("Error while reading from " + path + \
|
||||||
|
". Response code: " + hex(res.code))
|
||||||
|
packets = self._bulk_read(chunk_size+16, \
|
||||||
|
command_number=FileIO.RNUMBER, packet_size=4096)
|
||||||
|
try:
|
||||||
|
# The first 16 bytes are meta information on the packet stream
|
||||||
|
array('B', packets[0][16:]).tofile(outfile)
|
||||||
|
for i in range(1, len(packets)):
|
||||||
|
array('B', packets[i]).tofile(outfile)
|
||||||
|
except IOError, err:
|
||||||
|
self.send_validated_command(FileClose(_id))
|
||||||
|
raise ArgumentError("File get operation failed. " + \
|
||||||
|
"Could not write to local location: " + str(err))
|
||||||
|
bytes_left -= chunk_size
|
||||||
|
pos += chunk_size
|
||||||
|
if self.report_progress:
|
||||||
|
self.report_progress(int(100*((1.*pos)/bytes)))
|
||||||
|
self.send_validated_command(FileClose(_id))
|
||||||
|
# Not going to check response code to see if close was successful
|
||||||
|
# as there's not much we can do if it wasnt
|
||||||
|
|
||||||
|
@safe
|
||||||
|
def list(self, path, recurse=False, end_session=True):
|
||||||
|
"""
|
||||||
|
Return a listing of path. See the code for details. See L{DirOpen},
|
||||||
|
L{DirRead} and L{DirClose} for details on the command packets used.
|
||||||
|
|
||||||
|
@type path: string
|
||||||
|
@param path: The path to list
|
||||||
|
@type recurse: boolean
|
||||||
|
@param recurse: If true do a recursive listing
|
||||||
|
@return: A list of tuples. The first element of each tuple is a path.
|
||||||
|
The second element is a list of L{Files<File>}.
|
||||||
|
The path is the path we are listing, the C{Files} are the
|
||||||
|
files/directories in that path. If it is a recursive list, then the first
|
||||||
|
element will be (C{path}, children), the next will be
|
||||||
|
(child, its children) and so on. If it is not recursive the length of the
|
||||||
|
outermost list will be 1.
|
||||||
|
"""
|
||||||
|
def _list(path):
|
||||||
|
""" Do a non recursive listsing of path """
|
||||||
|
if not path.endswith("/"):
|
||||||
|
path += "/" # Initially assume path is a directory
|
||||||
|
files = []
|
||||||
|
candidate = self.path_properties(path, end_session=False)
|
||||||
|
if not candidate.is_dir:
|
||||||
|
path = path[:-1]
|
||||||
|
data = self.path_properties(path, end_session=False)
|
||||||
|
files = [ File((path, data)) ]
|
||||||
|
else:
|
||||||
|
# Get query ID used to ask for next element in list
|
||||||
|
res = self.send_validated_command(DirOpen(path))
|
||||||
|
if res.code != 0:
|
||||||
|
raise PathError("Unable to open directory " + path + \
|
||||||
|
" for reading. Response code: " + hex(res.code))
|
||||||
|
_id = self._bulk_read(0x14, data_type=IdAnswer, \
|
||||||
|
command_number=DirOpen.NUMBER)[0].id
|
||||||
|
# Create command asking for next element in list
|
||||||
|
next = DirRead(_id)
|
||||||
|
items = []
|
||||||
|
while True:
|
||||||
|
res = self.send_validated_command(next, response_type=ListResponse)
|
||||||
|
size = res.data_size + 16
|
||||||
|
data = self._bulk_read(size, data_type=ListAnswer, \
|
||||||
|
command_number=DirRead.NUMBER)[0]
|
||||||
|
# path_not_found seems to happen if the usb server
|
||||||
|
# doesn't have the permissions to access the directory
|
||||||
|
if res.is_eol or res.path_not_found:
|
||||||
|
break
|
||||||
|
elif res.code != 0:
|
||||||
|
raise ProtocolError("Unknown error occured while "+\
|
||||||
|
"reading contents of directory " + path + \
|
||||||
|
". Response code: " + hex(res.code))
|
||||||
|
items.append(data.name)
|
||||||
|
self.send_validated_command(DirClose(_id))
|
||||||
|
# Ignore res.code as we cant do anything if close fails
|
||||||
|
for item in items:
|
||||||
|
ipath = path + item
|
||||||
|
data = self.path_properties(ipath, end_session=False)
|
||||||
|
files.append( File( (ipath, data) ) )
|
||||||
|
files.sort()
|
||||||
|
return files
|
||||||
|
|
||||||
|
files = _list(path)
|
||||||
|
dirs = [(path, files)]
|
||||||
|
|
||||||
|
for _file in files:
|
||||||
|
if recurse and _file.is_dir and not _file.path.startswith(("/dev","/proc")):
|
||||||
|
dirs[len(dirs):] = self.list(_file.path, recurse=True, end_session=False)
|
||||||
|
return dirs
|
||||||
|
|
||||||
|
@safe
|
||||||
|
def total_space(self, end_session=True):
|
||||||
|
"""
|
||||||
|
Get total space available on the mountpoints:
|
||||||
|
1. Main memory
|
||||||
|
2. Memory Stick
|
||||||
|
3. SD Card
|
||||||
|
|
||||||
|
@return: A 3 element list with total space in bytes of (1, 2, 3)
|
||||||
|
"""
|
||||||
|
data = []
|
||||||
|
for path in ("/Data/", "a:/", "b:/"):
|
||||||
|
# Timeout needs to be increased as it takes time to read card
|
||||||
|
res = self.send_validated_command(TotalSpaceQuery(path), \
|
||||||
|
timeout=5000)
|
||||||
|
buffer_size = 16 + res.data[2]
|
||||||
|
pkt = self._bulk_read(buffer_size, data_type=TotalSpaceAnswer, \
|
||||||
|
command_number=TotalSpaceQuery.NUMBER)[0]
|
||||||
|
data.append( pkt.total )
|
||||||
|
return data
|
||||||
|
|
||||||
|
@safe
|
||||||
|
def free_space(self, end_session=True):
|
||||||
|
"""
|
||||||
|
Get free space available on the mountpoints:
|
||||||
|
1. Main memory
|
||||||
|
2. Memory Stick
|
||||||
|
3. SD Card
|
||||||
|
|
||||||
|
@return: A 3 element list with free space in bytes of (1, 2, 3)
|
||||||
|
"""
|
||||||
|
data = []
|
||||||
|
for path in ("/", "a:/", "b:/"):
|
||||||
|
# Timeout needs to be increased as it takes time to read card
|
||||||
|
self.send_validated_command(FreeSpaceQuery(path), \
|
||||||
|
timeout=5000)
|
||||||
|
pkt = self._bulk_read(FreeSpaceAnswer.SIZE, \
|
||||||
|
data_type=FreeSpaceAnswer, \
|
||||||
|
command_number=FreeSpaceQuery.NUMBER)[0]
|
||||||
|
data.append( pkt.free )
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _exists(self, path):
|
||||||
|
""" Return (True, FileProperties) if path exists or (False, None) otherwise """
|
||||||
|
dest = None
|
||||||
|
try:
|
||||||
|
dest = self.path_properties(path, end_session=False)
|
||||||
|
except PathError, err:
|
||||||
|
if "does not exist" in str(err) or "not mounted" in str(err):
|
||||||
|
return (False, None)
|
||||||
|
else: raise
|
||||||
|
return (True, dest)
|
||||||
|
|
||||||
|
@safe
|
||||||
|
def touch(self, path, end_session=True):
|
||||||
|
"""
|
||||||
|
Create a file at path
|
||||||
|
@todo: Update file modification time if it exists.
|
||||||
|
Opening the file in write mode and then closing it doesn't work.
|
||||||
|
"""
|
||||||
|
if path.endswith("/") and len(path) > 1:
|
||||||
|
path = path[:-1]
|
||||||
|
exists, _file = self._exists(path)
|
||||||
|
if exists and _file.is_dir:
|
||||||
|
raise PathError("Cannot touch directories")
|
||||||
|
if not exists:
|
||||||
|
res = self.send_validated_command(FileCreate(path))
|
||||||
|
if res.code != 0:
|
||||||
|
raise PathError("Could not create file " + path + \
|
||||||
|
". Response code: " + str(hex(res.code)))
|
||||||
|
|
||||||
|
@safe
|
||||||
|
def put_file(self, infile, path, replace_file=False, end_session=True):
|
||||||
|
"""
|
||||||
|
Put infile onto the devoce at path
|
||||||
|
@param infile: An open file object. infile must have a name attribute.
|
||||||
|
If you are using a StringIO object set its name attribute manually.
|
||||||
|
@param path: The path on the device at which to put infile.
|
||||||
|
It should point to an existing directory.
|
||||||
|
@param replace_file: If True and path points to a file that already exists, it is replaced
|
||||||
|
"""
|
||||||
|
pos = infile.tell()
|
||||||
|
infile.seek(0, 2)
|
||||||
|
bytes = infile.tell() - pos
|
||||||
|
start_pos = pos
|
||||||
|
infile.seek(pos)
|
||||||
|
exists, dest = self._exists(path)
|
||||||
|
if exists:
|
||||||
|
if dest.is_dir:
|
||||||
|
if not path.endswith("/"):
|
||||||
|
path += "/"
|
||||||
|
path += os.path.basename(infile.name)
|
||||||
|
return self.put_file(infile, path, replace_file=replace_file, end_session=False)
|
||||||
|
else:
|
||||||
|
if not replace_file:
|
||||||
|
raise PathError("Cannot write to " + \
|
||||||
|
path + " as it already exists")
|
||||||
|
_file = self.path_properties(path, end_session=False)
|
||||||
|
if _file.file_size > bytes:
|
||||||
|
self.del_file(path, end_session=False)
|
||||||
|
self.touch(path, end_session=False)
|
||||||
|
else: self.touch(path, end_session=False)
|
||||||
|
chunk_size = 0x8000
|
||||||
|
data_left = True
|
||||||
|
res = self.send_validated_command(FileOpen(path, mode=FileOpen.WRITE))
|
||||||
|
if res.code != 0:
|
||||||
|
raise ProtocolError("Unable to open " + path + \
|
||||||
|
" for writing. Response code: " + hex(res.code))
|
||||||
|
_id = self._bulk_read(20, data_type=IdAnswer, \
|
||||||
|
command_number=FileOpen.NUMBER)[0].id
|
||||||
|
|
||||||
|
while data_left:
|
||||||
|
data = array('B')
|
||||||
|
try:
|
||||||
|
data.fromfile(infile, chunk_size)
|
||||||
|
except EOFError:
|
||||||
|
data_left = False
|
||||||
|
res = self.send_validated_command(FileIO(_id, pos, len(data), \
|
||||||
|
mode=FileIO.WNUMBER))
|
||||||
|
if res.code != 0:
|
||||||
|
raise ProtocolError("Unable to write to " + \
|
||||||
|
path + ". Response code: " + hex(res.code))
|
||||||
|
self._bulk_write(data)
|
||||||
|
pos += len(data)
|
||||||
|
if self.report_progress:
|
||||||
|
self.report_progress( int(100*(pos-start_pos)/(1.*bytes)) )
|
||||||
|
self.send_validated_command(FileClose(_id))
|
||||||
|
# Ignore res.code as cant do anything if close fails
|
||||||
|
_file = self.path_properties(path, end_session=False)
|
||||||
|
if _file.file_size != pos:
|
||||||
|
raise ProtocolError("Copying to device failed. The file " +\
|
||||||
|
"on the device is larger by " + \
|
||||||
|
str(_file.file_size - pos) + " bytes")
|
||||||
|
|
||||||
|
@safe
|
||||||
|
def del_file(self, path, end_session=True):
|
||||||
|
""" Delete C{path} from device iff path is a file """
|
||||||
|
data = self.path_properties(path, end_session=False)
|
||||||
|
if data.is_dir:
|
||||||
|
raise PathError("Cannot delete directories")
|
||||||
|
res = self.send_validated_command(FileDelete(path), \
|
||||||
|
response_type=ListResponse)
|
||||||
|
if res.code != 0:
|
||||||
|
raise ProtocolError("Unable to delete " + path + \
|
||||||
|
" with response:\n" + str(res))
|
||||||
|
|
||||||
|
@safe
|
||||||
|
def mkdir(self, path, end_session=True):
|
||||||
|
""" Make directory """
|
||||||
|
if not path.endswith("/"):
|
||||||
|
path += "/"
|
||||||
|
error_prefix = "Cannot create directory " + path
|
||||||
|
res = self.send_validated_command(DirCreate(path)).data[0]
|
||||||
|
if res == 0xffffffcc:
|
||||||
|
raise PathError(error_prefix + " as it already exists")
|
||||||
|
elif res == PathResponseCodes.NOT_FOUND:
|
||||||
|
raise PathError(error_prefix + " as " + \
|
||||||
|
path[0:path[:-1].rfind("/")] + " does not exist ")
|
||||||
|
elif res == PathResponseCodes.INVALID:
|
||||||
|
raise PathError(error_prefix + " as " + path + " is invalid")
|
||||||
|
elif res != 0:
|
||||||
|
raise PathError(error_prefix + ". Response code: " + hex(res))
|
||||||
|
|
||||||
|
@safe
|
||||||
|
def rm(self, path, end_session=True):
|
||||||
|
""" Delete path from device if it is a file or an empty directory """
|
||||||
|
dir = self.path_properties(path, end_session=False)
|
||||||
|
if not dir.is_dir:
|
||||||
|
self.del_file(path, end_session=False)
|
||||||
|
else:
|
||||||
|
if not path.endswith("/"):
|
||||||
|
path += "/"
|
||||||
|
res = self.send_validated_command(DirDelete(path))
|
||||||
|
if res.code == PathResponseCodes.HAS_CHILDREN:
|
||||||
|
raise PathError("Cannot delete directory " + path + \
|
||||||
|
" as it is not empty")
|
||||||
|
if res.code != 0:
|
||||||
|
raise ProtocolError("Failed to delete directory " + path + \
|
||||||
|
". Response code: " + hex(res.code))
|
||||||
|
|
||||||
|
@safe
|
||||||
|
def card(self, end_session=True):
|
||||||
|
""" Return path prefix to installed card or None """
|
||||||
|
card = None
|
||||||
|
if self._exists("a:/")[0]:
|
||||||
|
card = "a:"
|
||||||
|
if self._exists("b:/")[0]:
|
||||||
|
card = "b:"
|
||||||
|
return card
|
||||||
|
|
||||||
|
@safe
|
||||||
|
def books(self, oncard=False, end_session=True):
|
||||||
|
"""
|
||||||
|
Return a list of ebooks on the device.
|
||||||
|
@param oncard: If True return a list of ebooks on the storage card,
|
||||||
|
otherwise return list of ebooks in main memory of device
|
||||||
|
|
||||||
|
@return: L{BookList}
|
||||||
|
"""
|
||||||
|
root = "/Data/media/"
|
||||||
|
prefix = "xs1:"
|
||||||
|
tfile = TemporaryFile()
|
||||||
|
if oncard:
|
||||||
|
prefix = ""
|
||||||
|
try:
|
||||||
|
self.get_file("a:"+self.CACHE_XML, tfile, end_session=False)
|
||||||
|
root = "a:/"
|
||||||
|
except PathError:
|
||||||
|
try:
|
||||||
|
self.get_file("b:"+self.CACHE_XML, tfile, end_session=False)
|
||||||
|
root = "b:/"
|
||||||
|
except PathError: pass
|
||||||
|
if tfile.tell() == 0:
|
||||||
|
tfile = None
|
||||||
|
else:
|
||||||
|
self.get_file(self.MEDIA_XML, tfile, end_session=False)
|
||||||
|
return BookList(prefix=prefix, root=root, sfile=tfile)
|
||||||
|
|
||||||
|
@safe
|
||||||
|
def add_book(self, infile, name, info, booklists, oncard=False, \
|
||||||
|
sync_booklists=False, end_session=True):
|
||||||
|
"""
|
||||||
|
Add a book to the device. If oncard is True then the book is copied
|
||||||
|
to the card rather than main memory.
|
||||||
|
|
||||||
|
@param infile: The source file, should be opened in "rb" mode
|
||||||
|
@param name: The name of the book file when uploaded to the
|
||||||
|
device. The extension of name must be one of
|
||||||
|
the supported formats for this device.
|
||||||
|
@param info: A dictionary that must have the keys "title", "authors", "cover".
|
||||||
|
C{info["cover"]} should be a three element tuple (width, height, data)
|
||||||
|
where data is the image data in JPEG format as a string
|
||||||
|
@param booklists: A tuple containing the result of calls to
|
||||||
|
(L{books}(oncard=False), L{books}(oncard=True)).
|
||||||
|
"""
|
||||||
|
infile.seek(0, 2)
|
||||||
|
size = infile.tell()
|
||||||
|
infile.seek(0)
|
||||||
|
card = self.card(end_session=False)
|
||||||
|
space = self.free_space(end_session=False)
|
||||||
|
mspace = space[0]
|
||||||
|
cspace = space[1] if space[1] >= space[2] else space[2]
|
||||||
|
if oncard and size > cspace - 1024*1024:
|
||||||
|
raise FreeSpaceError("There is insufficient free space "+\
|
||||||
|
"on the storage card")
|
||||||
|
if not oncard and size > mspace - 1024*1024:
|
||||||
|
raise FreeSpaceError("There is insufficient free space " +\
|
||||||
|
"in main memory")
|
||||||
|
prefix = "/Data/media/"
|
||||||
|
if oncard:
|
||||||
|
prefix = card + "/"
|
||||||
|
else: name = "books/"+name
|
||||||
|
path = prefix + name
|
||||||
|
self.put_file(infile, path, end_session=False)
|
||||||
|
ctime = self.path_properties(path, end_session=False).ctime
|
||||||
|
bkl = booklists[1] if oncard else booklists[0]
|
||||||
|
bkl.add_book(info, name, size, ctime)
|
||||||
|
fix_ids(booklists[0], booklists[1])
|
||||||
|
if sync_booklists:
|
||||||
|
self.upload_book_list(booklists[0], end_session=False)
|
||||||
|
if len(booklists[1]):
|
||||||
|
self.upload_book_list(booklists[1], end_session=False)
|
||||||
|
|
||||||
|
@safe
|
||||||
|
def upload_book_list(self, booklist, end_session=True):
|
||||||
|
if not len(booklist):
|
||||||
|
raise ArgumentError("booklist is empty")
|
||||||
|
path = self.MEDIA_XML
|
||||||
|
if not booklist.prefix:
|
||||||
|
card = self.card(end_session=True)
|
||||||
|
if not card:
|
||||||
|
raise ArgumentError("Cannot upload list to card as "+\
|
||||||
|
"card is not present")
|
||||||
|
path = card + self.CACHE_XML
|
||||||
|
f = TemporaryFile()
|
||||||
|
booklist.write(f)
|
||||||
|
f.seek(0)
|
||||||
|
self.put_file(f, path, replace_file=True, end_session=False)
|
||||||
|
f.close()
|
||||||
|
|
71
src/libprs500/errors.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
## Copyright (C) 2006 Kovid Goyal kovid@kovidgoyal.net
|
||||||
|
## This program is free software; you can redistribute it and/or modify
|
||||||
|
## it under the terms of the GNU General Public License as published by
|
||||||
|
## the Free Software Foundation; either version 2 of the License, or
|
||||||
|
## (at your option) any later version.
|
||||||
|
##
|
||||||
|
## This program is distributed in the hope that it will be useful,
|
||||||
|
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
## GNU General Public License for more details.
|
||||||
|
##
|
||||||
|
## You should have received a copy of the GNU General Public License along
|
||||||
|
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
"""
|
||||||
|
Defines the errors that libprs500 generates.
|
||||||
|
|
||||||
|
G{classtree ProtocolError}
|
||||||
|
"""
|
||||||
|
|
||||||
|
class ProtocolError(Exception):
|
||||||
|
""" The base class for all exceptions in this package """
|
||||||
|
def __init__(self, msg):
|
||||||
|
Exception.__init__(self, msg)
|
||||||
|
|
||||||
|
class TimeoutError(ProtocolError):
|
||||||
|
""" There was a timeout during communication """
|
||||||
|
def __init__(self, func_name):
|
||||||
|
ProtocolError.__init__(self, \
|
||||||
|
"There was a timeout while communicating with the device in function: "\
|
||||||
|
+func_name)
|
||||||
|
|
||||||
|
class DeviceError(ProtocolError):
|
||||||
|
""" Raised when device is not found """
|
||||||
|
def __init__(self):
|
||||||
|
ProtocolError.__init__(self, \
|
||||||
|
"Unable to find SONY Reader. Is it connected?")
|
||||||
|
|
||||||
|
class DeviceBusy(ProtocolError):
|
||||||
|
""" Raised when device is busy """
|
||||||
|
def __init__(self):
|
||||||
|
ProtocolError.__init__(self, "Device is in use by another application")
|
||||||
|
|
||||||
|
class PacketError(ProtocolError):
|
||||||
|
""" Errors with creating/interpreting packets """
|
||||||
|
|
||||||
|
class FreeSpaceError(ProtocolError):
|
||||||
|
""" Errors caused when trying to put files onto an overcrowded device """
|
||||||
|
|
||||||
|
class ArgumentError(ProtocolError):
|
||||||
|
""" Errors caused by invalid arguments to a public interface function """
|
||||||
|
|
||||||
|
class PathError(ArgumentError):
|
||||||
|
""" When a user supplies an incorrect/invalid path """
|
||||||
|
|
||||||
|
class ControlError(ProtocolError):
|
||||||
|
""" Errors in Command/Response pairs while communicating with the device """
|
||||||
|
def __init__(self, query=None, response=None, desc=None):
|
||||||
|
self.query = query
|
||||||
|
self.response = response
|
||||||
|
ProtocolError.__init__(self, desc)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.query and self.response:
|
||||||
|
return "Got unexpected response:\n" + \
|
||||||
|
"query:\n"+str(self.query.query)+"\n"+\
|
||||||
|
"expected:\n"+str(self.query.response)+"\n" +\
|
||||||
|
"actual:\n"+str(self.response)
|
||||||
|
if self.desc:
|
||||||
|
return self.desc
|
||||||
|
return "Unknown control error occurred"
|
60
src/libprs500/gui/__init__.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
## Copyright (C) 2006 Kovid Goyal kovid@kovidgoyal.net
|
||||||
|
## This program is free software; you can redistribute it and/or modify
|
||||||
|
## it under the terms of the GNU General Public License as published by
|
||||||
|
## the Free Software Foundation; either version 2 of the License, or
|
||||||
|
## (at your option) any later version.
|
||||||
|
##
|
||||||
|
## This program is distributed in the hope that it will be useful,
|
||||||
|
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
## GNU General Public License for more details.
|
||||||
|
##
|
||||||
|
## You should have received a copy of the GNU General Public License along
|
||||||
|
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
""" The GUI to libprs500. Also has ebook library management features. """
|
||||||
|
__docformat__ = "epytext"
|
||||||
|
__author__ = "Kovid Goyal <kovid@kovidgoyal.net>"
|
||||||
|
APP_TITLE = "libprs500"
|
||||||
|
|
||||||
|
import pkg_resources, sys, os, re, StringIO, traceback
|
||||||
|
from PyQt4.uic.Compiler import compiler
|
||||||
|
from PyQt4 import QtCore, QtGui # Needed in globals() for import_ui
|
||||||
|
|
||||||
|
error_dialog = None
|
||||||
|
|
||||||
|
def extension(path):
|
||||||
|
return os.path.splitext(path)[1][1:].lower()
|
||||||
|
|
||||||
|
def installErrorHandler(dialog):
|
||||||
|
global error_dialog
|
||||||
|
error_dialog = dialog
|
||||||
|
error_dialog.resize(600, 400)
|
||||||
|
error_dialog.setWindowTitle(APP_TITLE + " - Error")
|
||||||
|
error_dialog.setModal(True)
|
||||||
|
|
||||||
|
|
||||||
|
def _Warning(msg, e):
|
||||||
|
print >> sys.stderr, msg
|
||||||
|
if e: traceback.print_exc(e)
|
||||||
|
|
||||||
|
def Error(msg, e):
|
||||||
|
if error_dialog:
|
||||||
|
if e:
|
||||||
|
msg += "<br>" + traceback.format_exc(e)
|
||||||
|
msg = re.sub("Traceback", "<b>Traceback</b>", msg)
|
||||||
|
msg = re.sub(r"\n", "<br>", msg)
|
||||||
|
error_dialog.showMessage(msg)
|
||||||
|
error_dialog.show()
|
||||||
|
|
||||||
|
def import_ui(name):
|
||||||
|
uifile = pkg_resources.resource_stream(__name__, name)
|
||||||
|
code_string = StringIO.StringIO()
|
||||||
|
winfo = compiler.UICompiler().compileUi(uifile, code_string)
|
||||||
|
#ui = pkg_resources.resource_filename(__name__, name)
|
||||||
|
exec code_string.getvalue()
|
||||||
|
return locals()[winfo["uiclass"]]
|
||||||
|
|
||||||
|
# Needed in globals() for import_ui
|
||||||
|
from libprs500.gui.widgets import LibraryBooksView, \
|
||||||
|
DeviceBooksView, CoverDisplay, DeviceView
|
299
src/libprs500/gui/database.py
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
## Copyright (C) 2006 Kovid Goyal kovid@kovidgoyal.net
|
||||||
|
## This program is free software; you can redistribute it and/or modify
|
||||||
|
## it under the terms of the GNU General Public License as published by
|
||||||
|
## the Free Software Foundation; either version 2 of the License, or
|
||||||
|
## (at your option) any later version.
|
||||||
|
##
|
||||||
|
## This program is distributed in the hope that it will be useful,
|
||||||
|
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
## GNU General Public License for more details.
|
||||||
|
##
|
||||||
|
## You should have received a copy of the GNU General Public License along
|
||||||
|
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
import sqlite3 as sqlite
|
||||||
|
import os
|
||||||
|
from zlib import compress, decompress
|
||||||
|
from stat import ST_SIZE
|
||||||
|
from libprs500.lrf.meta import LRFMetaFile, LRFException
|
||||||
|
from cStringIO import StringIO as cStringIO
|
||||||
|
|
||||||
|
class LibraryDatabase(object):
|
||||||
|
|
||||||
|
BOOKS_SQL = \
|
||||||
|
"""
|
||||||
|
create table if not exists books_meta(id INTEGER PRIMARY KEY, title TEXT,
|
||||||
|
authors TEXT, publisher TEXT, size INTEGER, tags TEXT,
|
||||||
|
date DATE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
comments TEXT, rating INTEGER);
|
||||||
|
create table if not exists books_data(id INTEGER, extension TEXT,
|
||||||
|
uncompressed_size INTEGER, data BLOB);
|
||||||
|
create table if not exists books_cover(id INTEGER,
|
||||||
|
uncompressed_size INTEGER, data BLOB);
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, dbpath):
|
||||||
|
self.con = sqlite.connect(dbpath)
|
||||||
|
# Allow case insensitive field access by name
|
||||||
|
self.con.row_factory = sqlite.Row
|
||||||
|
self.con.executescript(LibraryDatabase.BOOKS_SQL)
|
||||||
|
|
||||||
|
def get_cover(self, _id):
|
||||||
|
raw = self.con.execute("select data from books_cover where id=?", \
|
||||||
|
(_id,)).next()["data"]
|
||||||
|
return decompress(str(raw)) if raw else None
|
||||||
|
|
||||||
|
def get_extensions(self, _id):
|
||||||
|
exts = []
|
||||||
|
cur = self.con.execute("select extension from books_data where id=?", \
|
||||||
|
(_id,))
|
||||||
|
for row in cur:
|
||||||
|
exts.append(row["extension"])
|
||||||
|
return exts
|
||||||
|
|
||||||
|
def add_book(self, path):
|
||||||
|
_file = os.path.abspath(path)
|
||||||
|
title, author, publisher, size, cover = os.path.basename(_file), \
|
||||||
|
None, None, os.stat(_file)[ST_SIZE], None
|
||||||
|
ext = title[title.rfind(".")+1:].lower() if title.find(".") > -1 else None
|
||||||
|
if ext == "lrf":
|
||||||
|
lrf = LRFMetaFile(open(_file, "r+b"))
|
||||||
|
title, author, cover, publisher = lrf.title, lrf.author.strip(), \
|
||||||
|
lrf.thumbnail, lrf.publisher.strip()
|
||||||
|
if "unknown" in publisher.lower():
|
||||||
|
publisher = None
|
||||||
|
if "unknown" in author.lower():
|
||||||
|
author = None
|
||||||
|
data = open(_file).read()
|
||||||
|
usize = len(data)
|
||||||
|
data = compress(data)
|
||||||
|
csize = 0
|
||||||
|
if cover:
|
||||||
|
csize = len(cover)
|
||||||
|
cover = sqlite.Binary(compress(cover))
|
||||||
|
self.con.execute("insert into books_meta (title, authors, publisher, "+\
|
||||||
|
"size, tags, comments, rating) values "+\
|
||||||
|
"(?,?,?,?,?,?,?)", \
|
||||||
|
(title, author, publisher, size, None, None, None))
|
||||||
|
_id = self.con.execute("select max(id) from books_meta").next()[0]
|
||||||
|
self.con.execute("insert into books_data values (?,?,?,?)", \
|
||||||
|
(_id, ext, usize, sqlite.Binary(data)))
|
||||||
|
self.con.execute("insert into books_cover values (?,?,?)", \
|
||||||
|
(_id, csize, cover))
|
||||||
|
self.con.commit()
|
||||||
|
return _id
|
||||||
|
|
||||||
|
def get_row_by_id(self, _id, columns):
|
||||||
|
"""
|
||||||
|
Return C{columns} of meta data as a dict.
|
||||||
|
@param columns: list of column names
|
||||||
|
"""
|
||||||
|
cols = ",".join([ c for c in columns])
|
||||||
|
cur = self.con.execute("select " + cols + " from books_meta where id=?"\
|
||||||
|
, (_id,))
|
||||||
|
row, r = cur.next(), {}
|
||||||
|
for c in columns:
|
||||||
|
r[c] = row[c]
|
||||||
|
return r
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
self.con.commit()
|
||||||
|
|
||||||
|
def delete_by_id(self, _id):
|
||||||
|
self.con.execute("delete from books_meta where id=?", (_id,))
|
||||||
|
self.con.execute("delete from books_data where id=?", (_id,))
|
||||||
|
self.con.execute("delete from books_cover where id=?", (_id,))
|
||||||
|
self.commit()
|
||||||
|
|
||||||
|
def get_table(self, columns):
|
||||||
|
""" Return C{columns} of the metadata table as a list of dicts. """
|
||||||
|
cols = ",".join([ c for c in columns])
|
||||||
|
cur = self.con.execute("select " + cols + " from books_meta")
|
||||||
|
rows = []
|
||||||
|
for row in cur:
|
||||||
|
r = {}
|
||||||
|
for c in columns:
|
||||||
|
r[c] = row[c]
|
||||||
|
rows.append(r)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def get_format(self, _id, ext):
|
||||||
|
"""
|
||||||
|
Return format C{ext} corresponding to the logical book C{id} or
|
||||||
|
None if the format is unavailable.
|
||||||
|
Format is returned as a string of binary data suitable for
|
||||||
|
C{ file.write} operations.
|
||||||
|
"""
|
||||||
|
ext = ext.lower()
|
||||||
|
cur = self.con.execute("select data from books_data where id=? and "+\
|
||||||
|
"extension=?",(_id, ext))
|
||||||
|
try:
|
||||||
|
data = cur.next()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
return decompress(str(data["data"]))
|
||||||
|
|
||||||
|
def remove_format(self, _id, ext):
|
||||||
|
""" Remove format C{ext} from book C{_id} """
|
||||||
|
self.con.execute("delete from books_data where id=? and extension=?", \
|
||||||
|
(_id, ext))
|
||||||
|
self.update_max_size(_id)
|
||||||
|
self.con.commit()
|
||||||
|
|
||||||
|
def add_format(self, _id, ext, data):
|
||||||
|
"""
|
||||||
|
If data for format ext already exists, it is replaced
|
||||||
|
@type ext: string or None
|
||||||
|
@type data: string or file object
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data.seek(0)
|
||||||
|
data = data.read()
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
metadata = self.get_metadata(_id)
|
||||||
|
if ext:
|
||||||
|
ext = ext.strip().lower()
|
||||||
|
if ext == "lrf":
|
||||||
|
s = cStringIO()
|
||||||
|
print >> s, data
|
||||||
|
try:
|
||||||
|
lrf = LRFMetaFile(s)
|
||||||
|
lrf.author = metadata["authors"]
|
||||||
|
lrf.title = metadata["title"]
|
||||||
|
except LRFException:
|
||||||
|
pass
|
||||||
|
data = s.getvalue()
|
||||||
|
s.close()
|
||||||
|
size = len(data)
|
||||||
|
|
||||||
|
data = sqlite.Binary(compress(data))
|
||||||
|
cur = self.con.execute("select extension from books_data where id=? "+\
|
||||||
|
"and extension=?", (_id, ext))
|
||||||
|
present = True
|
||||||
|
try:
|
||||||
|
cur.next()
|
||||||
|
except:
|
||||||
|
present = False
|
||||||
|
if present:
|
||||||
|
self.con.execute("update books_data set uncompressed_size=? \
|
||||||
|
where id=? and extension=?", (size, _id, ext))
|
||||||
|
self.con.execute("update books_data set data=? where id=? "+\
|
||||||
|
"and extension=?", (data, _id, ext))
|
||||||
|
else:
|
||||||
|
self.con.execute("insert into books_data \
|
||||||
|
(id, extension, uncompressed_size, data) values (?, ?, ?, ?)", \
|
||||||
|
(_id, ext, size, data))
|
||||||
|
oldsize = self.get_row_by_id(_id, ['size'])['size']
|
||||||
|
if size > oldsize:
|
||||||
|
self.con.execute("update books_meta set size=? where id=? ", \
|
||||||
|
(size, _id))
|
||||||
|
self.con.commit()
|
||||||
|
|
||||||
|
def get_metadata(self, _id):
|
||||||
|
""" Return metadata in a dict """
|
||||||
|
try:
|
||||||
|
row = self.con.execute("select * from books_meta where id=?", \
|
||||||
|
(_id,)).next()
|
||||||
|
except StopIteration:
|
||||||
|
return None
|
||||||
|
data = {}
|
||||||
|
for field in ("id", "title", "authors", "publisher", "size", "tags",
|
||||||
|
"date"):
|
||||||
|
data[field] = row[field]
|
||||||
|
return data
|
||||||
|
|
||||||
|
def set_metadata(self, _id, title=None, authors=None, rating=None, \
|
||||||
|
publisher=None, tags=None, comments=None):
|
||||||
|
"""
|
||||||
|
Update metadata fields for book C{_id}. Metadata is not updated
|
||||||
|
in formats. See L{set_metadata_item}.
|
||||||
|
"""
|
||||||
|
if authors and not len(authors):
|
||||||
|
authors = None
|
||||||
|
if publisher and not len(publisher):
|
||||||
|
publisher = None
|
||||||
|
if tags and not len(tags):
|
||||||
|
tags = None
|
||||||
|
if comments and not len(comments):
|
||||||
|
comments = None
|
||||||
|
self.con.execute('update books_meta set title=?, authors=?, '+\
|
||||||
|
'publisher=?, tags=?, comments=?, rating=? '+\
|
||||||
|
'where id=?', \
|
||||||
|
(title, authors, publisher, tags, comments, \
|
||||||
|
rating, _id))
|
||||||
|
self.con.commit()
|
||||||
|
|
||||||
|
def set_metadata_item(self, _id, col, val):
|
||||||
|
"""
|
||||||
|
Convenience method used to set metadata. Metadata is updated
|
||||||
|
automatically in supported formats.
|
||||||
|
@param col: If it is either 'title' or 'authors' the value is updated
|
||||||
|
in supported formats as well.
|
||||||
|
"""
|
||||||
|
self.con.execute('update books_meta set '+col+'=? where id=?', \
|
||||||
|
(val, _id))
|
||||||
|
if col in ["authors", "title"]:
|
||||||
|
lrf = self.get_format(_id, "lrf")
|
||||||
|
if lrf:
|
||||||
|
c = cStringIO()
|
||||||
|
c.write(lrf)
|
||||||
|
lrf = LRFMetaFile(c)
|
||||||
|
if col == "authors":
|
||||||
|
lrf.authors = val
|
||||||
|
else: lrf.title = val
|
||||||
|
self.add_format(_id, "lrf", c.getvalue())
|
||||||
|
self.con.commit()
|
||||||
|
|
||||||
|
def update_cover(self, _id, cover, scaled=None):
|
||||||
|
"""
|
||||||
|
Update the stored cover. The cover is updated in supported formats
|
||||||
|
as well.
|
||||||
|
@param cover: The cover data
|
||||||
|
@param scaled: scaled version of cover that shoould be written to
|
||||||
|
format files. If None, cover is used.
|
||||||
|
"""
|
||||||
|
data = None
|
||||||
|
size = 0
|
||||||
|
if cover:
|
||||||
|
size = len(cover)
|
||||||
|
data = sqlite.Binary(compress(cover))
|
||||||
|
self.con.execute('update books_cover set uncompressed_size=?, data=? \
|
||||||
|
where id=?', (size, data, _id))
|
||||||
|
if not scaled:
|
||||||
|
scaled = cover
|
||||||
|
if scaled:
|
||||||
|
lrf = self.get_format(_id, "lrf")
|
||||||
|
if lrf:
|
||||||
|
c = cStringIO()
|
||||||
|
c.write(lrf)
|
||||||
|
lrf = LRFMetaFile(c)
|
||||||
|
lrf.thumbnail = scaled
|
||||||
|
self.add_format(_id, "lrf", c.getvalue())
|
||||||
|
self.update_max_size(_id)
|
||||||
|
self.commit()
|
||||||
|
|
||||||
|
def update_max_size(self, _id):
|
||||||
|
cur = self.con.execute("select uncompressed_size from books_data \
|
||||||
|
where id=?", (_id,))
|
||||||
|
maxsize = 0
|
||||||
|
for row in cur:
|
||||||
|
maxsize = row[0] if row[0] > maxsize else maxsize
|
||||||
|
self.con.execute("update books_meta set size=? where id=? ", \
|
||||||
|
(maxsize, _id))
|
||||||
|
self.con.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#if __name__ == "__main__":
|
||||||
|
# lbm = LibraryDatabase("/home/kovid/library.db")
|
||||||
|
# lbm.add_book("/home/kovid/documents/ebooks/hobbfar01.lrf")
|
||||||
|
# lbm.add_book("/home/kovid/documents/ebooks/hobbfar02.lrf")
|
||||||
|
# lbm.add_book("/home/kovid/documents/ebooks/hobbfar03.lrf")
|
||||||
|
# lbm.add_book("/home/kovid/documents/ebooks/hobblive01.lrf")
|
||||||
|
# lbm.add_book("/home/kovid/documents/ebooks/hobbtawny01.lrf")
|
||||||
|
# lbm.add_book("/home/kovid/documents/ebooks/hobbtawny02.lrf")
|
||||||
|
# lbm.add_book("/home/kovid/documents/ebooks/hobbtawny03.lrf")
|
||||||
|
# print lbm.get_table(["id","title"])
|
166
src/libprs500/gui/editbook.py
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
## Copyright (C) 2006 Kovid Goyal kovid@kovidgoyal.net
|
||||||
|
## This program is free software; you can redistribute it and/or modify
|
||||||
|
## it under the terms of the GNU General Public License as published by
|
||||||
|
## the Free Software Foundation; either version 2 of the License, or
|
||||||
|
## (at your option) any later version.
|
||||||
|
##
|
||||||
|
## This program is distributed in the hope that it will be useful,
|
||||||
|
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
## GNU General Public License for more details.
|
||||||
|
##
|
||||||
|
## You should have received a copy of the GNU General Public License along
|
||||||
|
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
"""
|
||||||
|
The dialog used to edit meta information for a book as well as
|
||||||
|
add/remove formats
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from PyQt4.QtCore import Qt, SIGNAL
|
||||||
|
from PyQt4.Qt import QObject, QPixmap, QListWidgetItem, QErrorMessage, \
|
||||||
|
QVariant, QSettings, QFileDialog
|
||||||
|
|
||||||
|
from libprs500.gui import import_ui, extension
|
||||||
|
|
||||||
|
class Format(QListWidgetItem):
|
||||||
|
def __init__(self, parent, ext, path=None):
|
||||||
|
self.path = path
|
||||||
|
self.ext = ext
|
||||||
|
QListWidgetItem.__init__(self, ext.upper(), parent, \
|
||||||
|
QListWidgetItem.UserType)
|
||||||
|
|
||||||
|
Ui_BookEditDialog = import_ui("editbook.ui")
|
||||||
|
class EditBookDialog(Ui_BookEditDialog):
|
||||||
|
|
||||||
|
def select_cover(self, checked):
|
||||||
|
settings = QSettings()
|
||||||
|
_dir = settings.value("change cover dir", \
|
||||||
|
QVariant(os.path.expanduser("~"))).toString()
|
||||||
|
_file = str(QFileDialog.getOpenFileName(self.parent, \
|
||||||
|
"Choose cover for " + str(self.title.text()), _dir, \
|
||||||
|
"Images (*.png *.gif *.jpeg *.jpg);;All files (*)"))
|
||||||
|
if len(_file):
|
||||||
|
_file = os.path.abspath(_file)
|
||||||
|
settings.setValue("change cover dir", \
|
||||||
|
QVariant(os.path.dirname(_file)))
|
||||||
|
if not os.access(_file, os.R_OK):
|
||||||
|
QErrorMessage(self.parent).showMessage("You do not have "+\
|
||||||
|
"permission to read the file: " + _file)
|
||||||
|
return
|
||||||
|
cf, cover = None, None
|
||||||
|
try:
|
||||||
|
cf = open(_file, "rb")
|
||||||
|
cover = cf.read()
|
||||||
|
except IOError, e:
|
||||||
|
QErrorMessage(self.parent).showMessage("There was an error"+\
|
||||||
|
" reading from file: " + _file + "\n"+str(e))
|
||||||
|
if cover:
|
||||||
|
pix = QPixmap()
|
||||||
|
pix.loadFromData(cover, "", Qt.AutoColor)
|
||||||
|
if pix.isNull():
|
||||||
|
QErrorMessage(self.parent).showMessage(_file + \
|
||||||
|
" is not a valid picture")
|
||||||
|
else:
|
||||||
|
self.cover_path.setText(_file)
|
||||||
|
self.cover.setPixmap(pix)
|
||||||
|
|
||||||
|
|
||||||
|
def add_format(self, x):
|
||||||
|
settings = QSettings()
|
||||||
|
_dir = settings.value("add formats dialog dir", \
|
||||||
|
QVariant(os.path.expanduser("~"))).toString()
|
||||||
|
files = QFileDialog.getOpenFileNames(self.parent, \
|
||||||
|
"Choose formats for " + str(self.title.text()), _dir, \
|
||||||
|
"Books (*.lrf *.lrx *.rtf *.txt *.html *.xhtml *.htm *.rar);;"+\
|
||||||
|
"All files (*)")
|
||||||
|
if not files.isEmpty():
|
||||||
|
x = str(files[0])
|
||||||
|
settings.setValue("add formats dialog dir", \
|
||||||
|
QVariant(os.path.dirname(x)))
|
||||||
|
files = str(files.join("|||")).split("|||")
|
||||||
|
for _file in files:
|
||||||
|
_file = os.path.abspath(_file)
|
||||||
|
if not os.access(_file, os.R_OK):
|
||||||
|
QErrorMessage(self.parent).showMessage("You do not have "+\
|
||||||
|
"permission to read the file: " + _file)
|
||||||
|
continue
|
||||||
|
ext = extension(_file)
|
||||||
|
for row in range(self.formats.count()):
|
||||||
|
fmt = self.formats.item(row)
|
||||||
|
if fmt.ext == ext:
|
||||||
|
self.formats.takeItem(row)
|
||||||
|
break
|
||||||
|
Format(self.formats, ext, path=_file)
|
||||||
|
self.formats_changed = True
|
||||||
|
|
||||||
|
def remove_format(self, x):
|
||||||
|
rows = self.formats.selectionModel().selectedRows(0)
|
||||||
|
for row in rows:
|
||||||
|
self.formats.takeItem(row.row())
|
||||||
|
self.formats_changed = True
|
||||||
|
|
||||||
|
def sync_formats(self):
|
||||||
|
old_extensions, new_extensions, paths = set(), set(), {}
|
||||||
|
for row in range(self.formats.count()):
|
||||||
|
fmt = self.formats.item(row)
|
||||||
|
ext, path = fmt.ext, fmt.path
|
||||||
|
if "unknown" in ext.lower():
|
||||||
|
ext = None
|
||||||
|
if path:
|
||||||
|
new_extensions.add(ext)
|
||||||
|
paths[ext] = path
|
||||||
|
else:
|
||||||
|
old_extensions.add(ext)
|
||||||
|
for ext in new_extensions:
|
||||||
|
self.db.add_format(self.id, ext, file(paths[ext], "rb"))
|
||||||
|
db_extensions = self.db.get_extensions(self.id)
|
||||||
|
extensions = new_extensions.union(old_extensions)
|
||||||
|
for ext in db_extensions:
|
||||||
|
if ext not in extensions:
|
||||||
|
self.db.remove_format(self.id, ext)
|
||||||
|
self.db.update_max_size(self.id)
|
||||||
|
|
||||||
|
def __init__(self, dialog, _id, db):
|
||||||
|
Ui_BookEditDialog.__init__(self)
|
||||||
|
self.parent = dialog
|
||||||
|
self.setupUi(dialog)
|
||||||
|
self.splitter.setStretchFactor(100, 1)
|
||||||
|
self.db = db
|
||||||
|
self.id = _id
|
||||||
|
self.cover_data = None
|
||||||
|
self.formats_changed = False
|
||||||
|
QObject.connect(self.cover_button, SIGNAL("clicked(bool)"), \
|
||||||
|
self.select_cover)
|
||||||
|
QObject.connect(self.add_format_button, SIGNAL("clicked(bool)"), \
|
||||||
|
self.add_format)
|
||||||
|
QObject.connect(self.remove_format_button, SIGNAL("clicked(bool)"), \
|
||||||
|
self.remove_format)
|
||||||
|
QObject.connect(self.button_box, SIGNAL("accepted()"), \
|
||||||
|
self.sync_formats)
|
||||||
|
|
||||||
|
data = self.db.get_row_by_id(self.id, \
|
||||||
|
["title","authors","rating","publisher","tags","comments"])
|
||||||
|
self.title.setText(data["title"])
|
||||||
|
self.authors.setText(data["authors"] if data["authors"] else "")
|
||||||
|
self.publisher.setText(data["publisher"] if data["publisher"] else "")
|
||||||
|
self.tags.setText(data["tags"] if data["tags"] else "")
|
||||||
|
if data["rating"] > 0:
|
||||||
|
self.rating.setValue(data["rating"])
|
||||||
|
self.comments.setPlainText(data["comments"] if data["comments"] else "")
|
||||||
|
cover = self.db.get_cover(self.id)
|
||||||
|
if cover:
|
||||||
|
pm = QPixmap()
|
||||||
|
pm.loadFromData(cover, "", Qt.AutoColor)
|
||||||
|
if not pm.isNull():
|
||||||
|
self.cover.setPixmap(pm)
|
||||||
|
else:
|
||||||
|
self.cover.setPixmap(QPixmap(":/default_cover"))
|
||||||
|
else:
|
||||||
|
self.cover.setPixmap(QPixmap(":/default_cover"))
|
||||||
|
exts = self.db.get_extensions(self.id)
|
||||||
|
for ext in exts:
|
||||||
|
if not ext:
|
||||||
|
ext = "Unknown"
|
||||||
|
Format(self.formats, ext)
|
427
src/libprs500/gui/editbook.ui
Normal file
@ -0,0 +1,427 @@
|
|||||||
|
<ui version="4.0" >
|
||||||
|
<class>BookEditDialog</class>
|
||||||
|
<widget class="QDialog" name="BookEditDialog" >
|
||||||
|
<property name="geometry" >
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>865</width>
|
||||||
|
<height>776</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle" >
|
||||||
|
<string>SONY Reader - Edit Meta Information</string>
|
||||||
|
</property>
|
||||||
|
<layout class="QGridLayout" >
|
||||||
|
<property name="margin" >
|
||||||
|
<number>9</number>
|
||||||
|
</property>
|
||||||
|
<property name="spacing" >
|
||||||
|
<number>6</number>
|
||||||
|
</property>
|
||||||
|
<item row="0" column="0" >
|
||||||
|
<widget class="QSplitter" name="splitter" >
|
||||||
|
<property name="orientation" >
|
||||||
|
<enum>Qt::Horizontal</enum>
|
||||||
|
</property>
|
||||||
|
<widget class="QWidget" name="" >
|
||||||
|
<layout class="QVBoxLayout" >
|
||||||
|
<property name="margin" >
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<property name="spacing" >
|
||||||
|
<number>6</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<widget class="QGroupBox" name="groupBox" >
|
||||||
|
<property name="title" >
|
||||||
|
<string>Meta information</string>
|
||||||
|
</property>
|
||||||
|
<layout class="QGridLayout" >
|
||||||
|
<property name="margin" >
|
||||||
|
<number>9</number>
|
||||||
|
</property>
|
||||||
|
<property name="spacing" >
|
||||||
|
<number>6</number>
|
||||||
|
</property>
|
||||||
|
<item row="2" 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="suffix" >
|
||||||
|
<string> stars</string>
|
||||||
|
</property>
|
||||||
|
<property name="maximum" >
|
||||||
|
<number>5</number>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="2" column="0" >
|
||||||
|
<widget class="QLabel" name="label_6" >
|
||||||
|
<property name="text" >
|
||||||
|
<string>&Rating:</string>
|
||||||
|
</property>
|
||||||
|
<property name="alignment" >
|
||||||
|
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="3" column="1" colspan="2" >
|
||||||
|
<widget class="QLineEdit" name="publisher" >
|
||||||
|
<property name="toolTip" >
|
||||||
|
<string>Change the publisher of this book</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="3" column="0" >
|
||||||
|
<widget class="QLabel" name="label_3" >
|
||||||
|
<property name="text" >
|
||||||
|
<string>&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="0" >
|
||||||
|
<widget class="QLabel" name="label_4" >
|
||||||
|
<property name="text" >
|
||||||
|
<string>Ta&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="4" column="1" colspan="2" >
|
||||||
|
<widget class="QLineEdit" name="tags" >
|
||||||
|
<property name="toolTip" >
|
||||||
|
<string>Tags categorize the book. This is particularly useful while searching. <br><br>They can be any words or phrases, separated by commas.</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="1" column="1" colspan="2" >
|
||||||
|
<widget class="QLineEdit" name="authors" >
|
||||||
|
<property name="toolTip" >
|
||||||
|
<string>Change the author(s) of this book. Multiple authors should be separated by the & character</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="0" column="1" colspan="2" >
|
||||||
|
<widget class="QLineEdit" name="title" >
|
||||||
|
<property name="toolTip" >
|
||||||
|
<string>Change the title of this book</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="1" column="0" >
|
||||||
|
<widget class="QLabel" name="label_2" >
|
||||||
|
<property name="text" >
|
||||||
|
<string>&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="0" column="0" >
|
||||||
|
<widget class="QLabel" name="label" >
|
||||||
|
<property name="text" >
|
||||||
|
<string>&Title: </string>
|
||||||
|
</property>
|
||||||
|
<property name="alignment" >
|
||||||
|
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
|
||||||
|
</property>
|
||||||
|
<property name="buddy" >
|
||||||
|
<cstring>title</cstring>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="6" column="2" >
|
||||||
|
<layout class="QVBoxLayout" >
|
||||||
|
<property name="margin" >
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<property name="spacing" >
|
||||||
|
<number>6</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="label_5" >
|
||||||
|
<property name="text" >
|
||||||
|
<string>Change &cover image:</string>
|
||||||
|
</property>
|
||||||
|
<property name="buddy" >
|
||||||
|
<cstring>cover_path</cstring>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" >
|
||||||
|
<property name="margin" >
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<property name="spacing" >
|
||||||
|
<number>6</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<widget class="QLineEdit" name="cover_path" >
|
||||||
|
<property name="readOnly" >
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QToolButton" name="cover_button" >
|
||||||
|
<property name="toolTip" >
|
||||||
|
<string>Browse for an image to use as the cover of this book.</string>
|
||||||
|
</property>
|
||||||
|
<property name="text" >
|
||||||
|
<string>...</string>
|
||||||
|
</property>
|
||||||
|
<property name="icon" >
|
||||||
|
<iconset resource="images.qrc" >:/images/fileopen.png</iconset>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item rowspan="3" row="5" column="0" colspan="2" >
|
||||||
|
<widget class="QLabel" name="cover" >
|
||||||
|
<property name="sizePolicy" >
|
||||||
|
<sizepolicy>
|
||||||
|
<hsizetype>0</hsizetype>
|
||||||
|
<vsizetype>0</vsizetype>
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize" >
|
||||||
|
<size>
|
||||||
|
<width>100</width>
|
||||||
|
<height>120</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="text" >
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
<property name="pixmap" >
|
||||||
|
<pixmap resource="images.qrc" >:/images/cherubs.jpg</pixmap>
|
||||||
|
</property>
|
||||||
|
<property name="scaledContents" >
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="7" column="2" >
|
||||||
|
<spacer>
|
||||||
|
<property name="orientation" >
|
||||||
|
<enum>Qt::Vertical</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeHint" >
|
||||||
|
<size>
|
||||||
|
<width>20</width>
|
||||||
|
<height>40</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</spacer>
|
||||||
|
</item>
|
||||||
|
<item row="5" column="2" >
|
||||||
|
<spacer>
|
||||||
|
<property name="orientation" >
|
||||||
|
<enum>Qt::Vertical</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeHint" >
|
||||||
|
<size>
|
||||||
|
<width>20</width>
|
||||||
|
<height>21</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</spacer>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QGroupBox" name="groupBox_2" >
|
||||||
|
<property name="title" >
|
||||||
|
<string>Comments</string>
|
||||||
|
</property>
|
||||||
|
<layout class="QGridLayout" >
|
||||||
|
<property name="margin" >
|
||||||
|
<number>9</number>
|
||||||
|
</property>
|
||||||
|
<property name="spacing" >
|
||||||
|
<number>6</number>
|
||||||
|
</property>
|
||||||
|
<item row="0" column="0" >
|
||||||
|
<widget class="QTextEdit" name="comments" />
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
<widget class="QGroupBox" name="groupBox_3" >
|
||||||
|
<property name="title" >
|
||||||
|
<string>Available Formats</string>
|
||||||
|
</property>
|
||||||
|
<layout class="QGridLayout" >
|
||||||
|
<property name="margin" >
|
||||||
|
<number>9</number>
|
||||||
|
</property>
|
||||||
|
<property name="spacing" >
|
||||||
|
<number>6</number>
|
||||||
|
</property>
|
||||||
|
<item row="0" column="1" >
|
||||||
|
<layout class="QVBoxLayout" >
|
||||||
|
<property name="margin" >
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<property name="spacing" >
|
||||||
|
<number>6</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<spacer>
|
||||||
|
<property name="orientation" >
|
||||||
|
<enum>Qt::Vertical</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeHint" >
|
||||||
|
<size>
|
||||||
|
<width>20</width>
|
||||||
|
<height>40</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</spacer>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QToolButton" name="add_format_button" >
|
||||||
|
<property name="toolTip" >
|
||||||
|
<string>Add a new format for this book</string>
|
||||||
|
</property>
|
||||||
|
<property name="text" >
|
||||||
|
<string>...</string>
|
||||||
|
</property>
|
||||||
|
<property name="icon" >
|
||||||
|
<iconset resource="images.qrc" >:/images/plus.png</iconset>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<spacer>
|
||||||
|
<property name="orientation" >
|
||||||
|
<enum>Qt::Vertical</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeType" >
|
||||||
|
<enum>QSizePolicy::Fixed</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeHint" >
|
||||||
|
<size>
|
||||||
|
<width>26</width>
|
||||||
|
<height>10</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</spacer>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QToolButton" name="remove_format_button" >
|
||||||
|
<property name="toolTip" >
|
||||||
|
<string>Remove the selected formats for this book from the database.</string>
|
||||||
|
</property>
|
||||||
|
<property name="text" >
|
||||||
|
<string>...</string>
|
||||||
|
</property>
|
||||||
|
<property name="icon" >
|
||||||
|
<iconset resource="images.qrc" >:/images/minus.png</iconset>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<spacer>
|
||||||
|
<property name="orientation" >
|
||||||
|
<enum>Qt::Vertical</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeHint" >
|
||||||
|
<size>
|
||||||
|
<width>20</width>
|
||||||
|
<height>40</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</spacer>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item row="0" column="0" >
|
||||||
|
<widget class="QListWidget" name="formats" />
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="1" column="0" >
|
||||||
|
<widget class="QDialogButtonBox" name="button_box" >
|
||||||
|
<property name="orientation" >
|
||||||
|
<enum>Qt::Horizontal</enum>
|
||||||
|
</property>
|
||||||
|
<property name="standardButtons" >
|
||||||
|
<set>QDialogButtonBox::Cancel|QDialogButtonBox::NoButton|QDialogButtonBox::Ok</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
<resources>
|
||||||
|
<include location="images.qrc" />
|
||||||
|
</resources>
|
||||||
|
<connections>
|
||||||
|
<connection>
|
||||||
|
<sender>button_box</sender>
|
||||||
|
<signal>rejected()</signal>
|
||||||
|
<receiver>BookEditDialog</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>
|
||||||
|
<connection>
|
||||||
|
<sender>button_box</sender>
|
||||||
|
<signal>accepted()</signal>
|
||||||
|
<receiver>BookEditDialog</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>
|
||||||
|
</connections>
|
||||||
|
</ui>
|
14
src/libprs500/gui/images.qrc
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE RCC><RCC version="1.0">
|
||||||
|
<qresource>
|
||||||
|
<file alias="icon">images/library.png</file>
|
||||||
|
<file alias="default_cover">images/cherubs.jpg</file>
|
||||||
|
<file alias="library">images/library.png</file>
|
||||||
|
<file alias="reader">images/reader.png</file>
|
||||||
|
<file alias="card">images/memory_stick_unmount.png</file>
|
||||||
|
<file>images/clear.png</file>
|
||||||
|
<file>images/minus.png</file>
|
||||||
|
<file>images/plus.png</file>
|
||||||
|
<file>images/edit.png</file>
|
||||||
|
<file>images/fileopen.png</file>
|
||||||
|
</qresource>
|
||||||
|
</RCC>
|
BIN
src/libprs500/gui/images/cherubs.jpg
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
src/libprs500/gui/images/clear.png
Normal file
After Width: | Height: | Size: 929 B |
BIN
src/libprs500/gui/images/edit.png
Normal file
After Width: | Height: | Size: 205 B |
BIN
src/libprs500/gui/images/fileopen.png
Normal file
After Width: | Height: | Size: 2.2 KiB |
BIN
src/libprs500/gui/images/library.png
Normal file
After Width: | Height: | Size: 31 KiB |
BIN
src/libprs500/gui/images/memory_stick_unmount.png
Normal file
After Width: | Height: | Size: 4.5 KiB |
BIN
src/libprs500/gui/images/minus.png
Normal file
After Width: | Height: | Size: 532 B |
BIN
src/libprs500/gui/images/plus.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
src/libprs500/gui/images/reader.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
5961
src/libprs500/gui/images_rc.py
Normal file
648
src/libprs500/gui/main.py
Normal file
@ -0,0 +1,648 @@
|
|||||||
|
## Copyright (C) 2006 Kovid Goyal kovid@kovidgoyal.net
|
||||||
|
## This program is free software; you can redistribute it and/or modify
|
||||||
|
## it under the terms of the GNU General Public License as published by
|
||||||
|
## the Free Software Foundation; either version 2 of the License, or
|
||||||
|
## (at your option) any later version.
|
||||||
|
##
|
||||||
|
## This program is distributed in the hope that it will be useful,
|
||||||
|
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
## GNU General Public License for more details.
|
||||||
|
##
|
||||||
|
## You should have received a copy of the GNU General Public License along
|
||||||
|
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.Warning
|
||||||
|
""" Create and launch the GUI """
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
import traceback
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from PyQt4.QtCore import Qt, SIGNAL, QObject, QCoreApplication, \
|
||||||
|
QSettings, QVariant, QSize, QEventLoop, QString, \
|
||||||
|
QBuffer, QIODevice, QModelIndex
|
||||||
|
from PyQt4.QtGui import QPixmap, QErrorMessage, \
|
||||||
|
QMessageBox, QFileDialog, QIcon, QDialog
|
||||||
|
from PyQt4.Qt import qDebug, qFatal, qWarning, qCritical
|
||||||
|
|
||||||
|
from libprs500.communicate import PRS500Device as device
|
||||||
|
from libprs500.books import fix_ids
|
||||||
|
from libprs500.errors import *
|
||||||
|
from libprs500.gui import import_ui, installErrorHandler, Error, _Warning, \
|
||||||
|
extension, APP_TITLE
|
||||||
|
from libprs500.gui.widgets import LibraryBooksModel, DeviceBooksModel, \
|
||||||
|
DeviceModel
|
||||||
|
from database import LibraryDatabase
|
||||||
|
from editbook import EditBookDialog
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_BOOK_COVER = None
|
||||||
|
LIBRARY_BOOK_TEMPLATE = QString("<table><tr><td><b>Formats:</b> %1 \
|
||||||
|
</td><td><b>Tags:</b> %2</td></tr> \
|
||||||
|
<tr><td colspan='2'><b>Comments:</b> %3</td>\
|
||||||
|
</tr></table>")
|
||||||
|
DEVICE_BOOK_TEMPLATE = QString("<table><tr><td><b>Title: </b>%1</td><td> \
|
||||||
|
<b> Size:</b> %2</td></tr>\
|
||||||
|
<tr><td><b>Author: </b>%3</td>\
|
||||||
|
<td><b> Type: </b>%4</td></tr></table>")
|
||||||
|
|
||||||
|
Ui_MainWindow = import_ui("main.ui")
|
||||||
|
class Main(QObject, Ui_MainWindow):
|
||||||
|
def report_error(func):
|
||||||
|
"""
|
||||||
|
Decorator to ensure that unhandled exceptions are displayed
|
||||||
|
to users via the GUI
|
||||||
|
"""
|
||||||
|
def function(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except Exception, e:
|
||||||
|
Error("There was an error calling " + func.__name__, e)
|
||||||
|
raise
|
||||||
|
return function
|
||||||
|
|
||||||
|
""" Create GUI """
|
||||||
|
def show_device(self, yes):
|
||||||
|
"""
|
||||||
|
If C{yes} show the items on the device otherwise show the items
|
||||||
|
in the library
|
||||||
|
"""
|
||||||
|
self.device_view.selectionModel().reset()
|
||||||
|
self.library_view.selectionModel().reset()
|
||||||
|
self.book_cover.hide()
|
||||||
|
self.book_info.hide()
|
||||||
|
if yes:
|
||||||
|
self.action_add.setEnabled(False)
|
||||||
|
self.action_edit.setEnabled(False)
|
||||||
|
self.device_view.show()
|
||||||
|
self.library_view.hide()
|
||||||
|
self.book_cover.setAcceptDrops(False)
|
||||||
|
self.device_view.resizeColumnsToContents()
|
||||||
|
self.device_view.resizeRowsToContents()
|
||||||
|
else:
|
||||||
|
self.action_add.setEnabled(True)
|
||||||
|
self.action_edit.setEnabled(True)
|
||||||
|
self.device_view.hide()
|
||||||
|
self.library_view.show()
|
||||||
|
self.book_cover.setAcceptDrops(True)
|
||||||
|
self.current_view.sortByColumn(3, Qt.DescendingOrder)
|
||||||
|
|
||||||
|
|
||||||
|
def tree_clicked(self, index):
|
||||||
|
if index.isValid():
|
||||||
|
self.search.clear()
|
||||||
|
show_dev = True
|
||||||
|
model = self.device_tree.model()
|
||||||
|
if model.is_library(index):
|
||||||
|
show_dev = False
|
||||||
|
elif model.is_reader(index):
|
||||||
|
self.device_view.setModel(self.reader_model)
|
||||||
|
QObject.connect(self.device_view.selectionModel(), \
|
||||||
|
SIGNAL("currentChanged(QModelIndex, QModelIndex)"), \
|
||||||
|
self.show_book)
|
||||||
|
elif model.is_card(index):
|
||||||
|
self.device_view.setModel(self.card_model)
|
||||||
|
QObject.connect(self.device_view.selectionModel(), \
|
||||||
|
SIGNAL("currentChanged(QModelIndex, QModelIndex)"), \
|
||||||
|
self.show_book)
|
||||||
|
self.show_device(show_dev)
|
||||||
|
|
||||||
|
|
||||||
|
def model_modified(self):
|
||||||
|
if self.current_view.selectionModel():
|
||||||
|
self.current_view.selectionModel().reset()
|
||||||
|
self.current_view.resizeColumnsToContents()
|
||||||
|
self.current_view.resizeRowsToContents()
|
||||||
|
self.book_cover.hide()
|
||||||
|
self.book_info.hide()
|
||||||
|
QCoreApplication.processEvents(QEventLoop.ExcludeUserInputEvents)
|
||||||
|
|
||||||
|
def resize_rows_and_columns(self, topleft, bottomright):
|
||||||
|
for c in range(topleft.column(), bottomright.column()+1):
|
||||||
|
self.current_view.resizeColumnToContents(c)
|
||||||
|
for r in range(topleft.row(), bottomright.row()+1):
|
||||||
|
self.current_view.resizeRowToContents(c)
|
||||||
|
|
||||||
|
def show_book(self, current, previous):
|
||||||
|
if not len(self.current_view.selectedIndexes()):
|
||||||
|
return
|
||||||
|
if self.library_view.isVisible():
|
||||||
|
formats, tags, comments, cover = self.library_model\
|
||||||
|
.info(current.row())
|
||||||
|
data = LIBRARY_BOOK_TEMPLATE.arg(formats).arg(tags).arg(comments)
|
||||||
|
tooltip = "To save the cover, drag it to the desktop.<br>To \
|
||||||
|
change the cover drag the new cover onto this picture"
|
||||||
|
else:
|
||||||
|
title, author, size, mime, cover = self.device_view.model()\
|
||||||
|
.info(current.row())
|
||||||
|
data = DEVICE_BOOK_TEMPLATE.arg(title).arg(size).arg(author).arg(mime)
|
||||||
|
tooltip = "To save the cover, drag it to the desktop."
|
||||||
|
self.book_info.setText(data)
|
||||||
|
self.book_cover.setToolTip(tooltip)
|
||||||
|
if not cover: cover = DEFAULT_BOOK_COVER
|
||||||
|
self.book_cover.setPixmap(cover)
|
||||||
|
self.book_cover.show()
|
||||||
|
self.book_info.show()
|
||||||
|
self.current_view.scrollTo(current)
|
||||||
|
|
||||||
|
def formats_added(self, index):
|
||||||
|
if index == self.library_view.currentIndex():
|
||||||
|
self.show_book(index, index)
|
||||||
|
|
||||||
|
@report_error
|
||||||
|
def delete(self, action):
|
||||||
|
rows = self.current_view.selectionModel().selectedRows()
|
||||||
|
if not len(rows):
|
||||||
|
return
|
||||||
|
count = str(len(rows))
|
||||||
|
ret = QMessageBox.question(self.window, self.trUtf8(APP_TITLE + \
|
||||||
|
" - confirm"), self.trUtf8("Are you sure you want to \
|
||||||
|
<b>permanently delete</b> these ") +count+self.trUtf8(" item(s)?"), \
|
||||||
|
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
|
||||||
|
if ret != QMessageBox.Yes:
|
||||||
|
return
|
||||||
|
self.window.setCursor(Qt.WaitCursor)
|
||||||
|
if self.library_view.isVisible():
|
||||||
|
self.library_model.delete(self.library_view.selectionModel()\
|
||||||
|
.selectedRows())
|
||||||
|
else:
|
||||||
|
self.status("Deleting files from device")
|
||||||
|
paths = self.device_view.model().delete(rows)
|
||||||
|
for path in paths:
|
||||||
|
self.status("Deleting "+path[path.rfind("/")+1:])
|
||||||
|
self.dev.del_file(path, end_session=False)
|
||||||
|
fix_ids(self.reader_model.booklist, self.card_model.booklist)
|
||||||
|
self.status("Syncing media list to reader")
|
||||||
|
self.dev.upload_book_list(self.reader_model.booklist)
|
||||||
|
if len(self.card_model.booklist):
|
||||||
|
self.status("Syncing media list to card")
|
||||||
|
self.dev.upload_book_list(self.card_model.booklist)
|
||||||
|
self.update_availabe_space()
|
||||||
|
self.show_book(self.current_view.currentIndex(), QModelIndex())
|
||||||
|
self.window.setCursor(Qt.ArrowCursor)
|
||||||
|
|
||||||
|
def read_settings(self):
|
||||||
|
settings = QSettings()
|
||||||
|
settings.beginGroup("MainWindow")
|
||||||
|
self.window.resize(settings.value("size", QVariant(QSize(1000, 700))).\
|
||||||
|
toSize())
|
||||||
|
settings.endGroup()
|
||||||
|
self.database_path = settings.value("database path", QVariant(os.path\
|
||||||
|
.expanduser("~/library.db"))).toString()
|
||||||
|
|
||||||
|
def write_settings(self):
|
||||||
|
settings = QSettings()
|
||||||
|
settings.beginGroup("MainWindow")
|
||||||
|
settings.setValue("size", QVariant(self.window.size()))
|
||||||
|
settings.endGroup()
|
||||||
|
|
||||||
|
def close_event(self, e):
|
||||||
|
self.write_settings()
|
||||||
|
e.accept()
|
||||||
|
|
||||||
|
def add(self, action):
|
||||||
|
settings = QSettings()
|
||||||
|
_dir = settings.value("add books dialog dir", \
|
||||||
|
QVariant(os.path.expanduser("~"))).toString()
|
||||||
|
files = QFileDialog.getOpenFileNames(self.window, \
|
||||||
|
"Choose books to add to library", _dir, \
|
||||||
|
"Books (*.lrf *.lrx *.rtf *.pdf *.txt);;All files (*)")
|
||||||
|
if not files.isEmpty():
|
||||||
|
x = unicode(files[0].toUtf8(), 'utf-8')
|
||||||
|
settings.setValue("add books dialog dir", \
|
||||||
|
QVariant(os.path.dirname(x)))
|
||||||
|
files = unicode(files.join("|||").toUtf8(), 'utf-8').split("|||")
|
||||||
|
self.add_books(files)
|
||||||
|
|
||||||
|
@report_error
|
||||||
|
def add_books(self, files):
|
||||||
|
self.window.setCursor(Qt.WaitCursor)
|
||||||
|
try:
|
||||||
|
for _file in files:
|
||||||
|
_file = os.path.abspath(_file)
|
||||||
|
self.library_view.model().add_book(_file)
|
||||||
|
if self.library_view.isVisible():
|
||||||
|
if len(str(self.search.text())):
|
||||||
|
self.search.clear()
|
||||||
|
else:
|
||||||
|
self.library_model.search("")
|
||||||
|
else:
|
||||||
|
self.library_model.search("")
|
||||||
|
hv = self.library_view.horizontalHeader()
|
||||||
|
col = hv.sortIndicatorSection()
|
||||||
|
order = hv.sortIndicatorOrder()
|
||||||
|
self.library_view.model().sort(col, order)
|
||||||
|
finally:
|
||||||
|
self.window.setCursor(Qt.ArrowCursor)
|
||||||
|
|
||||||
|
@report_error
|
||||||
|
def edit(self, action):
|
||||||
|
if self.library_view.isVisible():
|
||||||
|
rows = self.library_view.selectionModel().selectedRows()
|
||||||
|
accepted = False
|
||||||
|
for row in rows:
|
||||||
|
_id = self.library_model.id_from_index(row)
|
||||||
|
dialog = QDialog(self.window)
|
||||||
|
ebd = EditBookDialog(dialog, _id, self.library_model.db)
|
||||||
|
if dialog.exec_() == QDialog.Accepted:
|
||||||
|
accepted = True
|
||||||
|
title = unicode(ebd.title.text().toUtf8(), 'utf-8').strip()
|
||||||
|
authors = unicode(ebd.authors.text().toUtf8(), 'utf-8').strip()
|
||||||
|
rating = ebd.rating.value()
|
||||||
|
tags = unicode(ebd.tags.text().toUtf8(), 'utf-8').strip()
|
||||||
|
publisher = unicode(ebd.publisher.text().toUtf8(), \
|
||||||
|
'utf-8').strip()
|
||||||
|
comments = unicode(ebd.comments.toPlainText().toUtf8(), \
|
||||||
|
'utf-8').strip()
|
||||||
|
pix = ebd.cover.pixmap()
|
||||||
|
if not pix.isNull():
|
||||||
|
self.update_cover(pix)
|
||||||
|
model = self.library_view.model()
|
||||||
|
if title:
|
||||||
|
index = model.index(row.row(), 0)
|
||||||
|
model.setData(index, QVariant(title), Qt.EditRole)
|
||||||
|
if authors:
|
||||||
|
index = model.index(row.row(), 1)
|
||||||
|
model.setData(index, QVariant(authors), Qt.EditRole)
|
||||||
|
if publisher:
|
||||||
|
index = model.index(row.row(), 5)
|
||||||
|
model.setData(index, QVariant(publisher), Qt.EditRole)
|
||||||
|
index = model.index(row.row(), 4)
|
||||||
|
model.setData(index, QVariant(rating), Qt.EditRole)
|
||||||
|
self.update_tags_and_comments(row, tags, comments)
|
||||||
|
self.library_model.refresh_row(row.row())
|
||||||
|
self.show_book(self.current_view.currentIndex(), QModelIndex())
|
||||||
|
|
||||||
|
|
||||||
|
def update_tags_and_comments(self, index, tags, comments):
|
||||||
|
self.library_model.update_tags_and_comments(index, tags, comments)
|
||||||
|
|
||||||
|
@report_error
|
||||||
|
def update_cover(self, pix):
|
||||||
|
if not pix.isNull():
|
||||||
|
try:
|
||||||
|
self.library_view.model().update_cover(self.library_view\
|
||||||
|
.currentIndex(), pix)
|
||||||
|
self.book_cover.setPixmap(pix)
|
||||||
|
except Exception, e:
|
||||||
|
Error("Unable to change cover", e)
|
||||||
|
|
||||||
|
@report_error
|
||||||
|
def upload_books(self, to, files, ids):
|
||||||
|
oncard = False if to == "reader" else True
|
||||||
|
booklists = (self.reader_model.booklist, self.card_model.booklist)
|
||||||
|
def update_models():
|
||||||
|
hv = self.device_view.horizontalHeader()
|
||||||
|
col = hv.sortIndicatorSection()
|
||||||
|
order = hv.sortIndicatorOrder()
|
||||||
|
model = self.card_model if oncard else self.reader_model
|
||||||
|
model.sort(col, order)
|
||||||
|
if self.device_view.isVisible() and \
|
||||||
|
self.device_view.model() == model:
|
||||||
|
if len(str(self.search.text())):
|
||||||
|
self.search.clear()
|
||||||
|
else:
|
||||||
|
self.device_view.model().search("")
|
||||||
|
else:
|
||||||
|
model.search("")
|
||||||
|
|
||||||
|
def sync_lists():
|
||||||
|
self.status("Syncing media list to device main memory")
|
||||||
|
self.dev.upload_book_list(booklists[0])
|
||||||
|
if len(booklists[1]):
|
||||||
|
self.status("Syncing media list to storage card")
|
||||||
|
self.dev.upload_book_list(booklists[1])
|
||||||
|
|
||||||
|
self.window.setCursor(Qt.WaitCursor)
|
||||||
|
ename = "file"
|
||||||
|
try:
|
||||||
|
if ids:
|
||||||
|
for _id in ids:
|
||||||
|
formats = []
|
||||||
|
info = self.library_view.model().book_info(_id)
|
||||||
|
if info["cover"]:
|
||||||
|
pix = QPixmap()
|
||||||
|
pix.loadFromData(str(info["cover"]))
|
||||||
|
if pix.isNull():
|
||||||
|
pix = DEFAULT_BOOK_COVER
|
||||||
|
pix = pix.scaledToHeight(self.dev.THUMBNAIL_HEIGHT, \
|
||||||
|
Qt.SmoothTransformation)
|
||||||
|
_buffer = QBuffer()
|
||||||
|
_buffer.open(QIODevice.WriteOnly)
|
||||||
|
pix.save(_buffer, "JPEG")
|
||||||
|
info["cover"] = (pix.width(), pix.height(), \
|
||||||
|
str(_buffer.buffer()))
|
||||||
|
ename = info["title"]
|
||||||
|
for f in files:
|
||||||
|
if re.match("libprs500_\S+_......_" + \
|
||||||
|
str(_id) + "_", os.path.basename(f)):
|
||||||
|
formats.append(f)
|
||||||
|
_file = None
|
||||||
|
try:
|
||||||
|
for format in self.dev.FORMATS:
|
||||||
|
for f in formats:
|
||||||
|
if extension(f) == format:
|
||||||
|
_file = f
|
||||||
|
raise StopIteration()
|
||||||
|
except StopIteration: pass
|
||||||
|
if not _file:
|
||||||
|
Error("The library does not have any formats that "+\
|
||||||
|
"can be viewed on the device for " + ename, None)
|
||||||
|
continue
|
||||||
|
f = open(_file, "rb")
|
||||||
|
self.status("Sending "+info["title"]+" to device")
|
||||||
|
try:
|
||||||
|
self.dev.add_book(f, "libprs500_"+str(_id)+"."+\
|
||||||
|
extension(_file), info, booklists, oncard=oncard, \
|
||||||
|
end_session=False)
|
||||||
|
update_models()
|
||||||
|
except PathError, e:
|
||||||
|
if "already exists" in str(e):
|
||||||
|
Error(info["title"] + \
|
||||||
|
" already exists on the device", None)
|
||||||
|
self.progress(100)
|
||||||
|
continue
|
||||||
|
else: raise
|
||||||
|
finally: f.close()
|
||||||
|
sync_lists()
|
||||||
|
else:
|
||||||
|
for _file in files:
|
||||||
|
ename = _file
|
||||||
|
if extension(_file) not in self.dev.FORMATS:
|
||||||
|
Error(ename + " is not in a supported format")
|
||||||
|
continue
|
||||||
|
info = { "title":os.path.basename(_file), \
|
||||||
|
"authors":"Unknown", "cover":(None, None, None) }
|
||||||
|
f = open(_file, "rb")
|
||||||
|
self.status("Sending "+info["title"]+" to device")
|
||||||
|
try:
|
||||||
|
self.dev.add_book(f, os.path.basename(_file), info, \
|
||||||
|
booklists, oncard=oncard, end_session=False)
|
||||||
|
update_models()
|
||||||
|
except PathError, e:
|
||||||
|
if "already exists" in str(e):
|
||||||
|
Error(info["title"] + \
|
||||||
|
" already exists on the device", None)
|
||||||
|
self.progress(100)
|
||||||
|
continue
|
||||||
|
else: raise
|
||||||
|
finally: f.close()
|
||||||
|
sync_lists()
|
||||||
|
except Exception, e:
|
||||||
|
Error("Unable to send "+ename+" to device", e)
|
||||||
|
finally:
|
||||||
|
self.window.setCursor(Qt.ArrowCursor)
|
||||||
|
self.update_availabe_space()
|
||||||
|
|
||||||
|
@apply
|
||||||
|
def current_view():
|
||||||
|
doc = """ The currently visible view """
|
||||||
|
def fget(self):
|
||||||
|
return self.library_view if self.library_view.isVisible() \
|
||||||
|
else self.device_view
|
||||||
|
return property(doc=doc, fget=fget)
|
||||||
|
|
||||||
|
def __init__(self, window, log_packets):
|
||||||
|
QObject.__init__(self)
|
||||||
|
Ui_MainWindow.__init__(self)
|
||||||
|
|
||||||
|
self.dev = device(report_progress=self.progress, log_packets=log_packets)
|
||||||
|
self.setupUi(window)
|
||||||
|
self.card = None
|
||||||
|
self.window = window
|
||||||
|
window.closeEvent = self.close_event
|
||||||
|
self.read_settings()
|
||||||
|
|
||||||
|
# Setup Library Book list
|
||||||
|
self.library_model = LibraryBooksModel(window)
|
||||||
|
self.library_model.set_data(LibraryDatabase(str(self.database_path)))
|
||||||
|
self.library_view.setModel(self.library_model)
|
||||||
|
QObject.connect(self.library_model, SIGNAL("layoutChanged()"), \
|
||||||
|
self.library_view.resizeRowsToContents)
|
||||||
|
QObject.connect(self.library_view.selectionModel(), \
|
||||||
|
SIGNAL("currentChanged(QModelIndex, QModelIndex)"), self.show_book)
|
||||||
|
QObject.connect(self.search, SIGNAL("textChanged(QString)"), \
|
||||||
|
self.library_model.search)
|
||||||
|
QObject.connect(self.library_model, SIGNAL("sorted()"), \
|
||||||
|
self.model_modified)
|
||||||
|
QObject.connect(self.library_model, SIGNAL("searched()"), \
|
||||||
|
self.model_modified)
|
||||||
|
QObject.connect(self.library_model, SIGNAL("deleted()"), \
|
||||||
|
self.model_modified)
|
||||||
|
QObject.connect(self.library_model, \
|
||||||
|
SIGNAL("dataChanged(QModelIndex, QModelIndex)"), \
|
||||||
|
self.resize_rows_and_columns)
|
||||||
|
QObject.connect(self.library_view, \
|
||||||
|
SIGNAL('books_dropped'), self.add_books)
|
||||||
|
QObject.connect(self.library_model, \
|
||||||
|
SIGNAL('formats_added'), self.formats_added)
|
||||||
|
self.library_view.sortByColumn(3, Qt.DescendingOrder)
|
||||||
|
|
||||||
|
# Create Device tree
|
||||||
|
model = DeviceModel(self.device_tree)
|
||||||
|
QObject.connect(self.device_tree, SIGNAL("activated(QModelIndex)"), \
|
||||||
|
self.tree_clicked)
|
||||||
|
QObject.connect(self.device_tree, SIGNAL("clicked(QModelIndex)"), \
|
||||||
|
self.tree_clicked)
|
||||||
|
QObject.connect(model, SIGNAL('books_dropped'), self.add_books)
|
||||||
|
QObject.connect(model, SIGNAL('upload_books'), self.upload_books)
|
||||||
|
self.device_tree.setModel(model)
|
||||||
|
|
||||||
|
# Create Device Book list
|
||||||
|
self.reader_model = DeviceBooksModel(window)
|
||||||
|
self.card_model = DeviceBooksModel(window)
|
||||||
|
self.device_view.setModel(self.reader_model)
|
||||||
|
QObject.connect(self.device_view.selectionModel(), \
|
||||||
|
SIGNAL("currentChanged(QModelIndex, QModelIndex)"), self.show_book)
|
||||||
|
for model in (self.reader_model, self. card_model):
|
||||||
|
QObject.connect(model, SIGNAL("layoutChanged()"), \
|
||||||
|
self.device_view.resizeRowsToContents)
|
||||||
|
QObject.connect(self.search, SIGNAL("textChanged(QString)"), \
|
||||||
|
model.search)
|
||||||
|
QObject.connect(model, SIGNAL("sorted()"), self.model_modified)
|
||||||
|
QObject.connect(model, SIGNAL("searched()"), self.model_modified)
|
||||||
|
QObject.connect(model, SIGNAL("deleted()"), self.model_modified)
|
||||||
|
QObject.connect(model, \
|
||||||
|
SIGNAL("dataChanged(QModelIndex, QModelIndex)"), \
|
||||||
|
self.resize_rows_and_columns)
|
||||||
|
|
||||||
|
# Setup book display
|
||||||
|
self.book_cover.hide()
|
||||||
|
self.book_info.hide()
|
||||||
|
|
||||||
|
# Connect actions
|
||||||
|
QObject.connect(self.action_add, SIGNAL("triggered(bool)"), self.add)
|
||||||
|
QObject.connect(self.action_del, SIGNAL("triggered(bool)"), self.delete)
|
||||||
|
QObject.connect(self.action_edit, SIGNAL("triggered(bool)"), self.edit)
|
||||||
|
|
||||||
|
# DnD setup
|
||||||
|
QObject.connect(self.book_cover, SIGNAL("cover_received(QPixmap)"), \
|
||||||
|
self.update_cover)
|
||||||
|
|
||||||
|
self.detector = DeviceConnectDetector(self.dev)
|
||||||
|
self.connect(self.detector, SIGNAL("device_connected()"), \
|
||||||
|
self.establish_connection)
|
||||||
|
self.connect(self.detector, SIGNAL("device_removed()"), self.device_removed)
|
||||||
|
self.search.setFocus(Qt.OtherFocusReason)
|
||||||
|
self.show_device(False)
|
||||||
|
self.df_template = self.df.text()
|
||||||
|
self.df.setText(self.df_template.arg("").arg("").arg(""))
|
||||||
|
window.show()
|
||||||
|
self.library_view.resizeColumnsToContents()
|
||||||
|
self.library_view.resizeRowsToContents()
|
||||||
|
|
||||||
|
|
||||||
|
def device_removed(self):
|
||||||
|
self.df.setText(self.df_template.arg("").arg("").arg(""))
|
||||||
|
self.device_tree.hide_reader(True)
|
||||||
|
self.device_tree.hide_card(True)
|
||||||
|
self.device_tree.selectionModel().reset()
|
||||||
|
if self.device_view.isVisible():
|
||||||
|
self.device_view.hide()
|
||||||
|
self.library_view.selectionModel().reset()
|
||||||
|
self.library_view.show()
|
||||||
|
self.book_cover.hide()
|
||||||
|
self.book_info.hide()
|
||||||
|
|
||||||
|
def progress(self, val):
|
||||||
|
if val < 0:
|
||||||
|
self.progress_bar.setMaximum(0)
|
||||||
|
else: self.progress_bar.setValue(val)
|
||||||
|
QCoreApplication.processEvents(QEventLoop.ExcludeUserInputEvents)
|
||||||
|
|
||||||
|
def status(self, msg):
|
||||||
|
self.progress_bar.setMaximum(100)
|
||||||
|
self.progress_bar.reset()
|
||||||
|
self.progress_bar.setFormat(msg + ": %p%")
|
||||||
|
self.progress(0)
|
||||||
|
QCoreApplication.processEvents(QEventLoop.ExcludeUserInputEvents)
|
||||||
|
|
||||||
|
def establish_connection(self):
|
||||||
|
self.window.setCursor(Qt.WaitCursor)
|
||||||
|
self.status("Connecting to device")
|
||||||
|
try:
|
||||||
|
info = self.dev.get_device_information(end_session=False)
|
||||||
|
except DeviceBusy, err:
|
||||||
|
qFatal(str(err))
|
||||||
|
except DeviceError, err:
|
||||||
|
self.dev.reconnect()
|
||||||
|
self.thread().msleep(100)
|
||||||
|
return self.establish_connection()
|
||||||
|
except ProtocolError, e:
|
||||||
|
traceback.print_exc(e)
|
||||||
|
qFatal("Unable to connect to device. Please try unplugging and"+\
|
||||||
|
" reconnecting it")
|
||||||
|
self.df.setText(self.df_template.arg("Connected: "+info[0])\
|
||||||
|
.arg(info[1]).arg(info[2]))
|
||||||
|
self.update_availabe_space(end_session=False)
|
||||||
|
self.card = self.dev.card()
|
||||||
|
self.is_connected = True
|
||||||
|
if self.card: self.device_tree.hide_card(False)
|
||||||
|
else: self.device_tree.hide_card(True)
|
||||||
|
self.device_tree.hide_reader(False)
|
||||||
|
self.status("Loading media list from SONY Reader")
|
||||||
|
self.reader_model.set_data(self.dev.books(end_session=False))
|
||||||
|
if self.card: self.status("Loading media list from Storage Card")
|
||||||
|
self.card_model.set_data(self.dev.books(oncard=True))
|
||||||
|
self.progress(100)
|
||||||
|
self.window.setCursor(Qt.ArrowCursor)
|
||||||
|
|
||||||
|
def update_availabe_space(self, end_session=True):
|
||||||
|
space = self.dev.free_space(end_session=end_session)
|
||||||
|
sc = space[1] if int(space[1])>0 else space[2]
|
||||||
|
self.device_tree.model().update_free_space(space[0], sc)
|
||||||
|
|
||||||
|
class LockFile(object):
|
||||||
|
def __init__(self, path):
|
||||||
|
self.path = path
|
||||||
|
f = open(path, "w")
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
if os.access(self.path, os.F_OK): os.remove(self.path)
|
||||||
|
|
||||||
|
class DeviceConnectDetector(QObject):
|
||||||
|
|
||||||
|
def timerEvent(self, e):
|
||||||
|
if e.timerId() == self.device_detector:
|
||||||
|
is_connected = self.dev.is_connected()
|
||||||
|
if is_connected and not self.is_connected:
|
||||||
|
self.emit(SIGNAL("device_connected()"))
|
||||||
|
self.is_connected = True
|
||||||
|
elif not is_connected and self.is_connected:
|
||||||
|
self.emit(SIGNAL("device_removed()"))
|
||||||
|
self.is_connected = False
|
||||||
|
|
||||||
|
def udi_is_device(self, udi):
|
||||||
|
ans = False
|
||||||
|
try:
|
||||||
|
devobj = bus.get_object('org.freedesktop.Hal', udi)
|
||||||
|
dev = dbus.Interface(devobj, "org.freedesktop.Hal.Device")
|
||||||
|
properties = dev.GetAllProperties()
|
||||||
|
vendor_id = int(properties["usb_device.vendor_id"]),
|
||||||
|
product_id = int(properties["usb_device.product_id"])
|
||||||
|
if self.dev.signature() == (vendor_id, product_id): ans = True
|
||||||
|
except:
|
||||||
|
self.device_detector = self.startTimer(1000)
|
||||||
|
return ans
|
||||||
|
|
||||||
|
def device_added_callback(self, udi):
|
||||||
|
if self.udi_is_device(udi):
|
||||||
|
self.emit(SIGNAL("device_connected()"))
|
||||||
|
|
||||||
|
def device_removed_callback(self, udi):
|
||||||
|
if self.udi_is_device(udi):
|
||||||
|
self.emit(SIGNAL("device_removed()"))
|
||||||
|
|
||||||
|
def __init__(self, dev):
|
||||||
|
QObject.__init__(self)
|
||||||
|
self.dev = dev
|
||||||
|
try:
|
||||||
|
raise Exception("DBUS doesn't support the Qt mainloop")
|
||||||
|
import dbus
|
||||||
|
bus = dbus.SystemBus()
|
||||||
|
hal_manager_obj = bus.get_object('org.freedesktop.Hal',\
|
||||||
|
'/org/freedesktop/Hal/Manager')
|
||||||
|
hal_manager = dbus.Interface(hal_manager_obj,\
|
||||||
|
'org.freedesktop.Hal.Manager')
|
||||||
|
hal_manager.connect_to_signal('DeviceAdded', \
|
||||||
|
self.device_added_callback)
|
||||||
|
hal_manager.connect_to_signal('DeviceRemoved', \
|
||||||
|
self.device_removed_callback)
|
||||||
|
except Exception, e:
|
||||||
|
#_Warning("Could not connect to HAL", e)
|
||||||
|
self.is_connected = False
|
||||||
|
self.device_detector = self.startTimer(1000)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
from optparse import OptionParser
|
||||||
|
from libprs500 import __version__ as VERSION
|
||||||
|
lock = os.path.join(tempfile.gettempdir(),"libprs500_gui_lock")
|
||||||
|
if os.access(lock, os.F_OK):
|
||||||
|
print >>sys.stderr, "Another instance of", APP_TITLE, "is running"
|
||||||
|
print >>sys.stderr, "If you are sure this is not the case then "+\
|
||||||
|
"manually delete the file", lock
|
||||||
|
sys.exit(1)
|
||||||
|
parser = OptionParser(usage="usage: %prog [options]", version=VERSION)
|
||||||
|
parser.add_option("--log-packets", help="print out packet stream to stdout. "+\
|
||||||
|
"The numbers in the left column are byte offsets that allow"+\
|
||||||
|
" the packet size to be read off easily.", \
|
||||||
|
dest="log_packets", action="store_true", default=False)
|
||||||
|
options, args = parser.parse_args()
|
||||||
|
from PyQt4.Qt import QApplication, QMainWindow
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
global DEFAULT_BOOK_COVER
|
||||||
|
DEFAULT_BOOK_COVER = QPixmap(":/default_cover")
|
||||||
|
window = QMainWindow()
|
||||||
|
window.setWindowTitle(APP_TITLE)
|
||||||
|
window.setWindowIcon(QIcon(":/icon"))
|
||||||
|
installErrorHandler(QErrorMessage(window))
|
||||||
|
QCoreApplication.setOrganizationName("KovidsBrain")
|
||||||
|
QCoreApplication.setApplicationName(APP_TITLE)
|
||||||
|
Main(window, options.log_packets)
|
||||||
|
lock = LockFile(lock)
|
||||||
|
return app.exec_()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
393
src/libprs500/gui/main.ui
Normal file
@ -0,0 +1,393 @@
|
|||||||
|
<ui version="4.0" >
|
||||||
|
<author>Kovid Goyal</author>
|
||||||
|
<class>MainWindow</class>
|
||||||
|
<widget class="QMainWindow" name="MainWindow" >
|
||||||
|
<property name="geometry" >
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>728</width>
|
||||||
|
<height>822</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="sizePolicy" >
|
||||||
|
<sizepolicy>
|
||||||
|
<hsizetype>5</hsizetype>
|
||||||
|
<vsizetype>5</vsizetype>
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle" >
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
<property name="windowIcon" >
|
||||||
|
<iconset resource="images.qrc" >:/images/library.png</iconset>
|
||||||
|
</property>
|
||||||
|
<widget class="QWidget" name="centralwidget" >
|
||||||
|
<layout class="QVBoxLayout" >
|
||||||
|
<property name="margin" >
|
||||||
|
<number>9</number>
|
||||||
|
</property>
|
||||||
|
<property name="spacing" >
|
||||||
|
<number>6</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" >
|
||||||
|
<property name="margin" >
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<property name="spacing" >
|
||||||
|
<number>6</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<widget class="DeviceView" name="device_tree" >
|
||||||
|
<property name="sizePolicy" >
|
||||||
|
<sizepolicy>
|
||||||
|
<hsizetype>5</hsizetype>
|
||||||
|
<vsizetype>5</vsizetype>
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize" >
|
||||||
|
<size>
|
||||||
|
<width>10000</width>
|
||||||
|
<height>95</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="verticalScrollBarPolicy" >
|
||||||
|
<enum>Qt::ScrollBarAlwaysOff</enum>
|
||||||
|
</property>
|
||||||
|
<property name="horizontalScrollBarPolicy" >
|
||||||
|
<enum>Qt::ScrollBarAlwaysOff</enum>
|
||||||
|
</property>
|
||||||
|
<property name="dragDropMode" >
|
||||||
|
<enum>QAbstractItemView::DragDrop</enum>
|
||||||
|
</property>
|
||||||
|
<property name="flow" >
|
||||||
|
<enum>QListView::TopToBottom</enum>
|
||||||
|
</property>
|
||||||
|
<property name="spacing" >
|
||||||
|
<number>20</number>
|
||||||
|
</property>
|
||||||
|
<property name="viewMode" >
|
||||||
|
<enum>QListView::IconMode</enum>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="df" >
|
||||||
|
<property name="sizePolicy" >
|
||||||
|
<sizepolicy>
|
||||||
|
<hsizetype>5</hsizetype>
|
||||||
|
<vsizetype>0</vsizetype>
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text" >
|
||||||
|
<string>For help visit <a href="https://libprs500.kovidgoyal.net/wiki/GuiUsage">http://libprs500.kovidgoyal.net</a><br><br><b>libprs500</b> was created by <b>Kovid Goyal</b> &copy; 2006<br>%1 %2 %3</string>
|
||||||
|
</property>
|
||||||
|
<property name="textFormat" >
|
||||||
|
<enum>Qt::RichText</enum>
|
||||||
|
</property>
|
||||||
|
<property name="openExternalLinks" >
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" >
|
||||||
|
<property name="margin" >
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<property name="spacing" >
|
||||||
|
<number>6</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="label" >
|
||||||
|
<property name="text" >
|
||||||
|
<string>&Search:</string>
|
||||||
|
</property>
|
||||||
|
<property name="buddy" >
|
||||||
|
<cstring>search</cstring>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLineEdit" name="search" >
|
||||||
|
<property name="enabled" >
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="acceptDrops" >
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="toolTip" >
|
||||||
|
<string>Search the list of books by title or author<br><br>Words separated by spaces are ANDed</string>
|
||||||
|
</property>
|
||||||
|
<property name="whatsThis" >
|
||||||
|
<string>Search the list of books by title or author<br><br>Words separated by spaces are ANDed</string>
|
||||||
|
</property>
|
||||||
|
<property name="autoFillBackground" >
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="text" >
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
<property name="frame" >
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QToolButton" name="clear_button" >
|
||||||
|
<property name="toolTip" >
|
||||||
|
<string>Reset Quick Search</string>
|
||||||
|
</property>
|
||||||
|
<property name="text" >
|
||||||
|
<string>...</string>
|
||||||
|
</property>
|
||||||
|
<property name="icon" >
|
||||||
|
<iconset resource="images.qrc" >:/images/clear.png</iconset>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QGridLayout" >
|
||||||
|
<property name="margin" >
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<property name="spacing" >
|
||||||
|
<number>6</number>
|
||||||
|
</property>
|
||||||
|
<item row="0" column="0" >
|
||||||
|
<widget class="DeviceBooksView" name="device_view" >
|
||||||
|
<property name="sizePolicy" >
|
||||||
|
<sizepolicy>
|
||||||
|
<hsizetype>5</hsizetype>
|
||||||
|
<vsizetype>5</vsizetype>
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="dragEnabled" >
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="dragDropOverwriteMode" >
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="alternatingRowColors" >
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="selectionBehavior" >
|
||||||
|
<enum>QAbstractItemView::SelectRows</enum>
|
||||||
|
</property>
|
||||||
|
<property name="showGrid" >
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="1" column="0" >
|
||||||
|
<widget class="LibraryBooksView" name="library_view" >
|
||||||
|
<property name="sizePolicy" >
|
||||||
|
<sizepolicy>
|
||||||
|
<hsizetype>5</hsizetype>
|
||||||
|
<vsizetype>5</vsizetype>
|
||||||
|
<horstretch>0</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>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" >
|
||||||
|
<property name="margin" >
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<property name="spacing" >
|
||||||
|
<number>6</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<widget class="CoverDisplay" name="book_cover" >
|
||||||
|
<property name="maximumSize" >
|
||||||
|
<size>
|
||||||
|
<width>60</width>
|
||||||
|
<height>80</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="acceptDrops" >
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="pixmap" >
|
||||||
|
<pixmap resource="images.qrc" >:/images/cherubs.jpg</pixmap>
|
||||||
|
</property>
|
||||||
|
<property name="scaledContents" >
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="book_info" >
|
||||||
|
<property name="text" >
|
||||||
|
<string><table><tr><td><b>Title: </b>%1</td><td><b>&nbsp;Size:</b> %2</td></tr><tr><td><b>Author: </b>%3</td><td><b>&nbsp;Type: </b>%4</td></tr></table></string>
|
||||||
|
</property>
|
||||||
|
<property name="textFormat" >
|
||||||
|
<enum>Qt::RichText</enum>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QProgressBar" name="progress_bar" >
|
||||||
|
<property name="value" >
|
||||||
|
<number>100</number>
|
||||||
|
</property>
|
||||||
|
<property name="orientation" >
|
||||||
|
<enum>Qt::Horizontal</enum>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
<widget class="QToolBar" name="tool_bar" >
|
||||||
|
<property name="minimumSize" >
|
||||||
|
<size>
|
||||||
|
<width>163</width>
|
||||||
|
<height>58</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="movable" >
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="orientation" >
|
||||||
|
<enum>Qt::Horizontal</enum>
|
||||||
|
</property>
|
||||||
|
<property name="iconSize" >
|
||||||
|
<size>
|
||||||
|
<width>22</width>
|
||||||
|
<height>22</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="toolButtonStyle" >
|
||||||
|
<enum>Qt::ToolButtonTextUnderIcon</enum>
|
||||||
|
</property>
|
||||||
|
<attribute name="toolBarArea" >
|
||||||
|
<number>4</number>
|
||||||
|
</attribute>
|
||||||
|
<addaction name="action_add" />
|
||||||
|
<addaction name="action_del" />
|
||||||
|
<addaction name="action_edit" />
|
||||||
|
</widget>
|
||||||
|
<action name="action_add" >
|
||||||
|
<property name="icon" >
|
||||||
|
<iconset resource="images.qrc" >:/images/plus.png</iconset>
|
||||||
|
</property>
|
||||||
|
<property name="text" >
|
||||||
|
<string>Add books to Library</string>
|
||||||
|
</property>
|
||||||
|
<property name="shortcut" >
|
||||||
|
<string>A</string>
|
||||||
|
</property>
|
||||||
|
<property name="autoRepeat" >
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
<action name="action_del" >
|
||||||
|
<property name="icon" >
|
||||||
|
<iconset resource="images.qrc" >:/images/minus.png</iconset>
|
||||||
|
</property>
|
||||||
|
<property name="text" >
|
||||||
|
<string>Delete books</string>
|
||||||
|
</property>
|
||||||
|
<property name="shortcut" >
|
||||||
|
<string>Del</string>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
<action name="action_edit" >
|
||||||
|
<property name="icon" >
|
||||||
|
<iconset resource="images.qrc" >:/images/edit.png</iconset>
|
||||||
|
</property>
|
||||||
|
<property name="text" >
|
||||||
|
<string>Edit meta-information</string>
|
||||||
|
</property>
|
||||||
|
<property name="shortcut" >
|
||||||
|
<string>E</string>
|
||||||
|
</property>
|
||||||
|
<property name="autoRepeat" >
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
</widget>
|
||||||
|
<customwidgets>
|
||||||
|
<customwidget>
|
||||||
|
<class>LibraryBooksView</class>
|
||||||
|
<extends>QTableView</extends>
|
||||||
|
<header>widgets.h</header>
|
||||||
|
</customwidget>
|
||||||
|
<customwidget>
|
||||||
|
<class>DeviceBooksView</class>
|
||||||
|
<extends>QTableView</extends>
|
||||||
|
<header>widgets.h</header>
|
||||||
|
</customwidget>
|
||||||
|
<customwidget>
|
||||||
|
<class>CoverDisplay</class>
|
||||||
|
<extends>QLabel</extends>
|
||||||
|
<header>widgets.h</header>
|
||||||
|
</customwidget>
|
||||||
|
<customwidget>
|
||||||
|
<class>DeviceView</class>
|
||||||
|
<extends>QListView</extends>
|
||||||
|
<header>widgets.h</header>
|
||||||
|
</customwidget>
|
||||||
|
</customwidgets>
|
||||||
|
<resources>
|
||||||
|
<include location="images.qrc" />
|
||||||
|
</resources>
|
||||||
|
<connections>
|
||||||
|
<connection>
|
||||||
|
<sender>clear_button</sender>
|
||||||
|
<signal>clicked()</signal>
|
||||||
|
<receiver>search</receiver>
|
||||||
|
<slot>clear()</slot>
|
||||||
|
<hints>
|
||||||
|
<hint type="sourcelabel" >
|
||||||
|
<x>853</x>
|
||||||
|
<y>61</y>
|
||||||
|
</hint>
|
||||||
|
<hint type="destinationlabel" >
|
||||||
|
<x>784</x>
|
||||||
|
<y>58</y>
|
||||||
|
</hint>
|
||||||
|
</hints>
|
||||||
|
</connection>
|
||||||
|
</connections>
|
||||||
|
</ui>
|
859
src/libprs500/gui/widgets.py
Normal file
@ -0,0 +1,859 @@
|
|||||||
|
## Copyright (C) 2006 Kovid Goyal kovid@kovidgoyal.net
|
||||||
|
## This program is free software; you can redistribute it and/or modify
|
||||||
|
## it under the terms of the GNU General Public License as published by
|
||||||
|
## the Free Software Foundation; either version 2 of the License, or
|
||||||
|
## (at your option) any later version.
|
||||||
|
##
|
||||||
|
## This program is distributed in the hope that it will be useful,
|
||||||
|
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
## GNU General Public License for more details.
|
||||||
|
##
|
||||||
|
## You should have received a copy of the GNU General Public License along
|
||||||
|
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
import textwrap
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
import sys
|
||||||
|
from operator import itemgetter, attrgetter
|
||||||
|
from socket import gethostname
|
||||||
|
from urlparse import urlparse, urlunparse
|
||||||
|
from urllib import quote, unquote
|
||||||
|
from math import sin, cos, pi
|
||||||
|
|
||||||
|
from libprs500.gui import Error, _Warning
|
||||||
|
from libprs500.ptempfile import PersistentTemporaryFile
|
||||||
|
|
||||||
|
from PyQt4 import QtGui, QtCore
|
||||||
|
from PyQt4.QtCore import Qt, SIGNAL
|
||||||
|
from PyQt4.Qt import QApplication, QString, QFont, QAbstractListModel, \
|
||||||
|
QVariant, QAbstractTableModel, QTableView, QListView, \
|
||||||
|
QLabel, QAbstractItemView, QPixmap, QIcon, QSize, \
|
||||||
|
QMessageBox, QSettings, QFileDialog, QErrorMessage, \
|
||||||
|
QSpinBox, QPoint, \
|
||||||
|
QIODevice, QPainterPath, QItemDelegate, QPainter, QPen, \
|
||||||
|
QColor, QLinearGradient, QBrush, QStyle, QStringList, \
|
||||||
|
QByteArray, QBuffer, QMimeData, QTextStream, QIODevice, \
|
||||||
|
QDrag, QRect
|
||||||
|
|
||||||
|
NONE = QVariant() #: Null value to return from the data function of item models
|
||||||
|
TIME_WRITE_FMT = "%d %b %Y" #: The display format used to show dates
|
||||||
|
|
||||||
|
class FileDragAndDrop(object):
|
||||||
|
_drag_start_position = QPoint()
|
||||||
|
_dragged_files = []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _bytes_to_string(cls, qba):
|
||||||
|
"""
|
||||||
|
Assumes qba is encoded in ASCII which is usually fine, since
|
||||||
|
this method is used mainly for escaped URIs.
|
||||||
|
@type qba: QByteArray
|
||||||
|
"""
|
||||||
|
return str(QString.fromAscii(qba.data())).strip()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_r_ok_files(cls, event):
|
||||||
|
"""
|
||||||
|
Return list of paths from event that point to files to
|
||||||
|
which the user has read permission.
|
||||||
|
"""
|
||||||
|
files = []
|
||||||
|
md = event.mimeData()
|
||||||
|
if md.hasFormat("text/uri-list"):
|
||||||
|
candidates = cls._bytes_to_string(md.data("text/uri-list")).split()
|
||||||
|
for url in candidates:
|
||||||
|
o = urlparse(url)
|
||||||
|
if o.scheme and o.scheme != 'file':
|
||||||
|
_Warning(o.scheme + " not supported in drop events", None)
|
||||||
|
continue
|
||||||
|
path = unquote(o.path)
|
||||||
|
if not os.access(path, os.R_OK):
|
||||||
|
_Warning("You do not have read permission for: " + path, None)
|
||||||
|
continue
|
||||||
|
if os.path.isdir(path):
|
||||||
|
root, dirs, files2 = os.walk(path)
|
||||||
|
for _file in files2:
|
||||||
|
path = root + _file
|
||||||
|
if os.access(path, os.R_OK):
|
||||||
|
files.append(path)
|
||||||
|
else:
|
||||||
|
files.append(path)
|
||||||
|
return files
|
||||||
|
|
||||||
|
def __init__(self, QtBaseClass, enable_drag=True):
|
||||||
|
self.QtBaseClass = QtBaseClass
|
||||||
|
self.enable_drag = enable_drag
|
||||||
|
|
||||||
|
def mousePressEvent(self, event):
|
||||||
|
self.QtBaseClass.mousePressEvent(self, event)
|
||||||
|
if self.enable_drag:
|
||||||
|
if event.button == Qt.LeftButton:
|
||||||
|
self._drag_start_position = event.pos()
|
||||||
|
|
||||||
|
|
||||||
|
def mouseMoveEvent(self, event):
|
||||||
|
self.QtBaseClass.mousePressEvent(self, event)
|
||||||
|
if self.enable_drag:
|
||||||
|
if event.buttons() & Qt.LeftButton != Qt.LeftButton:
|
||||||
|
return
|
||||||
|
if (event.pos() - self._drag_start_position).manhattanLength() < \
|
||||||
|
QApplication.startDragDistance():
|
||||||
|
return
|
||||||
|
self.start_drag(self._drag_start_position)
|
||||||
|
|
||||||
|
|
||||||
|
def start_drag(self, pos):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def dragEnterEvent(self, event):
|
||||||
|
if event.mimeData().hasFormat("text/uri-list"):
|
||||||
|
event.acceptProposedAction()
|
||||||
|
|
||||||
|
def dragMoveEvent(self, event):
|
||||||
|
event.acceptProposedAction()
|
||||||
|
|
||||||
|
def dropEvent(self, event):
|
||||||
|
files = self._get_r_ok_files(event)
|
||||||
|
if files:
|
||||||
|
try:
|
||||||
|
event.setDropAction(Qt.CopyAction)
|
||||||
|
if self.files_dropped(files, event):
|
||||||
|
event.accept()
|
||||||
|
except Exception, e:
|
||||||
|
Error("There was an error processing the dropped files.", e)
|
||||||
|
raise e
|
||||||
|
|
||||||
|
|
||||||
|
def files_dropped(self, files, event):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def drag_object_from_files(self, files):
|
||||||
|
if files:
|
||||||
|
drag = QDrag(self)
|
||||||
|
mime_data = QMimeData()
|
||||||
|
self._dragged_files, urls = [], []
|
||||||
|
for _file in files:
|
||||||
|
urls.append(urlunparse(('file', quote(gethostname()), \
|
||||||
|
quote(_file.name.encode('utf-8')), '','','')))
|
||||||
|
self._dragged_files.append(_file)
|
||||||
|
mime_data.setData("text/uri-list", QByteArray("\n".join(urls)))
|
||||||
|
user = os.getenv('USER')
|
||||||
|
if user:
|
||||||
|
mime_data.setData("text/x-xdnd-username", QByteArray(user))
|
||||||
|
drag.setMimeData(mime_data)
|
||||||
|
return drag
|
||||||
|
|
||||||
|
def drag_object(self, extensions):
|
||||||
|
if extensions:
|
||||||
|
files = []
|
||||||
|
for ext in extensions:
|
||||||
|
f = PersistentTemporaryFile(suffix="."+ext)
|
||||||
|
files.append(f)
|
||||||
|
return self.drag_object_from_files(files), self._dragged_files
|
||||||
|
|
||||||
|
|
||||||
|
class TableView(FileDragAndDrop, QTableView):
|
||||||
|
wrapper = textwrap.TextWrapper(width=20)
|
||||||
|
|
||||||
|
def __init__(self, parent):
|
||||||
|
FileDragAndDrop.__init__(self, QTableView)
|
||||||
|
QTableView.__init__(self, parent)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def wrap(cls, s, width=20):
|
||||||
|
cls.wrapper.width = width
|
||||||
|
return cls.wrapper.fill(s)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def human_readable(cls, size):
|
||||||
|
""" Convert a size in bytes into a human readable form """
|
||||||
|
if size < 1024:
|
||||||
|
divisor, suffix = 1, "B"
|
||||||
|
elif 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, "GB"
|
||||||
|
size = str(size/divisor)
|
||||||
|
if size.find(".") > -1:
|
||||||
|
size = size[:size.find(".")+2]
|
||||||
|
return size + " " + suffix
|
||||||
|
|
||||||
|
def render_to_pixmap(self, indices):
|
||||||
|
rect = self.visualRect(indices[0])
|
||||||
|
rects = []
|
||||||
|
for i in range(len(indices)):
|
||||||
|
rects.append(self.visualRect(indices[i]))
|
||||||
|
rect |= rects[i]
|
||||||
|
rect = rect.intersected(self.viewport().rect())
|
||||||
|
pixmap = QPixmap(rect.size())
|
||||||
|
pixmap.fill(self.palette().base().color())
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
option = self.viewOptions()
|
||||||
|
option.state |= QStyle.State_Selected
|
||||||
|
for j in range(len(indices)):
|
||||||
|
option.rect = QRect(rects[j].topLeft() - rect.topLeft(), \
|
||||||
|
rects[j].size())
|
||||||
|
self.itemDelegate(indices[j]).paint(painter, option, indices[j])
|
||||||
|
painter.end()
|
||||||
|
return pixmap
|
||||||
|
|
||||||
|
def drag_object_from_files(self, files):
|
||||||
|
drag = FileDragAndDrop.drag_object_from_files(self, files)
|
||||||
|
drag.setPixmap(self.render_to_pixmap(self.selectedIndexes()))
|
||||||
|
return drag
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class CoverDisplay(FileDragAndDrop, QLabel):
|
||||||
|
def __init__(self, parent):
|
||||||
|
FileDragAndDrop.__init__(self, QLabel)
|
||||||
|
QLabel.__init__(self, parent)
|
||||||
|
def files_dropped(self, files, event):
|
||||||
|
pix = QPixmap()
|
||||||
|
for _file in files:
|
||||||
|
pix = QPixmap(_file)
|
||||||
|
if not pix.isNull(): break
|
||||||
|
if not pix.isNull():
|
||||||
|
self.emit(SIGNAL("cover_received(QPixmap)"), pix)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def start_drag(self, event):
|
||||||
|
drag, files = self.drag_object(["jpeg"])
|
||||||
|
if drag and files:
|
||||||
|
_file = files[0]
|
||||||
|
_file.close()
|
||||||
|
drag.setPixmap(self.pixmap().scaledToHeight(68, \
|
||||||
|
Qt.SmoothTransformation))
|
||||||
|
self.pixmap().save(os.path.abspath(_file.name))
|
||||||
|
drag.start(Qt.MoveAction)
|
||||||
|
|
||||||
|
class DeviceView(FileDragAndDrop, QListView):
|
||||||
|
def __init__(self, parent):
|
||||||
|
FileDragAndDrop.__init__(self, QListView, enable_drag=False)
|
||||||
|
QListView.__init__(self, parent)
|
||||||
|
|
||||||
|
def hide_reader(self, x):
|
||||||
|
self.model().update_devices(reader=not x)
|
||||||
|
|
||||||
|
def hide_card(self, x):
|
||||||
|
self.model().update_devices(card=not x)
|
||||||
|
|
||||||
|
def files_dropped(self, files, event):
|
||||||
|
ids = []
|
||||||
|
md = event.mimeData()
|
||||||
|
if md.hasFormat("application/x-libprs500-id"):
|
||||||
|
ids = [ int(id) for id in FileDragAndDrop._bytes_to_string(\
|
||||||
|
md.data("application/x-libprs500-id")).split()]
|
||||||
|
index = self.indexAt(event.pos())
|
||||||
|
if index.isValid():
|
||||||
|
return self.model().files_dropped(files, index, ids)
|
||||||
|
|
||||||
|
class DeviceBooksView(TableView):
|
||||||
|
def __init__(self, parent):
|
||||||
|
TableView.__init__(self, parent)
|
||||||
|
self.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||||
|
self.setSortingEnabled(True)
|
||||||
|
|
||||||
|
class LibraryBooksView(TableView):
|
||||||
|
def __init__(self, parent):
|
||||||
|
TableView.__init__(self, parent)
|
||||||
|
self.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||||
|
self.setSortingEnabled(True)
|
||||||
|
self.setItemDelegate(LibraryDelegate(self, rating_column=4))
|
||||||
|
|
||||||
|
def dragEnterEvent(self, event):
|
||||||
|
if not event.mimeData().hasFormat("application/x-libprs500-id"):
|
||||||
|
FileDragAndDrop.dragEnterEvent(self, event)
|
||||||
|
|
||||||
|
|
||||||
|
def start_drag(self, pos):
|
||||||
|
index = self.indexAt(pos)
|
||||||
|
if index.isValid():
|
||||||
|
rows = frozenset([ index.row() for index in self.selectedIndexes()])
|
||||||
|
files = self.model().extract_formats(rows)
|
||||||
|
drag = self.drag_object_from_files(files)
|
||||||
|
if drag:
|
||||||
|
ids = [ str(self.model().id_from_row(row)) for row in rows ]
|
||||||
|
drag.mimeData().setData("application/x-libprs500-id", \
|
||||||
|
QByteArray("\n".join(ids)))
|
||||||
|
drag.start()
|
||||||
|
|
||||||
|
|
||||||
|
def files_dropped(self, files, event):
|
||||||
|
if not files: return
|
||||||
|
index = self.indexAt(event.pos())
|
||||||
|
if index.isValid():
|
||||||
|
self.model().add_formats(files, index)
|
||||||
|
else: self.emit(SIGNAL('books_dropped'), files)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryDelegate(QItemDelegate):
|
||||||
|
COLOR = QColor("blue")
|
||||||
|
SIZE = 16
|
||||||
|
PEN = QPen(COLOR, 1, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
|
||||||
|
|
||||||
|
def __init__(self, parent, rating_column=-1):
|
||||||
|
QItemDelegate.__init__(self, parent )
|
||||||
|
self.rating_column = rating_column
|
||||||
|
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):
|
||||||
|
if index.column() != self.rating_column:
|
||||||
|
return QItemDelegate.sizeHint(self, option, index)
|
||||||
|
num = index.model().data(index, Qt.DisplayRole).toInt()[0]
|
||||||
|
return QSize(num*(self.SIZE), self.SIZE+4)
|
||||||
|
|
||||||
|
def paint(self, painter, option, index):
|
||||||
|
if index.column() != self.rating_column:
|
||||||
|
return QItemDelegate.paint(self, painter, option, index)
|
||||||
|
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()
|
||||||
|
try:
|
||||||
|
if option.state & QStyle.State_Selected:
|
||||||
|
painter.fillRect(option.rect, option.palette.highlight())
|
||||||
|
painter.setRenderHint(QPainter.Antialiasing)
|
||||||
|
y = option.rect.center().y()-self.SIZE/2.
|
||||||
|
x = option.rect.right() - self.SIZE
|
||||||
|
painter.setPen(self.PEN)
|
||||||
|
painter.setBrush(self.brush)
|
||||||
|
painter.translate(x, y)
|
||||||
|
for i in range(num):
|
||||||
|
draw_star()
|
||||||
|
painter.translate(-self.SIZE, 0)
|
||||||
|
except Exception, e:
|
||||||
|
traceback.print_exc(e)
|
||||||
|
painter.restore()
|
||||||
|
|
||||||
|
def createEditor(self, parent, option, index):
|
||||||
|
if index.column() != 4:
|
||||||
|
return QItemDelegate.createEditor(self, parent, option, index)
|
||||||
|
editor = QSpinBox(parent)
|
||||||
|
editor.setSuffix(" stars")
|
||||||
|
editor.setMinimum(0)
|
||||||
|
editor.setMaximum(5)
|
||||||
|
editor.installEventFilter(self)
|
||||||
|
return editor
|
||||||
|
|
||||||
|
def setEditorData(self, editor, index):
|
||||||
|
if index.column() != 4:
|
||||||
|
return QItemDelegate.setEditorData(self, editor, index)
|
||||||
|
val = index.model()._data[index.row()]["rating"]
|
||||||
|
if not val: val = 0
|
||||||
|
editor.setValue(val)
|
||||||
|
|
||||||
|
def setModelData(self, editor, model, index):
|
||||||
|
if index.column() != 4:
|
||||||
|
return QItemDelegate.setModelData(self, editor, model, index)
|
||||||
|
editor.interpretText()
|
||||||
|
index.model().setData(index, QVariant(editor.value()), Qt.EditRole)
|
||||||
|
|
||||||
|
def updateEditorGeometry(self, editor, option, index):
|
||||||
|
if index.column() != 4:
|
||||||
|
return QItemDelegate.updateEditorGeometry(self, editor, option, index)
|
||||||
|
editor.setGeometry(option.rect)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryBooksModel(QAbstractTableModel):
|
||||||
|
FIELDS = ["id", "title", "authors", "size", "date", "rating", "publisher", \
|
||||||
|
"tags", "comments"]
|
||||||
|
TIME_READ_FMT = "%Y-%m-%d %H:%M:%S"
|
||||||
|
def __init__(self, parent):
|
||||||
|
QAbstractTableModel.__init__(self, parent)
|
||||||
|
self.db = None
|
||||||
|
self._data = None
|
||||||
|
self._orig_data = None
|
||||||
|
|
||||||
|
def extract_formats(self, rows):
|
||||||
|
files = []
|
||||||
|
for row in rows:
|
||||||
|
_id = self.id_from_row(row)
|
||||||
|
au = self._data[row]["authors"] if self._data[row]["authors"] \
|
||||||
|
else "Unknown"
|
||||||
|
basename = re.sub("\n", "", "_"+str(_id)+"_"+\
|
||||||
|
self._data[row]["title"]+" by "+ au)
|
||||||
|
exts = self.db.get_extensions(_id)
|
||||||
|
for ext in exts:
|
||||||
|
fmt = self.db.get_format(_id, ext)
|
||||||
|
if not ext:
|
||||||
|
ext =""
|
||||||
|
else:
|
||||||
|
ext = "."+ext
|
||||||
|
name = basename+ext
|
||||||
|
file = PersistentTemporaryFile(suffix=name)
|
||||||
|
if not fmt:
|
||||||
|
continue
|
||||||
|
file.write(fmt)
|
||||||
|
file.close()
|
||||||
|
files.append(file)
|
||||||
|
return files
|
||||||
|
|
||||||
|
def update_cover(self, index, pix):
|
||||||
|
spix = pix.scaledToHeight(68, Qt.SmoothTransformation)
|
||||||
|
_id = self.id_from_index(index)
|
||||||
|
qb, sqb = QBuffer(), QBuffer()
|
||||||
|
qb.open(QBuffer.ReadWrite)
|
||||||
|
sqb.open(QBuffer.ReadWrite)
|
||||||
|
pix.save(qb, "JPG")
|
||||||
|
spix.save(sqb, "JPG")
|
||||||
|
data = str(qb.data())
|
||||||
|
sdata = str(sqb.data())
|
||||||
|
qb.close()
|
||||||
|
sqb.close()
|
||||||
|
self.db.update_cover(_id, data, scaled=sdata)
|
||||||
|
self.refresh_row(index.row())
|
||||||
|
|
||||||
|
def add_formats(self, paths, index):
|
||||||
|
for path in paths:
|
||||||
|
f = open(path, "rb")
|
||||||
|
title = os.path.basename(path)
|
||||||
|
ext = title[title.rfind(".")+1:].lower() if "." in title > -1 else None
|
||||||
|
_id = self.id_from_index(index)
|
||||||
|
self.db.add_format(_id, ext, f)
|
||||||
|
f.close()
|
||||||
|
self.refresh_row(index.row())
|
||||||
|
self.emit(SIGNAL('formats_added'), index)
|
||||||
|
|
||||||
|
def rowCount(self, parent):
|
||||||
|
return len(self._data)
|
||||||
|
|
||||||
|
def columnCount(self, parent):
|
||||||
|
return len(self.FIELDS)-3
|
||||||
|
|
||||||
|
def setData(self, index, value, role):
|
||||||
|
done = False
|
||||||
|
if role == Qt.EditRole:
|
||||||
|
row = index.row()
|
||||||
|
_id = self._data[row]["id"]
|
||||||
|
col = index.column()
|
||||||
|
val = unicode(value.toString().toUtf8(), 'utf-8').strip()
|
||||||
|
if col == 0:
|
||||||
|
col = "title"
|
||||||
|
elif col == 1:
|
||||||
|
col = "authors"
|
||||||
|
elif col == 2:
|
||||||
|
return False
|
||||||
|
elif col == 3:
|
||||||
|
return False
|
||||||
|
elif col == 4:
|
||||||
|
col, val = "rating", int(value.toInt()[0])
|
||||||
|
if val < 0:
|
||||||
|
val = 0
|
||||||
|
if val > 5:
|
||||||
|
val = 5
|
||||||
|
elif col == 5:
|
||||||
|
col = "publisher"
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
self.db.set_metadata_item(_id, col, val)
|
||||||
|
self._data[row][col] = val
|
||||||
|
self.emit(SIGNAL("dataChanged(QModelIndex, QModelIndex)"), \
|
||||||
|
index, index)
|
||||||
|
for i in range(len(self._orig_data)):
|
||||||
|
if self._orig_data[i]["id"] == self._data[row]["id"]:
|
||||||
|
self._orig_data[i][col] = self._data[row][col]
|
||||||
|
break
|
||||||
|
done = True
|
||||||
|
return done
|
||||||
|
|
||||||
|
def update_tags_and_comments(self, index, tags, comments):
|
||||||
|
_id = self.id_from_index(index)
|
||||||
|
self.db.set_metadata_item(_id, "tags", tags)
|
||||||
|
self.db.set_metadata_item(_id, "comments", comments)
|
||||||
|
self.refresh_row(index.row())
|
||||||
|
|
||||||
|
def flags(self, index):
|
||||||
|
flags = QAbstractTableModel.flags(self, index)
|
||||||
|
if index.isValid():
|
||||||
|
if index.column() not in [2, 3]:
|
||||||
|
flags |= Qt.ItemIsEditable
|
||||||
|
return flags
|
||||||
|
|
||||||
|
def set_data(self, db):
|
||||||
|
self.db = db
|
||||||
|
self._data = self.db.get_table(self.FIELDS)
|
||||||
|
self._orig_data = self._data
|
||||||
|
self.sort(0, Qt.DescendingOrder)
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def headerData(self, section, orientation, role):
|
||||||
|
if role != Qt.DisplayRole:
|
||||||
|
return NONE
|
||||||
|
text = ""
|
||||||
|
if orientation == Qt.Horizontal:
|
||||||
|
if section == 0: text = "Title"
|
||||||
|
elif section == 1: text = "Author(s)"
|
||||||
|
elif section == 2: text = "Size"
|
||||||
|
elif section == 3: text = "Date"
|
||||||
|
elif section == 4: text = "Rating"
|
||||||
|
elif section == 5: text = "Publisher"
|
||||||
|
return QVariant(self.trUtf8(text))
|
||||||
|
else: return QVariant(str(1+section))
|
||||||
|
|
||||||
|
def info(self, row):
|
||||||
|
row = self._data[row]
|
||||||
|
cover = self.db.get_cover(row["id"])
|
||||||
|
exts = ",".join(self.db.get_extensions(row["id"]))
|
||||||
|
if cover:
|
||||||
|
pix = QPixmap()
|
||||||
|
pix.loadFromData(cover, "", Qt.AutoColor)
|
||||||
|
cover = None if pix.isNull() else pix
|
||||||
|
tags = row["tags"]
|
||||||
|
if not tags: tags = ""
|
||||||
|
comments = row["comments"]
|
||||||
|
if not comments: comments = ""
|
||||||
|
return exts, tags, comments, cover
|
||||||
|
|
||||||
|
def id_from_index(self, index): return self._data[index.row()]["id"]
|
||||||
|
def id_from_row(self, row): return self._data[row]["id"]
|
||||||
|
|
||||||
|
def refresh_row(self, row):
|
||||||
|
datum = self.db.get_row_by_id(self._data[row]["id"], self.FIELDS)
|
||||||
|
self._data[row:row+1] = [datum]
|
||||||
|
for i in range(len(self._orig_data)):
|
||||||
|
if self._orig_data[i]["id"] == datum["id"]:
|
||||||
|
self._orig_data[i:i+1] = [datum]
|
||||||
|
break
|
||||||
|
self.emit(SIGNAL("dataChanged(QModelIndex, QModelIndex)"), \
|
||||||
|
self.index(row, 0), self.index(row, self.columnCount(0)-1))
|
||||||
|
|
||||||
|
def book_info(self, _id):
|
||||||
|
""" Return title, authors and cover in a dict """
|
||||||
|
cover = self.db.get_cover(_id)
|
||||||
|
info = self.db.get_row_by_id(_id, ["title", "authors"])
|
||||||
|
info["cover"] = cover
|
||||||
|
return info
|
||||||
|
|
||||||
|
def data(self, index, role):
|
||||||
|
if role == Qt.DisplayRole or role == Qt.EditRole:
|
||||||
|
row, col = index.row(), index.column()
|
||||||
|
text = None
|
||||||
|
row = self._data[row]
|
||||||
|
if col == 4:
|
||||||
|
r = row["rating"] if row["rating"] else 0
|
||||||
|
if r < 0:
|
||||||
|
r = 0
|
||||||
|
if r > 5:
|
||||||
|
r = 5
|
||||||
|
return QVariant(r)
|
||||||
|
if col == 0:
|
||||||
|
text = TableView.wrap(row["title"], width=35)
|
||||||
|
elif col == 1:
|
||||||
|
au = row["authors"]
|
||||||
|
if au:
|
||||||
|
au = au.split("&")
|
||||||
|
jau = [ TableView.wrap(a, width=30).strip() for a in au ]
|
||||||
|
text = "\n".join(jau)
|
||||||
|
elif col == 2:
|
||||||
|
text = TableView.human_readable(row["size"])
|
||||||
|
elif col == 3:
|
||||||
|
text = time.strftime(TIME_WRITE_FMT, \
|
||||||
|
time.strptime(row["date"], self.TIME_READ_FMT))
|
||||||
|
elif col == 5:
|
||||||
|
pub = row["publisher"]
|
||||||
|
if pub:
|
||||||
|
text = TableView.wrap(pub, 20)
|
||||||
|
if text == None:
|
||||||
|
text = "Unknown"
|
||||||
|
return QVariant(text)
|
||||||
|
elif role == Qt.TextAlignmentRole and index.column() in [2,3,4]:
|
||||||
|
return QVariant(Qt.AlignRight | Qt.AlignVCenter)
|
||||||
|
elif role == Qt.ToolTipRole and index.isValid():
|
||||||
|
if index.column() in [0, 1, 4, 5]:
|
||||||
|
edit = "Double click to <b>edit</b> me<br><br>"
|
||||||
|
else:
|
||||||
|
edit = ""
|
||||||
|
return QVariant(edit + "You can <b>drag and drop</b> me to the \
|
||||||
|
desktop to save all my formats to your hard disk.")
|
||||||
|
return NONE
|
||||||
|
|
||||||
|
def sort(self, col, order):
|
||||||
|
descending = order != Qt.AscendingOrder
|
||||||
|
def getter(key, func):
|
||||||
|
return lambda x : func(itemgetter(key)(x))
|
||||||
|
if col == 0: key, func = "title", lambda x : x.lower()
|
||||||
|
if col == 1: key, func = "authors", lambda x : x.split()[-1:][0].lower()\
|
||||||
|
if x else ""
|
||||||
|
if col == 2: key, func = "size", int
|
||||||
|
if col == 3: key, func = "date", lambda x: time.mktime(\
|
||||||
|
time.strptime(x, self.TIME_READ_FMT))
|
||||||
|
if col == 4: key, func = "rating", lambda x: x if x else 0
|
||||||
|
if col == 5: key, func = "publisher", lambda x : x.lower() if x else ""
|
||||||
|
self.emit(SIGNAL("layoutAboutToBeChanged()"))
|
||||||
|
self._data.sort(key=getter(key, func))
|
||||||
|
if descending: self._data.reverse()
|
||||||
|
self.emit(SIGNAL("layoutChanged()"))
|
||||||
|
self.emit(SIGNAL("sorted()"))
|
||||||
|
|
||||||
|
def search(self, query):
|
||||||
|
def query_in(book, q):
|
||||||
|
au = book["authors"]
|
||||||
|
if not au : au = "unknown"
|
||||||
|
pub = book["publisher"]
|
||||||
|
if not pub : pub = "unknown"
|
||||||
|
return q in book["title"].lower() or q in au.lower() or \
|
||||||
|
q in pub.lower()
|
||||||
|
queries = unicode(query, 'utf-8').lower().split()
|
||||||
|
self.emit(SIGNAL("layoutAboutToBeChanged()"))
|
||||||
|
self._data = []
|
||||||
|
for book in self._orig_data:
|
||||||
|
match = True
|
||||||
|
for q in queries:
|
||||||
|
if query_in(book, q) : continue
|
||||||
|
else:
|
||||||
|
match = False
|
||||||
|
break
|
||||||
|
if match: self._data.append(book)
|
||||||
|
self.emit(SIGNAL("layoutChanged()"))
|
||||||
|
self.emit(SIGNAL("searched()"))
|
||||||
|
|
||||||
|
def delete(self, indices):
|
||||||
|
if len(indices): self.emit(SIGNAL("layoutAboutToBeChanged()"))
|
||||||
|
items = [ self._data[index.row()] for index in indices ]
|
||||||
|
for item in items:
|
||||||
|
_id = item["id"]
|
||||||
|
try:
|
||||||
|
self._data.remove(item)
|
||||||
|
except ValueError: continue
|
||||||
|
self.db.delete_by_id(_id)
|
||||||
|
for x in self._orig_data:
|
||||||
|
if x["id"] == _id: self._orig_data.remove(x)
|
||||||
|
self.emit(SIGNAL("layoutChanged()"))
|
||||||
|
self.emit(SIGNAL("deleted()"))
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def add_book(self, path):
|
||||||
|
""" Must call search and sort on this models view after this """
|
||||||
|
_id = self.db.add_book(path)
|
||||||
|
self._orig_data.append(self.db.get_row_by_id(_id, self.FIELDS))
|
||||||
|
|
||||||
|
class DeviceBooksModel(QAbstractTableModel):
|
||||||
|
@apply
|
||||||
|
def booklist():
|
||||||
|
doc = """ The booklist this model is based on """
|
||||||
|
def fget(self):
|
||||||
|
return self._orig_data
|
||||||
|
return property(doc=doc, fget=fget)
|
||||||
|
|
||||||
|
def __init__(self, parent):
|
||||||
|
QAbstractTableModel.__init__(self, parent)
|
||||||
|
self._data = []
|
||||||
|
self._orig_data = []
|
||||||
|
|
||||||
|
def set_data(self, book_list):
|
||||||
|
self._data = book_list
|
||||||
|
self._orig_data = book_list
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def rowCount(self, parent): return len(self._data)
|
||||||
|
def columnCount(self, parent): return 4
|
||||||
|
|
||||||
|
def headerData(self, section, orientation, role):
|
||||||
|
if role != Qt.DisplayRole:
|
||||||
|
return NONE
|
||||||
|
text = ""
|
||||||
|
if orientation == Qt.Horizontal:
|
||||||
|
if section == 0: text = "Title"
|
||||||
|
elif section == 1: text = "Author(s)"
|
||||||
|
elif section == 2: text = "Size"
|
||||||
|
elif section == 3: text = "Date"
|
||||||
|
return QVariant(self.trUtf8(text))
|
||||||
|
else: return QVariant(str(1+section))
|
||||||
|
|
||||||
|
def data(self, index, role):
|
||||||
|
if role == Qt.DisplayRole:
|
||||||
|
row, col = index.row(), index.column()
|
||||||
|
book = self._data[row]
|
||||||
|
if col == 0:
|
||||||
|
text = TableView.wrap(book.title, width=40)
|
||||||
|
elif col == 1:
|
||||||
|
au = book.author
|
||||||
|
au = au.split("&")
|
||||||
|
jau = [ TableView.wrap(a, width=25).strip() for a in au ]
|
||||||
|
text = "\n".join(jau)
|
||||||
|
elif col == 2:
|
||||||
|
text = TableView.human_readable(book.size)
|
||||||
|
elif col == 3:
|
||||||
|
text = time.strftime(TIME_WRITE_FMT, book.datetime)
|
||||||
|
return QVariant(text)
|
||||||
|
elif role == Qt.TextAlignmentRole and index.column() in [2,3]:
|
||||||
|
return QVariant(Qt.AlignRight | Qt.AlignVCenter)
|
||||||
|
return NONE
|
||||||
|
|
||||||
|
def info(self, row):
|
||||||
|
row = self._data[row]
|
||||||
|
cover = None
|
||||||
|
try:
|
||||||
|
cover = row.thumbnail
|
||||||
|
pix = QPixmap()
|
||||||
|
pix.loadFromData(cover, "", Qt.AutoColor)
|
||||||
|
cover = None if pix.isNull() else pix
|
||||||
|
except:
|
||||||
|
traceback.print_exc()
|
||||||
|
au = row.author if row.author else "Unknown"
|
||||||
|
return row.title, au, TableView.human_readable(row.size), row.mime, cover
|
||||||
|
|
||||||
|
def sort(self, col, order):
|
||||||
|
def getter(key, func):
|
||||||
|
return lambda x : func(attrgetter(key)(x))
|
||||||
|
if col == 0: key, func = "title", lambda x : x.lower()
|
||||||
|
if col == 1: key, func = "author", lambda x : x.split()[-1:][0].lower()
|
||||||
|
if col == 2: key, func = "size", int
|
||||||
|
if col == 3: key, func = "datetime", lambda x: x
|
||||||
|
descending = order != Qt.AscendingOrder
|
||||||
|
self.emit(SIGNAL("layoutAboutToBeChanged()"))
|
||||||
|
self._data.sort(key=getter(key, func))
|
||||||
|
if descending: self._data.reverse()
|
||||||
|
self.emit(SIGNAL("layoutChanged()"))
|
||||||
|
self.emit(SIGNAL("sorted()"))
|
||||||
|
|
||||||
|
def search(self, query):
|
||||||
|
queries = unicode(query, 'utf-8').lower().split()
|
||||||
|
self.emit(SIGNAL("layoutAboutToBeChanged()"))
|
||||||
|
self._data = []
|
||||||
|
for book in self._orig_data:
|
||||||
|
match = True
|
||||||
|
for q in queries:
|
||||||
|
if q in book.title.lower() or q in book.author.lower(): continue
|
||||||
|
else:
|
||||||
|
match = False
|
||||||
|
break
|
||||||
|
if match: self._data.append(book)
|
||||||
|
self.emit(SIGNAL("layoutChanged()"))
|
||||||
|
self.emit(SIGNAL("searched()"))
|
||||||
|
|
||||||
|
def delete(self, indices):
|
||||||
|
paths = []
|
||||||
|
rows = [ index.row() for index in indices ]
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
self.emit(SIGNAL("layoutAboutToBeChanged()"))
|
||||||
|
elems = [ self._data[row] for row in rows ]
|
||||||
|
for e in elems:
|
||||||
|
_id = e.id
|
||||||
|
paths.append(e.path)
|
||||||
|
self._orig_data.delete_book(_id)
|
||||||
|
try:
|
||||||
|
self._data.remove(e)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
self.emit(SIGNAL("layoutChanged()"))
|
||||||
|
return paths
|
||||||
|
|
||||||
|
def path(self, index):
|
||||||
|
return self._data[index.row()].path
|
||||||
|
def title(self, index):
|
||||||
|
return self._data[index.row()].title
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceModel(QAbstractListModel):
|
||||||
|
|
||||||
|
memory_free = 0
|
||||||
|
card_free = 0
|
||||||
|
show_reader = False
|
||||||
|
show_card = False
|
||||||
|
|
||||||
|
def update_devices(self, reader=None, card=None):
|
||||||
|
if reader != None:
|
||||||
|
self.show_reader = reader
|
||||||
|
if card != None:
|
||||||
|
self.show_card = card
|
||||||
|
self.emit(SIGNAL("layoutChanged()"))
|
||||||
|
|
||||||
|
def rowCount(self, parent):
|
||||||
|
base = 1
|
||||||
|
if self.show_reader:
|
||||||
|
base += 1
|
||||||
|
if self.show_card:
|
||||||
|
base += 1
|
||||||
|
return base
|
||||||
|
|
||||||
|
def update_free_space(self, reader, card):
|
||||||
|
self.memory_free = reader
|
||||||
|
self.card_free = card
|
||||||
|
self.emit(SIGNAL("dataChanged(QModelIndex, QModelIndex)"), \
|
||||||
|
self.index(1), self.index(2))
|
||||||
|
|
||||||
|
def data(self, index, role):
|
||||||
|
row = index.row()
|
||||||
|
data = NONE
|
||||||
|
if role == Qt.DisplayRole:
|
||||||
|
text = None
|
||||||
|
if row == 0:
|
||||||
|
text = "Library"
|
||||||
|
if row == 1 and self.show_reader:
|
||||||
|
text = "Reader\n" + TableView.human_readable(self.memory_free) \
|
||||||
|
+ " available"
|
||||||
|
elif row == 2 and self.show_card:
|
||||||
|
text = "Card\n" + TableView.human_readable(self.card_free) \
|
||||||
|
+ " available"
|
||||||
|
if text:
|
||||||
|
data = QVariant(text)
|
||||||
|
elif role == Qt.DecorationRole:
|
||||||
|
icon = None
|
||||||
|
if row == 0:
|
||||||
|
icon = QIcon(":/library")
|
||||||
|
elif row == 1 and self.show_reader:
|
||||||
|
icon = QIcon(":/reader")
|
||||||
|
elif self.show_card:
|
||||||
|
icon = QIcon(":/card")
|
||||||
|
if icon:
|
||||||
|
data = QVariant(icon)
|
||||||
|
elif role == Qt.SizeHintRole:
|
||||||
|
if row == 1:
|
||||||
|
return QVariant(QSize(150, 70))
|
||||||
|
elif role == Qt.FontRole:
|
||||||
|
font = QFont()
|
||||||
|
font.setBold(True)
|
||||||
|
data = QVariant(font)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def is_library(self, index):
|
||||||
|
return index.row() == 0
|
||||||
|
def is_reader(self, index):
|
||||||
|
return index.row() == 1
|
||||||
|
def is_card(self, index):
|
||||||
|
return index.row() == 2
|
||||||
|
|
||||||
|
def files_dropped(self, files, index, ids):
|
||||||
|
ret = False
|
||||||
|
if self.is_library(index) and not ids:
|
||||||
|
self.emit(SIGNAL("books_dropped"), files)
|
||||||
|
ret = True
|
||||||
|
elif self.is_reader(index):
|
||||||
|
self.emit(SIGNAL("upload_books"), "reader", files, ids)
|
||||||
|
elif self.is_card(index):
|
||||||
|
self.emit(SIGNAL("upload_books"), "card", files, ids)
|
||||||
|
return ret
|
BIN
src/libprs500/lrf/BBeBook-0.2.jar
Normal file
21
src/libprs500/lrf/__init__.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
## Copyright (C) 2006 Kovid Goyal kovid@kovidgoyal.net
|
||||||
|
## This program is free software; you can redistribute it and/or modify
|
||||||
|
## it under the terms of the GNU General Public License as published by
|
||||||
|
## the Free Software Foundation; either version 2 of the License, or
|
||||||
|
## (at your option) any later version.
|
||||||
|
##
|
||||||
|
## This program is distributed in the hope that it will be useful,
|
||||||
|
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
## GNU General Public License for more details.
|
||||||
|
##
|
||||||
|
## You should have received a copy of the GNU General Public License along
|
||||||
|
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
"""
|
||||||
|
This package contains logic to read and write LRF files. The LRF file format is documented at U{http://www.sven.de/librie/Librie/LrfFormat}.
|
||||||
|
At the time fo writing, this package only supports reading and writing LRF meat information. See L{meta}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__docformat__ = "epytext"
|
||||||
|
__author__ = "Kovid Goyal <kovid@kovidgoyal.net>"
|
BIN
src/libprs500/lrf/cover.jpg
Normal file
After Width: | Height: | Size: 1.8 KiB |
191
src/libprs500/lrf/makelrf.py
Executable file
@ -0,0 +1,191 @@
|
|||||||
|
## Copyright (C) 2006 Kovid Goyal kovid@kovidgoyal.net
|
||||||
|
## This program is free software; you can redistribute it and/or modify
|
||||||
|
## it under the terms of the GNU General Public License as published by
|
||||||
|
## the Free Software Foundation; either version 2 of the License, or
|
||||||
|
## (at your option) any later version.
|
||||||
|
##
|
||||||
|
## This program is distributed in the hope that it will be useful,
|
||||||
|
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
## GNU General Public License for more details.
|
||||||
|
##
|
||||||
|
## You should have received a copy of the GNU General Public License along
|
||||||
|
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import pkg_resources
|
||||||
|
import subprocess
|
||||||
|
from tempfile import mkdtemp
|
||||||
|
from optparse import OptionParser
|
||||||
|
import xml.dom.minidom as dom
|
||||||
|
|
||||||
|
from libprs500.lrf.meta import LRFException, LRFMetaFile
|
||||||
|
from libprs500.ptempfile import PersistentTemporaryFile
|
||||||
|
|
||||||
|
_bbebook = 'BBeBook-0.2.jar'
|
||||||
|
|
||||||
|
def generate_thumbnail(path):
|
||||||
|
""" Generate a JPEG thumbnail of size ~ 128x128 (aspect ratio preserved)"""
|
||||||
|
try:
|
||||||
|
import Image
|
||||||
|
except ImportError:
|
||||||
|
raise LRFException("Unable to initialize Python Imaging Library." \
|
||||||
|
"Thumbnail generation is disabled")
|
||||||
|
im = Image.open(path)
|
||||||
|
im.thumbnail((128, 128), Image.ANTIALIAS)
|
||||||
|
thumb = PersistentTemporaryFile(prefix="makelrf_", suffix=".jpeg")
|
||||||
|
thumb.close()
|
||||||
|
im = im.convert()
|
||||||
|
im.save(thumb.name)
|
||||||
|
return thumb
|
||||||
|
|
||||||
|
def create_xml(cfg):
|
||||||
|
doc = dom.getDOMImplementation().createDocument(None, None, None)
|
||||||
|
def add_field(parent, tag, value):
|
||||||
|
elem = doc.createElement(tag)
|
||||||
|
elem.appendChild(doc.createTextNode(value))
|
||||||
|
parent.appendChild(elem)
|
||||||
|
|
||||||
|
info = doc.createElement('Info')
|
||||||
|
info.setAttribute('version', '1.0')
|
||||||
|
book_info = doc.createElement('BookInfo')
|
||||||
|
doc_info = doc.createElement('DocInfo')
|
||||||
|
info.appendChild(book_info)
|
||||||
|
info.appendChild(doc_info)
|
||||||
|
add_field(book_info, 'File', cfg['File'])
|
||||||
|
add_field(doc_info, 'Output', cfg['Output'])
|
||||||
|
for field in ['Title', 'Author', 'BookID', 'Publisher', 'Label', \
|
||||||
|
'Category', 'Classification', 'Icon', 'Cover', 'FreeText']:
|
||||||
|
if cfg.has_key(field):
|
||||||
|
add_field(book_info, field, cfg[field])
|
||||||
|
add_field(doc_info, 'Language', 'en')
|
||||||
|
add_field(doc_info, 'Creator', _bbebook)
|
||||||
|
add_field(doc_info, 'CreationDate', time.strftime('%Y-%m-%d', time.gmtime()))
|
||||||
|
doc.appendChild(info)
|
||||||
|
return doc.toxml()
|
||||||
|
|
||||||
|
def makelrf(author=None, title=None, \
|
||||||
|
thumbnail=None, src=None, odir=".",\
|
||||||
|
rasterize=True, cover=None):
|
||||||
|
src = os.path.normpath(os.path.abspath(src))
|
||||||
|
bbebook = pkg_resources.resource_filename(__name__, _bbebook)
|
||||||
|
if not os.access(src, os.R_OK):
|
||||||
|
raise LRFException("Unable to read from file: " + src)
|
||||||
|
if thumbnail:
|
||||||
|
thumb = os.path.abspath(options.thumbnail)
|
||||||
|
if not os.access(thumb, os.R_OK):
|
||||||
|
raise LRFException("Unable to read from " + thumb)
|
||||||
|
else:
|
||||||
|
thumb = pkg_resources.resource_filename(__name__, 'cover.jpg')
|
||||||
|
|
||||||
|
if not author:
|
||||||
|
author = "Unknown"
|
||||||
|
if not title:
|
||||||
|
title = os.path.basename(src)
|
||||||
|
label = os.path.basename(src)
|
||||||
|
id = hashlib.md5(os.path.basename(label)).hexdigest()
|
||||||
|
name, ext = os.path.splitext(label)
|
||||||
|
cwd = os.path.dirname(src)
|
||||||
|
dirpath = None
|
||||||
|
try:
|
||||||
|
if ext == ".rar":
|
||||||
|
dirpath = mkdtemp('','makelrf')
|
||||||
|
cwd = dirpath
|
||||||
|
cmd = " ".join(["unrar", "e", '"'+src+'"'])
|
||||||
|
proc = subprocess.Popen(cmd, cwd=cwd, shell=True, stderr=subprocess.PIPE)
|
||||||
|
if proc.wait():
|
||||||
|
raise LRFException("unrar failed with error:\n\n" + \
|
||||||
|
proc.stderr.read())
|
||||||
|
path, msize = None, 0
|
||||||
|
for root, dirs, files in os.walk(dirpath):
|
||||||
|
for name in files:
|
||||||
|
if os.path.splitext(name)[1] == ".html":
|
||||||
|
size = os.stat(os.path.join(root, name)).st_size
|
||||||
|
if size > msize:
|
||||||
|
msize, path = size, os.path.join(root, name)
|
||||||
|
if not path:
|
||||||
|
raise LRFException("Could not find .html file in rar archive")
|
||||||
|
src = path
|
||||||
|
|
||||||
|
name = re.sub("\s", "_", name)
|
||||||
|
name = os.path.abspath(os.path.join(odir, name)) + ".lrf"
|
||||||
|
cfg = { 'File' : src, 'Output' : name, 'Label' : label, 'BookID' : id, \
|
||||||
|
'Author' : author, 'Title' : title, 'Publisher' : 'Unknown' \
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if cover:
|
||||||
|
cover = os.path.normpath(os.path.abspath(cover))
|
||||||
|
try:
|
||||||
|
thumbf = generate_thumbnail(cover)
|
||||||
|
thumb = thumbf.name
|
||||||
|
except Exception, e:
|
||||||
|
print >> sys.stderr, "WARNING: Unable to generate thumbnail:\n", \
|
||||||
|
str(e)
|
||||||
|
thumb = cover
|
||||||
|
cfg['Cover'] = cover
|
||||||
|
cfg['Icon'] = thumb
|
||||||
|
config = PersistentTemporaryFile(prefix='makelrf_', suffix='.xml')
|
||||||
|
config.write(create_xml(cfg))
|
||||||
|
config.close()
|
||||||
|
jar = '-jar "' + bbebook + '"'
|
||||||
|
cmd = " ".join(["java", jar, "-r" if rasterize else "", '"'+config.name+'"'])
|
||||||
|
proc = subprocess.Popen(cmd, \
|
||||||
|
cwd=cwd, shell=True, stderr=subprocess.PIPE)
|
||||||
|
if proc.wait():
|
||||||
|
raise LRFException("BBeBook failed with error:\n\n" + \
|
||||||
|
proc.stderr.read())
|
||||||
|
# Needed as BBeBook-0.2 doesn't handle non GIF thumbnails correctly.
|
||||||
|
lrf = open(name, "r+b")
|
||||||
|
LRFMetaFile(lrf).fix_thumbnail_type()
|
||||||
|
lrf.close()
|
||||||
|
return name
|
||||||
|
finally:
|
||||||
|
if dirpath:
|
||||||
|
shutil.rmtree(dirpath, True)
|
||||||
|
|
||||||
|
def main(cargs=None):
|
||||||
|
parser = OptionParser(usage=\
|
||||||
|
"""usage: %prog [options] mybook.[html|pdf|rar]
|
||||||
|
|
||||||
|
%prog converts mybook to mybook.lrf
|
||||||
|
If you specify a rar file you must have the unrar command line client
|
||||||
|
installed. makelrf assumes the rar file is an archive containing the
|
||||||
|
html file you want converted."""\
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_option("-t", "--title", action="store", type="string", \
|
||||||
|
dest="title", help="Set the book title")
|
||||||
|
parser.add_option("-a", "--author", action="store", type="string", \
|
||||||
|
dest="author", help="Set the author")
|
||||||
|
parser.add_option('-r', '--rasterize', action='store_true', \
|
||||||
|
dest="rasterize",
|
||||||
|
help="Convert pdfs into image files.")
|
||||||
|
parser.add_option('-c', '--cover', action='store', dest='cover',\
|
||||||
|
help="Path to a graphic that will be set as the cover. "\
|
||||||
|
"If it is specified the thumbnail is automatically "\
|
||||||
|
"generated from it")
|
||||||
|
parser.add_option("--thumbnail", action="store", type="string", \
|
||||||
|
dest="thumbnail", \
|
||||||
|
help="Path to a graphic that will be set as the thumbnail")
|
||||||
|
if not cargs:
|
||||||
|
cargs = sys.argv
|
||||||
|
options, args = parser.parse_args()
|
||||||
|
if len(args) != 1:
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
src = args[0]
|
||||||
|
root, ext = os.path.splitext(src)
|
||||||
|
if ext not in ['.html', '.pdf', '.rar']:
|
||||||
|
print >> sys.stderr, "Can only convert files ending in .html|.pdf|.rar"
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
name = makelrf(author=options.author, title=options.title, \
|
||||||
|
thumbnail=options.thumbnail, src=src, cover=options.cover, \
|
||||||
|
rasterize=options.rasterize)
|
||||||
|
print "LRF generated:", name
|
481
src/libprs500/lrf/meta.py
Normal file
@ -0,0 +1,481 @@
|
|||||||
|
## Copyright (C) 2006 Kovid Goyal kovid@kovidgoyal.net
|
||||||
|
## This program is free software; you can redistribute it and/or modify
|
||||||
|
## it under the terms of the GNU General Public License as published by
|
||||||
|
## the Free Software Foundation; either version 2 of the License, or
|
||||||
|
## (at your option) any later version.
|
||||||
|
##
|
||||||
|
## This program is distributed in the hope that it will be useful,
|
||||||
|
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
## GNU General Public License for more details.
|
||||||
|
##
|
||||||
|
## You should have received a copy of the GNU General Public License along
|
||||||
|
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
|
||||||
|
"""
|
||||||
|
This module presents an easy to use interface for getting and setting
|
||||||
|
meta information in LRF files.
|
||||||
|
Just create an L{LRFMetaFile} object and use its properties
|
||||||
|
to get and set meta information. For example:
|
||||||
|
|
||||||
|
>>> lrf = LRFMetaFile("mybook.lrf")
|
||||||
|
>>> print lrf.title, lrf.author
|
||||||
|
>>> lrf.category = "History"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import struct
|
||||||
|
import array
|
||||||
|
import zlib
|
||||||
|
import xml.dom.minidom as dom
|
||||||
|
|
||||||
|
from libprs500.prstypes import field
|
||||||
|
|
||||||
|
BYTE = "<B" #: Unsigned char little endian encoded in 1 byte
|
||||||
|
WORD = "<H" #: Unsigned short little endian encoded in 2 bytes
|
||||||
|
DWORD = "<I" #: Unsigned integer little endian encoded in 4 bytes
|
||||||
|
QWORD = "<Q" #: Unsigned long long little endian encoded in 8 bytes
|
||||||
|
|
||||||
|
class versioned_field(field):
|
||||||
|
def __init__(self, vfield, version, start=0, fmt=WORD):
|
||||||
|
field.__init__(self, start=start, fmt=fmt)
|
||||||
|
self.vfield, self.version = vfield, version
|
||||||
|
|
||||||
|
def enabled(self):
|
||||||
|
return self.vfield > self.version
|
||||||
|
|
||||||
|
def __get__(self, obj, typ=None):
|
||||||
|
if self.enabled():
|
||||||
|
return field.__get__(self, obj, typ=typ)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __set__(self, obj, val):
|
||||||
|
if not self.enabled():
|
||||||
|
raise LRFException("Trying to set disabled field")
|
||||||
|
else:
|
||||||
|
field.__set__(self, obj, val)
|
||||||
|
|
||||||
|
class LRFException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class fixed_stringfield(object):
|
||||||
|
""" A field storing a variable length string. """
|
||||||
|
def __init__(self, length=8, start=0):
|
||||||
|
"""
|
||||||
|
@param length: Size of this string
|
||||||
|
@param start: The byte at which this field is stored in the buffer
|
||||||
|
"""
|
||||||
|
self._length = length
|
||||||
|
self._start = start
|
||||||
|
|
||||||
|
def __get__(self, obj, typ=None):
|
||||||
|
length = str(self._length)
|
||||||
|
return obj.unpack(start=self._start, fmt="<"+length+"s")[0]
|
||||||
|
|
||||||
|
def __set__(self, obj, val):
|
||||||
|
if val.__class__.__name__ != 'str': val = str(val)
|
||||||
|
if len(val) != self._length:
|
||||||
|
raise LRFException("Trying to set fixed_stringfield with a " + \
|
||||||
|
"string of incorrect length")
|
||||||
|
obj.pack(val, start=self._start, fmt="<"+str(len(val))+"s")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return "A string of length " + str(self._length) + \
|
||||||
|
" starting at byte " + str(self._start)
|
||||||
|
|
||||||
|
class xml_field(object):
|
||||||
|
"""
|
||||||
|
Descriptor that gets and sets XML based meta information from an LRF file.
|
||||||
|
Works for simple XML fields of the form <tagname>data</tagname>
|
||||||
|
"""
|
||||||
|
def __init__(self, tag_name, parent="BookInfo"):
|
||||||
|
"""
|
||||||
|
@param tag_name: The XML tag whose data we operate on
|
||||||
|
@param parent: The tagname of the parent element of C{tag_name}
|
||||||
|
"""
|
||||||
|
self.tag_name = tag_name
|
||||||
|
self.parent = parent
|
||||||
|
|
||||||
|
def __get__(self, obj, typ=None):
|
||||||
|
""" Return the data in this field or '' if the field is empty """
|
||||||
|
document = dom.parseString(obj.info)
|
||||||
|
elems = document.getElementsByTagName(self.tag_name)
|
||||||
|
if len(elems):
|
||||||
|
elem = None
|
||||||
|
for candidate in elems:
|
||||||
|
if candidate.parentNode.nodeName == self.parent:
|
||||||
|
elem = candidate
|
||||||
|
if elem:
|
||||||
|
elem.normalize()
|
||||||
|
if elem.hasChildNodes():
|
||||||
|
return elem.firstChild.data.strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def __set__(self, obj, val):
|
||||||
|
document = dom.parseString(obj.info)
|
||||||
|
def create_elem():
|
||||||
|
elem = document.createElement(self.tag_name)
|
||||||
|
elem.appendChild(dom.Text())
|
||||||
|
parent = document.getElementsByTagName(self.parent)[0]
|
||||||
|
parent.appendChild(elem)
|
||||||
|
return elem
|
||||||
|
|
||||||
|
if not val:
|
||||||
|
val = u''
|
||||||
|
if type(val).__name__ != 'unicode':
|
||||||
|
val = unicode(val, 'utf-8')
|
||||||
|
|
||||||
|
elems = document.getElementsByTagName(self.tag_name)
|
||||||
|
elem = None
|
||||||
|
if len(elems):
|
||||||
|
for candidate in elems:
|
||||||
|
if candidate.parentNode.nodeName == self.parent:
|
||||||
|
elem = candidate
|
||||||
|
if not elem:
|
||||||
|
elem = create_elem()
|
||||||
|
else:
|
||||||
|
elem.normalize()
|
||||||
|
while elem.hasChildNodes():
|
||||||
|
elem.removeChild(elem.lastChild)
|
||||||
|
elem.appendChild(dom.Text())
|
||||||
|
else:
|
||||||
|
elem = create_elem()
|
||||||
|
elem.firstChild.data = val
|
||||||
|
info = document.toxml(encoding='utf-16')
|
||||||
|
obj.info = info
|
||||||
|
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.tag_name
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return "XML Field: " + self.tag_name + " in " + self.parent
|
||||||
|
|
||||||
|
class LRFMetaFile(object):
|
||||||
|
""" Has properties to read and write all Meta information in a LRF file. """
|
||||||
|
# The first 8 bytes of all valid LRF files
|
||||||
|
LRF_HEADER = 'LRF'.encode('utf-16le')+'\0\0'
|
||||||
|
|
||||||
|
lrf_header = fixed_stringfield(length=8, start=0)
|
||||||
|
version = field(fmt=WORD, start=8)
|
||||||
|
xor_key = field(fmt=WORD, start=10)
|
||||||
|
root_object_id = field(fmt=DWORD, start=12)
|
||||||
|
number_of_objets = field(fmt=QWORD, start=16)
|
||||||
|
object_index_offset = field(fmt=QWORD, start=24)
|
||||||
|
binding = field(fmt=BYTE, start=36)
|
||||||
|
dpi = field(fmt=WORD, start=38)
|
||||||
|
width = field(fmt=WORD, start=42)
|
||||||
|
height = field(fmt=WORD, start=44)
|
||||||
|
color_depth = field(fmt=BYTE, start=46)
|
||||||
|
toc_object_id = field(fmt=DWORD, start=0x44)
|
||||||
|
toc_object_offset = field(fmt=DWORD, start=0x48)
|
||||||
|
compressed_info_size = field(fmt=WORD, start=0x4c)
|
||||||
|
thumbnail_type = versioned_field(version, 800, fmt=WORD, start=0x4e)
|
||||||
|
thumbnail_size = versioned_field(version, 800, fmt=DWORD, start=0x50)
|
||||||
|
uncompressed_info_size = versioned_field(compressed_info_size, 0, \
|
||||||
|
fmt=DWORD, start=0x54)
|
||||||
|
|
||||||
|
title = xml_field("Title", parent="BookInfo")
|
||||||
|
author = xml_field("Author", parent="BookInfo")
|
||||||
|
book_id = xml_field("BookID", parent="BookInfo")
|
||||||
|
publisher = xml_field("Publisher", parent="BookInfo")
|
||||||
|
label = xml_field("Label", parent="BookInfo")
|
||||||
|
category = xml_field("Category", parent="BookInfo")
|
||||||
|
classification = xml_field("Classification", parent="BookInfo")
|
||||||
|
free_text = xml_field("FreeText", parent="BookInfo")
|
||||||
|
|
||||||
|
language = xml_field("Language", parent="DocInfo")
|
||||||
|
creator = xml_field("Creator", parent="DocInfo")
|
||||||
|
# Format is %Y-%m-%d
|
||||||
|
creation_date = xml_field("CreationDate", parent="DocInfo")
|
||||||
|
producer = xml_field("Producer", parent="DocInfo")
|
||||||
|
page = xml_field("Page", parent="DocInfo")
|
||||||
|
|
||||||
|
def safe(func):
|
||||||
|
"""
|
||||||
|
Decorator that ensures that function calls leave the pos
|
||||||
|
in the underlying file unchanged
|
||||||
|
"""
|
||||||
|
def restore_pos(*args, **kwargs):
|
||||||
|
obj = args[0]
|
||||||
|
pos = obj._file.tell()
|
||||||
|
res = func(*args, **kwargs)
|
||||||
|
obj._file.seek(0, 2)
|
||||||
|
if obj._file.tell() >= pos:
|
||||||
|
obj._file.seek(pos)
|
||||||
|
return res
|
||||||
|
return restore_pos
|
||||||
|
|
||||||
|
def safe_property(func):
|
||||||
|
"""
|
||||||
|
Decorator that ensures that read or writing a property leaves
|
||||||
|
the position in the underlying file unchanged
|
||||||
|
"""
|
||||||
|
def decorator(f):
|
||||||
|
def restore_pos(*args, **kwargs):
|
||||||
|
obj = args[0]
|
||||||
|
pos = obj._file.tell()
|
||||||
|
res = f(*args, **kwargs)
|
||||||
|
obj._file.seek(0, 2)
|
||||||
|
if obj._file.tell() >= pos:
|
||||||
|
obj._file.seek(pos)
|
||||||
|
return res
|
||||||
|
return restore_pos
|
||||||
|
locals_ = func()
|
||||||
|
if locals_.has_key("fget"):
|
||||||
|
locals_["fget"] = decorator(locals_["fget"])
|
||||||
|
if locals_.has_key("fset"):
|
||||||
|
locals_["fset"] = decorator(locals_["fset"])
|
||||||
|
return property(**locals_)
|
||||||
|
|
||||||
|
@safe_property
|
||||||
|
def info():
|
||||||
|
doc = \
|
||||||
|
"""
|
||||||
|
Document meta information in raw XML format as a byte string encoded in
|
||||||
|
utf-16.
|
||||||
|
To set use raw XML in a byte string encoded in utf-16.
|
||||||
|
"""
|
||||||
|
def fget(self):
|
||||||
|
if self.compressed_info_size == 0:
|
||||||
|
raise LRFException("This document has no meta info")
|
||||||
|
size = self.compressed_info_size - 4
|
||||||
|
self._file.seek(self.info_start)
|
||||||
|
try:
|
||||||
|
src = zlib.decompress(self._file.read(size))
|
||||||
|
if len(src) != self.uncompressed_info_size:
|
||||||
|
raise LRFException("Decompression of document meta info\
|
||||||
|
yielded unexpected results")
|
||||||
|
candidate = unicode(src, 'utf-16')
|
||||||
|
# LRF files produced with makelrf dont have a correctly
|
||||||
|
# encoded metadata block.
|
||||||
|
# Decoding using latin1 is the most useful for me since I
|
||||||
|
# occassionally read french books.
|
||||||
|
if not u"Info" in candidate:
|
||||||
|
candidate = unicode(src, 'latin1', errors='ignore')
|
||||||
|
if candidate[-1:] == '\0':
|
||||||
|
candidate = candidate[:-1]
|
||||||
|
candidate = dom.parseString(candidate.encode('utf-8')).\
|
||||||
|
toxml(encoding='utf-16')
|
||||||
|
else:
|
||||||
|
candidate = candidate.encode('utf-16')
|
||||||
|
return candidate.strip()
|
||||||
|
except zlib.error, e:
|
||||||
|
raise LRFException("Unable to decompress document meta information")
|
||||||
|
|
||||||
|
def fset(self, info):
|
||||||
|
self.uncompressed_info_size = len(info)
|
||||||
|
stream = zlib.compress(info)
|
||||||
|
self.compressed_info_size = len(stream) + 4
|
||||||
|
self._file.seek(self.info_start)
|
||||||
|
self._file.write(stream)
|
||||||
|
self._file.flush()
|
||||||
|
|
||||||
|
return { "fget":fget, "fset":fset, "doc":doc }
|
||||||
|
|
||||||
|
@safe_property
|
||||||
|
def thumbnail_pos():
|
||||||
|
doc = """ The position of the thumbnail in the LRF file """
|
||||||
|
def fget(self):
|
||||||
|
return self.info_start+ self.compressed_info_size-4
|
||||||
|
return { "fget":fget, "doc":doc }
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _detect_thumbnail_type(cls, slice):
|
||||||
|
""" @param slice: The first 16 bytes of the thumbnail """
|
||||||
|
ttype = 0x14 # GIF
|
||||||
|
if "PNG" in slice:
|
||||||
|
ttype = 0x12
|
||||||
|
if "BM" in slice:
|
||||||
|
ttype = 0x13
|
||||||
|
if "JFIF" in slice:
|
||||||
|
ttype = 0x11
|
||||||
|
return ttype
|
||||||
|
|
||||||
|
|
||||||
|
@safe_property
|
||||||
|
def thumbnail():
|
||||||
|
doc = \
|
||||||
|
"""
|
||||||
|
The thumbnail.
|
||||||
|
Represented as a string.
|
||||||
|
The string you would get from the file read function.
|
||||||
|
"""
|
||||||
|
def fget(self):
|
||||||
|
size = self.thumbnail_size
|
||||||
|
if size:
|
||||||
|
self._file.seek(self.thumbnail_pos)
|
||||||
|
return self._file.read(size)
|
||||||
|
|
||||||
|
def fset(self, data):
|
||||||
|
if self.version <= 800:
|
||||||
|
raise LRFException("Cannot store thumbnails in LRF files \
|
||||||
|
of version <= 800")
|
||||||
|
slice = data[0:16]
|
||||||
|
orig_size = self.thumbnail_size
|
||||||
|
self._file.seek(self.toc_object_offset)
|
||||||
|
toc = self._file.read(self.object_index_offset - self.toc_object_offset)
|
||||||
|
self._file.seek(self.object_index_offset)
|
||||||
|
objects = self._file.read()
|
||||||
|
self.thumbnail_size = len(data)
|
||||||
|
self._file.seek(self.thumbnail_pos)
|
||||||
|
self._file.write(data)
|
||||||
|
orig_offset = self.toc_object_offset
|
||||||
|
self.toc_object_offset = self._file.tell()
|
||||||
|
self._file.write(toc)
|
||||||
|
self.object_index_offset = self._file.tell()
|
||||||
|
self._file.write(objects)
|
||||||
|
self._file.flush()
|
||||||
|
self._file.truncate() # Incase old thumbnail was bigger than new
|
||||||
|
self.thumbnail_type = self._detect_thumbnail_type(slice)
|
||||||
|
# Needed as new thumbnail may have different size than old thumbnail
|
||||||
|
self.update_object_offsets(self.toc_object_offset - orig_offset)
|
||||||
|
return { "fget":fget, "fset":fset, "doc":doc }
|
||||||
|
|
||||||
|
def __init__(self, file):
|
||||||
|
""" @param file: A file object opened in the r+b mode """
|
||||||
|
file.seek(0, 2)
|
||||||
|
self.size = file.tell()
|
||||||
|
self._file = file
|
||||||
|
if self.lrf_header != LRFMetaFile.LRF_HEADER:
|
||||||
|
raise LRFException(file.name + \
|
||||||
|
" has an invalid LRF header. Are you sure it is an LRF file?")
|
||||||
|
# Byte at which the compressed meta information starts
|
||||||
|
self.info_start = 0x58 if self.version > 800 else 0x53
|
||||||
|
|
||||||
|
@safe
|
||||||
|
def update_object_offsets(self, delta):
|
||||||
|
""" Run through the LRF Object index changing the offset by C{delta}. """
|
||||||
|
self._file.seek(self.object_index_offset)
|
||||||
|
while(True):
|
||||||
|
try:
|
||||||
|
self._file.read(4)
|
||||||
|
except EOFError:
|
||||||
|
break
|
||||||
|
pos = self._file.tell()
|
||||||
|
try:
|
||||||
|
offset = self.unpack(fmt=DWORD, start=pos)[0] + delta
|
||||||
|
except struct.error:
|
||||||
|
break
|
||||||
|
if offset >= (2**8)**4:
|
||||||
|
# New offset is larger than a DWORD, so leave
|
||||||
|
# offset unchanged
|
||||||
|
offset -= delta
|
||||||
|
self.pack(offset, fmt=DWORD, start=pos)
|
||||||
|
try:
|
||||||
|
self._file.read(12)
|
||||||
|
except EOFError:
|
||||||
|
break
|
||||||
|
self._file.flush()
|
||||||
|
|
||||||
|
@safe
|
||||||
|
def unpack(self, fmt=DWORD, start=0):
|
||||||
|
"""
|
||||||
|
Return decoded data from file.
|
||||||
|
|
||||||
|
@param fmt: See U{struct<http://docs.python.org/lib/module-struct.html>}
|
||||||
|
@param start: Position in file from which to decode
|
||||||
|
"""
|
||||||
|
end = start + struct.calcsize(fmt)
|
||||||
|
self._file.seek(start)
|
||||||
|
self._file.seek(start)
|
||||||
|
ret = struct.unpack(fmt, self._file.read(end-start))
|
||||||
|
return ret
|
||||||
|
|
||||||
|
@safe
|
||||||
|
def pack(self, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Encode C{args} and write them to file.
|
||||||
|
C{kwargs} must contain the keywords C{fmt} and C{start}
|
||||||
|
|
||||||
|
@param args: The values to pack
|
||||||
|
@param fmt: See U{struct<http://docs.python.org/lib/module-struct.html>}
|
||||||
|
@param start: Position in file at which to write encoded data
|
||||||
|
"""
|
||||||
|
encoded = struct.pack(kwargs["fmt"], *args)
|
||||||
|
self._file.seek(kwargs["start"])
|
||||||
|
self._file.write(encoded)
|
||||||
|
self._file.flush()
|
||||||
|
|
||||||
|
def thumbail_extension(self):
|
||||||
|
"""
|
||||||
|
Return the extension for the thumbnail image type as specified
|
||||||
|
by L{self.thumbnail_type}. If the LRF file was created by buggy
|
||||||
|
software, the extension maye be incorrect. See L{self.fix_thumbnail_type}.
|
||||||
|
"""
|
||||||
|
ext = "gif"
|
||||||
|
ttype = self.thumbnail_type
|
||||||
|
if ttype == 0x11:
|
||||||
|
ext = "jpeg"
|
||||||
|
elif ttype == 0x12:
|
||||||
|
ext = "png"
|
||||||
|
elif ttype == 0x13:
|
||||||
|
ext = "bm"
|
||||||
|
return ext
|
||||||
|
|
||||||
|
def fix_thumbnail_type(self):
|
||||||
|
"""
|
||||||
|
Attempt to guess the thumbnail image format and set
|
||||||
|
L{self.thumbnail_type} accordingly.
|
||||||
|
"""
|
||||||
|
slice = self.thumbnail[0:16]
|
||||||
|
self.thumbnail_type = self._detect_thumbnail_type(slice)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import sys, os.path
|
||||||
|
from optparse import OptionParser
|
||||||
|
from libprs500 import __version__ as VERSION
|
||||||
|
parser = OptionParser(usage="usage: %prog [options] mybook.lrf\n\
|
||||||
|
\nWARNING: Based on reverse engineering the LRF format."+\
|
||||||
|
" Making changes may render your LRF file unreadable. ", \
|
||||||
|
version=VERSION)
|
||||||
|
parser.add_option("-t", "--title", action="store", type="string", \
|
||||||
|
dest="title", help="Set the book title")
|
||||||
|
parser.add_option("-a", "--author", action="store", type="string", \
|
||||||
|
dest="author", help="Set the author")
|
||||||
|
parser.add_option("-c", "--category", action="store", type="string", \
|
||||||
|
dest="category", help="The category this book belongs"+\
|
||||||
|
" to. E.g.: History")
|
||||||
|
parser.add_option("--thumbnail", action="store", type="string", \
|
||||||
|
dest="thumbnail", help="Path to a graphic that will be"+\
|
||||||
|
" set as this files' thumbnail")
|
||||||
|
parser.add_option("--get-thumbnail", action="store_true", \
|
||||||
|
dest="get_thumbnail", default=False, \
|
||||||
|
help="Extract thumbnail from LRF file")
|
||||||
|
parser.add_option("-p", "--page", action="store", type="string", \
|
||||||
|
dest="page", help="Don't know what this is for")
|
||||||
|
options, args = parser.parse_args()
|
||||||
|
if len(args) != 1:
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
lrf = LRFMetaFile(open(args[0], "r+b"))
|
||||||
|
if options.title:
|
||||||
|
lrf.title = options.title
|
||||||
|
if options.author:
|
||||||
|
lrf.author = options.author
|
||||||
|
if options.category:
|
||||||
|
lrf.category = options.category
|
||||||
|
if options.page:
|
||||||
|
lrf.page = options.page
|
||||||
|
if options.thumbnail:
|
||||||
|
f = open(options.thumbnail, "r")
|
||||||
|
lrf.thumbnail = f.read()
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
if options.get_thumbnail:
|
||||||
|
t = lrf.thumbnail
|
||||||
|
td = "None"
|
||||||
|
if t and len(t) > 0:
|
||||||
|
td = os.path.basename(args[0])+"_thumbnail_."+lrf.thumbail_extension()
|
||||||
|
f = open(td, "w")
|
||||||
|
f.write(t)
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
fields = LRFMetaFile.__dict__.items()
|
||||||
|
for f in fields:
|
||||||
|
if "XML" in str(f):
|
||||||
|
print str(f[1]) + ":", lrf.__getattribute__(f[0])
|
||||||
|
if options.get_thumbnail:
|
||||||
|
print "Thumbnail:", td
|
838
src/libprs500/prstypes.py
Executable file
@ -0,0 +1,838 @@
|
|||||||
|
## Copyright (C) 2006 Kovid Goyal kovid@kovidgoyal.net
|
||||||
|
## This program is free software; you can redistribute it and/or modify
|
||||||
|
## it under the terms of the GNU General Public License as published by
|
||||||
|
## the Free Software Foundation; either version 2 of the License, or
|
||||||
|
## (at your option) any later version.
|
||||||
|
##
|
||||||
|
## This program is distributed in the hope that it will be useful,
|
||||||
|
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
## GNU General Public License for more details.
|
||||||
|
##
|
||||||
|
## You should have received a copy of the GNU General Public License along
|
||||||
|
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
"""
|
||||||
|
Defines the structure of packets that are sent to/received from the device.
|
||||||
|
|
||||||
|
Packet structure is defined using classes and inheritance. Each class is a
|
||||||
|
view that imposes structure on the underlying data buffer.
|
||||||
|
The data buffer is encoded in little-endian format, but you don't
|
||||||
|
have to worry about that if you are using the classes.
|
||||||
|
The classes have instance variables with getter/setter functions defined
|
||||||
|
to take care of the encoding/decoding.
|
||||||
|
The classes are intended to mimic C structs.
|
||||||
|
|
||||||
|
There are three kinds of packets. L{Commands<Command>},
|
||||||
|
L{Responses<Response>}, and L{Answers<Answer>}.
|
||||||
|
C{Commands} are sent to the device on the control bus,
|
||||||
|
C{Responses} are received from the device,
|
||||||
|
also on the control bus. C{Answers} and their sub-classes represent
|
||||||
|
data packets sent to/received from the device via bulk transfers.
|
||||||
|
|
||||||
|
Commands are organized as follows: G{classtree Command}
|
||||||
|
|
||||||
|
You will typically only use sub-classes of Command.
|
||||||
|
|
||||||
|
Responses are organized as follows: G{classtree Response}
|
||||||
|
|
||||||
|
Responses inherit Command as they share header structure.
|
||||||
|
|
||||||
|
Answers are organized as follows: G{classtree Answer}
|
||||||
|
"""
|
||||||
|
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
|
||||||
|
from libprs500.errors import PacketError
|
||||||
|
|
||||||
|
DWORD = "<I" #: Unsigned integer little endian encoded in 4 bytes
|
||||||
|
DDWORD = "<Q" #: Unsigned long long little endian encoded in 8 bytes
|
||||||
|
|
||||||
|
|
||||||
|
class PathResponseCodes(object):
|
||||||
|
""" Known response commands to path related commands """
|
||||||
|
NOT_FOUND = 0xffffffd7
|
||||||
|
INVALID = 0xfffffff9
|
||||||
|
IS_FILE = 0xffffffd2
|
||||||
|
HAS_CHILDREN = 0xffffffcc
|
||||||
|
|
||||||
|
|
||||||
|
class TransferBuffer(list):
|
||||||
|
|
||||||
|
"""
|
||||||
|
Represents raw (unstructured) data packets sent over the usb bus.
|
||||||
|
|
||||||
|
C{TransferBuffer} is a wrapper around the tuples used by L{PyUSB<usb>} for communication.
|
||||||
|
It has convenience methods to read and write data from the underlying buffer. See
|
||||||
|
L{TransferBuffer.pack} and L{TransferBuffer.unpack}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, packet):
|
||||||
|
"""
|
||||||
|
Create a L{TransferBuffer} from C{packet} or an empty buffer.
|
||||||
|
|
||||||
|
@type packet: integer or listable object
|
||||||
|
@param packet: If packet is a list, it is copied into the C{TransferBuffer} and then normalized (see L{TransferBuffer._normalize}).
|
||||||
|
If it is an integer, a zero buffer of that length is created.
|
||||||
|
"""
|
||||||
|
if "__len__" in dir(packet):
|
||||||
|
list.__init__(self, list(packet))
|
||||||
|
self._normalize()
|
||||||
|
else: list.__init__(self, [0 for i in range(packet)])
|
||||||
|
|
||||||
|
def __add__(self, tb):
|
||||||
|
""" Return a TransferBuffer rather than a list as the sum """
|
||||||
|
return TransferBuffer(list.__add__(self, tb))
|
||||||
|
|
||||||
|
def __getslice__(self, start, end):
|
||||||
|
""" Return a TransferBuffer rather than a list as the slice """
|
||||||
|
return TransferBuffer(list.__getslice__(self, start, end))
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""
|
||||||
|
Return a string representation of this buffer.
|
||||||
|
|
||||||
|
Packets are represented as hex strings, in 2-byte pairs, S{<=} 16 bytes to a line. An ASCII representation is included. For example::
|
||||||
|
0700 0100 0000 0000 0000 0000 0c00 0000 ................
|
||||||
|
0200 0000 0400 0000 4461 7461 ........Data
|
||||||
|
"""
|
||||||
|
ans, ascii = ": ".rjust(10,"0"), ""
|
||||||
|
for i in range(0, len(self), 2):
|
||||||
|
for b in range(2):
|
||||||
|
try:
|
||||||
|
ans += TransferBuffer.phex(self[i+b])
|
||||||
|
ascii += chr(self[i+b]) if self[i+b] > 31 and self[i+b] < 127 else "."
|
||||||
|
except IndexError: break
|
||||||
|
ans = ans + " "
|
||||||
|
if (i+2)%16 == 0:
|
||||||
|
if i+2 < len(self):
|
||||||
|
ans += " " + ascii + "\n" + (TransferBuffer.phex(i+2)+": ").rjust(10, "0")
|
||||||
|
ascii = ""
|
||||||
|
last_line = ans[ans.rfind("\n")+1:]
|
||||||
|
padding = 50 - len(last_line)
|
||||||
|
ans += "".ljust(padding) + " " + ascii
|
||||||
|
return ans.strip()
|
||||||
|
|
||||||
|
def unpack(self, fmt=DWORD, start=0):
|
||||||
|
"""
|
||||||
|
Return decoded data from buffer.
|
||||||
|
|
||||||
|
@param fmt: See U{struct<http://docs.python.org/lib/module-struct.html>}
|
||||||
|
@param start: Position in buffer from which to decode
|
||||||
|
"""
|
||||||
|
end = start + struct.calcsize(fmt)
|
||||||
|
return struct.unpack(fmt, "".join([ chr(i) for i in list.__getslice__(self, start, end) ]))
|
||||||
|
|
||||||
|
def pack(self, val, fmt=DWORD, start=0):
|
||||||
|
"""
|
||||||
|
Encode C{val} and write it to buffer.
|
||||||
|
|
||||||
|
@param fmt: See U{struct<http://docs.python.org/lib/module-struct.html>}
|
||||||
|
@param start: Position in buffer at which to write encoded data
|
||||||
|
"""
|
||||||
|
self[start:start+struct.calcsize(fmt)] = [ ord(i) for i in struct.pack(fmt, val) ]
|
||||||
|
|
||||||
|
def _normalize(self):
|
||||||
|
""" Replace negative bytes in C{self} by 256 + byte """
|
||||||
|
for i in range(len(self)):
|
||||||
|
if self[i] < 0:
|
||||||
|
self[i] = 256 + self[i]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def phex(cls, num):
|
||||||
|
"""
|
||||||
|
Return the hex representation of num without the 0x prefix.
|
||||||
|
|
||||||
|
If the hex representation is only 1 digit it is padded to the left with a zero. Used in L{TransferBuffer.__str__}.
|
||||||
|
"""
|
||||||
|
index, sign = 2, ""
|
||||||
|
if num < 0:
|
||||||
|
index, sign = 3, "-"
|
||||||
|
h = hex(num)[index:]
|
||||||
|
if len(h) < 2:
|
||||||
|
h = "0"+h
|
||||||
|
return sign + h
|
||||||
|
|
||||||
|
|
||||||
|
class field(object):
|
||||||
|
""" A U{Descriptor<http://www.cafepy.com/article/python_attributes_and_methods/python_attributes_and_methods.html>}, that implements access
|
||||||
|
to protocol packets in a human readable way.
|
||||||
|
"""
|
||||||
|
def __init__(self, start=16, fmt=DWORD):
|
||||||
|
"""
|
||||||
|
@param start: The byte at which this field is stored in the buffer
|
||||||
|
@param fmt: The packing format for this field.
|
||||||
|
See U{struct<http://docs.python.org/lib/module-struct.html>}.
|
||||||
|
"""
|
||||||
|
self._fmt, self._start = fmt, start
|
||||||
|
|
||||||
|
def __get__(self, obj, typ=None):
|
||||||
|
return obj.unpack(start=self._start, fmt=self._fmt)[0]
|
||||||
|
|
||||||
|
def __set__(self, obj, val):
|
||||||
|
obj.pack(val, start=self._start, fmt=self._fmt)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
typ = ""
|
||||||
|
if self._fmt == DWORD:
|
||||||
|
typ = "unsigned int"
|
||||||
|
if self._fmt == DDWORD:
|
||||||
|
typ = "unsigned long long"
|
||||||
|
return "An " + typ + " stored in " + \
|
||||||
|
str(struct.calcsize(self._fmt)) + \
|
||||||
|
" bytes starting at byte " + str(self._start)
|
||||||
|
|
||||||
|
class stringfield(object):
|
||||||
|
""" A field storing a variable length string. """
|
||||||
|
def __init__(self, length_field, start=16):
|
||||||
|
"""
|
||||||
|
@param length_field: A U{Descriptor<http://www.cafepy.com/article/python_attributes_and_methods/python_attributes_and_methods.html>}
|
||||||
|
that returns the length of the string.
|
||||||
|
@param start: The byte at which this field is stored in the buffer
|
||||||
|
"""
|
||||||
|
self._length_field = length_field
|
||||||
|
self._start = start
|
||||||
|
|
||||||
|
def __get__(self, obj, typ=None):
|
||||||
|
length = str(self._length_field.__get__(obj))
|
||||||
|
return obj.unpack(start=self._start, fmt="<"+length+"s")[0]
|
||||||
|
|
||||||
|
def __set__(self, obj, val):
|
||||||
|
val = str(val)
|
||||||
|
obj.pack(val, start=self._start, fmt="<"+str(len(val))+"s")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return "A string starting at byte " + str(self._start)
|
||||||
|
|
||||||
|
class Command(TransferBuffer):
|
||||||
|
|
||||||
|
""" Defines the structure of command packets sent to the device. """
|
||||||
|
# Command number. C{unsigned int} stored in 4 bytes at byte 0.
|
||||||
|
#
|
||||||
|
# Command numbers are:
|
||||||
|
# 0 GetUsbProtocolVersion
|
||||||
|
# 1 ReqEndSession
|
||||||
|
# 10 FskFileOpen
|
||||||
|
# 11 FskFileClose
|
||||||
|
# 12 FskGetSize
|
||||||
|
# 13 FskSetSize
|
||||||
|
# 14 FskFileSetPosition
|
||||||
|
# 15 FskGetPosition
|
||||||
|
# 16 FskFileRead
|
||||||
|
# 17 FskFileWrite
|
||||||
|
# 18 FskFileGetFileInfo
|
||||||
|
# 19 FskFileSetFileInfo
|
||||||
|
# 1A FskFileCreate
|
||||||
|
# 1B FskFileDelete
|
||||||
|
# 1C FskFileRename
|
||||||
|
# 30 FskFileCreateDirectory
|
||||||
|
# 31 FskFileDeleteDirectory
|
||||||
|
# 32 FskFileRenameDirectory
|
||||||
|
# 33 FskDirectoryIteratorNew
|
||||||
|
# 34 FskDirectoryIteratorDispose
|
||||||
|
# 35 FskDirectoryIteratorGetNext
|
||||||
|
# 52 FskVolumeGetInfo
|
||||||
|
# 53 FskVolumeGetInfoFromPath
|
||||||
|
# 80 FskFileTerminate
|
||||||
|
# 100 ConnectDevice
|
||||||
|
# 101 GetProperty
|
||||||
|
# 102 GetMediaInfo
|
||||||
|
# 103 GetFreeSpace
|
||||||
|
# 104 SetTime
|
||||||
|
# 105 DeviceBeginEnd
|
||||||
|
# 106 UnlockDevice
|
||||||
|
# 107 SetBulkSize
|
||||||
|
# 110 GetHttpRequest
|
||||||
|
# 111 SetHttpRespponse
|
||||||
|
# 112 Needregistration
|
||||||
|
# 114 GetMarlinState
|
||||||
|
# 200 ReqDiwStart
|
||||||
|
# 201 SetDiwPersonalkey
|
||||||
|
# 202 GetDiwPersonalkey
|
||||||
|
# 203 SetDiwDhkey
|
||||||
|
# 204 GetDiwDhkey
|
||||||
|
# 205 SetDiwChallengeserver
|
||||||
|
# 206 GetDiwChallengeserver
|
||||||
|
# 207 GetDiwChallengeclient
|
||||||
|
# 208 SetDiwChallengeclient
|
||||||
|
# 209 GetDiwVersion
|
||||||
|
# 20A SetDiwWriteid
|
||||||
|
# 20B GetDiwWriteid
|
||||||
|
# 20C SetDiwSerial
|
||||||
|
# 20D GetDiwModel
|
||||||
|
# 20C SetDiwSerial
|
||||||
|
# 20E GetDiwDeviceid
|
||||||
|
# 20F GetDiwSerial
|
||||||
|
# 210 ReqDiwCheckservicedata
|
||||||
|
# 211 ReqDiwCheckiddata
|
||||||
|
# 212 ReqDiwCheckserialdata
|
||||||
|
# 213 ReqDiwFactoryinitialize
|
||||||
|
# 214 GetDiwMacaddress
|
||||||
|
# 215 ReqDiwTest
|
||||||
|
# 216 ReqDiwDeletekey
|
||||||
|
# 300 UpdateChangemode
|
||||||
|
# 301 UpdateDeletePartition
|
||||||
|
# 302 UpdateCreatePartition
|
||||||
|
# 303 UpdateCreatePartitionWithImage
|
||||||
|
# 304 UpdateGetPartitionSize
|
||||||
|
number = field(start=0, fmt=DWORD)
|
||||||
|
# Known types are 0x00 and 0x01. Acknowledge commands are always type 0x00
|
||||||
|
type = field(start=4, fmt=DDWORD)
|
||||||
|
# Length of the data part of this packet
|
||||||
|
length = field(start=12, fmt=DWORD)
|
||||||
|
|
||||||
|
@apply
|
||||||
|
def data():
|
||||||
|
doc = \
|
||||||
|
"""
|
||||||
|
The data part of this command. Returned/set as/by a TransferBuffer.
|
||||||
|
Stored at byte 16.
|
||||||
|
|
||||||
|
Setting it by default changes self.length to the length of the new
|
||||||
|
buffer. You may have to reset it to the significant part of the buffer.
|
||||||
|
You would normally use the C{command} property of
|
||||||
|
L{ShortCommand} or L{LongCommand} instead.
|
||||||
|
"""
|
||||||
|
def fget(self):
|
||||||
|
return self[16:]
|
||||||
|
|
||||||
|
def fset(self, buff):
|
||||||
|
self[16:] = buff
|
||||||
|
self.length = len(buff)
|
||||||
|
|
||||||
|
return property(doc=doc, fget=fget, fset=fset)
|
||||||
|
|
||||||
|
def __init__(self, packet):
|
||||||
|
"""
|
||||||
|
@param packet: len(packet) > 15 or packet > 15
|
||||||
|
"""
|
||||||
|
if ("__len__" in dir(packet) and len(packet) < 16) or\
|
||||||
|
("__len__" not in dir(packet) and packet < 16):
|
||||||
|
raise PacketError(str(self.__class__)[7:-2] + \
|
||||||
|
" packets must have length atleast 16")
|
||||||
|
TransferBuffer.__init__(self, packet)
|
||||||
|
|
||||||
|
|
||||||
|
class SetTime(Command):
|
||||||
|
"""
|
||||||
|
Set time on device. All fields refer to time in the GMT time zone.
|
||||||
|
"""
|
||||||
|
NUMBER = 0x104
|
||||||
|
# -time.timezone with negative numbers encoded
|
||||||
|
# as int(0xffffffff +1 -time.timezone/60.)
|
||||||
|
timezone = field(start=0x10, fmt=DWORD)
|
||||||
|
year = field(start=0x14, fmt=DWORD) #: year e.g. 2006
|
||||||
|
month = field(start=0x18, fmt=DWORD) #: month 1-12
|
||||||
|
day = field(start=0x1c, fmt=DWORD) #: day 1-31
|
||||||
|
hour = field(start=0x20, fmt=DWORD) #: hour 0-23
|
||||||
|
minute = field(start=0x24, fmt=DWORD) #: minute 0-59
|
||||||
|
second = field(start=0x28, fmt=DWORD) #: second 0-59
|
||||||
|
|
||||||
|
def __init__(self, t=None):
|
||||||
|
""" @param t: time as an epoch """
|
||||||
|
self.number = SetTime.NUMBER
|
||||||
|
self.type = 0x01
|
||||||
|
self.length = 0x1c
|
||||||
|
tz = int(-time.timezone/60.)
|
||||||
|
self.timezone = tz if tz > 0 else 0xffffffff +1 + tz
|
||||||
|
if not t: t = time.time()
|
||||||
|
t = time.gmtime(t)
|
||||||
|
self.year = t[0]
|
||||||
|
self.month = t[1]
|
||||||
|
self.day = t[2]
|
||||||
|
self.hour = t[3]
|
||||||
|
self.minute = t[4]
|
||||||
|
# Hack you should actually update the entire time tree is
|
||||||
|
# second is > 59
|
||||||
|
self.second = t[5] if t[5] < 60 else 59
|
||||||
|
|
||||||
|
|
||||||
|
class ShortCommand(Command):
|
||||||
|
|
||||||
|
""" A L{Command} whoose data section is 4 bytes long """
|
||||||
|
|
||||||
|
SIZE = 20 #: Packet size in bytes
|
||||||
|
# Usually carries additional information
|
||||||
|
command = field(start=16, fmt=DWORD)
|
||||||
|
|
||||||
|
def __init__(self, number=0x00, type=0x00, command=0x00):
|
||||||
|
"""
|
||||||
|
@param number: L{Command.number}
|
||||||
|
@param type: L{Command.type}
|
||||||
|
@param command: L{ShortCommand.command}
|
||||||
|
"""
|
||||||
|
Command.__init__(self, ShortCommand.SIZE)
|
||||||
|
self.number = number
|
||||||
|
self.type = type
|
||||||
|
self.length = 4
|
||||||
|
self.command = command
|
||||||
|
|
||||||
|
class DirRead(ShortCommand):
|
||||||
|
""" The command that asks the device to send the next item in the list """
|
||||||
|
NUMBER = 0x35 #: Command number
|
||||||
|
def __init__(self, _id):
|
||||||
|
""" @param id: The identifier returned as a result of a L{DirOpen} command """
|
||||||
|
ShortCommand.__init__(self, number=DirRead.NUMBER, type=0x01, \
|
||||||
|
command=_id)
|
||||||
|
|
||||||
|
class DirClose(ShortCommand):
|
||||||
|
""" Close a previously opened directory """
|
||||||
|
NUMBER = 0x34 #: Command number
|
||||||
|
def __init__(self, _id):
|
||||||
|
""" @param id: The identifier returned as a result of a L{DirOpen} command """
|
||||||
|
ShortCommand.__init__(self, number=DirClose.NUMBER, type=0x01,
|
||||||
|
command=_id)
|
||||||
|
|
||||||
|
class EndSession(ShortCommand):
|
||||||
|
"""
|
||||||
|
Ask device to change status to 'USB connected' i.e., tell the
|
||||||
|
device that the present sequence of commands is complete
|
||||||
|
"""
|
||||||
|
NUMBER = 0x1 #: Command number
|
||||||
|
def __init__(self):
|
||||||
|
ShortCommand.__init__(self, \
|
||||||
|
number=EndSession.NUMBER, type=0x01, command=0x00)
|
||||||
|
|
||||||
|
class GetUSBProtocolVersion(ShortCommand):
|
||||||
|
""" Get USB Protocol version used by device """
|
||||||
|
NUMBER = 0x0 #: Command number
|
||||||
|
def __init__(self):
|
||||||
|
ShortCommand.__init__(self, \
|
||||||
|
number=GetUSBProtocolVersion.NUMBER, \
|
||||||
|
type=0x01, command=0x00)
|
||||||
|
|
||||||
|
class SetBulkSize(ShortCommand):
|
||||||
|
""" Set size for bulk transfers in this session """
|
||||||
|
NUMBER = 0x107 #: Command number
|
||||||
|
def __init__(self, size=0x028000):
|
||||||
|
ShortCommand.__init__(self, \
|
||||||
|
number=SetBulkSize.NUMBER, type=0x01, command=size)
|
||||||
|
|
||||||
|
class UnlockDevice(ShortCommand):
|
||||||
|
""" Unlock the device """
|
||||||
|
NUMBER = 0x106 #: Command number
|
||||||
|
def __init__(self, key=0x312d):
|
||||||
|
ShortCommand.__init__(self, \
|
||||||
|
number=UnlockDevice.NUMBER, type=0x01, command=key)
|
||||||
|
|
||||||
|
class LongCommand(Command):
|
||||||
|
|
||||||
|
""" A L{Command} whoose data section is 16 bytes long """
|
||||||
|
|
||||||
|
SIZE = 32 #: Size in bytes of C{LongCommand} packets
|
||||||
|
|
||||||
|
def __init__(self, number=0x00, type=0x00, command=0x00):
|
||||||
|
"""
|
||||||
|
@param number: L{Command.number}
|
||||||
|
@param type: L{Command.type}
|
||||||
|
@param command: L{LongCommand.command}
|
||||||
|
"""
|
||||||
|
Command.__init__(self, LongCommand.SIZE)
|
||||||
|
self.number = number
|
||||||
|
self.type = type
|
||||||
|
self.length = 16
|
||||||
|
self.command = command
|
||||||
|
|
||||||
|
@apply
|
||||||
|
def command():
|
||||||
|
doc = \
|
||||||
|
"""
|
||||||
|
Usually carries extra information needed for the command
|
||||||
|
It is a list of C{unsigned integers} of length between 1 and 4. 4
|
||||||
|
C{unsigned int} stored in 16 bytes at byte 16.
|
||||||
|
"""
|
||||||
|
def fget(self):
|
||||||
|
return self.unpack(start=16, fmt="<"+str(self.length/4)+"I")
|
||||||
|
|
||||||
|
def fset(self, val):
|
||||||
|
if "__len__" not in dir(val): val = (val,)
|
||||||
|
start = 16
|
||||||
|
for command in val:
|
||||||
|
self.pack(command, start=start, fmt=DWORD)
|
||||||
|
start += struct.calcsize(DWORD)
|
||||||
|
|
||||||
|
return property(doc=doc, fget=fget, fset=fset)
|
||||||
|
|
||||||
|
class PathCommand(Command):
|
||||||
|
""" Abstract class that defines structure common to all path related commands. """
|
||||||
|
|
||||||
|
path_length = field(start=16, fmt=DWORD) #: Length of the path to follow
|
||||||
|
path = stringfield(path_length, start=20) #: The path this query is about
|
||||||
|
def __init__(self, path, number, path_len_at_byte=16):
|
||||||
|
Command.__init__(self, path_len_at_byte+4+len(path))
|
||||||
|
self.path_length = len(path)
|
||||||
|
self.path = path
|
||||||
|
self.type = 0x01
|
||||||
|
self.length = len(self) - 16
|
||||||
|
self.number = number
|
||||||
|
|
||||||
|
class TotalSpaceQuery(PathCommand):
|
||||||
|
""" Query the total space available on the volume represented by path """
|
||||||
|
NUMBER = 0x53 #: Command number
|
||||||
|
def __init__(self, path):
|
||||||
|
""" @param path: valid values are 'a:', 'b:', '/Data/' """
|
||||||
|
PathCommand.__init__(self, path, TotalSpaceQuery.NUMBER)
|
||||||
|
|
||||||
|
class FreeSpaceQuery(ShortCommand):
|
||||||
|
""" Query the free space available """
|
||||||
|
NUMBER = 0x103 #: Command number
|
||||||
|
def __init__(self, where):
|
||||||
|
""" @param where: valid values are: 'a:', 'b:', '/' """
|
||||||
|
c = 0
|
||||||
|
if where.startswith('a:'): c = 1
|
||||||
|
elif where.startswith('b:'): c = 2
|
||||||
|
ShortCommand.__init__(self, \
|
||||||
|
number=FreeSpaceQuery.NUMBER, type=0x01, command=c)
|
||||||
|
|
||||||
|
class DirCreate(PathCommand):
|
||||||
|
""" Create a directory """
|
||||||
|
NUMBER = 0x30
|
||||||
|
def __init__(self, path):
|
||||||
|
PathCommand.__init__(self, path, DirCreate.NUMBER)
|
||||||
|
|
||||||
|
class DirOpen(PathCommand):
|
||||||
|
""" Open a directory for reading its contents """
|
||||||
|
NUMBER = 0x33 #: Command number
|
||||||
|
def __init__(self, path):
|
||||||
|
PathCommand.__init__(self, path, DirOpen.NUMBER)
|
||||||
|
|
||||||
|
|
||||||
|
class AcknowledgeBulkRead(LongCommand):
|
||||||
|
""" Must be sent to device after a bulk read """
|
||||||
|
def __init__(self, bulk_read_id):
|
||||||
|
"""
|
||||||
|
bulk_read_id is an integer, the id of the bulk read
|
||||||
|
we are acknowledging. See L{Answer.id}
|
||||||
|
"""
|
||||||
|
LongCommand.__init__(self, number=0x1000, \
|
||||||
|
type=0x00, command=bulk_read_id)
|
||||||
|
|
||||||
|
class DeviceInfoQuery(Command):
|
||||||
|
""" The command used to ask for device information """
|
||||||
|
NUMBER = 0x101 #: Command number
|
||||||
|
def __init__(self):
|
||||||
|
Command.__init__(self, 16)
|
||||||
|
self.number = DeviceInfoQuery.NUMBER
|
||||||
|
self.type = 0x01
|
||||||
|
|
||||||
|
class FileClose(ShortCommand):
|
||||||
|
""" File close command """
|
||||||
|
NUMBER = 0x11 #: Command number
|
||||||
|
def __init__(self, _id):
|
||||||
|
ShortCommand.__init__(self, number=FileClose.NUMBER, \
|
||||||
|
type=0x01, command=_id)
|
||||||
|
|
||||||
|
class FileCreate(PathCommand):
|
||||||
|
""" Create a file """
|
||||||
|
NUMBER = 0x1a #: Command number
|
||||||
|
def __init__(self, path):
|
||||||
|
PathCommand.__init__(self, path, FileCreate.NUMBER)
|
||||||
|
|
||||||
|
class FileDelete(PathCommand):
|
||||||
|
""" Delete a file """
|
||||||
|
NUMBER = 0x1B
|
||||||
|
def __init__(self, path):
|
||||||
|
PathCommand.__init__(self, path, FileDelete.NUMBER)
|
||||||
|
|
||||||
|
class DirDelete(PathCommand):
|
||||||
|
""" Delete a directory """
|
||||||
|
NUMBER = 0x31
|
||||||
|
def __init__(self, path):
|
||||||
|
PathCommand.__init__(self, path, DirDelete.NUMBER)
|
||||||
|
|
||||||
|
class FileOpen(PathCommand):
|
||||||
|
""" File open command """
|
||||||
|
NUMBER = 0x10 #: Command number
|
||||||
|
READ = 0x00 #: Open file in read mode
|
||||||
|
WRITE = 0x01 #: Open file in write mode
|
||||||
|
path_length = field(start=20, fmt=DWORD)
|
||||||
|
path = stringfield(path_length, start=24)
|
||||||
|
|
||||||
|
def __init__(self, path, mode=0x00):
|
||||||
|
PathCommand.__init__(self, path, FileOpen.NUMBER, path_len_at_byte=20)
|
||||||
|
self.mode = mode
|
||||||
|
|
||||||
|
@apply
|
||||||
|
def mode():
|
||||||
|
doc = \
|
||||||
|
"""
|
||||||
|
The file open mode. Is either L{FileOpen.READ}
|
||||||
|
or L{FileOpen.WRITE}. C{unsigned int} stored at byte 16.
|
||||||
|
"""
|
||||||
|
def fget(self):
|
||||||
|
return self.unpack(start=16, fmt=DWORD)[0]
|
||||||
|
|
||||||
|
def fset(self, val):
|
||||||
|
self.pack(val, start=16, fmt=DWORD)
|
||||||
|
|
||||||
|
return property(doc=doc, fget=fget, fset=fset)
|
||||||
|
|
||||||
|
|
||||||
|
class FileIO(Command):
|
||||||
|
""" Command to read/write from an open file """
|
||||||
|
RNUMBER = 0x16 #: Command number to read from a file
|
||||||
|
WNUMBER = 0x17 #: Command number to write to a file
|
||||||
|
id = field(start=16, fmt=DWORD) #: The file ID returned by a FileOpen command
|
||||||
|
offset = field(start=20, fmt=DDWORD) #: offset in the file at which to read
|
||||||
|
size = field(start=28, fmt=DWORD) #: The number of bytes to reead from file.
|
||||||
|
def __init__(self, _id, offset, size, mode=0x16):
|
||||||
|
"""
|
||||||
|
@param _id: File identifier returned by a L{FileOpen} command
|
||||||
|
@type id: C{unsigned int}
|
||||||
|
@param offset: Position in file at which to read
|
||||||
|
@type offset: C{unsigned long long}
|
||||||
|
@param size: number of bytes to read
|
||||||
|
@type size: C{unsigned int}
|
||||||
|
@param mode: Either L{FileIO.RNUMBER} or L{File.WNUMBER}
|
||||||
|
"""
|
||||||
|
Command.__init__(self, 32)
|
||||||
|
self.number = mode
|
||||||
|
self.type = 0x01
|
||||||
|
self.length = 16
|
||||||
|
self.id = _id
|
||||||
|
self.offset = offset
|
||||||
|
self.size = size
|
||||||
|
|
||||||
|
|
||||||
|
class PathQuery(PathCommand):
|
||||||
|
""" Defines structure of command that requests information about a path """
|
||||||
|
NUMBER = 0x18 #: Command number
|
||||||
|
def __init__(self, path):
|
||||||
|
PathCommand.__init__(self, path, PathQuery.NUMBER)
|
||||||
|
|
||||||
|
class SetFileInfo(PathCommand):
|
||||||
|
""" Set File information """
|
||||||
|
NUMBER = 0x19 #: Command number
|
||||||
|
def __init__(self, path):
|
||||||
|
PathCommand.__init__(self, path, SetFileInfo.NUMBER)
|
||||||
|
|
||||||
|
class Response(Command):
|
||||||
|
"""
|
||||||
|
Defines the structure of response packets received from the device.
|
||||||
|
|
||||||
|
C{Response} inherits from C{Command} as the
|
||||||
|
first 16 bytes have the same structure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SIZE = 32 #: Size of response packets in the SONY protocol
|
||||||
|
# Response number, the command number of a command
|
||||||
|
# packet sent sometime before this packet was received
|
||||||
|
rnumber = field(start=16, fmt=DWORD)
|
||||||
|
# Used to indicate error conditions. A value of 0 means
|
||||||
|
# there was no error
|
||||||
|
code = field(start=20, fmt=DWORD)
|
||||||
|
# Used to indicate the size of the next bulk read
|
||||||
|
data_size = field(start=28, fmt=DWORD)
|
||||||
|
|
||||||
|
def __init__(self, packet):
|
||||||
|
""" C{len(packet) == Response.SIZE} """
|
||||||
|
if len(packet) != Response.SIZE:
|
||||||
|
raise PacketError(str(self.__class__)[7:-2] + \
|
||||||
|
" packets must have exactly " + \
|
||||||
|
str(Response.SIZE) + " bytes not " + str(len(packet)))
|
||||||
|
Command.__init__(self, packet)
|
||||||
|
if self.number != 0x00001000:
|
||||||
|
raise PacketError("Response packets must have their number set to " \
|
||||||
|
+ hex(0x00001000))
|
||||||
|
|
||||||
|
@apply
|
||||||
|
def data():
|
||||||
|
doc = \
|
||||||
|
"""
|
||||||
|
The last 3 DWORDs (12 bytes) of data in this
|
||||||
|
response packet. Returned as a list of unsigned integers.
|
||||||
|
"""
|
||||||
|
def fget(self):
|
||||||
|
return self.unpack(start=20, fmt="<III")
|
||||||
|
|
||||||
|
def fset(self, val):
|
||||||
|
self.pack(val, start=20, fmt="<III")
|
||||||
|
|
||||||
|
return property(doc=doc, fget=fget, fset=fset)
|
||||||
|
|
||||||
|
class ListResponse(Response):
|
||||||
|
|
||||||
|
"""
|
||||||
|
Defines the structure of response packets received
|
||||||
|
during list (ll) queries. See L{PathQuery}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
IS_FILE = 0xffffffd2 #: Queried path is a file
|
||||||
|
IS_INVALID = 0xfffffff9 #: Queried path is malformed/invalid
|
||||||
|
# Queried path is not mounted (i.e. a removed storage card/stick)
|
||||||
|
IS_UNMOUNTED = 0xffffffc8
|
||||||
|
IS_EOL = 0xfffffffa #: There are no more entries in the list
|
||||||
|
PATH_NOT_FOUND = 0xffffffd7 #: Queried path is not found
|
||||||
|
|
||||||
|
@apply
|
||||||
|
def is_file():
|
||||||
|
doc = """ True iff queried path is a file """
|
||||||
|
def fget(self):
|
||||||
|
return self.code == ListResponse.IS_FILE
|
||||||
|
return property(doc=doc, fget=fget)
|
||||||
|
|
||||||
|
@apply
|
||||||
|
def is_invalid():
|
||||||
|
doc = """ True iff queried path is invalid """
|
||||||
|
def fget(self):
|
||||||
|
return self.code == ListResponse.IS_INVALID
|
||||||
|
return property(doc=doc, fget=fget)
|
||||||
|
|
||||||
|
@apply
|
||||||
|
def path_not_found():
|
||||||
|
doc = """ True iff queried path is not found """
|
||||||
|
def fget(self):
|
||||||
|
return self.code == ListResponse.PATH_NOT_FOUND
|
||||||
|
return property(doc=doc, fget=fget)
|
||||||
|
|
||||||
|
@apply
|
||||||
|
def is_unmounted():
|
||||||
|
doc = """ True iff queried path is unmounted (i.e. removed storage card) """
|
||||||
|
def fget(self):
|
||||||
|
return self.code == ListResponse.IS_UNMOUNTED
|
||||||
|
return property(doc=doc, fget=fget)
|
||||||
|
|
||||||
|
@apply
|
||||||
|
def is_eol():
|
||||||
|
doc = """ True iff there are no more items in the list """
|
||||||
|
def fget(self):
|
||||||
|
return self.code == ListResponse.IS_EOL
|
||||||
|
return property(doc=doc, fget=fget)
|
||||||
|
|
||||||
|
class Answer(TransferBuffer):
|
||||||
|
"""
|
||||||
|
Defines the structure of packets sent to host via a
|
||||||
|
bulk transfer (i.e., bulk reads)
|
||||||
|
"""
|
||||||
|
|
||||||
|
number = field(start=0, fmt=DWORD) #: Answer identifier
|
||||||
|
length = field(start=12, fmt=DWORD) #: Length of data to follow
|
||||||
|
|
||||||
|
def __init__(self, packet):
|
||||||
|
""" @param packet: C{len(packet)} S{>=} C{16} """
|
||||||
|
if "__len__" in dir(packet):
|
||||||
|
if len(packet) < 16 :
|
||||||
|
raise PacketError(str(self.__class__)[7:-2] + \
|
||||||
|
" packets must have a length of atleast 16 bytes")
|
||||||
|
elif packet < 16:
|
||||||
|
raise PacketError(str(self.__class__)[7:-2] + \
|
||||||
|
" packets must have a length of atleast 16 bytes")
|
||||||
|
TransferBuffer.__init__(self, packet)
|
||||||
|
|
||||||
|
|
||||||
|
class FileProperties(Answer):
|
||||||
|
|
||||||
|
"""
|
||||||
|
Defines the structure of packets that contain size, date and
|
||||||
|
permissions information about files/directories.
|
||||||
|
"""
|
||||||
|
|
||||||
|
file_size = field(start=16, fmt=DDWORD) #: Size in bytes of the file
|
||||||
|
file_type = field(start=24, fmt=DWORD) #: 1 == file, 2 == dir
|
||||||
|
ctime = field(start=28, fmt=DWORD) #: Creation time as an epoch
|
||||||
|
wtime = field(start=32, fmt=DWORD) #: Modification time as an epoch
|
||||||
|
# 0 = default permissions, 4 = read only
|
||||||
|
permissions = field(start=36, fmt=DWORD)
|
||||||
|
|
||||||
|
@apply
|
||||||
|
def is_dir():
|
||||||
|
doc = """True if path points to a directory, False if it points to a file."""
|
||||||
|
|
||||||
|
def fget(self):
|
||||||
|
return (self.file_type == 2)
|
||||||
|
|
||||||
|
def fset(self, val):
|
||||||
|
if val:
|
||||||
|
val = 2
|
||||||
|
else:
|
||||||
|
val = 1
|
||||||
|
self.file_type = val
|
||||||
|
|
||||||
|
return property(doc=doc, fget=fget, fset=fset)
|
||||||
|
|
||||||
|
|
||||||
|
@apply
|
||||||
|
def is_readonly():
|
||||||
|
doc = """ Whether this file is readonly."""
|
||||||
|
|
||||||
|
def fget(self):
|
||||||
|
return self.unpack(start=36, fmt=DWORD)[0] != 0
|
||||||
|
|
||||||
|
def fset(self, val):
|
||||||
|
if val:
|
||||||
|
val = 4
|
||||||
|
else:
|
||||||
|
val = 0
|
||||||
|
self.pack(val, start=36, fmt=DWORD)
|
||||||
|
|
||||||
|
return property(doc=doc, fget=fget, fset=fset)
|
||||||
|
|
||||||
|
|
||||||
|
class USBProtocolVersion(Answer):
|
||||||
|
""" Get USB Protocol version """
|
||||||
|
version = field(start=16, fmt=DDWORD)
|
||||||
|
|
||||||
|
class IdAnswer(Answer):
|
||||||
|
|
||||||
|
""" Defines the structure of packets that contain identifiers for queries. """
|
||||||
|
|
||||||
|
@apply
|
||||||
|
def id():
|
||||||
|
doc = \
|
||||||
|
"""
|
||||||
|
The identifier. C{unsigned int} stored in 4 bytes
|
||||||
|
at byte 16. Should be sent in commands asking
|
||||||
|
for the next item in the list.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def fget(self):
|
||||||
|
return self.unpack(start=16, fmt=DWORD)[0]
|
||||||
|
|
||||||
|
def fset(self, val):
|
||||||
|
self.pack(val, start=16, fmt=DWORD)
|
||||||
|
|
||||||
|
return property(doc=doc, fget=fget, fset=fset)
|
||||||
|
|
||||||
|
class DeviceInfo(Answer):
|
||||||
|
""" Defines the structure of the packet containing information about the device """
|
||||||
|
device_name = field(start=16, fmt="<32s")
|
||||||
|
device_version = field(start=48, fmt="<32s")
|
||||||
|
software_version = field(start=80, fmt="<24s")
|
||||||
|
mime_type = field(start=104, fmt="<32s")
|
||||||
|
|
||||||
|
|
||||||
|
class TotalSpaceAnswer(Answer):
|
||||||
|
total = field(start=24, fmt=DDWORD) #: Total space available
|
||||||
|
# Supposedly free space available, but it does not work for main memory
|
||||||
|
free_space = field(start=32, fmt=DDWORD)
|
||||||
|
|
||||||
|
class FreeSpaceAnswer(Answer):
|
||||||
|
SIZE = 24
|
||||||
|
free = field(start=16, fmt=DDWORD)
|
||||||
|
|
||||||
|
|
||||||
|
class ListAnswer(Answer):
|
||||||
|
""" Defines the structure of packets that contain items in a list. """
|
||||||
|
name_length = field(start=20, fmt=DWORD)
|
||||||
|
name = stringfield(name_length, start=24)
|
||||||
|
|
||||||
|
@apply
|
||||||
|
def is_dir():
|
||||||
|
doc = \
|
||||||
|
"""
|
||||||
|
True if list item points to a directory, False if it points to a file.
|
||||||
|
C{unsigned int} stored in 4 bytes at byte 16.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def fget(self):
|
||||||
|
return (self.unpack(start=16, fmt=DWORD)[0] == 2)
|
||||||
|
|
||||||
|
def fset(self, val):
|
||||||
|
if val: val = 2
|
||||||
|
else: val = 1
|
||||||
|
self.pack(val, start=16, fmt=DWORD)
|
||||||
|
|
||||||
|
return property(doc=doc, fget=fget, fset=fset)
|
||||||
|
|
||||||
|
|
59
src/libprs500/ptempfile.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
## Copyright (C) 2006 Kovid Goyal kovid@kovidgoyal.net
|
||||||
|
## This program is free software; you can redistribute it and/or modify
|
||||||
|
## it under the terms of the GNU General Public License as published by
|
||||||
|
## the Free Software Foundation; either version 2 of the License, or
|
||||||
|
## (at your option) any later version.
|
||||||
|
##
|
||||||
|
## This program is distributed in the hope that it will be useful,
|
||||||
|
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
## GNU General Public License for more details.
|
||||||
|
##
|
||||||
|
## You should have received a copy of the GNU General Public License along
|
||||||
|
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.Warning
|
||||||
|
"""
|
||||||
|
Provides platform independent temporary files that persist even after
|
||||||
|
being closed.
|
||||||
|
"""
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
|
||||||
|
from libprs500 import __version__
|
||||||
|
|
||||||
|
class _TemporaryFileWrapper(object):
|
||||||
|
"""
|
||||||
|
Temporary file wrapper
|
||||||
|
|
||||||
|
This class provides a wrapper around files opened for
|
||||||
|
temporary use. In particular, it seeks to automatically
|
||||||
|
remove the file when the object is deleted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, _file, name):
|
||||||
|
self.file = _file
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
_file = self.__dict__['file']
|
||||||
|
a = getattr(_file, name)
|
||||||
|
if type(a) != type(0):
|
||||||
|
setattr(self, name, a)
|
||||||
|
return a
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
if os.access(self.name, os.F_OK):
|
||||||
|
os.unlink(self.name)
|
||||||
|
|
||||||
|
|
||||||
|
def PersistentTemporaryFile(suffix="", prefix=""):
|
||||||
|
"""
|
||||||
|
Return a temporary file that is available even after being closed on
|
||||||
|
all platforms. It is automatically deleted when this object is deleted.
|
||||||
|
Uses tempfile.mkstemp to create the file. The file is opened in mode 'wb'.
|
||||||
|
"""
|
||||||
|
if prefix == None:
|
||||||
|
prefix = ""
|
||||||
|
fd, name = tempfile.mkstemp(suffix, "libprs500_"+ __version__+"_" + prefix)
|
||||||
|
_file = os.fdopen(fd, "wb")
|
||||||
|
return _TemporaryFileWrapper(_file, name)
|