Added a class for accessing and handling directories
[automanga.git] / automanga.py
1 #!/usr/bin/python
2 #encoding: utf8
3 #
4 # automanga
5 # by Kaka <kaka@dolda2000.com>
6
7 import os, sys, optparse
8 from user import home
9 try:
10     import gtk
11     import pygtk; pygtk.require("2.0")
12 except AssertionError, e:
13     error("You need to install the package 'python-gtk2'")
14
15 DIR_BASE      = os.path.join(home, ".automanga")
16 DIR_PROFILES  = os.path.join(DIR_BASE, "profiles")
17 FILE_SETTINGS = os.path.join(DIR_BASE, "settings")
18
19 def init():
20     global settings, profile, opts, args, cwd
21
22     if not os.path.exists(DIR_PROFILES):
23         os.makedirs(DIR_PROFILES)
24     settings = Settings()
25
26     usage = "Usage: %prog [options]"
27     parser = optparse.OptionParser(usage) # one delete option to use in combination with profile and directory?
28     parser.add_option("-p", "--profile", help="load or create a profile", metavar="profile")
29     parser.add_option("-r", "--remove-profile", help="remove profile",    metavar="profile")
30     parser.add_option("-a", "--add",     help="add a directory",          metavar="dir")
31     parser.add_option("-d", "--delete",  help="delete a directory",       metavar="dir")
32     parser.add_option("-s", "--silent",  help="no output",                default=False, action="store_true")
33     parser.add_option("-v", "--verbose", help="show output",              default=False, action="store_true")
34     opts, args = parser.parse_args()
35
36     cwd = os.getcwd()
37
38 def load_profile(name):
39     global profile
40     profile = Profile(name)
41     settings.last_profile(profile.name)
42
43 def output(msg):
44     if not settings.silent():
45         print msg
46
47 def error(msg):
48     print >>sys.stderr, msg
49     sys.exit(1)
50
51 def abs_path(path):
52     """Returns the absolute path"""
53     if not os.path.isabs(path): ret = os.path.join(cwd, path)
54     else:                       ret = path
55     return os.path.abspath(ret)
56
57 def manga_dir(path):
58     """Checks if path is a manga directory"""
59     for node in os.listdir(path):
60         if node.rsplit(".", 1)[-1] in ("jpg", "png", "gif", "bmp"):
61             return True
62     return False
63
64 def natsorted(strings):
65     """Sorts a list of strings naturally"""
66 #    import re
67 #    return sorted(strings, key=lambda s: [int(t) if t.isdigit() else t for t in re.split(r'(\d+)', s)])
68     return sorted(strings, key=lambda s: [int(t) if t.isdigit() else t for t in s.rsplit(".")[0].split("-")])
69
70 class Reader(object):
71     """GUI"""
72     def __init__(self):
73         self.window = gtk.Window(gtk.WINDOW_TOPLEVEL)
74         self.window.connect("delete_event",    lambda widget, event, data = None: False)
75         self.window.connect("destroy",         lambda widget, data = None: self.quit())
76         self.window.connect("key_press_event", self.keypress)
77 #        self.window.set_border_width(10)
78         self.window.set_position(gtk.WIN_POS_CENTER) # save position in settings?
79 #        self.window.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse("#222")) # seems to do nothing
80
81         self.set_title()
82         self.fullscreen = False
83         self.cursor     = None
84
85         self.container = gtk.VBox()
86         self.menu_bar = self.build_menu()
87         self.page_bar = gtk.HBox()
88         self.key = gtk.Label()
89 #        self.key.set_padding(10, 10)
90         self.page_num = gtk.Label()
91         self.page = gtk.Image()
92         self.sep = gtk.HSeparator()
93         self.sbar = gtk.Statusbar(); self.sbar.show(); self.sbar.set_has_resize_grip(False)
94
95         self.combo = gtk.combo_box_new_text()
96         self.combo.set_wrap_width(2)
97         for i in range(50):
98             self.combo.append_text('item - %d' % i)
99         self.combo.set_active(0)
100
101         self.page_bar.pack_start(self.key, expand=False)
102         vsep = gtk.VSeparator(); vsep.show(); self.page_bar.pack_start(vsep, expand=False)
103 #        self.page_bar.pack_start(self.combo, expand=False); self.combo.show()
104         self.page_bar.pack_start(self.page_num, expand=False)
105 #        self.container.pack_start(self.menu_bar, expand=False)
106         self.container.pack_start(self.page_bar, expand=False)
107         self.container.pack_start(self.sep, expand=False)
108         self.container.pack_start(self.page)
109         self.container.pack_start(self.sbar, expand=False)
110         self.window.add(self.container)
111
112         self.key.show()
113         self.page_num.show()
114         self.sep.show()
115         self.page.show()
116         self.page_bar.show()
117         self.container.show()
118         self.window.show()
119
120     def build_menu(self):
121         menus = (("_File", (
122                     ("_New profile",    gtk.STOCK_NEW,         lambda widget, data: None),
123                     ("_Load profile",   gtk.STOCK_OPEN,        lambda widget, data: None),
124                     ("_Delete profile", gtk.STOCK_DELETE,      lambda widget, data: None),
125                     (),
126                     ("_Quit",           gtk.STOCK_QUIT,        lambda widget, data: self.quit()),
127                     )),
128                  ("_Edit", (
129                     ("_Profile",        gtk.STOCK_EDIT,        lambda widget, data: None),
130                     (),
131                     ("_Settings",       gtk.STOCK_PREFERENCES, lambda widget, data: None),
132                     )),
133                  )
134         menu_bar = gtk.MenuBar()
135         menu_bar.show()
136         for submenu in menus:
137             lbl, items = submenu
138             menu = gtk.Menu()
139             menu.show()
140             mi = gtk.MenuItem(lbl, True)
141             mi.show()
142             mi.set_submenu(menu)
143             menu_bar.add(mi)
144             for item in items:
145                 if not item:
146                     mi = gtk.SeparatorMenuItem()
147                     mi.show()
148                     menu.add(mi)
149                 else:
150                     lbl, icon, func = item
151                     img = gtk.Image()
152                     img.set_from_stock(icon, gtk.ICON_SIZE_MENU)
153                     mi = gtk.ImageMenuItem(lbl, True)
154                     mi.show()
155                     mi.set_image(img)
156                     mi.connect("activate", func, None)
157                     menu.add(mi)
158         return menu_bar
159
160     def start(self):
161         gtk.main()
162
163     def quit(self):
164         gtk.main_quit()
165
166     def set_title(self, title = None):
167         self.window.set_title("Automanga" + (" - " + title if title else ""))
168
169     def set_manga(self, manga):
170         self.manga = manga
171         self.set_title(manga.title())
172         self.cursor = manga.mark
173         self.update_page()
174
175     def update_page(self):
176         self.page.set_from_file(self.cursor.path)
177         self.page_num.set_label("Mark's at %s (%s/%s)\t\tYou're at %s (%s/%s)" % (self.manga.mark.name, self.manga.index_of(self.manga.mark) + 1, self.manga.num_pages(), self.cursor.name, self.manga.index_of(self.cursor) + 1, self.manga.num_pages()))
178         self.window.resize(*self.container.size_request())
179
180         self.sbar.pop(self.sbar.get_context_id("stat"))
181         self.sbar.push(self.sbar.get_context_id("stat"), "Mark's at %s (%s/%s)\t\tYou're at %s (%s/%s)" % (self.manga.mark.name, self.manga.index_of(self.manga.mark) + 1, self.manga.num_pages(), self.cursor.name, self.manga.index_of(self.cursor) + 1, self.manga.num_pages()))
182
183     def keypress(self, widget, event, data = None):
184         if   event.keyval in [32]:             self.read_page(1)        # space
185         elif event.keyval in [65288]:          self.read_page(-1)       # backspace
186         elif event.keyval in [65362, 65363]:   self.browse_page(1)      # up, right
187         elif event.keyval in [65361, 65364]:   self.browse_page(-1)     # left, down
188         elif event.keyval in [70, 102]:        self.toggle_fullscreen() # f, F
189         elif event.keyval in [81, 113, 65307]: self.quit()              # q, Q, esc
190         elif event.keyval in [65360]:          self.browse_start()      # home
191         elif event.keyval in [65367]:          self.browse_end()        # end
192         else: self.key.set_text(str(event.keyval))
193
194     def browse_page(self, step):
195         self.cursor = self.cursor.previous() if step < 0 else self.cursor.next()
196         self.update_page()
197
198     def read_page(self, step):
199         if self.cursor == self.manga.mark:
200             if step < 0: self.manga.set_mark(self.cursor.previous())
201             else:        self.cursor = self.cursor.next()
202         if step < 0: self.cursor = self.manga.mark
203         else:        self.manga.set_mark(self.cursor)
204         self.update_page()
205
206     def browse_start(self):
207         self.cursor = self.manga.first_page()
208         self.update_page()
209
210     def browse_end(self):
211         self.cursor = self.manga.last_page()
212         self.update_page()
213
214     def toggle_fullscreen(self):
215         self.fullscreen = not self.fullscreen
216 #         if self.fullscreen: self.window.fullscreen()
217 #         else:               self.window.unfullscreen()
218         if self.fullscreen:
219             self.window.set_decorated(False)
220             self.window.set_position(gtk.WIN_POS_CENTER_ALWAYS)
221             self.menu_bar.hide()
222         else:
223             self.window.set_decorated(True)
224             self.window.set_position(gtk.WIN_POS_NONE)
225             self.menu_bar.show()
226
227 class File(object):
228     """A class for accessing the parsed content of a file as
229     attributes on an object."""
230
231     def __init__(self, path, create = False): ## add autosync - save everytime an attribute is set - does not work well with list attributes
232         self.path = path
233         self.attributes = []    ## make this a dict which stores the attributes, instead of adding them as attributes to the File object
234         if os.path.exists(path):
235             file = open(path)
236             self.parse(file)
237             file.close()
238         elif create:
239             self.save()
240
241     def __setattr__(self, name, val):
242         if name not in ("path", "attributes") and name not in self.attributes:
243             self.attributes.append(name)
244         object.__setattr__(self, name, val)
245
246     def __getattr__(self, name):
247         try:    return object.__getattribute__(self, name)
248         except: return None
249
250     def __delattr__(self, name):
251         self.attributes.remove(name)
252         object.__delattr__(self, name)
253
254     def parse(self, file):
255         def add_attr(type, name, val):
256             if type and name and val:
257                 if   type == "str":  val = val[0]
258                 elif type == "int":  val = int(val[0])
259                 elif type == "bool": val = (val[0].lower() == "true")
260                 setattr(self, name, val)
261         type, attr, val = None, "", []
262         for line in file.xreadlines():
263             line = line.strip()
264             if line.startswith("["):
265                 add_attr(type, attr, val)
266                 (type, attr), val = line[1:-1].split(":"), []
267             elif line:
268                 val.append(line)
269         add_attr(type, attr, val)
270
271     def exists(self):
272         return os.path.exists(self.path)
273
274     def delete(self):
275         self.attributes = []
276         os.remove(self.path)
277
278     def save(self):
279         dir = self.path.rsplit("/", 1)[0]
280         if not os.path.exists(dir):
281             os.makedirs(dir)
282         file = open(self.path, "w")
283         for attr in self.attributes:
284             val = getattr(self, attr)
285             file.write("[%s:%s]\n" % (str(type(val))[7:-2], attr))
286             if type(val) in (list, tuple):
287                 for i in val:
288                     file.write(i + "\n")
289             else:
290                 file.write(str(val) + "\n")
291             file.write("\n")
292         file.close()
293
294 class Directory(object):
295     """A class for accessing files, or directories, in a directory as
296     attributes on an object."""
297
298     def __init__(self, path):
299         self._path = path
300         self._nodes = [f for f in os.listdir(path) if "." not in f] if os.path.exists(path) else []
301         self._opened_nodes = {}
302
303     def __getattr__(self, name):
304         if name in self._nodes:
305             if name not in self._opened_nodes:
306                 if os.path.isdir(self.path(name)): self._opened_nodes[name] = Directory(self.path(name))
307                 else:                              self._opened_nodes[name] = File(self.path(name))
308             return self._opened_nodes[name]
309         return object.__getattribute__(self, name)
310
311     def __delattr__(self, name):
312         if name in self._nodes:
313             self._nodes.remove(name)
314             if name in self._opened_nodes:
315                 self._opened_nodes.pop(name).delete()
316         else:
317             object.__delattr__(self, name)
318
319     def delete(self):
320         for n in self._nodes:
321             getattr(self, n).delete()
322         self._opened_nodes = {}
323         self._nodes = []
324         os.rmdir(self.path())
325
326     def save(self):
327         os.makedirs(self.path())
328
329     def path(self, *name):
330         return os.path.join(self._path, *name)
331
332     def add_dir(self, name):
333         if not os.path.exists(self.path(name)):
334             self._nodes.append(name)
335             self._opened_nodes[name] = Directory(self.path(name))
336         return self._opened_nodes[name]
337
338     def add_file(self, name):
339         if not os.path.exists(self.path(name)):
340             self._nodes.append(name)
341             self._opened_nodes[name] = File(self.path(name))
342         return self._opened_nodes[name]
343
344 class Settings(object):
345     def __init__(self):
346         self.file = File(FILE_SETTINGS, True)
347         if self.file.dirs is None:
348             self.file.dirs = []
349
350     def save(self):
351         self.file.save()
352
353     def add_dir(self, path):
354         if not os.path.exists(path):
355             output("Failed to add directory - '%s' does not exist!" % path)
356             return
357         if path not in self.file.dirs:
358             self.file.dirs.append(path)
359             self.save()
360             output("Added '%s'!" % path)
361
362     def delete_dir(self, dir):
363         if path not in self.file.dirs:
364             output("Failed to remove directory - '%s' not in list!" % path)
365             return
366         self.file.dirs.remove(dir)
367         self.save()
368         output("Removed '%s'!" % path)
369
370     def silent(self, state = None):
371         if state is None: return self.file.silent
372         else:             self.file.silent = state
373
374     def last_profile(self, val = None):
375         if val is None: return self.file.last_profile
376         else:           self.file.last_profile = val
377
378     def load_last_profile(self):
379         if not self.file.last_profile:
380             return False
381         load_profile(self.file.last_profile)
382         if not profile.exists():
383             del self.file.last_profile
384             self.save()
385             return False
386         return True
387
388     def list_manga(self):
389         ret = []
390         for dir in self.file.dirs:
391             if os.path.exists(dir):
392                 ret += os.listdir(dir)
393         return ret
394
395 class Profile(object):
396     def __init__(self, name):
397         self.name = name
398         self.file = File(os.path.join(DIR_PROFILES, name))
399         if not self.file.mangas:
400             self.file.mangas = []
401
402     def exists(self):
403         return self.file.exists()
404
405     def delete(self):
406         os.remove(self.file.path)
407
408     def rename(self, new_name):
409         if self.exists():
410             new_path = os.path.join(DIR_PROFILES, new_name)
411             if os.path.exists(new_path):
412                 return False
413             os.rename(self.file.path, new_path)
414         self.name = new_name
415         return True
416
417     def save(self):
418         self.file.save()
419
420     def save_page(self, manga, page):
421         for n, m in enumerate(self.file.mangas):
422             manga_path = m.split("\t")[0]
423             if manga_path == manga.path:
424                 self.file.mangas[n] = "%s\t%s" % (manga_path, page.name)
425                 break
426         else:
427             self.file.mangas.append("%s\t%s" % (manga.path, page.name))
428         self.save()
429
430     def load_manga(self, path):
431         self.file.last_read = path
432         for manga in self.file.mangas:
433             manga_path, page_name = manga.split("\t")
434             if manga_path == path:
435                 return Manga(self, path, page_name)
436         return Manga(self, path)
437
438 class Manga(object):
439     def __init__(self, reader_profile, path, page = None):
440         self.reader  = reader_profile
441         self.path    = path
442         self.pages   = self.load_pages()
443         self.mark = Page(self, page) if page else self.first_page()
444
445     def load_pages(self):
446         files = [f for f in os.listdir(self.path) if f.rsplit(".", 1)[-1] in ("jpg", "png", "gif", "bmp")]
447         return natsorted(files) # check if there's an order file / only load image files
448
449     def first_page(self):
450         return Page(self, self.pages[0])
451
452     def last_page(self):
453         return Page(self, self.pages[-1])
454
455     def num_pages(self):
456         return len(self.pages)
457
458     def index_of(self, page):
459         return self.pages.index(page.name)
460
461     def set_mark(self, page):
462         self.mark = page
463         self.reader.save_page(self, page)
464
465     def previous(self, page, step = 1):
466         return Page(self, self.pages[max(0, self.pages.index(page) - step)])
467
468     def next(self, page, step = 1):
469         return Page(self, self.pages[min(len(self.pages) - 1, self.pages.index(page) + step)])
470
471     def title(self):
472         return self.path.rsplit("/", 1)[-1]
473
474 class Page(object):
475     def __init__(self, manga, name):
476         self.manga = manga
477         self.name = name
478         self.path = os.path.join(manga.path, name)
479
480     def previous(self, step = 1):
481         return self.manga.previous(self.name, step)
482
483     def next(self, step = 1):
484         return self.manga.next(self.name, step)
485
486 if __name__ == "__main__":
487     init()
488
489     if opts.add:
490         path = abs_path(opts.add)
491         settings.add_dir(path)
492
493     if opts.delete:
494         path = abs_path(opts.delete)
495         settings.delete_dir(path)
496
497     if opts.silent:
498         settings.silent(True)
499     if opts.verbose:
500         settings.silent(False)
501
502     if opts.profile:
503         load_profile(opts.profile)
504         if profile.exists():
505             output("Loaded profile '%s'" % profile.name)
506         else:
507             profile.save()
508             output("Created profile '%s'" % profile.name)
509     else:
510         if settings.load_last_profile():
511             output("No profile specified - loading '%s'" % profile.name)
512         else:
513             user = home.split("/")[-1]
514             output("No profile exists - creating one for '%s'" % user)
515             load_profile(user)
516             profile.save()
517
518     reader = Reader()
519     manga_path = abs_path(args[0]) if args else cwd
520     if manga_dir(manga_path):
521         reader.set_manga(profile.load_manga(manga_path))
522     reader.start()
523
524     profile.save()
525     settings.save()