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