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