Automatic tags are returned as lists
[superjot.git] / jot.pyw
blob248ea58a145bb5a27117058b2c2e49133ae9a81d
1 #!/usr/bin/env python
2 # -*- coding: utf-8 -*-
4 # Copyright 2009 Felix Rabe <public@felixrabe.net>
5 # Licensed under the terms of the GNU General Public License, version 3 or later.
7 import collections
8 import datetime
9 import optparse
10 import os
11 import shlex
12 import sqlite3
13 import sys
14 import textwrap
16 from PyGTKShell.API import *
19 def automatic_tags():
20 t = datetime.datetime.now()
21 s = t.strftime("%Y-%m-%dT%H:%M:%S.%f")
22 return " #time#%s " % s
24 def get_automatic_tags(tags):
25 res = collections.defaultdict(list)
26 for tag in tags.split():
27 if tag.startswith("#"):
28 i = tag.index("#", 1)
29 res[tag[1:i]].append(tag[i+1:])
30 if "time" in res:
31 time = datetime.datetime.strptime(res["time"][0], "%Y-%m-%dT%H:%M:%S.%f")
32 res["time"] = [time]
33 return res
36 ## JOT DATABASE
38 class JotDB(object):
40 def __init__(self, filename):
41 super(JotDB, self).__init__()
42 self.conn = sqlite3.connect(filename)
43 self.filename = filename
44 self.conn.execute("""create table if not exists jotdb
45 (id integer primary key, note text, tags text)""")
46 self.conn.execute("""create table if not exists config
47 (key text primary key, value text)""")
48 self.conn.commit()
50 def enter(self, row_id, note, tags): # row_id may be None to add a new row
51 # print "enter() %r %r %r" % (row_id, note, tags)
52 cur = self.conn.cursor()
53 tags = " %s " % " ".join(tags.split())
54 s = " #tags-of#"
55 i = tags.find(s)
56 if i != -1 and row_id is not None:
57 # print "* removing %u" % row_id
58 cur.execute("delete from jotdb where id = ?", (row_id,))
59 self.conn.commit()
60 tags_note = note
61 row_id = int(tags[i+len(s):].split(None, 1)[0])
62 row_id, note, tags = self.get_item(row_id)
63 if tags_note: # erase all tags by entering " " (a space)
64 tags = tags_note
65 else:
66 if not note: # if no note is entered, erase the specified row
67 if not row_id:
68 return None
69 # print "* removing %u" % row_id
70 cur.execute("delete from jotdb where id = ?", (row_id,))
71 self.conn.commit()
72 return row_id
73 # print "* insert/replace %r %r %r" % (row_id, note, tags)
74 cur.execute("insert or replace into jotdb values (?, ?, ?)",
75 (row_id, note, tags))
76 last_id = cur.lastrowid
77 self.conn.commit()
78 return last_id
80 def get_count(self):
81 cur = self.conn.cursor()
82 cur.execute("select count(*) from jotdb")
83 return cur.fetchone()[0]
85 def get_list(self, tag_filter=None): # TODO: implement tag filtering
86 cur = self.conn.cursor()
87 cur.execute("select * from jotdb")
88 return cur.fetchall()
90 def get_item(self, row_id):
91 cur = self.conn.cursor()
92 if row_id is None:
93 cur.execute("select * from jotdb order by id desc limit 1")
94 else:
95 cur.execute("select * from jotdb where id = ?", (row_id,))
96 return cur.fetchone()
98 def get_config(self, key):
99 cur = self.conn.cursor()
100 cur.execute("select value from config where key = ?", (key,))
101 value = cur.fetchone()
102 if value is None: return None
103 return value[0]
105 def set_config(self, key, value):
106 cur = self.conn.cursor()
107 cur.execute("insert or replace into config values (?, ?)", (key, value))
108 self.conn.commit()
111 ## COMMAND EXECUTION LAYER
113 class CommandExec(object):
115 # API
117 def __init__(self, jotdb):
118 super(CommandExec, self).__init__()
119 self.jotdb = jotdb
121 def enter(self, row_id, note, tags):
122 if not note.startswith(","): # not a command
123 self._enter(row_id, note, tags)
124 return
125 command = shlex.split(note[1:])
126 try:
127 cmd, args = command[0], command[1:]
128 cmd_attr = "_cmd_%s" % cmd.lower()
129 if not hasattr(self, cmd_attr):
130 raise Exception, "Unknown command: %s" % cmd
131 getattr(self, cmd_attr)(row_id, tags, *args)
132 except Exception, e:
133 self._cb_raise_exception(e)
135 def _enter(self, row_id, note, tags, show_row=False):
136 row_id = self.jotdb.enter(row_id, note, tags)
137 self._cb_refresh_view()
138 if show_row and row_id is not None:
139 self._cb_show_row(row_id, show_row)
140 return row_id
142 # Callbacks (ordered alphabetically)
144 def _cb_quit(self):
145 return None
147 def _cb_raise_exception(self, e):
148 note = "## ERROR: %s" % e
149 tags = automatic_tags()
150 self._enter(None, note, tags, True)
152 def _cb_refresh_view(self):
153 return None
155 def _cb_show_row(self, row_id, how):
156 return None
158 # Commands (ordered alphabetically, long names first)
160 def _cmd_append(self, row_id, tags):
162 Append to a note.
164 row_id, note, tags = self.jotdb.get_item(row_id)
165 self._enter(row_id, note, tags, "append")
166 _cmd_a = _cmd_append
168 def _cmd_delete(self, row_id, tags):
170 Delete a note.
172 row_id, note, tags = self.jotdb.get_item(row_id)
173 self._enter(row_id, "", tags)
174 _cmd_d = _cmd_delete
176 def _cmd_edit(self, row_id, tags):
178 Edit a note.
180 row_id, note, tags = self.jotdb.get_item(row_id)
181 self._enter(row_id, note, tags, True)
182 _cmd_e = _cmd_edit
184 def _cmd_filter(self, row_id, tags, cmd, *args):
186 List and edit filters.
188 Type ',filter list' to list all registered filters.
189 Type ',filter edit <name>' to edit or create a filter called <name>.
191 if "list".startswith(cmd.lower()):
192 filters = {}
193 for row_id, note, tags in self.jotdb.get_list():
194 atags = get_automatic_tags(tags)
195 filters[atags.get("filter")] = None
196 note = ["List of filters:", ""]
197 for name in filters:
198 if not name: continue
199 note = "\n".join(note) + "\n"
200 tags = automatic_tags()
201 self._enter(None, note, tags, True)
202 return
203 if "edit".startswith(cmd.lower()):
204 name = args[0]
205 for row_id, note, tags in self.jotdb.get_list():
206 atags = get_automatic_tags(tags)
207 if name in atags["filter"]: break
208 else:
209 row_id = None
210 note = "# Filter: %s\n\n" % name
211 tags = automatic_tags() + " #filter#" + name
212 row_id = self._enter(row_id, note, tags)
213 self._cb_show_row(row_id, "append")
214 return
215 _cmd_f = _cmd_filter
217 def _cmd_help(self, row_id, tags, command=None):
219 Get help about Jot or about a Jot command.
221 Type ',help' (comma + help) to get help.
222 Type ',help <command>' to get more help about a specific command.
224 general_note = textwrap.dedent("""
225 Jot Help
227 With Jot, you can easily jot down notes of all sorts. To manage
228 your notes, you can tag, filter, and sort them. You can even format
229 the metadata information that is displayed to the left of the note
230 display. Both graphical and command line interfaces are very easy
231 to work with.
233 Type ',list' to list all available commands together with a summary.
234 Type ',help <command>' to get more help about a specific command.
236 Hint: To erase a note, make it empty.
237 """).strip()
238 tags = automatic_tags()
239 note = general_note
240 if command is not None:
241 method_name = "_cmd_%s" % command.lower()
242 if not hasattr(self, method_name):
243 raise Exception, "Unknown command: %s" % command
244 method = getattr(self, method_name)
245 note = textwrap.dedent(method.__doc__).strip() + "\n"
246 self._enter(None, note, tags, True)
247 _cmd_h = _cmd_help
249 def _cmd_list(self, row_id, tags):
251 List all available commands.
253 note = ["List of Commands", ""]
254 for names, method in self._command_list:
255 note.append(" ".join(",%s" % n for n in names))
256 note[-1] += " - " + method.__doc__.strip().split("\n", 1)[0]
257 note = "\n".join(note) + "\n"
258 tags = automatic_tags()
259 self._enter(None, note, tags, True)
260 _cmd_l = _cmd_list
262 def _cmd_multiline(self, row_id, tags):
264 Convert a note to a multi-line note by appending a line break.
266 row_id, note, tags = self.jotdb.get_item(row_id)
267 note += "\n"
268 self._enter(row_id, note, tags, "append")
269 _cmd_m = _cmd_multiline
271 def _cmd_new(self, row_id, tags):
273 Edit a new note.
275 self._enter(None, "", tags)
276 _cmd_n = _cmd_new
278 def _cmd_quit(self, row_id, tags):
280 Quit Jot immediately.
282 self._cb_quit()
283 _cmd_q = _cmd_quit
285 def _cmd_tag(self, row_id, tags):
287 Edit a note's tags.
289 A new note will be created with a tag that references the original note.
290 Tags are separated by whitespace (blanks, end-of-lines, etc.). Erasing
291 the text of the new note will just erase the new note, not remove all
292 tags of the referenced note. To remove all tags of the referenced note,
293 enter a space before hitting "Jot".
295 row_id, note, tags = self.jotdb.get_item(row_id)
296 note = "\n".join(tags.split()) or " "
297 tags = automatic_tags() + " #tags-of#%u" % row_id
298 self._enter(None, note, tags, "append")
299 _cmd_t = _cmd_tag
301 loc = locals()
302 dic = collections.defaultdict(list)
303 for k in loc.keys():
304 if not k.startswith("_cmd_"): continue
305 dic[loc[k]].append(k[5:])
306 _command_list = []
307 for fn, n in dic.iteritems():
308 _command_list.append((sorted(n), fn))
309 _command_list.sort()
310 del dic, fn, k, loc, n
313 ## GRAPHICAL USER INTERFACE
315 class CommandExecGUI(CommandExec):
317 def __init__(self, main_window):
318 super(CommandExecGUI, self).__init__(main_window.jotdb)
319 self.main_window = main_window
321 def _cb_quit(self):
322 self.main_window.save_window_state()
323 self.main_window.destroy()
325 def _cb_refresh_view(self):
326 self.main_window.refresh_jotdb_model()
328 def _cb_show_row(self, row_id, how):
329 mwin = self.main_window
330 sel = mwin.jotdb_view.get_selection()
331 for row in mwin.jotdb_model:
332 if row[0] == row_id:
333 sel.select_path(row.path)
334 gobject.idle_add(mwin.jot_widget_grab_focus, how)
335 return None
336 raise Exception, "invalid row id"
338 class MainWindow(Window):
340 def __init__(self, jotdb, cache_path):
341 super(MainWindow, self).__init__()
342 self.set_default_size(800, 600)
343 self.connect("delete-event", self.delete_event)
344 self.connect("window-state-event", self.window_state_event)
346 self.jotdb = jotdb
347 self.cmdexec = CommandExecGUI(self)
348 self.set_title("%s - Jot" % os.path.basename(self.jotdb.filename))
349 self.cache_path = cache_path
351 geom = self.jotdb.get_config("window-geometry")
352 if geom: self.parse_geometry(geom)
353 window_state = int(self.jotdb.get_config("window-state") or "0")
354 self.is_maximized = False
355 if window_state == 1:
356 self.maximize()
357 self.is_maximized = True
359 outer_vbox, inner_vbox = gnome_hig(self)[1:]
360 self.paned = gnome_hig(inner_vbox(VPaned(), True, True))
362 # The model contains these fields:
363 # row_id, note, tags, info, note_display
364 self.jotdb_model = gtk.ListStore(int, str, str, str, str)
365 sw = gnome_hig(self.paned(ScrolledWindow(), True, False))
366 self.jotdb_view = gnome_hig(sw(TreeView(self.jotdb_model)))
367 self.jotdb_view.connect("row-activated", self.jot_row_activated)
368 jot_selection = self.jotdb_view.get_selection()
369 jot_selection.connect("changed", self.jot_selection_changed)
370 cellr = gtk.CellRendererText()
372 col = gtk.TreeViewColumn("Info", cellr, text=3)
373 col.set_resizable(True)
374 width = self.jotdb.get_config("info-column-width")
375 if width:
376 width = int(width)
377 col.set_fixed_width(width)
378 col.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
379 self.jotdb_view.append_column(col)
381 col = gtk.TreeViewColumn("Note", cellr, text=4)
382 col.set_resizable(True)
383 self.jotdb_view.append_column(col)
385 jot_box = self.paned(VBox(), False, False)
386 self.jot_single = gnome_hig(jot_box(HBox(), False, False))
387 self.jot_entry = self.jot_single(Entry(), True, True)
388 self.jot_entry.connect("key-press-event", self.jot_key_press)
389 self.jot_widget = self.jot_entry
390 self.jot_single_button = DefaultButton("_Jot")
391 self.jot_single(self.jot_single_button, False, False)
392 self.jot_single_button.connect("clicked", self.jot_clicked)
394 self.jot_multi = gnome_hig(jot_box(VBox(auto_show=False), True, True))
395 sw = gnome_hig(self.jot_multi(ScrolledWindow(), True, True))
396 self.jot_textview = gnome_hig(sw(TextViewForCode()))
397 self.jot_textview.connect("key-press-event", self.jot_key_press)
398 self.jot_multi_button = DefaultButton("_Jot", auto_default=False)
399 self.jot_multi(self.jot_multi_button, False, False)
400 self.jot_multi_button.connect("clicked", self.jot_clicked)
402 gobject.idle_add(self.jot_widget_grab_focus)
403 self.jot_single.paned_position = 0
404 pos = self.jotdb.get_config("single-paned-position")
405 if pos:
406 self.jot_single.paned_position = int(pos)
407 self.paned_set_position(int(pos))
408 self.jot_multi.paned_position = 300
409 pos = self.jotdb.get_config("multi-paned-position")
410 if pos:
411 self.jot_multi.paned_position = int(pos)
413 self.refresh_jotdb_model()
415 # Window and Paned state management
417 def delete_event(self, window, event):
418 self.save_window_state()
419 return False
421 def save_window_state(self):
422 geom = "%ux%u+%u+%u" % (self.get_size() + self.get_position())
423 self.jotdb.set_config("window-geometry", geom)
424 window_state = int(self.is_maximized)
425 self.jotdb.set_config("window-state", str(window_state))
426 width = self.jotdb_view.get_column(0).get_width()
427 self.jotdb.set_config("info-column-width", str(width))
428 pos = self.jot_single.paned_position
429 self.jotdb.set_config("single-paned-position", str(pos))
430 pos = self.jot_multi.paned_position
431 self.jotdb.set_config("multi-paned-position", str(pos))
433 def window_state_event(self, window, event):
434 if event.changed_mask & gtk.gdk.WINDOW_STATE_MAXIMIZED:
435 self.is_maximized = bool(event.new_window_state &
436 gtk.gdk.WINDOW_STATE_MAXIMIZED)
437 return False
439 def paned_get_position(self):
440 return self.get_size()[1] - self.paned.get_position()
442 def paned_set_position(self, pos):
443 self.paned.set_position(self.get_size()[1] - pos)
445 # Jot single / multi / widget
447 def set_jot_is_multiline(self, is_multiline):
448 single_visible = self.jot_single.get_property("visible")
449 if not (is_multiline ^ single_visible):
450 if not is_multiline: # single-line
451 self.jot_multi.paned_position = self.paned_get_position()
452 self.jot_multi.hide()
453 self.jot_single.show()
454 self.jot_widget = self.jot_entry
455 self.paned_set_position(self.jot_single.paned_position)
456 self.jot_single_button.grab_default()
457 else: # multi-line
458 self.jot_single.paned_position = self.paned_get_position()
459 self.jot_single.hide()
460 self.jot_multi.show()
461 self.jot_widget = self.jot_textview
462 self.paned_set_position(self.jot_multi.paned_position)
463 self.jot_multi_button.grab_default()
464 return is_multiline
466 def jot_widget_grab_focus(self, how=True):
467 self.jot_widget.grab_focus()
468 if self.jot_single.get_property("visible"): # single-line
469 if how == "append":
470 self.jot_widget.set_position(-1)
471 else: # multi-line
472 buf = self.jot_widget.get_buffer()
473 start, end = buf.get_start_iter(), buf.get_end_iter()
474 if how == "append":
475 buf.select_range(end, end)
476 else:
477 buf.select_range(start, end)
478 self.jot_widget.scroll_to_iter(buf.get_end_iter(), 0.0)
480 def jot_key_press(self, widget, event):
481 if KeyPressEval("CONTROL, Return")(event):
482 self.jot_single_button.clicked()
483 return True
484 return False
486 def jot_widget_get_text(self):
487 if self.jot_single.get_property("visible"): # single-line
488 return self.jot_entry.get_text()
489 else: # multi-line
490 return self.jot_textview.get_buffer()()
492 def jot_widget_set_text(self, text):
493 if not self.set_jot_is_multiline("\n" in text): # single-line
494 self.jot_entry.set_text(text)
495 else: # multi-line
496 self.jot_textview.get_buffer().set_text(text)
498 # Jot button
500 def jot_clicked(self, button):
501 if self.row_id is None:
502 tags = automatic_tags()
503 else:
504 row_id, note, tags = self.jotdb.get_item(self.row_id)
505 note = self.jot_widget_get_text()
506 self.jot_widget_set_text("")
507 self.cmdexec.enter(self.row_id, note, tags)
509 # Jot DB model and view
511 def jot_row_activated(self, treeview, path, view_column):
512 self.jot_widget_grab_focus()
514 def jot_selection_changed(self, jot_selection):
515 it = jot_selection.get_selected()[1]
516 if it is None:
517 self.row_id = None
518 else:
519 row = self.jotdb_model[it]
520 self.row_id = row[0]
521 note = ""
522 if self.row_id is not None:
523 note = row[1]
524 self.jot_widget_set_text(note)
525 if it is None:
526 gobject.idle_add(self.jot_widget_grab_focus)
527 return False
529 def refresh_jotdb_model(self):
530 self.row_id = None
531 self.jotdb_model.clear()
532 for row_id, note, tags in self.jotdb.get_list():
533 atags = get_automatic_tags(tags)
534 info = (atags.get("time") or [""])[0]
535 note_display = note
536 if "\n" in note:
537 note_display = "[+] %s" % note.split("\n", 1)[0]
538 self.jotdb_model.append((row_id, note, tags, info, note_display))
539 if len(self.jotdb_model):
540 self.jotdb_view.scroll_to_cell(self.jotdb_model[-1].path)
543 ## COMMAND LINE INTERFACE
545 class CommandExecCLI(CommandExec):
547 pass
550 class OptionParser(optparse.OptionParser):
552 def __init__(self, *a, **kw):
553 optparse.OptionParser.__init__(self, *a, **kw)
554 self.add_option("-f", "--jot-file",
555 help="Jot file file to use")
556 self.add_option("-F", "--file-chooser", action="store_true",
557 help="Show file chooser dialog to select Jot file")
560 def main(argv):
561 option_parser = OptionParser()
562 options, args = option_parser.parse_args(argv[1:])
564 cache = os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache"))
565 cache = os.path.join(cache, "jot")
566 if not os.path.isdir(cache):
567 os.makedirs(cache, 0700)
568 filename_cache = os.path.join(cache, "last-file.txt")
570 filename = options.jot_file
571 if not filename and os.path.isfile(filename_cache):
572 filename = file(filename_cache).readline().rstrip()
573 if not options.jot_file:
574 if options.file_chooser or not (filename and os.path.isfile(filename)):
575 fcd = FileChooserDialogStandard()
576 if filename: fcd.set_filename(filename)
577 if fcd.run() != gtk.RESPONSE_ACCEPT:
578 raise SystemExit, 0
579 filename = fcd.get_filename()
580 filename = os.path.abspath(filename)
582 jotdb = JotDB(filename)
583 print >>file(filename_cache, "w"), filename
585 cmdexec = CommandExecCLI(jotdb)
586 if not jotdb.get_count() and not jotdb.get_config("help-hint-added"):
587 jotdb.set_config("help-hint-added", True)
588 note = "Type ',help' (comma + help) to get help"
589 tags = automatic_tags()
590 jotdb.enter(None, note, tags)
592 if args: # CLI
593 note = " ".join(args)
594 tags = automatic_tags()
595 cmdexec.enter(None, note, tags)
596 else: # GUI
597 MainWindow(jotdb, cache)
598 main_loop_run()
601 if __name__ == "__main__":
602 main(sys.argv)