1
2# Copyright 2010 Fabian Stanke
3# Copyright 2011-2021 Jaap Karssenberg
4
5
6from gi.repository import GObject
7from gi.repository import Gtk
8from gi.repository import Pango
9
10import logging
11
12from functools import partial
13
14from zim.plugins import PluginClass
15from zim.signals import ConnectorMixin
16from zim.plugins.pageindex import PageTreeStore, PageTreeStoreBase, PageTreeView, \
17	NAME_COL, PATH_COL, EXISTS_COL, STYLE_COL, WEIGHT_COL, N_CHILD_COL, TIP_COL
18from zim.notebook import Path
19from zim.notebook.index import IndexNotFoundError
20from zim.notebook.index.pages import PageIndexRecord
21from zim.notebook.index.tags import IS_PAGE, IS_TAG, \
22	TagsView, TaggedPagesTreeModelMixin, TagsTreeModelMixin, IndexTag
23from zim.utils import natural_sort_key
24
25from zim.gui.notebookview import NotebookViewExtension
26from zim.gui.widgets import LEFT_PANE, PANE_POSITIONS, populate_popup_add_separator, ScrolledWindow, encode_markup_text, \
27	WindowSidePaneWidget
28
29
30logger = logging.getLogger('zim.plugins.tags')
31
32
33
34class TagsPlugin(PluginClass):
35
36	plugin_info = {
37		'name': _('Tags'), # T: plugin name
38		'description': _('''\
39This plugin provides a page index filtered by means of selecting tags in a cloud.
40'''), # T: plugin description
41		'author': 'Fabian Stanke & Jaap Karssenberg',
42		'help': 'Plugins:Tags',
43	}
44
45	plugin_preferences = (
46		# key, type, label, default
47		('pane', 'choice', _('Position in the window'), LEFT_PANE, PANE_POSITIONS),
48			# T: option for plugin preferences
49		('autoexpand', 'bool', _('Automatically expand sections on open page'), True),
50			# T: preferences option
51		('autocollapse', 'bool', _('Automatically collapse sections on close page'), True),
52			# T: preferences option
53		('use_hscroll', 'bool', _('Use horizontal scrollbar (may need restart)'), False),
54			# T: preferences option
55		('use_tooltip', 'bool', _('Use tooltips'), True),
56			# T: preferences option
57	)
58
59
60class TagsNotebookViewExtension(NotebookViewExtension):
61
62	def __init__(self, plugin, pageview):
63		NotebookViewExtension.__init__(self, plugin, pageview)
64
65		self.widget = TagsPluginWidget(
66			pageview.notebook,
67			self.navigation,
68			self.uistate
69		)
70
71		self.add_sidepane_widget(self.widget, 'pane')
72
73		self.uistate.setdefault('vpane_pos', 150)
74		self.widget.set_position(self.uistate['vpane_pos'])
75		def update_uistate(*a):
76			self.uistate['vpane_pos'] = self.widget.get_position()
77		self.widget.connect('notify::position', update_uistate)
78
79		#self.connectto_all(ui, ( # XXX
80		#	('start-index-update', lambda o: self.disconnect_model()),
81		#	('end-index-update', lambda o: self.reconnect_model()),
82		#))
83		self.connectto(pageview, 'page-changed', lambda o, p: self.widget.set_page(p))
84
85		self.on_preferences_changed(self.plugin.preferences)
86		self.plugin.preferences.connect('changed', self.on_preferences_changed)
87
88	def on_preferences_changed(self, preferences):
89		self.widget.treeview.set_use_tooltip(preferences['use_tooltip'])
90		self.widget.treeview.set_use_ellipsize(not preferences['use_hscroll'])
91			# To use horizontal scrolling, turn off ellipsize
92		self.widget.treeview.set_autoexpand(preferences['autoexpand'], preferences['autocollapse'])
93
94
95class TagsPluginWidget(Gtk.VPaned, WindowSidePaneWidget):
96	'''Widget combining a tag cloud and a tag based page treeview'''
97
98	title = _('T_ags') # T: title for sidepane tab
99
100	def __init__(self, notebook, navigation, uistate):
101		GObject.GObject.__init__(self)
102		self.notebook = notebook
103		self.index = notebook.index
104		self.uistate = uistate
105
106		self.uistate.setdefault('treeview', 'tags', {'tagged', 'tags'})
107		self.uistate.setdefault('tagcloud_sorting', 'score', {'alpha', 'score'})
108		self.uistate.setdefault('show_full_page_name', True)
109
110		self.tagcloud = TagCloudWidget(self.index, sorting=self.uistate['tagcloud_sorting'])
111		self.pack1(ScrolledWindow(self.tagcloud), shrink=False)
112
113		self.treeview = TagsPageTreeView(notebook, navigation)
114		self.pack2(ScrolledWindow(self.treeview), shrink=False)
115
116		self.treeview.connect('populate-popup', self.on_populate_popup)
117		self.tagcloud.connect('selection-changed', self.on_cloud_selection_changed)
118		self.tagcloud.connect('sorting-changed', self.on_cloud_sortin_changed)
119
120		self.reload_model()
121
122	def set_page(self, page):
123		treepath = self.treeview.set_current_page(page, vivificate=True)
124		if treepath:
125			selected_path = self.treeview.get_selected_path()
126			if page != selected_path:
127				self.treeview.select_treepath(treepath)
128
129	def toggle_treeview(self):
130		'''Toggle the treeview type in the widget'''
131		if self.uistate['treeview'] == 'tagged':
132			self.uistate['treeview'] = 'tags'
133		else:
134			self.uistate['treeview'] = 'tagged'
135
136		model = self.treeview.get_model()
137		if not isinstance(model, TaggedPageTreeStore):
138			self.reload_model()
139
140	def toggle_show_full_page_name(self):
141		self.uistate['show_full_page_name'] = not self.uistate['show_full_page_name']
142		self.reload_model()
143
144	def on_populate_popup(self, treeview, menu):
145		# Add a popup menu item to switch the treeview mode
146		populate_popup_add_separator(menu, prepend=True)
147
148		item = Gtk.CheckMenuItem(_('Show full page name')) # T: menu option
149		item.set_active(self.uistate['show_full_page_name'])
150		item.connect_object('toggled', self.__class__.toggle_show_full_page_name, self)
151		menu.prepend(item)
152
153		item = Gtk.CheckMenuItem(_('Sort pages by tags')) # T: menu option
154		item.set_active(self.uistate['treeview'] == 'tags')
155		item.connect_object('toggled', self.__class__.toggle_treeview, self)
156		model = self.treeview.get_model()
157		if isinstance(model, TaggedPageTreeStore):
158			item.set_sensitive(False) # with tag selection toggle does nothing
159		menu.prepend(item)
160
161		menu.show_all()
162
163	def on_cloud_selection_changed(self, cloud):
164		self.reload_model()
165		# FIXME - allow updating selection, requires signals for all added / removed pages
166
167	def on_cloud_sortin_changed(self, cloud, sorting):
168		self.uistate['tagcloud_sorting'] = sorting
169
170	def disconnect_model(self):
171		'''Stop the model from listening to the index. Used to
172		unhook the model before reloading the index. Typically
173		should be followed by reload_model().
174		'''
175		self.treeview.disconnect_index()
176		self.tagcloud.disconnect_index()
177
178	def reconnect_model(self):
179		self.tagcloud.connect_index(self.index)
180		self.reload_model()
181
182	def reload_model(self):
183		'''Re-initialize the treeview model. This is called when
184		reloading the index to get rid of out-of-sync model errors
185		without need to close the app first.
186		'''
187		assert self.uistate['treeview'] in ('tagged', 'tags')
188		self.treeview.disconnect_index()
189
190		tags = [t.name for t in self.tagcloud.get_tag_filter()]
191		if tags:
192			model = TaggedPageTreeStore(self.index, tags, self.uistate['show_full_page_name'])
193		elif self.uistate['treeview'] == 'tags':
194			model = TagsPageTreeStore(self.index, (), self.uistate['show_full_page_name'])
195		else:
196			model = PageTreeStore(self.index)
197
198		self.treeview.set_model(model)
199
200		def handler(o, *a):
201			signal = a[-1]
202			path = a[0].to_string()
203		for signal in ('row-inserted', 'row-changed', 'row-deleted', 'row-has-child-toggled'):
204			model.connect(signal, handler, signal)
205
206
207class DuplicatePageTreeStore(PageTreeStoreBase):
208	'''Sub-class of PageTreeStore that allows for the same page appearing
209	multiple times in the tree.
210	'''
211
212	def set_current_page(self, path):
213		'''Since there may be duplicates of each page, highlight all of them'''
214		oldpath = self.current_page
215		self.current_page = path
216
217		for mypath in (oldpath, path):
218			if mypath:
219				for treepath in self.find_all(mypath):
220					if treepath:
221						treeiter = self.get_iter(treepath)
222						self.emit('row-changed', treepath, treeiter)
223
224	def get_indexpath(self, treeiter):
225		'''Get an L{PageIndexRecord} for a C{Gtk.TreeIter}
226
227		@param treeiter: a C{Gtk.TreeIter}
228		@returns: an L{PageIndexRecord} object
229		'''
230		mytreeiter = self.get_user_data(treeiter)
231		if mytreeiter.hint == IS_PAGE:
232			return PageIndexRecord(mytreeiter.row)
233		elif mytreeiter.hint == IS_TAG:
234			return IndexTag(mytreeiter.row['name'], mytreeiter.row['id'])
235		else:
236			raise ValueError
237
238
239class TagsPageTreeStore(TagsTreeModelMixin, DuplicatePageTreeStore):
240	'''Subclass of the PageTreeStore that shows tags as the top level
241	for sub-sets of the page tree.
242	'''
243
244	def __init__(self, index, tags=None, show_full_page_name=True):
245		TagsTreeModelMixin.__init__(self, index, tags)
246		PageTreeStoreBase.__init__(self)
247		self.show_full_page_name = show_full_page_name
248
249	def on_get_value(self, iter, column):
250		'''Returns the data for a specific column'''
251		if iter.hint == IS_TAG:
252			if column == NAME_COL:
253				return iter.row['name']
254			elif column == TIP_COL:
255				return encode_markup_text(iter.row['name'])
256			elif column == PATH_COL:
257				return IndexTag(*iter.row)
258			elif column == EXISTS_COL:
259				return True
260			elif column == STYLE_COL:
261				return Pango.Style.NORMAL
262			elif column == WEIGHT_COL:
263				return Pango.Weight.NORMAL
264			elif column == N_CHILD_COL:
265				return iter.n_children
266		else:
267			if self.show_full_page_name \
268			and column == NAME_COL and len(iter.treepath) == 2:
269				# Show top level pages with full contex
270				# top level tree is tags, so top level pages len(path) is 2
271				return iter.row['name']
272			else:
273				return PageTreeStoreBase.on_get_value(self, iter, column)
274
275
276class TaggedPageTreeStore(TaggedPagesTreeModelMixin, DuplicatePageTreeStore):
277	'''A TreeModel that lists all Zim pages in a flat list.
278	Pages with associated sub-pages still show them as sub-nodes.
279	Intended to be filtered by tags.
280	'''
281
282	def __init__(self, index, tags, show_full_page_name=True):
283		TaggedPagesTreeModelMixin.__init__(self, index, tags)
284		PageTreeStoreBase.__init__(self)
285		self.show_full_page_name = show_full_page_name
286
287	def on_get_value(self, iter, column):
288		'''Returns the data for a specific column'''
289		if self.show_full_page_name \
290		and column == NAME_COL and len(iter.treepath) == 1:
291			# Show top level pages with full contex
292			return iter.row['name']
293		else:
294			return PageTreeStoreBase.on_get_value(self, iter, column)
295
296
297class TagsPageTreeView(PageTreeView):
298
299	def set_current_page(self, path, vivificate=False):
300		'''Set the current page in the treeview
301
302		@param path: a notebook L{Path} object for the page
303		@keyword vivificate: when C{True} the path is created
304		temporarily when it did not yet exist
305
306		@returns: a gtk TreePath (tuple of intergers) or C{None}
307		'''
308		#~ print('!! SELECT', path)
309		model = self.get_model()
310		if model is None:
311			return None # index not yet initialized ...
312
313		try:
314			treepath = model.find(path)
315			model.set_current_page(path) # highlight in model
316		except IndexNotFoundError:
317			pass
318		else:
319			return treepath
320
321
322class TagCloudItem(Gtk.ToggleButton):
323	'''Button item used on the tag cloud widget'''
324
325	def __init__(self, indextag):
326		Gtk.ToggleButton.__init__(self, indextag.name, use_underline=False)
327		self.set_relief(Gtk.ReliefStyle.NONE)
328		self.indextag = indextag
329
330		def update_label(self):
331			# Make button text bold when active
332			label = self.get_child()
333			if self.get_active():
334				label.set_markup('<b>' + label.get_text() + '</b>')
335			else:
336				label.set_text(label.get_text())
337					# get_text() gives string without markup
338
339		self.connect_after('toggled', update_label)
340
341
342class TagCloudWidget(ConnectorMixin, Gtk.TextView):
343	'''Text-view based list of tags, where each tag is represented by a
344	button inserted as a child in the textview.
345
346	@signal: C{selection-changed ()}: emitted when tag selection changes
347	@signal: C{sorting-changed ()}: emitted when tag sorting changes
348	'''
349
350	# define signals we want to use - (closure type, return type and arg types)
351	__gsignals__ = {
352		'selection-changed': (GObject.SignalFlags.RUN_LAST, None, ()),
353		'sorting-changed': (GObject.SignalFlags.RUN_LAST, None, (object,)),
354	}
355
356	def __init__(self, index, sorting='score'):
357		GObject.GObject.__init__(self)
358		self.set_name('zim-tags-tagcloud')
359		self.index = None
360
361		self.set_editable(False)
362		self.set_cursor_visible(False)
363		self.set_wrap_mode(Gtk.WrapMode.CHAR)
364
365		self.set_sorting(sorting)
366		self.connect_index(index)
367
368	def set_sorting(self, sorting):
369		self._alphabetically = (sorting == 'alpha')
370
371	def connect_index(self, index):
372		'''Connect to an Index object'''
373		self.disconnect_index() # just to be sure
374		self.index = index
375		self.connectto_all(self.index.update_iter.tags, (
376			('tag-row-inserted', self._update),
377			('tag-row-deleted', self._update),
378		))
379		self._update()
380
381	def disconnect_index(self):
382		'''Stop the model from listening to the index. Used to unhook
383		the model before reloading the index.
384		'''
385		if self.index is not None:
386			self.disconnect_from(self.index.update_iter.tags)
387		self._clear()
388
389	def get_tag_filter(self):
390		'''Returns a tuple with two lists of tags; the first gives all
391		tags that are selected, the second gives all tags shown in the
392		cloud. By definition the first list is a subset of the second.
393		If no tags are selected returns None instead.
394		'''
395		return [
396			b.indextag for b in self.get_children() if b.get_active()
397		]
398
399	def _clear(self):
400		'''Clears the cloud'''
401		self.foreach(lambda b: self.remove(b))
402		buffer = self.get_buffer()
403		buffer.delete(*buffer.get_bounds())
404
405	def _update(self, *a):
406		'''Update the cloud to show only tags that share a set of pages
407		with the selected tags.'''
408		tagview = TagsView.new_from_index(self.index)
409		selected = []
410		for button in self.get_children():
411			if button.get_active():
412				try:
413					selected.append(tagview.lookup_by_tagname(button.indextag))
414				except IndexNotFoundError:
415					pass
416				# Need the lookup here in case the tag went missing in the
417				# mean time e.g. due to editing of the page
418		self._clear()
419
420		if selected:
421			tags = tagview.list_intersecting_tags(selected)
422		else:
423			tags = tagview.list_all_tags_by_n_pages()
424
425		if self._alphabetically:
426			tags = sorted(tags, key=lambda t: natural_sort_key(t.name))
427		# else leave sorted by score
428
429		buffer = self.get_buffer()
430		for tag in tags:
431			iter = buffer.get_end_iter()
432			anchor = buffer.create_child_anchor(iter)
433			button = TagCloudItem(tag)
434			button.set_active(tag in selected)
435			button.connect("toggled", lambda b: self._update())
436			self.add_child_at_anchor(button, anchor)
437
438		self.show_all()
439		self.emit('selection-changed')
440
441	def do_populate_popup(self, menu):
442		populate_popup_add_separator(menu, prepend=True)
443
444		item = Gtk.CheckMenuItem(_('Sort alphabetically')) # T: Context menu item for tag cloud
445		item.set_active(self._alphabetically)
446		item.connect('toggled', self._switch_sorting)
447		item.show_all()
448		menu.prepend(item)
449
450	def _switch_sorting(self, widget, *a):
451		self._alphabetically = widget.get_active()
452		self._update()
453		if self._alphabetically:
454			self.emit('sorting-changed', 'alpha')
455		else:
456			self.emit('sorting-changed', 'score')
457