1
2# Copyright 2010-2014 Jaap Karssenberg <jaap.karssenberg@gmail.com>
3
4from gi.repository import Gtk
5
6import re
7from datetime import date as dateclass
8
9from zim.fs import Dir, isabs
10
11from zim.plugins import PluginClass
12from zim.actions import action
13from zim.config import data_file, ConfigManager
14from zim.notebook import Path, Notebook, NotebookInfo, \
15	resolve_notebook, build_notebook
16from zim.templates import get_template
17from zim.main import GtkCommand, ZIM_APPLICATION
18
19from zim.gui.mainwindow import MainWindowExtension
20from zim.gui.widgets import Dialog, ScrolledTextView, IconButton, \
21	InputForm, QuestionDialog
22from zim.gui.clipboard import Clipboard, SelectionClipboard
23from zim.gui.notebookdialog import NotebookComboBox
24
25
26import logging
27
28logger = logging.getLogger('zim.plugins.quicknote')
29
30
31usagehelp = '''\
32usage: zim --plugin quicknote [OPTIONS]
33
34Options:
35  --help, -h             Print this help text and exit
36  --notebook URI         Select the notebook in the dialog
37  --page STRING          Fill in full page name
38  --section STRING       Fill in the page section in the dialog
39  --basename STRING      Fill in the page name in the dialog
40  --append [true|false]  Set whether to append or create new page
41  --text TEXT            Provide the text directly
42  --input stdin          Provide the text on stdin
43  --input clipboard      Take the text from the clipboard
44  --encoding base64      Text is encoded in base64
45  --encoding url         Text is url encoded
46                         (In both cases expects UTF-8 after decoding)
47  --attachments FOLDER   Import all files in FOLDER as attachments,
48                         wiki input can refer these files relatively
49  --option url=STRING    Set template parameter
50'''
51
52
53class QuickNotePluginCommand(GtkCommand):
54
55	options = (
56		('help', 'h', 'Print this help text and exit'),
57		('notebook=', '', 'Select the notebook in the dialog'),
58		('page=', '', 'Fill in full page name'),
59		('section=', '', 'Fill in the page section in the dialog'),
60		('namespace=', '', 'Fill in the page section in the dialog'), # backward compatibility
61		('basename=', '', 'Fill in the page name in the dialog'),
62		('append=', '', 'Set whether to append or create new page ("true" or "false")'),
63		('text=', '', 'Provide the text directly'),
64		('input=', '', 'Provide the text on stdin ("stdin") or take the text from the clipboard ("clipboard")'),
65		('encoding=', '', 'Text encoding ("base64" or "url")'),
66		('attachments=', '', 'Import all files in FOLDER as attachments, wiki input can refer these files relatively'),
67		('option=', '', 'Set template parameter, e.g. "url=URL"'),
68	)
69
70	def parse_options(self, *args):
71		self.opts['option'] = [] # allow list
72
73		if all(not a.startswith('-') for a in args):
74			# Backward compartibility for options not prefixed by "--"
75			# used "=" as separator for values
76			# template options came as "option:KEY=VALUE"
77			for arg in args:
78				if arg.startswith('option:'):
79					self.opts['option'].append(arg[7:])
80				elif arg == 'help':
81					self.opts['help'] = True
82				else:
83					key, value = arg.split('=', 1)
84					self.opts[key] = value
85		else:
86			GtkCommand.parse_options(self, *args)
87
88		self.template_options = {}
89		for arg in self.opts['option']:
90			key, value = arg.split('=', 1)
91			self.template_options[key] = value
92
93		if 'append' in self.opts:
94			self.opts['append'] = \
95				self.opts['append'].lower() == 'true'
96
97		if self.opts.get('attachments', None):
98			if isabs(self.opts['attachments']):
99				self.opts['attachments'] = Dir(self.opts['attachments'])
100			else:
101				self.opts['attachments'] = Dir((self.pwd, self.opts['attachments']))
102
103	def get_text(self):
104		if 'input' in self.opts:
105			if self.opts['input'] == 'stdin':
106				import sys
107				text = sys.stdin.read()
108			elif self.opts['input'] == 'clipboard':
109				text = \
110					SelectionClipboard.get_text() \
111					or Clipboard.get_text()
112			else:
113				raise AssertionError('Unknown input type: %s' % self.opts['input'])
114		else:
115			text = self.opts.get('text', '')
116
117		if text and 'encoding' in self.opts:
118			if self.opts['encoding'] == 'base64':
119				import base64
120				text = base64.b64decode(text).decode('UTF-8')
121			elif self.opts['encoding'] == 'url':
122				from zim.parsing import url_decode, URL_ENCODE_DATA
123				text = url_decode(text, mode=URL_ENCODE_DATA)
124			else:
125				raise AssertionError('Unknown encoding: %s' % self.opts['encoding'])
126
127		assert isinstance(text, str), '%r is not decoded' % text
128		return text
129
130	def run_local(self):
131		# Try to run dialog from local process
132		# - prevents issues where dialog pop behind other applications
133		#   (desktop preventing new window of existing process to hijack focus)
134		# - e.g. capturing stdin requires local process
135		if self.opts.get('help'):
136			print(usagehelp) # TODO handle this in the base class
137		else:
138			dialog = self.build_dialog()
139			dialog.run()
140		return True # Done - Don't call run() as well
141
142	def run(self):
143		# If called from primary process just run the dialog
144		return self.build_dialog()
145
146	def build_dialog(self):
147		if 'notebook' in self.opts:
148			notebook = resolve_notebook(self.opts['notebook'])
149		else:
150			notebook = None
151
152		dialog = QuickNoteDialog(None,
153			notebook=notebook,
154			namespace=self.opts.get('namespace'),
155			basename=self.opts.get('basename'),
156			append=self.opts.get('append'),
157			text=self.get_text(),
158			template_options=self.template_options,
159			attachments=self.opts.get('attachments')
160		)
161		dialog.show_all()
162		return dialog
163
164
165class QuickNotePlugin(PluginClass):
166
167	plugin_info = {
168		'name': _('Quick Note'), # T: plugin name
169		'description': _('''\
170This plugin adds a dialog to quickly drop some text or clipboard
171content into a zim page.
172
173This is a core plugin shipping with zim.
174'''), # T: plugin description
175		'author': 'Jaap Karssenberg',
176		'help': 'Plugins:Quick Note',
177	}
178
179	#~ plugin_preferences = (
180		# key, type, label, default
181	#~ )
182
183
184class QuickNoteMainWindowExtension(MainWindowExtension):
185
186	@action(_('Quick Note...'), menuhints='notebook') # T: menu item
187	def show_quick_note(self):
188		dialog = QuickNoteDialog.unique(self, self.window, self.window.notebook)
189		dialog.show()
190
191
192class QuickNoteDialog(Dialog):
193	'''Dialog bound to a specific notebook'''
194
195	def __init__(self, window, notebook=None,
196		page=None, namespace=None, basename=None,
197		append=None, text=None, template_options=None, attachments=None
198	):
199		assert page is None, 'TODO'
200
201		self.config = ConfigManager.get_config_dict('quicknote.conf')
202		self.uistate = self.config['QuickNoteDialog']
203
204		Dialog.__init__(self, window, _('Quick Note'))
205		self._updating_title = False
206		self._title_set_manually = not basename is None
207		self.attachments = attachments
208
209		if notebook and not isinstance(notebook, str):
210			notebook = notebook.uri
211
212		self.uistate.setdefault('lastnotebook', None, str)
213		if self.uistate['lastnotebook']:
214			notebook = notebook or self.uistate['lastnotebook']
215			self.config['Namespaces'].setdefault(notebook, None, str)
216			namespace = namespace or self.config['Namespaces'][notebook]
217
218		self.form = InputForm()
219		self.vbox.pack_start(self.form, False, True, 0)
220
221		# TODO dropdown could use an option "Other..."
222		label = Gtk.Label(label=_('Notebook') + ': ')
223		label.set_alignment(0.0, 0.5)
224		self.form.attach(label, 0, 1, 0, 1, xoptions=Gtk.AttachOptions.FILL)
225			# T: Field to select Notebook from drop down list
226		self.notebookcombobox = NotebookComboBox(current=notebook)
227		self.notebookcombobox.connect('changed', self.on_notebook_changed)
228		self.form.attach(self.notebookcombobox, 1, 2, 0, 1)
229
230		self._init_inputs(namespace, basename, append, text, template_options)
231
232		self.uistate['lastnotebook'] = notebook
233		self._set_autocomplete(notebook)
234
235	def _init_inputs(self, namespace, basename, append, text, template_options, custom=None):
236		if template_options is None:
237			template_options = {}
238		else:
239			template_options = template_options.copy()
240
241		if namespace is not None and basename is not None:
242			page = namespace + ':' + basename
243		else:
244			page = namespace or basename
245
246		self.form.add_inputs((
247				('page', 'page', _('Page')),
248				('namespace', 'namespace', _('Page section')), # T: text entry field
249				('new_page', 'bool', _('Create a new page for each note')), # T: checkbox in Quick Note dialog
250				('basename', 'string', _('Title')) # T: text entry field
251			))
252		self.form.update({
253				'page': page,
254				'namespace': namespace,
255				'new_page': True,
256				'basename': basename,
257			})
258
259		self.uistate.setdefault('open_page', True)
260		self.uistate.setdefault('new_page', True)
261
262		if basename:
263			self.uistate['new_page'] = True # Be consistent with input
264
265		# Set up the inputs and set page/ namespace to switch on
266		# toggling the checkbox
267		self.form.widgets['page'].set_no_show_all(True)
268		self.form.widgets['namespace'].set_no_show_all(True)
269		if append is None:
270			self.form['new_page'] = bool(self.uistate['new_page'])
271		else:
272			self.form['new_page'] = not append
273
274		def switch_input(*a):
275			if self.form['new_page']:
276				self.form.widgets['page'].hide()
277				self.form.widgets['namespace'].show()
278				self.form.widgets['basename'].set_sensitive(True)
279			else:
280				self.form.widgets['page'].show()
281				self.form.widgets['namespace'].hide()
282				self.form.widgets['basename'].set_sensitive(False)
283
284		switch_input()
285		self.form.widgets['new_page'].connect('toggled', switch_input)
286
287		self.open_page_check = Gtk.CheckButton.new_with_mnemonic(_('Open _Page')) # T: Option in quicknote dialog
288			# Don't use "O" as accelerator here to avoid conflict with "Ok"
289		self.open_page_check.set_active(self.uistate['open_page'])
290		self.action_area.pack_start(self.open_page_check, False, True, 0)
291		self.action_area.set_child_secondary(self.open_page_check, True)
292
293		# Add the main textview and hook up the basename field to
294		# sync with first line of the textview
295		window, textview = ScrolledTextView()
296		self.textview = textview
297		self.textview.set_editable(True)
298		self.vbox.pack_start(window, True, True, 0)
299
300		self.form.widgets['basename'].connect('changed', self.on_title_changed)
301		self.textview.get_buffer().connect('changed', self.on_text_changed)
302
303		# Initialize text from template
304		template = get_template('plugins', 'quicknote.txt')
305		template_options['text'] = text or ''
306		template_options.setdefault('url', '')
307
308		lines = []
309		template.process(lines, template_options)
310		buffer = self.textview.get_buffer()
311		buffer.set_text(''.join(lines))
312		begin, end = buffer.get_bounds()
313		buffer.place_cursor(begin)
314
315		buffer.set_modified(False)
316
317		self.connect('delete-event', self.do_delete_event)
318
319	def on_notebook_changed(self, o):
320		notebook = self.notebookcombobox.get_notebook()
321		if not notebook or notebook == self.uistate['lastnotebook']:
322			return
323
324		self.uistate['lastnotebook'] = notebook
325		self.config['Namespaces'].setdefault(notebook, None, str)
326		namespace = self.config['Namespaces'][notebook]
327		if namespace:
328			self.form['namespace'] = namespace
329
330		self._set_autocomplete(notebook)
331
332	def _set_autocomplete(self, notebook):
333		if notebook:
334			try:
335				if isinstance(notebook, str):
336					notebook = NotebookInfo(notebook)
337				obj, x = build_notebook(notebook)
338				self.form.widgets['namespace'].notebook = obj
339				self.form.widgets['page'].notebook = obj
340				logger.debug('Notebook for autocomplete: %s (%s)', obj, notebook)
341			except:
342				logger.exception('Could not set notebook: %s', notebook)
343		else:
344			self.form.widgets['namespace'].notebook = None
345			self.form.widgets['page'].notebook = None
346			logger.debug('Notebook for autocomplete unset')
347
348	def do_response(self, id):
349		if id == Gtk.ResponseType.DELETE_EVENT:
350			if self.textview.get_buffer().get_modified():
351				ok = QuestionDialog(self, _('Discard note?')).run()
352					# T: confirm closing quick note dialog
353				if ok:
354					Dialog.do_response(self, id)
355				# else pass
356			else:
357				Dialog.do_response(self, id)
358		else:
359			Dialog.do_response(self, id)
360
361	def do_delete_event(self, *a):
362		# Block deletion if do_response did not yet destroy the dialog
363		return True
364
365	def run(self):
366		self.textview.grab_focus()
367		Dialog.run(self)
368
369	def show(self):
370		self.textview.grab_focus()
371		Dialog.show(self)
372
373	def save_uistate(self):
374		notebook = self.notebookcombobox.get_notebook()
375		self.uistate['lastnotebook'] = notebook
376		self.uistate['new_page'] = self.form['new_page']
377		self.uistate['open_page'] = self.open_page_check.get_active()
378		if notebook is not None:
379			if self.uistate['new_page']:
380				self.config['Namespaces'][notebook] = self.form['namespace']
381			else:
382				self.config['Namespaces'][notebook] = self.form['page']
383		self.config.write()
384
385	def on_title_changed(self, o):
386		o.set_input_valid(True)
387		if not self._updating_title:
388			self._title_set_manually = True
389
390	def on_text_changed(self, buffer):
391		if not self._title_set_manually:
392			# Automatically generate a (valid) page name
393			self._updating_title = True
394			start, end = buffer.get_bounds()
395			title = start.get_text(end).strip()[:50]
396				# Cut off at 50 characters to prevent using a whole paragraph
397			title = title.replace(':', '')
398			if '\n' in title:
399				title, _ = title.split('\n', 1)
400			try:
401				title = Path.makeValidPageName(title.replace(':', ''))
402				self.form['basename'] = title
403			except ValueError:
404				pass
405			self._updating_title = False
406
407	def do_response_ok(self):
408		buffer = self.textview.get_buffer()
409		start, end = buffer.get_bounds()
410		text = start.get_text(end)
411
412		# HACK: change "[]" at start of line into "[ ]" so checkboxes get inserted correctly
413		text = re.sub(r'(?m)^(\s*)\[\](\s)', r'\1[ ]\2', text)
414		# Specify "(?m)" instead of re.M since "flags" keyword is not
415		# supported in python 2.6
416
417		notebook = self._get_notebook()
418		if notebook is None:
419			return False
420
421		if self.form['new_page']:
422			if not self.form.widgets['namespace'].get_input_valid() \
423			or not self.form['basename']:
424				if not self.form['basename']:
425					entry = self.form.widgets['basename']
426					entry.set_input_valid(False, show_empty_invalid=True)
427				return False
428
429			path = self.form['namespace'] + self.form['basename']
430			self.create_new_page(notebook, path, text)
431		else:
432			if not self.form.widgets['page'].get_input_valid() \
433			or not self.form['page']:
434				return False
435
436			path = self.form['page']
437			self.append_to_page(notebook, path, '\n------\n' + text)
438
439		if self.attachments:
440			self.import_attachments(notebook, path, self.attachments)
441
442		if self.open_page_check.get_active():
443			self.hide()
444			ZIM_APPLICATION.present(notebook, path)
445
446		return True
447
448	def _get_notebook(self):
449		uri = self.notebookcombobox.get_notebook()
450		notebook, p = build_notebook(Dir(uri))
451		return notebook
452
453	def create_new_page(self, notebook, path, text):
454		page = notebook.get_new_page(path)
455		page.parse('wiki', text) # FIXME format hard coded
456		notebook.store_page(page)
457
458	def append_to_page(self, notebook, path, text):
459		page = notebook.get_page(path)
460		page.parse('wiki', text, append=True) # FIXME format hard coded
461		notebook.store_page(page)
462
463	def import_attachments(self, notebook, path, dir):
464		attachments = notebook.get_attachments_dir(path)
465		attachments = Dir(attachments.path) # XXX
466		for name in dir.list():
467			# FIXME could use list objects, or list_files()
468			file = dir.file(name)
469			if not file.isdir():
470				file.copyto(attachments)
471