1
2# Copyright 2010 Jaap Karssenberg <jaap.karssenberg@gmail.com>
3
4'''This module contains code for defining and managing custom
5commands.
6'''
7
8
9
10
11from gi.repository import Gtk
12from gi.repository import GObject
13from gi.repository import GdkPixbuf
14
15
16import logging
17
18from functools import partial
19
20
21from zim.fs import File, TmpFile, cleanup_filename
22from zim.parsing import split_quoted_strings
23from zim.config import ConfigManager, XDG_CONFIG_HOME, INIConfigFile
24from zim.signals import SignalEmitter, SIGNAL_NORMAL, SignalHandler
25
26from zim.gui.applications import Application, DesktopEntryDict, String, Boolean
27from zim.gui.widgets import Dialog, IconButton, IconChooserButton
28
29import zim.errors
30
31logger = logging.getLogger('zim.gui')
32
33
34def _create_application(dir, basename, Name, Exec, NoDisplay=True, **param):
35	file = dir.file(basename)
36	i = 0
37	while file.exists():
38		assert i < 1000, 'BUG: Infinite loop ?'
39		i += 1
40		basename = basename[:-8] + '-' + str(i) + '.desktop'
41		file = dir.file(basename)
42
43	entry = CustomTool(file)
44	entry.update(
45		Type='X-Zim-CustomTool',
46		Version=1.0,
47		NoDisplay=NoDisplay,
48		Name=Name,
49		Exec=Exec,
50		**param
51	)
52
53	assert entry.isvalid(), 'BUG: created invalid desktop entry'
54	entry.write()
55	return entry
56
57
58class CustomToolManager(SignalEmitter):
59	'''Manager for dealing with the desktop files which are used to
60	store custom tools.
61
62	Custom tools are external commands that are intended to show in the
63	"Tools" menu in zim (and optionally in the tool bar). They are
64	defined as desktop entry files in a special folder (typically
65	"~/.local/share/zim/customtools") and use several non standard keys.
66	See L{CustomTool} for details.
67
68	This object is iterable and maintains a specific order for tools
69	to be shown in in the user interface.
70	'''
71
72	__signals__ = {
73		'changed': (SIGNAL_NORMAL, None, ())
74	}
75
76	def __init__(self):
77		self._names = []
78		self._tools = {}
79		self._listfile = ConfigManager.get_config_file('customtools/customtools.list')
80		self._read_list()
81		self._listfile.connect('changed', self._on_list_changed)
82
83	@SignalHandler
84	def _on_list_changed(self, *a):
85		self._read_list()
86		self.emit('changed')
87
88	def _on_tool_changed(self, tool, *a):
89		if not tool.modified: # XXX: modified means this is the instance that is writing
90			tool.read()
91		self.emit('changed')
92
93	def _read_list(self):
94		self._names = []
95		seen = set()
96		for line in self._listfile.readlines():
97			name = line.strip()
98			if not name in seen:
99				seen.add(name)
100				self._names.append(name)
101
102	def _write_list(self):
103		with self._on_list_changed.blocked():
104			self._listfile.writelines([name + '\n' for name in self._names])
105		self.emit('changed')
106
107	def __iter__(self):
108		for name in self._names:
109			tool = self.get_tool(name)
110			if tool and tool.isvalid():
111				yield tool
112
113	def get_tool(self, name):
114		'''Get a L{CustomTool} by name.
115		@param name: the tool name
116		@returns: a L{CustomTool} object or C{None}
117		'''
118		if not '-usercreated' in name:
119			name = cleanup_filename(name.lower()) + '-usercreated'
120
121		if not name in self._tools:
122			file = ConfigManager.get_config_file('customtools/%s.desktop' % name)
123			if file.exists():
124				tool = CustomTool(file)
125				self._tools[name] = tool
126				file.connect('changed', partial(self._on_tool_changed, tool))
127			else:
128				return None
129
130		return self._tools[name]
131
132	def create(self, Name, **properties):
133		'''Create a new custom tool
134
135		@param Name: the name to show in the Tools menu
136		@param properties: properties for the custom tool, e.g.:
137		  - Comment
138		  - Icon
139		  - X-Zim-ExecTool
140		  - X-Zim-ReadOnly
141		  - X-Zim-ShowInToolBar
142
143		@returns: a new L{CustomTool} object.
144		'''
145		dir = XDG_CONFIG_HOME.subdir('zim/customtools')
146		basename = cleanup_filename(Name.lower()) + '-usercreated.desktop'
147		tool = _create_application(dir, basename, Name, '', NoDisplay=False, **properties)
148
149		# XXX - hack to ensure we link to configmanager
150		file = ConfigManager.get_config_file('customtools/' + tool.file.basename)
151		tool.file = file
152		file.connect('changed', partial(self._on_tool_changed, tool))
153
154		self._tools[tool.key] = tool
155		self._names.append(tool.key)
156		self._write_list()
157
158		return tool
159
160	def delete(self, tool):
161		'''Remove a custom tool from the list and delete the definition
162		file.
163		@param tool: a custom tool name or L{CustomTool} object
164		'''
165		if not isinstance(tool, CustomTool):
166			tool = self.get_tool(tool)
167		tool.file.remove()
168		self._tools.pop(tool.key)
169		self._names.remove(tool.key)
170		self._write_list()
171
172	def index(self, tool):
173		'''Get the position of a specific tool in the list.
174		@param tool: a custom tool name or L{CustomTool} object
175		@returns: an integer for the position
176		'''
177		if isinstance(tool, CustomTool):
178			tool = tool.key
179		return self._names.index(tool)
180
181	def reorder(self, tool, i):
182		'''Change the position of a tool in the list.
183		@param tool: a custom tool name or L{CustomTool} object
184		@param i: the new position as integer
185		'''
186		if not 0 <= i < len(self._names):
187			return
188
189		if isinstance(tool, CustomTool):
190			tool = tool.key
191
192		j = self._names.index(tool)
193		self._names.pop(j)
194		self._names.insert(i, tool)
195		# Insert before i. If i was before old position indeed before
196		# old item at that position. However if i was after old position
197		# if shifted due to the pop(), now it inserts after the old item.
198		# This is intended behavior to make all moves possible.
199		self._write_list()
200
201
202
203from zim.config import Choice
204
205class CustomToolDict(DesktopEntryDict):
206	'''This is a specialized desktop entry type that is used for
207	custom tools for the "Tools" menu in zim. It uses a non-standard
208	Exec spec with zim specific escapes for "X-Zim-ExecTool".
209
210	The following fields are expanded:
211		- C{%f} for source file as tmp file current page
212		- C{%d} for attachment directory
213		- C{%s} for real source file (if any)
214		- C{%n} for notebook location (file or directory)
215		- C{%D} for document root
216		- C{%t} for selected text or word under cursor
217		- C{%T} for the selected text including wiki formatting
218
219	Other additional keys are:
220		- C{X-Zim-ReadOnly} - boolean
221		- C{X-Zim-ShowInToolBar} - boolean
222		- C{X-Zim-ShowInContextMenu} - 'None', 'Text' or 'Page'
223
224	These tools should always be executed with 3 arguments: notebook,
225	page & pageview.
226	'''
227
228	_definitions = DesktopEntryDict._definitions + (
229			('X-Zim-ExecTool', String(None)),
230			('X-Zim-ReadOnly', Boolean(True)),
231			('X-Zim-ShowInToolBar', Boolean(False)),
232			('X-Zim-ShowInContextMenu', Choice(None, ('Text', 'Page'))),
233			('X-Zim-ReplaceSelection', Boolean(False)),
234	)
235
236	def isvalid(self):
237		'''Check if all required fields are set.
238		@returns: C{True} if all required fields are set
239		'''
240		entry = self['Desktop Entry']
241		if entry.get('Type') == 'X-Zim-CustomTool' \
242		and entry.get('Version') == 1.0 \
243		and entry.get('Name') \
244		and entry.get('X-Zim-ExecTool') \
245		and not entry.get('X-Zim-ReadOnly') is None \
246		and not entry.get('X-Zim-ShowInToolBar') is None \
247		and 'X-Zim-ShowInContextMenu' in entry:
248			return True
249		else:
250			logger.error('Invalid custom tool entry: %s %s', self.key, entry)
251			return False
252
253	def get_pixbuf(self, size):
254		pixbuf = DesktopEntryDict.get_pixbuf(self, size)
255		if pixbuf is None:
256			pixbuf = Gtk.Label().render_icon(Gtk.STOCK_EXECUTE, size)
257			# FIXME hack to use arbitrary widget to render icon
258		return pixbuf
259
260	@property
261	def icon(self):
262		return self['Desktop Entry'].get('Icon') or Gtk.STOCK_EXECUTE
263			# get('Icon', Gtk.STOCK_EXECUTE) still returns empty string if key exists but no value
264
265	@property
266	def execcmd(self):
267		return self['Desktop Entry']['X-Zim-ExecTool']
268
269	@property
270	def isreadonly(self):
271		return self['Desktop Entry']['X-Zim-ReadOnly']
272
273	@property
274	def showintoolbar(self):
275		return self['Desktop Entry']['X-Zim-ShowInToolBar']
276
277	@property
278	def showincontextmenu(self):
279		return self['Desktop Entry']['X-Zim-ShowInContextMenu']
280
281	@property
282	def replaceselection(self):
283		return self['Desktop Entry']['X-Zim-ReplaceSelection']
284
285	def parse_exec(self, args=None):
286		if not (isinstance(args, tuple) and len(args) == 3):
287			raise AssertionError('Custom commands needs 3 arguments')
288			# assert statement could be optimized away
289		notebook, page, pageview = args
290
291		cmd = split_quoted_strings(self['Desktop Entry']['X-Zim-ExecTool'])
292		if '%f' in cmd:
293			self._tmpfile = TmpFile('tmp-page-source.txt')
294			self._tmpfile.writelines(page.dump('wiki'))
295			cmd[cmd.index('%f')] = self._tmpfile.path
296
297		if '%d' in cmd:
298			dir = notebook.get_attachments_dir(page)
299			if dir:
300				cmd[cmd.index('%d')] = dir.path
301			else:
302				cmd[cmd.index('%d')] = ''
303
304		if '%s' in cmd:
305			if hasattr(page, 'source') and isinstance(page.source, File):
306				cmd[cmd.index('%s')] = page.source.path
307			else:
308				cmd[cmd.index('%s')] = ''
309
310		if '%p' in cmd:
311			cmd[cmd.index('%p')] = page.name
312
313		if '%n' in cmd:
314			cmd[cmd.index('%n')] = File(notebook.uri).path
315
316		if '%D' in cmd:
317			dir = notebook.document_root
318			if dir:
319				cmd[cmd.index('%D')] = dir.path
320			else:
321				cmd[cmd.index('%D')] = ''
322
323		if '%t' in cmd and pageview is not None:
324			text = pageview.get_selection() or pageview.get_word()
325			cmd[cmd.index('%t')] = text or ''
326			# FIXME - need to substitute this in arguments + url encoding
327
328		if '%T' in cmd and pageview is not None:
329			text = pageview.get_selection(format='wiki') or pageview.get_word(format='wiki')
330			cmd[cmd.index('%T')] = text or ''
331			# FIXME - need to substitute this in arguments + url encoding
332
333		return tuple(cmd)
334
335	_cmd = parse_exec # To hook into Application.spawn and Application.run
336
337	def run(self, notebook, page, pageview=None):
338		args = (notebook, page, pageview)
339		cwd = page.source_file.parent()
340
341		if pageview:
342			pageview.save_changes()
343
344		if self.replaceselection:
345			if not pageview:
346				raise ValueError('This tool needs a PageView object')
347			output = self.pipe(args, cwd=cwd)
348			logger.debug('Replace selection with: %s', output)
349			pageview.replace_selection(output, autoselect='word')
350		elif self.isreadonly:
351			self.spawn(args, cwd=cwd)
352		else:
353			self._tmpfile = None
354			Application.run(self, args, cwd=cwd)
355			if self._tmpfile:
356				page.parse('wiki', self._tmpfile.readlines())
357				notebook.store_page(page)
358				self._tmpfile = None
359
360			page.check_source_changed()
361			notebook.index.start_background_check(notebook)
362			# TODO instead of using run, use spawn and show dialog
363			# with cancel button. Dialog blocks ui.
364
365	def update(self, E=(), **F):
366		self['Desktop Entry'].update(E, **F)
367
368		# Set sane default for X-Zim-ShowInContextMenus
369		if not (E and 'X-Zim-ShowInContextMenu' in E) \
370		and not 'X-Zim-ShowInContextMenu' in F:
371			cmd = split_quoted_strings(self['Desktop Entry']['X-Zim-ExecTool'])
372			if any(c in cmd for c in ['%f', '%d', '%s']):
373				context = 'Page'
374			elif '%t' in cmd:
375				context = 'Text'
376			else:
377				context = None
378			self['Desktop Entry']['X-Zim-ShowInContextMenu'] = context
379
380
381class CustomTool(CustomToolDict, INIConfigFile):
382	'''Class representing a file defining a custom tool, see
383	L{CustomToolDict} for the API documentation.
384	'''
385
386	def __init__(self, file):
387		CustomToolDict.__init__(self)
388		INIConfigFile.__init__(self, file)
389
390	@property
391	def key(self):
392		return self.file.basename[:-8] # len('.desktop') is 8
393
394
395class StubPageView(object):
396
397	def __init__(self, notebook, page):
398		self.notebook = notebook
399		self.page = page
400
401	def save_changes(self):
402		pass
403
404	def get_selection(self, format=None):
405		return None
406
407	def get_word(self, format=None):
408		return None
409
410	def replace_selection(self, string):
411		raise NotImplementedError
412
413
414class CustomToolManagerUI(object):
415
416	def __init__(self, uimanager, pageview):
417		'''Constructor
418		@param uimanager: a C{Gtk.UIManager}
419		@param pageview: either a L{PageView} or a L{StubPageView}
420		'''
421		# TODO check via abc base class ?
422		assert hasattr(pageview, 'notebook')
423		assert hasattr(pageview, 'page')
424		assert hasattr(pageview, 'get_selection')
425		assert hasattr(pageview, 'get_word')
426		assert hasattr(pageview, 'save_changes')
427		assert hasattr(pageview, 'replace_selection')
428
429		self.uimanager = uimanager
430		self.pageview = pageview
431
432		self._manager = CustomToolManager()
433		self._iconfactory = Gtk.IconFactory()
434		self._iconfactory.add_default()
435		self._ui_id = None
436		self._actiongroup = None
437
438		self.add_customtools()
439		self._manager.connect('changed', self.on_changed)
440
441	def on_changed(self, o):
442		self.uimanager.remove_ui(self._ui_id)
443		self.uimanager.remove_action_group(self._actiongroup)
444		self._ui_id = None
445		self._actiongroup = None
446		self.add_customtools()
447
448	def add_customtools(self):
449		assert self._ui_id is None
450		assert self._actiongroup is None
451
452		self._actiongroup = self.get_actiongroup()
453		ui_xml = self.get_ui_xml()
454
455		self.uimanager.insert_action_group(self._actiongroup, 0)
456		self._ui_id = self.uimanager.add_ui_from_string(ui_xml)
457
458	def get_actiongroup(self):
459		actions = []
460		for tool in self._manager:
461			icon = tool.icon
462			if '/' in icon or '\\' in icon:
463				# Assume icon is a file path - need to add it in order to make it loadable
464				icon = 'zim-custom-tool' + tool.key
465				try:
466					pixbuf = tool.get_pixbuf(Gtk.IconSize.LARGE_TOOLBAR)
467					self._iconfactory.add(icon, Gtk.IconSet(pixbuf=pixbuf))
468				except Exception:
469					logger.exception('Got exception while loading application icons')
470					icon = None
471
472			actions.append(
473				(tool.key, icon, tool.name, '', tool.comment, self._action_handler)
474			)
475
476		group = Gtk.ActionGroup('custom_tools')
477		group.add_actions(actions)
478		return group
479
480	def get_ui_xml(self):
481		tools = self._manager
482		menulines = ["<menuitem action='%s'/>\n" % t.key for t in tools]
483		textlines = ["<menuitem action='%s'/>\n" % t.key for t in tools if t.showincontextmenu == 'Text']
484		pagelines = ["<menuitem action='%s'/>\n" % t.key for t in tools if t.showincontextmenu == 'Page']
485		return """\
486		<ui>
487			<menubar name='menubar'>
488				<menu action='tools_menu'>
489					<placeholder name='custom_tools'>
490					 %s
491					</placeholder>
492				</menu>
493			</menubar>
494			<popup name='text_popup'>
495				<placeholder name='tools'>
496				%s
497				</placeholder>
498			</popup>
499			<popup name='page_popup'>
500				<placeholder name='tools'>
501				%s
502				</placeholder>
503			</popup>
504		</ui>
505		""" % (
506			''.join(menulines),
507			''.join(textlines),
508			''.join(pagelines)
509		)
510
511	def _action_handler(self, action):
512		tool = self._manager.get_tool(action.get_name())
513		logger.info('Execute custom tool %s', tool.name)
514		pageview = self.pageview
515		notebook, page = pageview.notebook, pageview.page
516		try:
517			tool.run(notebook, page, pageview)
518		except:
519			zim.errors.exception_handler(
520				'Exception during action: %s' % tool.name)
521
522
523class CustomToolManagerDialog(Dialog):
524
525	def __init__(self, parent):
526		Dialog.__init__(self, parent, _('Custom Tools'), buttons=Gtk.ButtonsType.CLOSE) # T: Dialog title
527		self.set_help(':Help:Custom Tools')
528		self.manager = CustomToolManager()
529
530		self.add_help_text(_(
531			'You can configure custom tools that will appear\n'
532			'in the tool menu and in the tool bar or context menus.'
533		)) # T: help text in "Custom Tools" dialog
534
535		hbox = Gtk.HBox(spacing=5)
536		self.vbox.pack_start(hbox, True, True, 0)
537
538		self.listview = CustomToolList(self.manager)
539		hbox.pack_start(self.listview, True, True, 0)
540
541		vbox = Gtk.VBox(spacing=5)
542		hbox.pack_start(vbox, False, True, 0)
543
544		for stock, handler, data in (
545			(Gtk.STOCK_ADD, self.__class__.on_add, None),
546			(Gtk.STOCK_EDIT, self.__class__.on_edit, None),
547			(Gtk.STOCK_DELETE, self.__class__.on_delete, None),
548			(Gtk.STOCK_GO_UP, self.__class__.on_move, -1),
549			(Gtk.STOCK_GO_DOWN, self.__class__.on_move, 1),
550		):
551			button = IconButton(stock) # TODO tooltips for icon button
552			if data:
553				button.connect_object('clicked', handler, self, data)
554			else:
555				button.connect_object('clicked', handler, self)
556			vbox.pack_start(button, False, True, 0)
557
558	def on_add(self):
559		properties = EditCustomToolDialog(self).run()
560		if properties:
561			self.manager.create(**properties)
562		self.listview.refresh()
563
564	def on_edit(self):
565		name = self.listview.get_selected()
566		if name:
567			tool = self.manager.get_tool(name)
568			properties = EditCustomToolDialog(self, tool=tool).run()
569			if properties:
570				tool.update(**properties)
571				tool.write()
572		self.listview.refresh()
573
574	def on_delete(self):
575		name = self.listview.get_selected()
576		if name:
577			self.manager.delete(name)
578			self.listview.refresh()
579
580	def on_move(self, step):
581		name = self.listview.get_selected()
582		if name:
583			i = self.manager.index(name)
584			self.manager.reorder(name, i + step)
585			self.listview.refresh()
586			self.listview.select(i + step)
587
588
589class CustomToolList(Gtk.TreeView):
590
591	PIXBUF_COL = 0
592	TEXT_COL = 1
593	NAME_COL = 2
594
595	def __init__(self, manager):
596		GObject.GObject.__init__(self)
597		self.manager = manager
598
599		model = Gtk.ListStore(GdkPixbuf.Pixbuf, str, str)
600				# PIXBUF_COL, TEXT_COL, NAME_COL
601		self.set_model(model)
602		self.set_headers_visible(False)
603
604		self.get_selection().set_mode(Gtk.SelectionMode.BROWSE)
605
606		cr = Gtk.CellRendererPixbuf()
607		column = Gtk.TreeViewColumn('_pixbuf_', cr, pixbuf=self.PIXBUF_COL)
608		self.append_column(column)
609
610		cr = Gtk.CellRendererText()
611		column = Gtk.TreeViewColumn('_text_', cr, markup=self.TEXT_COL)
612		self.append_column(column)
613
614		self.refresh()
615
616	def get_selected(self):
617		model, iter = self.get_selection().get_selected()
618		if model and iter:
619			return model[iter][self.NAME_COL]
620		else:
621			return None
622
623	def select(self, i):
624		path = (i, )
625		self.get_selection().select_path(path)
626
627	def select_by_name(self, name):
628		for i, r in enumerate(self.get_model()):
629			if r[self.NAME_COL] == name:
630				return self.select(i)
631		else:
632			raise ValueError
633
634	def refresh(self):
635		from zim.gui.widgets import encode_markup_text
636		model = self.get_model()
637		model.clear()
638		for tool in self.manager:
639			pixbuf = tool.get_pixbuf(Gtk.IconSize.MENU)
640			text = '<b>%s</b>\n%s' % (encode_markup_text(tool.name), encode_markup_text(tool.comment))
641			model.append((pixbuf, text, tool.key))
642
643
644class EditCustomToolDialog(Dialog):
645
646	def __init__(self, parent, tool=None):
647		Dialog.__init__(self, parent, _('Edit Custom Tool')) # T: Dialog title
648		self.set_help(':Help:Custom Tools')
649		self.vbox.set_spacing(12)
650
651		if tool:
652			name = tool.name
653			comment = tool.comment
654			execcmd = tool.execcmd
655			readonly = tool.isreadonly
656			toolbar = tool.showintoolbar
657			replaceselection = tool.replaceselection
658		else:
659			name = ''
660			comment = ''
661			execcmd = ''
662			readonly = False
663			toolbar = False
664			replaceselection = False
665
666		self.add_form((
667			('Name', 'string', _('Name')), # T: Input in "Edit Custom Tool" dialog
668			('Comment', 'string', _('Description')), # T: Input in "Edit Custom Tool" dialog
669			('X-Zim-ExecTool', 'string', _('Command')), # T: Input in "Edit Custom Tool" dialog
670		), {
671			'Name': name,
672			'Comment': comment,
673			'X-Zim-ExecTool': execcmd,
674		}, trigger_response=False)
675
676		# FIXME need ui builder to take care of this as well
677		self.iconbutton = IconChooserButton(stock=Gtk.STOCK_EXECUTE)
678		if tool and tool.icon and tool.icon != Gtk.STOCK_EXECUTE:
679			try:
680				self.iconbutton.set_file(File(tool.icon))
681			except Exception as error:
682				logger.exception('Could not load: %s', tool.icon)
683		label = Gtk.Label(label=_('Icon') + ':') # T: Input in "Edit Custom Tool" dialog
684		label.set_alignment(0.0, 0.5)
685		hbox = Gtk.HBox()
686		i = self.form.get_property('n-rows')
687		self.form.attach(label, 0, 1, i, i + 1, xoptions=0)
688		self.form.attach(hbox, 1, 2, i, i + 1)
689		hbox.pack_start(self.iconbutton, False, True, 0)
690
691		self.form.add_inputs((
692			('X-Zim-ReadOnly', 'bool', _('Command does not modify data')), # T: Input in "Edit Custom Tool" dialog
693			('X-Zim-ReplaceSelection', 'bool', _('Output should replace current selection')), # T: Input in "Edit Custom Tool" dialog
694			('X-Zim-ShowInToolBar', 'bool', _('Show in the toolbar')), # T: Input in "Edit Custom Tool" dialog
695		))
696		self.form.update({
697			'X-Zim-ReadOnly': readonly,
698			'X-Zim-ReplaceSelection': replaceselection,
699			'X-Zim-ShowInToolBar': toolbar,
700		})
701
702		self.add_help_text(_('''\
703The following parameters will be substituted
704in the command when it is executed:
705<tt>
706<b>%f</b> the page source as a temporary file
707<b>%d</b> the attachment directory of the current page
708<b>%s</b> the real page source file (if any)
709<b>%p</b> the page name
710<b>%n</b> the notebook location (file or folder)
711<b>%D</b> the document root (if any)
712<b>%t</b> the selected text or word under cursor
713<b>%T</b> the selected text including wiki formatting
714</tt>
715''') ) # T: Short help text in "Edit Custom Tool" dialog. The "%" is literal - please include the html formatting
716
717	def do_response_ok(self):
718		fields = self.form.copy()
719		fields['Icon'] = self.iconbutton.get_file() or None
720		self.result = fields
721		return True
722