Fixed bug with anonymous profile.
[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     def save_all(self):
295         self.save()
296
297 class Directory(object):
298     """A class for accessing files, or directories, in a directory as
299     attributes on an object."""
300
301     def __init__(self, path):
302         self._path = path
303         self._nodes = [f for f in os.listdir(path) if "." not in f] if os.path.exists(path) else []
304         self._opened_nodes = {}
305
306     def __iter__(self):
307         return self._opened_nodes.itervalues()
308
309     def __contains__(self, name):
310         return name in self._nodes
311
312     def __getitem__(self, name):
313         if name in self._nodes:
314             return getattr(self, name)
315         raise KeyError(name)
316
317     def __getattr__(self, name):
318         if name in self._nodes:
319             if name not in self._opened_nodes:
320                 if os.path.isdir(self.path(name)): self._opened_nodes[name] = Directory(self.path(name))
321                 else:                              self._opened_nodes[name] = File(self.path(name))
322             return self._opened_nodes[name]
323         return object.__getattribute__(self, name)
324
325     def __delattr__(self, name):
326         if name in self._nodes:
327             self._nodes.remove(name)
328             if name in self._opened_nodes:
329                 self._opened_nodes.pop(name).delete()
330         else:
331             object.__delattr__(self, name)
332
333     def delete(self):
334         for n in self._nodes:
335             getattr(self, n).delete()
336         self._opened_nodes = {}
337         self._nodes = []
338         os.rmdir(self.path())
339
340     def save(self):
341         if not self.exists():
342             os.makedirs(self.path())
343
344     def save_all(self):
345         self.save()
346         for name, node in self._opened_nodes.items():
347             node.save_all()
348
349     def exists(self):
350         return os.path.exists(self.path())
351
352     def move(self, new_path):
353         if not self.exists() or os.path.exists(new_path):
354             return False
355         os.rename(self.path(), new_path)
356         self._path = new_path
357         return True
358
359     def path(self, *name):
360         return os.path.join(self._path, *name)
361
362     def add_dir(self, name):
363         if not os.path.exists(self.path(name)):
364             self._nodes.append(name)
365             self._opened_nodes[name] = Directory(self.path(name))
366         return getattr(self, name)
367
368     def add_file(self, name):
369         if not os.path.exists(self.path(name)):
370             self._nodes.append(name)
371             self._opened_nodes[name] = File(self.path(name))
372         return getattr(self, name)
373
374 class Settings(object):
375     def __init__(self):
376         self.file = File(FILE_SETTINGS, True)
377         if self.file.dirs is None:
378             self.file.dirs = []
379
380     def save(self):
381         self.file.save()
382
383     def add_dir(self, path):
384         if not os.path.exists(path):
385             output("Failed to add directory - '%s' does not exist!" % path)
386             return
387         if path not in self.file.dirs:
388             self.file.dirs.append(path)
389             self.save()
390             output("Added '%s'!" % path)
391
392     def delete_dir(self, dir):
393         if path not in self.file.dirs:
394             output("Failed to remove directory - '%s' not in list!" % path)
395             return
396         self.file.dirs.remove(dir)
397         self.save()
398         output("Removed '%s'!" % path)
399
400     def silent(self, state = None):
401         if state is None: return self.file.silent
402         else:             self.file.silent = state
403
404     def last_profile(self, val = None):
405         if val is None: return self.file.last_profile
406         else:           self.file.last_profile = val
407
408     def load_last_profile(self):
409         if not self.file.last_profile:
410             return False
411         load_profile(self.file.last_profile)
412         if not profile.exists():
413             del self.file.last_profile
414             self.save()
415             return False
416         return True
417
418     def list_manga(self):
419         ret = []
420         for dir in self.file.dirs:
421             if os.path.exists(dir):
422                 ret += os.listdir(dir)
423         return ret
424
425 class Profile(object):
426     def __init__(self, name):
427         self.name = name
428         self.dir = Directory(os.path.join(DIR_PROFILES, name))
429         self.mangas = self.dir.add_dir("manga")
430         self.settings = self.dir.add_file("settings")
431
432     def exists(self):
433         return self.dir.exists()
434
435     def delete(self):
436         self.dir.delete()
437
438     def rename(self, new_name):
439         if not self.dir.move(os.path.join(DIR_PROFILES, new_name)):
440             return False
441         self.name = new_name
442         return True
443
444     def save(self):
445         self.dir.save_all()
446
447     def save_page(self, manga):
448         if manga.title() not in self.mangas:
449             self.mangas.add_file(manga.title())
450         file = self.mangas[manga.title()]
451         file.page = manga.mark.name
452         file.save()
453
454     def load_manga(self, path):
455         self.settings.last_read = path
456         manga = path.rsplit("/", 1)[-1]
457         if manga in self.mangas:
458             return Manga(self, path, self.mangas[manga].page)
459         return Manga(self, path)
460
461 class Manga(object):
462     def __init__(self, reader_profile, path, page = None):
463         self.reader  = reader_profile
464         self.path    = path
465         self.pages   = self.load_pages()
466         self.mark = Page(self, page) if page else self.first_page()
467
468     def load_pages(self):
469         files = [f for f in os.listdir(self.path) if f.rsplit(".", 1)[-1] in ("jpg", "png", "gif", "bmp")]
470         return natsorted(files) # check if there's an order file / only load image files
471
472     def first_page(self):
473         return Page(self, self.pages[0])
474
475     def last_page(self):
476         return Page(self, self.pages[-1])
477
478     def num_pages(self):
479         return len(self.pages)
480
481     def index_of(self, page):
482         return self.pages.index(page.name)
483
484     def set_mark(self, page):
485         self.mark = page
486         self.reader.save_page(self)
487
488     def previous(self, page, step = 1):
489         return Page(self, self.pages[max(0, self.pages.index(page) - step)])
490
491     def next(self, page, step = 1):
492         return Page(self, self.pages[min(len(self.pages) - 1, self.pages.index(page) + step)])
493
494     def title(self):
495         return self.path.rsplit("/", 1)[-1]
496
497 class Page(object):
498     def __init__(self, manga, name):
499         self.manga = manga
500         self.name = name
501         self.path = os.path.join(manga.path, name)
502
503     def previous(self, step = 1):
504         return self.manga.previous(self.name, step)
505
506     def next(self, step = 1):
507         return self.manga.next(self.name, step)
508
509 if __name__ == "__main__":
510     init()
511
512     if opts.add:
513         path = abs_path(opts.add)
514         settings.add_dir(path)
515
516     if opts.delete:
517         path = abs_path(opts.delete)
518         settings.delete_dir(path)
519
520     if opts.silent:
521         settings.silent(True)
522     if opts.verbose:
523         settings.silent(False)
524
525     if opts.profile:
526         load_profile(opts.profile)
527         if profile.exists():
528             output("Loaded profile '%s'" % profile.name)
529         else:
530             profile.save()
531             output("Created profile '%s'" % profile.name)
532     else:
533         if settings.load_last_profile():
534             output("No profile specified - loading '%s'" % profile.name)
535         else:
536             user = home.split("/")[-1]
537             output("No profile exists - creating one for '%s'" % user)
538             load_profile(user)
539             profile.save()
540
541     reader = Reader()
542     manga_path = abs_path(args[0]) if args else cwd
543     if manga_dir(manga_path):
544         reader.set_manga(profile.load_manga(manga_path))
545     reader.start()
546
547     profile.save()
548     settings.save()