Merge lp:~thisfred/u1db/u1todo-4 into lp:u1db

Proposed by Eric Casteleijn
Status: Merged
Approved by: Eric Casteleijn
Approved revision: 266
Merged at revision: 266
Proposed branch: lp:~thisfred/u1db/u1todo-4
Merge into: lp:u1db
Diff against target: 548 lines (+238/-98)
5 files modified
u1db/__init__.py (+6/-6)
u1todo/test_u1todo.py (+59/-19)
u1todo/u1todo.py (+40/-32)
u1todo/u1todo.ui (+48/-21)
u1todo/ui.py (+85/-20)
To merge this branch: bzr merge lp:~thisfred/u1db/u1todo-4
Reviewer Review Type Date Requested Status
Roberto Alsina (community) Approve
dobey (community) Approve
Review via email: mp+104802@code.launchpad.net

Description of the change

This adds tags.

to test, run PYTHONPATH=. python u1todo/ui.py

tags are whitespace separated strings starting with #, or anything between []s (Not being able to use whitespace in tags is a pet peeve of mine, this will allow it, while forcing no one to use it.)

To post a comment you must log in.
Revision history for this message
dobey (dobey) :
review: Approve
Revision history for this message
Roberto Alsina (ralsina) wrote :

Works!

The use of buttons for the tag column feels weird. The traditional solution would be a list with checkmarks. That would look quite like the task list, which is not good either. Maybe we could use highly stilized buttons instead, to make them look like colored labels (think github's issue page: http://bit.ly/KDmeNg)

Also, I see no way to visually represent what is the meaning of multiple "ON" tags: is it
"and" or "or"? Do we care?

OTOH, nothing wrong in the code. So, this just needs some serious design thought.

review: Needs Information
Revision history for this message
Eric Casteleijn (thisfred) wrote :

Stylized buttons were exactly what I was thinking, since that is how a lot of (web) apps represent tags. I have chosen multiple selections to mean AND, but I could be convinced to make it or. Ideally both would be possible. Not hard to do in code, but far from easy to represent in an intuitive way in the UI.

Revision history for this message
Roberto Alsina (ralsina) wrote :

+1 it is then

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'u1db/__init__.py'
2--- u1db/__init__.py 2012-04-30 11:19:18 +0000
3+++ u1db/__init__.py 2012-05-04 20:17:19 +0000
4@@ -185,13 +185,13 @@
5 def get_from_index(self, index_name, key_values):
6 """Return documents that match the keys supplied.
7
8- You must supply exactly the same number of values as the index has been
9- defined. It is possible to do a prefix match by using '*' to indicate a
10- wildcard match. You can only supply '*' to trailing entries, (eg
11- [('val', '*', '*')] is allowed, but [('*', 'val', 'val')] is not.)
12+ You must supply exactly the same number of values as has been defined
13+ in the index. It is possible to do a prefix match by using '*' to
14+ indicate a wildcard match. You can only supply '*' to trailing entries,
15+ (eg [('val', '*', '*')] is allowed, but [('*', 'val', 'val')] is not.)
16 It is also possible to append a '*' to the last supplied value (eg
17- [('val*', '*', '*')] or [('val', 'val*', '*')], but not
18- [('val*', 'val', '*')])
19+ [('val*', '*', '*')] or [('val', 'val*', '*')], but not [('val*',
20+ 'val', '*')])
21
22 :return: List of [Document]
23 :param index_name: The index to query
24
25=== modified file 'u1todo/test_u1todo.py'
26--- u1todo/test_u1todo.py 2012-04-23 20:44:10 +0000
27+++ u1todo/test_u1todo.py 2012-05-04 20:17:19 +0000
28@@ -17,7 +17,8 @@
29 """Tests for u1todo example application."""
30
31 from testtools import TestCase
32-from u1todo import Task, TodoStore, INDEXES, EMPTY_TASK, DONE, NOT_DONE
33+from u1todo import (
34+ Task, TodoStore, INDEXES, TAGS_INDEX, EMPTY_TASK, extract_tags)
35 from u1db.backends import inmemory
36
37
38@@ -55,13 +56,62 @@
39 store = TodoStore(self.db)
40 store.initialize_db()
41 new_expression = ['newtags']
42- INDEXES['tags'] = new_expression
43+ INDEXES[TAGS_INDEX] = new_expression
44 self.assertNotEqual(
45 new_expression, dict(self.db.list_indexes())['tags'])
46 store = TodoStore(self.db)
47 store.initialize_db()
48 self.assertEqual(new_expression, dict(self.db.list_indexes())['tags'])
49
50+ def test_get_all_tags(self):
51+ store = TodoStore(self.db)
52+ store.initialize_db()
53+ tags = ['foo', 'bar', 'bam']
54+ task = store.new_task(tags=tags)
55+ self.assertEqual(sorted(tags), sorted(store.get_all_tags()))
56+ tags = ['foo', 'sball']
57+ task.tags = tags
58+ store.save_task(task)
59+ self.assertEqual(sorted(tags), sorted(store.get_all_tags()))
60+
61+ def test_get_all_tags_duplicates(self):
62+ store = TodoStore(self.db)
63+ store.initialize_db()
64+ tags = ['foo', 'bar', 'bam']
65+ store.new_task(tags=tags)
66+ self.assertEqual(sorted(tags), sorted(store.get_all_tags()))
67+ tags2 = ['foo', 'sball']
68+ store.new_task(tags=tags2)
69+ self.assertEqual(set(tags + tags2), set(store.get_all_tags()))
70+
71+ def test_get_tasks_by_tags(self):
72+ store = TodoStore(self.db)
73+ store.initialize_db()
74+ tags = ['foo', 'bar', 'bam']
75+ task1 = store.new_task(tags=tags)
76+ tags2 = ['foo', 'sball']
77+ task2 = store.new_task(tags=tags2)
78+ self.assertEqual(
79+ sorted([task1.task_id, task2.task_id]),
80+ sorted([t.task_id for t in store.get_tasks_by_tags(['foo'])]))
81+ self.assertEqual(
82+ [task1.task_id],
83+ [t.task_id for t in store.get_tasks_by_tags(['foo', 'bar'])])
84+ self.assertEqual(
85+ [task2.task_id],
86+ [t.task_id for t in store.get_tasks_by_tags(['foo', 'sball'])])
87+
88+ def test_get_tasks_by_empty_tags(self):
89+ store = TodoStore(self.db)
90+ store.initialize_db()
91+ tags = ['foo', 'bar', 'bam']
92+ task1 = store.new_task(tags=tags)
93+ tags2 = ['foo', 'sball']
94+ task2 = store.new_task(tags=tags2)
95+ self.assertEqual(
96+ sorted([task1.task_id, task2.task_id]),
97+ sorted([t.task_id for t in store.get_tasks_by_tags([])]))
98+
99 def test_tag_task(self):
100 """Sets the tags for a task."""
101 store = TodoStore(self.db)
102@@ -154,9 +204,14 @@
103 def test_set_done(self):
104 """Changing the done property changes the underlying content."""
105 task = Task(self.document)
106- self.assertEqual(NOT_DONE, task._content['done'])
107+ self.assertEqual(False, task._content['done'])
108 task.done = True
109- self.assertEqual(DONE, task._content['done'])
110+ self.assertEqual(True, task._content['done'])
111+
112+ def test_extracts_tags(self):
113+ """Tags are extracted from the item's text."""
114+ title = "#buy beer at [liquor store]"
115+ self.assertEqual(['buy', 'liquor store'], sorted(extract_tags(title)))
116
117 def test_tags(self):
118 """Tags property returns a list."""
119@@ -168,18 +223,3 @@
120 task = Task(self.document)
121 task.tags = ["foo", "bar"]
122 self.assertEqual(["foo", "bar"], task._content['tags'])
123-
124- def test_add_tag(self):
125- """Tag is added to task's tags."""
126- task = Task(self.document)
127- task.add_tag("foo")
128- self.assertEqual(["foo"], task.tags)
129-
130- def test_remove_tag(self):
131- """Tag is removed from task's tags."""
132- task = Task(self.document)
133- task.add_tag("foo")
134- task.add_tag("bar")
135- self.assertEqual(["foo", "bar"], task.tags)
136- task.remove_tag("foo")
137- self.assertEqual(["bar"], task.tags)
138
139=== modified file 'u1todo/u1todo.py'
140--- u1todo/u1todo.py 2012-04-27 14:24:05 +0000
141+++ u1todo/u1todo.py 2012-05-04 20:17:19 +0000
142@@ -18,19 +18,21 @@
143
144 import json
145 import os
146+import re
147 import xdg.BaseDirectory
148 from u1db.backends.sqlite_backend import SQLitePartialExpandDatabase
149
150-DONE = 'true'
151-NOT_DONE = 'false'
152-
153-EMPTY_TASK = json.dumps({"title": "", "done": NOT_DONE, "tags": []})
154-
155+EMPTY_TASK = json.dumps({"title": "", "done": False, "tags": []})
156+
157+TAGS_INDEX = 'tags'
158+DONE_INDEX = 'done'
159 INDEXES = {
160- 'tags': ['tags'],
161- 'done': ['done'],
162+ TAGS_INDEX: ['tags'],
163+ DONE_INDEX: ['bool(done)'],
164 }
165
166+TAGS = re.compile('#(\w+)|\[(.+)\]')
167+
168
169 def get_database():
170 """Get the path that the database is stored in."""
171@@ -40,6 +42,11 @@
172 "u1todo.u1db"))
173
174
175+def extract_tags(text):
176+ """Extract the tags from the text."""
177+ return [t[0] if t[0] else t[1] for t in TAGS.findall(text)]
178+
179+
180 class TodoStore(object):
181 """The todo application backend."""
182
183@@ -65,6 +72,27 @@
184 """Set the tags of a task."""
185 task.tags = tags
186
187+ def get_all_tags(self):
188+ """Get all the tags in use."""
189+ return self.db.get_index_keys(TAGS_INDEX)
190+
191+ def get_tasks_by_tags(self, tags):
192+ if not tags:
193+ return self.get_all_tasks()
194+ results = {
195+ doc.doc_id: doc for doc in
196+ self.db.get_from_index(TAGS_INDEX, [(tags[0],)])}
197+ for tag in tags[1:]:
198+ ids = [
199+ doc.doc_id for doc in
200+ self.db.get_from_index(TAGS_INDEX, [(tag,)])]
201+ for key in results.keys():
202+ if key not in ids:
203+ del results[key]
204+ if not results:
205+ return []
206+ return [Task(doc) for doc in results.values()]
207+
208 def get_task(self, task_id):
209 """Get a task from the database."""
210 document = self.db.get_doc(task_id)
211@@ -81,6 +109,8 @@
212 def new_task(self, title=None, tags=None):
213 """Create a new task document."""
214 # Create the document in the u1db database
215+ if tags is None:
216+ tags = []
217 content = EMPTY_TASK
218 if title or tags:
219 content_object = json.loads(content)
220@@ -99,7 +129,7 @@
221
222 def get_all_tasks(self):
223 return [
224- Task(doc) for doc in self.db.get_from_index("done", ["*"])]
225+ Task(doc) for doc in self.db.get_from_index(DONE_INDEX, ["*"])]
226
227
228 class Task(object):
229@@ -126,16 +156,11 @@
230
231 def _get_done(self):
232 """Get the status of the task."""
233- # Indexes on booleans are not currently possible, so we convert to and
234- # from strings. TODO: LP #987412
235- return True if self._content['done'] == DONE else False
236+ return self._content['done']
237
238 def _set_done(self, value):
239 """Set the done status."""
240- # Indexes on booleans are not currently possible, so we convert to and
241- # from strings.
242- # from strings. TODO: LP #987412
243- self._content['done'] = DONE if value else NOT_DONE
244+ self._content['done'] = value
245
246 done = property(_get_done, _set_done, doc="Done flag.")
247
248@@ -149,23 +174,6 @@
249
250 tags = property(_get_tags, _set_tags, doc="Task tags.")
251
252- def add_tag(self, tag):
253- """Add a single tag to the task."""
254- tags = self._content['tags']
255- if tag in tags:
256- # Tasks cannot have the same tag more than once, so ignore the
257- # request to add it again.
258- return
259- tags.append(tag)
260-
261- def remove_tag(self, tag):
262- """Remove a single tag from the task."""
263- tags = self._content['tags']
264- if tag not in tags:
265- # Can't remove a tag that the task does not have.
266- raise KeyError("Task has no tag '%s'." % (tag,))
267- tags.remove(tag)
268-
269 @property
270 def document(self):
271 """The u1db document representing this task."""
272
273=== modified file 'u1todo/u1todo.ui'
274--- u1todo/u1todo.ui 2012-04-20 13:56:35 +0000
275+++ u1todo/u1todo.ui 2012-05-04 20:17:19 +0000
276@@ -6,7 +6,7 @@
277 <rect>
278 <x>0</x>
279 <y>0</y>
280- <width>334</width>
281+ <width>408</width>
282 <height>834</height>
283 </rect>
284 </property>
285@@ -14,28 +14,49 @@
286 <string>MainWindow</string>
287 </property>
288 <widget class="QWidget" name="centralwidget">
289- <layout class="QVBoxLayout" name="verticalLayout">
290+ <layout class="QHBoxLayout" name="horizontalLayout_2">
291 <item>
292- <widget class="QListWidget" name="list_widget"/>
293+ <layout class="QVBoxLayout" name="verticalLayout">
294+ <item>
295+ <widget class="QListWidget" name="todo_list"/>
296+ </item>
297+ <item>
298+ <widget class="QLineEdit" name="task_edit"/>
299+ </item>
300+ <item>
301+ <layout class="QHBoxLayout" name="horizontalLayout">
302+ <item>
303+ <widget class="QPushButton" name="edit_button">
304+ <property name="text">
305+ <string>Add/&amp;Edit</string>
306+ </property>
307+ </widget>
308+ </item>
309+ <item>
310+ <widget class="QPushButton" name="delete_button">
311+ <property name="text">
312+ <string>&amp;Delete</string>
313+ </property>
314+ </widget>
315+ </item>
316+ </layout>
317+ </item>
318+ </layout>
319 </item>
320 <item>
321- <layout class="QHBoxLayout" name="horizontalLayout">
322- <item>
323- <widget class="QLineEdit" name="line_edit"/>
324- </item>
325- <item>
326- <widget class="QPushButton" name="edit_button">
327- <property name="text">
328- <string>Add/Edit</string>
329- </property>
330- </widget>
331- </item>
332- <item>
333- <widget class="QPushButton" name="delete_button">
334- <property name="text">
335- <string>Delete</string>
336- </property>
337- </widget>
338+ <layout class="QVBoxLayout" name="tag_buttons">
339+ <item>
340+ <spacer name="verticalSpacer">
341+ <property name="orientation">
342+ <enum>Qt::Vertical</enum>
343+ </property>
344+ <property name="sizeHint" stdset="0">
345+ <size>
346+ <width>20</width>
347+ <height>40</height>
348+ </size>
349+ </property>
350+ </spacer>
351 </item>
352 </layout>
353 </item>
354@@ -46,13 +67,19 @@
355 <rect>
356 <x>0</x>
357 <y>0</y>
358- <width>334</width>
359+ <width>408</width>
360 <height>21</height>
361 </rect>
362 </property>
363 </widget>
364 <widget class="QStatusBar" name="statusbar"/>
365 </widget>
366+ <tabstops>
367+ <tabstop>task_edit</tabstop>
368+ <tabstop>edit_button</tabstop>
369+ <tabstop>delete_button</tabstop>
370+ <tabstop>todo_list</tabstop>
371+ </tabstops>
372 <resources/>
373 <connections/>
374 </ui>
375
376=== modified file 'u1todo/ui.py'
377--- u1todo/ui.py 2012-04-23 20:44:10 +0000
378+++ u1todo/ui.py 2012-05-04 20:17:19 +0000
379@@ -16,7 +16,8 @@
380
381 """User interface for the u1todo example application."""
382
383-from u1todo import TodoStore, get_database
384+from u1todo import TodoStore, get_database, extract_tags
385+from collections import defaultdict
386 import os
387 import sys
388 from PyQt4 import QtGui, QtCore, uic
389@@ -41,21 +42,36 @@
390 uifile = os.path.join(os.path.abspath(os.path.dirname(__file__)),
391 'u1todo.ui')
392 uic.loadUi(uifile, self)
393+ self.connect_events()
394 db = get_database()
395 self.store = TodoStore(db)
396+ # create or update the indexes if they are not up-to-date
397 self.store.initialize_db()
398- self.connect_events()
399- self.item = None
400 self.delete_button.setEnabled(False)
401+ self._tag_docs = defaultdict(list)
402+ self._tag_buttons = {}
403+ self._tag_filter = []
404 for task in self.store.get_all_tasks():
405 self.add_task(task)
406+ self.task_edit.clear()
407+ self.task_edit.setFocus()
408+ self.item = None
409
410 def connect_events(self):
411 """Hook up all the signal handlers."""
412+ self.task_edit.returnPressed.connect(self.update)
413 self.edit_button.clicked.connect(self.update)
414 self.delete_button.clicked.connect(self.delete)
415- self.list_widget.currentRowChanged.connect(self.row_changed)
416- self.list_widget.itemChanged.connect(self.item_changed)
417+ self.todo_list.currentRowChanged.connect(self.row_changed)
418+ self.todo_list.itemChanged.connect(self.item_changed)
419+
420+ def refresh_filter(self):
421+ while len(self.todo_list):
422+ self.todo_list.takeItem(0)
423+ for task in self.store.get_tasks_by_tags(self._tag_filter):
424+ self.add_task(task)
425+ self.item = None
426+ self.task_edit.clear()
427
428 def item_changed(self, item):
429 if item.checkState() == QtCore.Qt.Checked:
430@@ -63,54 +79,103 @@
431 else:
432 item.task.done = False
433 self.store.save_task(item.task)
434+ self.todo_list.setCurrentRow(-1)
435+ self.task_edit.clear()
436+ self.item = None
437
438 def update(self):
439 """Either add a new task or update an existing one."""
440- text = unicode(self.line_edit.text(), 'utf-8')
441+ text = unicode(self.task_edit.text(), 'utf-8')
442 if not text:
443 return
444 if self.item is None:
445- task = self.store.new_task(text)
446+ task = self.store.new_task(text, tags=extract_tags(text))
447 self.add_task(task)
448 else:
449 self.update_task_text(text)
450- self.line_edit.clear()
451+ self.todo_list.setCurrentRow(-1)
452+ self.task_edit.clear()
453 self.item = None
454
455 def delete(self):
456 """Delete a todo item."""
457- item = self.list_widget.takeItem(self.list_widget.currentRow())
458+ item = self.todo_list.takeItem(self.todo_list.currentRow())
459 self.store.delete_task(item.task)
460- if self.list_widget.count() == 0:
461+ self.todo_list.setCurrentRow(-1)
462+ if self.todo_list.count() == 0:
463 self.delete_button.setEnabled(False)
464
465 def add_task(self, task):
466 """Add a new todo item."""
467 item = UITask(task)
468- self.list_widget.addItem(item)
469+ self.todo_list.addItem(item)
470 self.delete_button.setEnabled(True)
471+ if not task.tags:
472+ return
473+ for tag in task.tags:
474+ self.add_tag(task.task_id, tag)
475+
476+ def add_tag(self, task_id, tag):
477+ self._tag_docs[tag].append(task_id)
478+ if len(self._tag_docs[tag]) > 1:
479+ return
480+ button = QtGui.QPushButton(tag)
481+ button._u1todo_tag = tag
482+ button.setCheckable(True)
483+ self._tag_buttons[tag] = button
484+
485+ def filter_toggle(checked):
486+ if checked:
487+ self._tag_filter.append(button._u1todo_tag)
488+ else:
489+ self._tag_filter.remove(button._u1todo_tag)
490+ self.refresh_filter()
491+
492+ button.clicked.connect(filter_toggle)
493+ index = sorted(self._tag_buttons.keys()).index(tag)
494+ self.tag_buttons.insertWidget(index, button)
495+
496+ def remove_tag(self, task_id, tag):
497+ self._tag_docs[tag].remove(task_id)
498+ if self._tag_docs[tag]:
499+ return
500+ button = self._tag_buttons[tag]
501+ self.tag_buttons.removeWidget(button)
502+ del self._tag_buttons[tag]
503+
504+ def update_tags(self, task_id, old_tags, new_tags):
505+ for tag in old_tags - new_tags:
506+ self.remove_tag(task_id, tag)
507+ for tag in new_tags - old_tags:
508+ self.add_tag(task_id, tag)
509
510 def update_task_text(self, text):
511 """Edit an existing todo item."""
512- self.item.task.title = text
513+ item = self.item
514+ task = item.task
515+ task.title = text
516+ old_tags = set(task.tags) if task.tags else set([])
517+ new_tags = set(extract_tags(text))
518+ self.update_tags(item.task.task_id, old_tags, new_tags)
519+ task.tags = list(new_tags)
520 # disconnect the signal temporarily while we change the title
521- self.list_widget.itemChanged.disconnect(self.item_changed)
522- self.item.setText(text)
523+ self.todo_list.itemChanged.disconnect(self.item_changed)
524+ item.setText(text)
525 # reconnect the signal after we changed the title
526- self.list_widget.itemChanged.connect(self.item_changed)
527- self.store.save_task(self.item.task)
528+ self.todo_list.itemChanged.connect(self.item_changed)
529+ self.store.save_task(task)
530
531 def row_changed(self, index):
532 """Edit item when row changes."""
533 if index == -1:
534- self.line_edit.clear()
535+ self.task_edit.clear()
536 return
537- self.edit_item(self.list_widget.item(index))
538+ self.edit_item(self.todo_list.item(index))
539
540 def edit_item(self, item):
541- """Edit item in line edit box."""
542+ """Edit item in task edit box."""
543 self.item = item
544- self.line_edit.setText(item.task.title)
545+ self.task_edit.setText(item.task.title)
546
547
548 if __name__ == "__main__":

Subscribers

People subscribed via source and target branches