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