Bugfix: dataloss because of wrong return value in setRecord, fixed!
[tabnote] / tabnote
1 #!/usr/bin/python
2 #encoding: utf-8
3
4 # TabNote - Note Taking Application
5 # Copyright 2011 Paul Hänsch
6 # Contact: haensch.paul@gmail.com
7 #
8 # This program is free software: you can redistribute it and/or modify
9 # it under the terms of the GNU General Public License as published by
10 # the Free Software Foundation, either version 3 of the License, or
11 # (at your option) any later version.
12 #
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 # GNU General Public License for more details.
17 #
18 # You should have received a copy of the GNU General Public License
19 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
20
21 from Tkinter import *
22 from pysqlite2 import dbapi2
23 import os
24 import Pmw
25
26 class Persistence():
27   """
28   Database storage layer
29   """
30   def __init__(self):
31     """
32     Constructor
33     Open a database, create and initialize the database if needed
34     """
35     self.dbcon = dbapi2.connect(os.getenv('HOME') + os.sep + '.tabnote.sqlite')
36     self.dbcur = self.dbcon.cursor()
37     self.initDB()
38
39   def initDB(self):
40     initnote = ('Welcome to TabNote!\n\n' +
41                 'Click the "+"-Tab to open a new note.\n' +
42                 'Double Click on the selected tab to relabel or delete it.\n' +
43                 'Notes are stored when you exit the program.\n\n' +
44                 'Shortcuts:\n' +
45                 'Ctrl Left   - switch to previous tab\n' +
46                 'Ctrl Right  - switch to next tab\n' +
47                 'Ctrl Return - bring up relabel/delete dialog\n' +
48                 'Ctrl t      - create new tab\n' +
49                 'Ctrl q      - save and quit')
50     initnote = self.escape(initnote)
51
52     try:
53       self.dbcur.execute('CREATE TABLE settings (key TEXT, value TEXT, UNIQUE (key));')
54       self.dbcur.execute('INSERT INTO settings (key, value) VALUES ("version", "1.6");')
55       self.dbcur.execute('INSERT INTO settings (key, value) VALUES ("geometry", "420x300");')
56       self.dbcur.execute('CREATE TABLE notes (uuid TEXT, seq INTEGER PRIMARY KEY, label TEXT, ' +
57                    'content TEXT, revision DATETIME, lastsync DATETIME, UNIQUE (uuid));')
58       self.dbcur.execute('INSERT INTO notes (uuid, label, content, revision, lastsync) VALUES ' +
59                    '(lower(hex(randomblob(16))), "Intro", "' + initnote +
60                    '", datetime(\'now\'), datetime(\'1970-01-01\'));')
61     except: pass
62
63   def getSetting(self, key):
64     self.dbcur.execute('SELECT value FROM settings WHERE key = "' + self.escape(key) + '";')
65     try: return self.dbcur.fetchall()[0][0]
66     except: return ''
67
68   def setSetting(self, key, value):
69     try:
70       self.dbcur.execute('INSERT INTO settings (key, value) VALUES ("' + self.escape(key) +
71                          '", "' + self.escape(value) + '");')
72     except:
73       self.dbcur.execute('UPDATE settings SET value = "' + self.escape(value) +
74                          '" WHERE key = "' + self.escape(key) + '";')
75
76   def escape(self, string):
77     """
78     Return sanitized string for use in SQL-Statements
79     """
80     ret = ''
81     for c in string:
82       if c == '"': ret += '""'
83       else: ret += c
84     return str(ret)
85
86   def allRecords(self):
87     """
88     Return list of all recorded notes
89     Each item is an array of the format [id, label, textcontent]
90     """
91     self.dbcur.execute('SELECT uuid, label, content FROM notes ORDER BY seq;')
92     return self.dbcur.fetchall()
93
94   def setRecord(self, uuid = None, label = None, content = None):
95     """
96     Create or alter a record
97     """
98     if not uuid:
99       if label: label = self.escape(label)
100       else: label = ''
101       if content: content = self.escape(content)
102       else: content = ''
103       self.dbcur.execute('INSERT INTO notes (uuid, label, content, revision, lastsync) VALUES ' +
104                          '(lower(hex(randomblob(16))), "' + str(label) + '", "' + str(content) +
105                          '", datetime(\'now\'), datetime(\'1970-01-01\'));')
106     else:    
107       if content:
108         self.dbcur.execute('UPDATE notes SET content = "' + self.escape(content) +
109                            '", revision = datetime(\'now\') WHERE uuid = "' + str(uuid) +
110                            '" AND NOT content = "' + self.escape(content) + '";')
111       if label:
112         self.dbcur.execute('UPDATE notes SET label = "' + self.escape(label) +
113                            '", revision = datetime(\'now\') WHERE uuid = "' + str(uuid) + '";')
114
115     try:
116       self.dbcur.execute('SELECT uuid FROM notes WHERE seq = "' + str(self.dbcur.lastrowid) + '";')
117       return self.dbcur.fetchall()[0][0]
118     except: return 0
119
120   def delRecord(self, uuid):
121     """
122     Delete record by ID
123     """
124     self.dbcur.execute('DELETE FROM notes WHERE uuid = "' + str(uuid) + '";')
125
126   def close(self):
127     """
128     Flush and close databese
129     """
130     self.dbcur.close()
131     self.dbcon.commit()
132     self.dbcon.close()
133
134 class Main(Tk):
135   """
136   Main application class
137   """
138   def __init__(self):
139     """
140     Open Database, create Main application window, and retrieve stored data
141     """
142     Tk.__init__(self)
143     self.storage = Persistence()
144     self.protocol('WM_DELETE_WINDOW', self.quit)
145     self.title('TabNote')
146
147     self.relabelWin = False
148     self.contents = []
149
150     self.tablist = Pmw.NoteBook(self, raisecommand = self.tabselect, lowercommand = self.tabunselect)
151     self.tablist.add('')
152     self.tablist.pack(expand = True, fill = BOTH)
153     for tab in self.storage.allRecords():
154       self.addTab(tab[0], tab[1], tab[2])
155     self.tablist.delete(Pmw.END)
156     self.tablist.add('+')
157
158     self.bind_all('<Control-Left>', self.previouspage)
159     self.bind_all('<Control-Right>', self.nextpage)
160     self.bind_all('<Control-t>', self.newTab)
161     self.bind_all('<Control-q>', self.quit)
162     self.bind_all('<Control-Return>', self.tabedit)
163
164     try:
165       self.geometry(self.storage.getSetting('geometry'))
166       self.tablist.selectpage(self.storage.getSetting('activeTab'))
167     except: pass
168
169   def newTab(self, junk):
170     """
171     Event wrapper for new tab
172     """
173     self.tablist.selectpage('+')
174
175   def previouspage(self, junk):
176     """
177     Event wrapper for tab switching
178     """
179     if self.tablist.getcurselection() == self.tablist.pagenames()[0]:
180       self.tablist.selectpage(self.tablist.pagenames()[-2])
181     else:
182       self.tablist.previouspage()
183
184   def nextpage(self, junk):
185     """
186     Event wrapper for tab switching
187     """
188     if self.tablist.getcurselection() == self.tablist.pagenames()[-2]:
189       self.tablist.selectpage(0)
190     else:
191       self.tablist.nextpage()
192
193   def closeRelabel(self, junk = None):
194     """
195     Close relabeling dialog window
196     """
197     self.relabelWin.destroy()
198     self.relabelWin = False
199
200   def delTab(self, junk = None):
201     """
202     Delete a tab (and its record)
203     Called by relabeling dialog
204     """
205     for tab in self.contents:
206       if tab[1] == self.tablist.page(self.relabel):
207         self.storage.delRecord(tab[0])
208         self.contents.remove(tab)
209         self.tablist.previouspage()
210         self.tablist.delete(self.relabel)
211         break
212     self.closeRelabel()
213
214   def relabelTab(self, junk = None):
215     """
216     Change the name of a tab
217     Called by relabeling dialog
218     """
219     for tab in self.contents:
220       if tab[1] == self.tablist.page(self.relabel) and self.relabel != self.relabelText.get():
221         label = self.newName(self.relabelText.get())
222         content = tab[2].getvalue()[:-1]
223  
224         tab[1] = self.tablist.insert(label, before = self.tablist.index(self.relabel))
225         self.tablist.tab(label).bind(sequence = '<Double-Button-1>', func = self.tabedit)
226         tab[2] = Pmw.ScrolledText(tab[1])
227         tab[2].setvalue(content)
228         tab[2].pack(expand = True, fill = BOTH)
229  
230         self.tablist.previouspage()
231         self.tablist.delete(self.relabel)
232         self.storage.setRecord(tab[0], label = label)
233         break
234     self.closeRelabel()
235
236   def tabedit(self, junk):
237     """
238     Bring up relabeling dialog
239     """
240     if not self.relabelWin:
241       self.relabel = self.tablist.getcurselection()
242       self.relabelWin = Tk()
243       self.relabelWin.bind('<Escape>', self.closeRelabel)
244       self.relabelWin.protocol('WM_DELETE_WINDOW', self.closeRelabel)
245       self.relabelText = Entry(self.relabelWin)
246       self.relabelText.insert(0, self.relabel)
247       self.relabelText.focus()
248       self.relabelText.bind('<Return>', self.relabelTab)
249       self.relabelText.pack(side = TOP, fill = X, expand = True)
250       relabelButton = Button(self.relabelWin, text='Relabel', command=self.relabelTab)
251       relabelButton.bind('<Return>', self.relabelTab)
252       relabelButton.pack(side = LEFT, fill = BOTH, expand = True)
253       deleteButton = Button(self.relabelWin, text='Delete', command=self.delTab)
254       deleteButton.bind('<Return>', self.delTab)
255       deleteButton.pack(side = LEFT, fill = BOTH, expand = True)
256
257   def newName(self, basename = 'New'):
258     """
259     Return a unique name for a tab by appending a number to the given basename
260     """
261     if not basename in self.tablist.pagenames(): return basename
262     incrementor = 1
263     while (basename + str(incrementor)) in self.tablist.pagenames(): incrementor += 1
264     return (basename + str(incrementor))
265
266   def tabunselect(self, name):
267     """
268     Called when a tab becomes unselected, write the DB record for this tab
269     """
270     for tab in self.contents:
271       if tab[1] == self.tablist.page(name):
272         self.storage.setRecord(tab[0], content = tab[2].getvalue()[:-1])
273
274   def addTab(self, rid, label = 'New', content = ""):
275     """
276     Create a new tab
277     """
278     label = self.newName(label)
279     self.contents.append([rid, self.tablist.insert(label, before = self.tablist.index(Pmw.END)), None])
280     self.tablist.tab(label).bind(sequence = '<Double-Button-1>', func = self.tabedit)
281     self.contents[-1][2] = Pmw.ScrolledText(self.contents[-1][1])
282     self.contents[-1][2].setvalue(content)
283     self.contents[-1][2].pack(expand = True, fill = BOTH)
284
285   def tabselect(self, name):
286     """
287     Called when a tab is selected
288     Normally sets focus to the text editor widget
289     If the + tab is selected, this function creates a new record and tab
290     """
291     if name == '+':
292       self.addTab(self.storage.setRecord(None, 'New', ''), 'New', '');
293       self.tablist.previouspage();
294     else:
295       for tab in self.contents:
296         if tab[1] == self.tablist.page(name):
297           tab[2].component('text').focus()
298
299   def quit(self, junk = None):
300     """
301     Store all data and terminate program
302     """
303     self.storage.setSetting('activeTab', self.tablist.getcurselection())
304     self.storage.setSetting('geometry', str(self.geometry()))
305     self.tabunselect(self.tablist.getcurselection())
306     self.storage.close()
307     self.destroy()
308
309 win = Main()
310 win.mainloop()