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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Roberto Alsina (community) | Approve | ||
dobey (community) | Approve | ||
Review via email: mp+104802@code.launchpad.net |
Commit message
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
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.
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/&Edit</string> |
306 | + </property> |
307 | + </widget> |
308 | + </item> |
309 | + <item> |
310 | + <widget class="QPushButton" name="delete_button"> |
311 | + <property name="text"> |
312 | + <string>&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__": |
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.