1#!/usr/local/bin/python3.8
2
3
4__license__   = 'GPL v3'
5__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
6"""
7Provides a command-line interface to ebook devices.
8
9For usage information run the script.
10"""
11
12import sys, time, os
13from optparse import OptionParser
14
15from calibre import __version__, __appname__, human_readable, fsync, prints
16from calibre.devices.errors import ArgumentError, DeviceError, DeviceLocked
17from calibre.customize.ui import device_plugins
18from calibre.devices.scanner import DeviceScanner
19from calibre.utils.config import device_prefs
20from polyglot.io import PolyglotStringIO
21
22MINIMUM_COL_WIDTH = 12  # : Minimum width of columns in ls output
23
24
25class FileFormatter:
26
27    def __init__(self, file):
28        self.is_dir      = file.is_dir
29        self.is_readonly = file.is_readonly
30        self.size        = file.size
31        self.ctime       = file.ctime
32        self.wtime       = file.wtime
33        self.name        = file.name
34        self.path        = file.path
35
36    @property
37    def mode_string(self):
38        """ The mode string for this file. There are only two modes read-only and read-write """
39        mode, x = "-", "-"
40        if self.is_dir:
41            mode, x = "d", "x"
42        if self.is_readonly:
43            mode += "r-"+x+"r-"+x+"r-"+x
44        else:
45            mode += "rw"+x+"rw"+x+"rw"+x
46        return mode
47
48    @property
49    def isdir_name(self):
50        '''Return self.name + '/' if self is a directory'''
51        name = self.name
52        if self.is_dir:
53            name += '/'
54        return name
55
56    @property
57    def name_in_color(self):
58        """ The name in ANSI text. Directories are blue, ebooks are green """
59        cname = self.name
60        blue, green, normal = "", "", ""
61        if self.term:
62            blue, green, normal = self.term.BLUE, self.term.GREEN, self.term.NORMAL
63        if self.is_dir:
64            cname = blue + self.name + normal
65        else:
66            ext = self.name[self.name.rfind("."):]
67            if ext in (".pdf", ".rtf", ".lrf", ".lrx", ".txt"):
68                cname = green + self.name + normal
69        return cname
70
71    @property
72    def human_readable_size(self):
73        """ File size in human readable form """
74        return human_readable(self.size)
75
76    @property
77    def modification_time(self):
78        """ Last modified time in the Linux ls -l format """
79        return time.strftime("%Y-%m-%d %H:%M", time.localtime(self.wtime))
80
81    @property
82    def creation_time(self):
83        """ Last modified time in the Linux ls -l format """
84        return time.strftime("%Y-%m-%d %H:%M", time.localtime(self.ctime))
85
86
87def info(dev):
88    info = dev.get_device_information()
89    print("Device name:     ", info[0])
90    print("Device version:  ", info[1])
91    print("Software version:", info[2])
92    print("Mime type:       ", info[3])
93
94
95def ls(dev, path, recurse=False, human_readable_size=False, ll=False, cols=0):
96    def col_split(l, cols):  # split list l into columns
97        rows = len(l) // cols
98        if len(l) % cols:
99            rows += 1
100        m = []
101        for i in range(rows):
102            m.append(l[i::rows])
103        return m
104
105    def row_widths(table):  # Calculate widths for each column in the row-wise table
106        tcols = len(table[0])
107        rowwidths = [0 for i in range(tcols)]
108        for row in table:
109            c = 0
110            for item in row:
111                rowwidths[c] = len(item) if len(item) > rowwidths[c] else rowwidths[c]
112                c += 1
113        return rowwidths
114
115    output = PolyglotStringIO()
116    if path.endswith("/") and len(path) > 1:
117        path = path[:-1]
118    dirs = dev.list(path, recurse)
119    for dir in dirs:
120        if recurse:
121            prints(dir[0] + ":", file=output)
122        lsoutput, lscoloutput = [], []
123        files = dir[1]
124        maxlen = 0
125        if ll:  # Calculate column width for size column
126            for file in files:
127                size = len(str(file.size))
128                if human_readable_size:
129                    file = FileFormatter(file)
130                    size = len(file.human_readable_size)
131                if size > maxlen:
132                    maxlen = size
133        for file in files:
134            file = FileFormatter(file)
135            name = file.name if ll else file.isdir_name
136            lsoutput.append(name)
137            lscoloutput.append(name)
138            if ll:
139                size = str(file.size)
140                if human_readable_size:
141                    size = file.human_readable_size
142                prints(file.mode_string, ("%"+str(maxlen)+"s")%size, file.modification_time, name, file=output)
143        if not ll and len(lsoutput) > 0:
144            trytable = []
145            for colwidth in range(MINIMUM_COL_WIDTH, cols):
146                trycols = int(cols//colwidth)
147                trytable = col_split(lsoutput, trycols)
148                works = True
149                for row in trytable:
150                    row_break = False
151                    for item in row:
152                        if len(item) > colwidth - 1:
153                            works, row_break = False, True
154                            break
155                    if row_break:
156                        break
157                if works:
158                    break
159            rowwidths = row_widths(trytable)
160            trytablecol = col_split(lscoloutput, len(trytable[0]))
161            for r in range(len(trytable)):
162                for c in range(len(trytable[r])):
163                    padding = rowwidths[c] - len(trytable[r][c])
164                    prints(trytablecol[r][c], "".ljust(padding), end=' ', file=output)
165                prints(file=output)
166        prints(file=output)
167    listing = output.getvalue().rstrip() + "\n"
168    output.close()
169    return listing
170
171
172def shutdown_plugins():
173    for d in device_plugins():
174        try:
175            d.shutdown()
176        except:
177            pass
178
179
180def main():
181    from calibre.utils.terminal import geometry
182    cols = geometry()[0]
183
184    parser = OptionParser(usage="usage: %prog [options] command args\n\ncommand "+
185            "is one of: info, books, df, ls, cp, mkdir, touch, cat, rm, eject, test_file\n\n"+
186    "For help on a particular command: %prog command", version=__appname__+" version: " + __version__)
187    parser.add_option("--log-packets", help="print out packet stream to stdout. "+
188                    "The numbers in the left column are byte offsets that allow the packet size to be read off easily.",
189    dest="log_packets", action="store_true", default=False)
190    parser.remove_option("-h")
191    parser.disable_interspersed_args()  # Allow unrecognized options
192    options, args = parser.parse_args()
193
194    if len(args) < 1:
195        parser.print_help()
196        return 1
197
198    command = args[0]
199    args = args[1:]
200    dev = None
201    scanner = DeviceScanner()
202    scanner.scan()
203    connected_devices = []
204
205    for d in device_plugins():
206        try:
207            d.startup()
208        except:
209            print('Startup failed for device plugin: %s'%d)
210        if d.MANAGES_DEVICE_PRESENCE:
211            cd = d.detect_managed_devices(scanner.devices)
212            if cd is not None:
213                connected_devices.append((cd, d))
214                dev = d
215                break
216            continue
217        ok, det = scanner.is_device_connected(d)
218        if ok:
219            dev = d
220            dev.reset(log_packets=options.log_packets, detected_device=det)
221            connected_devices.append((det, dev))
222
223    if dev is None:
224        print('Unable to find a connected ebook reader.', file=sys.stderr)
225        shutdown_plugins()
226        return 1
227
228    for det, d in connected_devices:
229        try:
230            d.open(det, None)
231        except:
232            continue
233        else:
234            dev = d
235            d.specialize_global_preferences(device_prefs)
236            break
237
238    try:
239        if command == "df":
240            total = dev.total_space(end_session=False)
241            free = dev.free_space()
242            where = ("Memory", "Card A", "Card B")
243            print("Filesystem\tSize \tUsed \tAvail \tUse%")
244            for i in range(3):
245                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]),
246                                                                            str(0 if total[i]==0 else int(100*(total[i]-free[i])/(total[i]*1.)))+"%"))
247        elif command == 'eject':
248            dev.eject()
249        elif command == "books":
250            print("Books in main memory:")
251            for book in dev.books():
252                print(book)
253            print("\nBooks on storage carda:")
254            for book in dev.books(oncard='carda'):
255                print(book)
256            print("\nBooks on storage cardb:")
257            for book in dev.books(oncard='cardb'):
258                print(book)
259        elif command == "mkdir":
260            parser = OptionParser(usage="usage: %prog mkdir [options] path\nCreate a folder on the device\n\npath must begin with / or card:/")
261            if len(args) != 1:
262                parser.print_help()
263                sys.exit(1)
264            dev.mkdir(args[0])
265        elif command == "ls":
266            parser = OptionParser(usage="usage: %prog ls [options] path\nList files on the device\n\npath must begin with / or card:/")
267            parser.add_option(
268                "-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.",  # noqa
269                dest="ll", action="store_true", default=False)
270            parser.add_option("-R", help="Recursively list subfolders encountered. /dev and /proc are omitted",
271                              dest="recurse", action="store_true", default=False)
272            parser.remove_option("-h")
273            parser.add_option("-h", "--human-readable", help="show sizes in human readable format", dest="hrs", action="store_true", default=False)
274            options, args = parser.parse_args(args)
275            if len(args) != 1:
276                parser.print_help()
277                return 1
278            print(ls(dev, args[0], recurse=options.recurse, ll=options.ll, human_readable_size=options.hrs, cols=cols), end=' ')
279        elif command == "info":
280            info(dev)
281        elif command == "cp":
282            usage="usage: %prog cp [options] source destination\nCopy files to/from the device\n\n"+\
283            "One of source or destination must be a path on the device. \n\nDevice paths have the form\n"+\
284            "dev:mountpoint/my/path\n"+\
285            "where mountpoint is one of / or carda: or cardb:/\n\n"+\
286            "source must point to a file for which you have read permissions\n"+\
287            "destination must point to a file or folder for which you have write permissions"
288            parser = OptionParser(usage=usage)
289            parser.add_option('-f', '--force', dest='force', action='store_true', default=False,
290                              help='Overwrite the destination file if it exists already.')
291            options, args = parser.parse_args(args)
292            if len(args) != 2:
293                parser.print_help()
294                return 1
295            if args[0].startswith("dev:"):
296                outfile = args[1]
297                path = args[0][4:]
298                if path.endswith("/"):
299                    path = path[:-1]
300                if os.path.isdir(outfile):
301                    outfile = os.path.join(outfile, path[path.rfind("/")+1:])
302                try:
303                    outfile = lopen(outfile, "wb")
304                except OSError as e:
305                    print(e, file=sys.stderr)
306                    parser.print_help()
307                    return 1
308                dev.get_file(path, outfile)
309                fsync(outfile)
310                outfile.close()
311            elif args[1].startswith("dev:"):
312                try:
313                    infile = lopen(args[0], "rb")
314                except OSError as e:
315                    print(e, file=sys.stderr)
316                    parser.print_help()
317                    return 1
318                dev.put_file(infile, args[1][4:], replace_file=options.force)
319                infile.close()
320            else:
321                parser.print_help()
322                return 1
323        elif command == "cat":
324            outfile = sys.stdout
325            parser = OptionParser(
326                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:/")
327            options, args = parser.parse_args(args)
328            if len(args) != 1:
329                parser.print_help()
330                return 1
331            if args[0].endswith("/"):
332                path = args[0][:-1]
333            else:
334                path = args[0]
335            outfile = sys.stdout
336            dev.get_file(path, outfile)
337        elif command == "rm":
338            parser = OptionParser(usage="usage: %prog rm path\nDelete files from the device\n\npath should point to a file or empty folder on the device "+
339                                  "and must begin with / or card:/\n\n"+
340                                  "rm will DELETE the file. Be very CAREFUL")
341            options, args = parser.parse_args(args)
342            if len(args) != 1:
343                parser.print_help()
344                return 1
345            dev.rm(args[0])
346        elif command == "touch":
347            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"+  # noqa
348            "Unfortunately, I can't figure out how to update file times on the device, so if path already exists, touch does nothing")
349            options, args = parser.parse_args(args)
350            if len(args) != 1:
351                parser.print_help()
352                return 1
353            dev.touch(args[0])
354        elif command == 'test_file':
355            parser = OptionParser(usage=("usage: %prog test_file path\n"
356                'Open device, copy file specified by path to device and '
357                'then eject device.'))
358            options, args = parser.parse_args(args)
359            if len(args) != 1:
360                parser.print_help()
361                return 1
362            path = args[0]
363            from calibre.ebooks.metadata.meta import get_metadata
364            mi = get_metadata(lopen(path, 'rb'), path.rpartition('.')[-1].lower())
365            print(dev.upload_books([args[0]], [os.path.basename(args[0])],
366                    end_session=False, metadata=[mi]))
367            dev.eject()
368        else:
369            parser.print_help()
370            if getattr(dev, 'handle', False):
371                dev.close()
372            return 1
373    except DeviceLocked:
374        print("The device is locked. Use the --unlock option", file=sys.stderr)
375    except (ArgumentError, DeviceError) as e:
376        print(e, file=sys.stderr)
377        return 1
378    finally:
379        shutdown_plugins()
380
381    return 0
382
383
384if __name__ == '__main__':
385    main()
386