1 2# Copyright 2008-2020 Jaap Karssenberg <jaap.karssenberg@gmail.com> 3 4 5 6 7import os 8import re 9import weakref 10import logging 11import threading 12 13logger = logging.getLogger('zim.notebook') 14 15from functools import partial 16 17import zim.templates 18import zim.formats 19 20from zim.fs import File, Dir, SEP, adapt_from_newfs 21from zim.newfs import LocalFolder 22from zim.config import INIConfigFile, String, ConfigDefinitionByClass, Boolean, Choice 23from zim.errors import Error 24from zim.utils import natural_sort_key 25from zim.newfs.helpers import TrashNotSupportedError 26from zim.config import HierarchicDict 27from zim.parsing import link_type, is_win32_path_re, valid_interwiki_key 28from zim.signals import ConnectorMixin, SignalEmitter, SIGNAL_NORMAL 29 30from .operations import notebook_state, NOOP, SimpleAsyncOperation, ongoing_operation 31from .page import Path, Page, HRef, HREF_REL_ABSOLUTE, HREF_REL_FLOATING, HREF_REL_RELATIVE 32from .index import IndexNotFoundError, LINK_DIR_BACKWARD, ROOT_PATH 33 34DATA_FORMAT_VERSION = (0, 4) 35 36 37class NotebookConfig(INIConfigFile): 38 '''Wrapper for the X{notebook.zim} file''' 39 40 # TODO - unify this call with NotebookInfo ? 41 42 def __init__(self, file): 43 INIConfigFile.__init__(self, file) 44 if os.name == 'nt': 45 endofline = 'dos' 46 else: 47 endofline = 'unix' 48 name = file.dir.basename if hasattr(file, 'dir') else file.parent().basename # HACK zim.fs and zim.newfs compat 49 self['Notebook'].define(( 50 ('version', String('.'.join(map(str, DATA_FORMAT_VERSION)))), 51 ('name', String(name)), 52 ('interwiki', String(None)), 53 ('home', ConfigDefinitionByClass(Path('Home'))), 54 ('icon', String(None)), # XXX should be file, but resolves relative 55 ('document_root', String(None)), # XXX should be dir, but resolves relative 56 ('short_links', Boolean(False)), 57 ('shared', Boolean(True)), 58 ('endofline', Choice(endofline, {'dos', 'unix'})), 59 ('disable_trash', Boolean(False)), 60 ('default_file_format', String('zim-wiki')), 61 ('default_file_extension', String('.txt')), 62 ('notebook_layout', String('files')), 63 )) 64 65 66def _resolve_relative_config(dir, config): 67 # Some code shared between Notebook and NotebookInfo 68 69 # Resolve icon, can be relative 70 icon = config.get('icon') 71 if icon: 72 if zim.fs.isabs(icon) or not dir: 73 icon = File(icon) 74 else: 75 icon = dir.resolve_file(icon) 76 77 # Resolve document_root, can also be relative 78 document_root = config.get('document_root') 79 if document_root: 80 if zim.fs.isabs(document_root) or not dir: 81 document_root = Dir(document_root) 82 else: 83 document_root = dir.resolve_dir(document_root) 84 85 return icon, document_root 86 87 88def _iswritable(dir): 89 if os.name == 'nt': 90 # Test access - (iswritable turns out to be unreliable for folders on windows..) 91 if isinstance(dir, Dir): 92 dir = LocalFolder(dir.path) # only newfs supports cleanup=False 93 f = dir.file('.zim.tmp') 94 try: 95 f.write('Test') 96 f.remove(cleanup=False) 97 except: 98 return False 99 else: 100 return True 101 else: 102 return dir.iswritable() 103 104 105def _cache_dir_for_dir(dir): 106 # Consider using md5 for path name here, like thumbnail spec 107 from zim.config import XDG_CACHE_HOME 108 109 if os.name == 'nt': 110 path = 'notebook-' + dir.path.replace('\\', '_').replace(':', '').strip('_') 111 else: 112 path = 'notebook-' + dir.path.replace('/', '_').strip('_') 113 114 return XDG_CACHE_HOME.subdir(('zim', path)) 115 116 117class PageError(Error): 118 119 def __init__(self, path): 120 self.path = path 121 self.msg = self._msg % path.name 122 123 124class PageNotFoundError(PageError): 125 _msg = _('No such page: %s') # T: message for PageNotFoundError 126 127 128class PageNotAllowedError(PageNotFoundError): 129 _msg = _('Page not allowed: %s') # T: message for PageNotAllowedError 130 description = _('This page name cannot be used due to technical limitations of the storage') 131 # T: description for PageNotAllowedError 132 133 134class PageNotAvailableError(PageNotFoundError): 135 _msg = _('Page not available: %s') # T: message for PageNotAvailableError 136 description = _('This page name cannot be used due to a conflicting file in the storage') 137 # T: description for PageNotAvailableError 138 139 def __init__(self, path, file): 140 PageError.__init__(self, path) 141 self.file = file 142 143 144class PageExistsError(Error): 145 _msg = _('Page already exists: %s') # T: message for PageExistsError 146 147 148class IndexNotUptodateError(Error): 149 pass # TODO description here? 150 151 152def assert_index_uptodate(method): 153 def wrapper(notebook, *arg, **kwarg): 154 if not notebook.index.is_uptodate: 155 raise IndexNotUptodateError('Index not up to date') 156 return method(notebook, *arg, **kwarg) 157 158 return wrapper 159 160 161_NOTEBOOK_CACHE = weakref.WeakValueDictionary() 162 163 164from zim.plugins import ExtensionBase, extendable 165 166class NotebookExtension(ExtensionBase): 167 '''Base class for extending the notebook 168 169 @ivar notebook: the L{Notebook} object 170 ''' 171 172 def __init__(self, plugin, notebook): 173 ExtensionBase.__init__(self, plugin, notebook) 174 self.notebook = notebook 175 176 177@extendable(NotebookExtension) 178class Notebook(ConnectorMixin, SignalEmitter): 179 '''Main class to access a notebook 180 181 This class defines an API that proxies between backend L{zim.stores} 182 and L{Index} objects on the one hand and the user interface on the 183 other hand. (See L{module docs<zim.notebook>} for more explanation.) 184 185 @signal: C{store-page (page)}: emitted before actually storing the page 186 @signal: C{stored-page (page)}: emitted after storing the page 187 @signal: C{move-page (oldpath, newpath)}: emitted before 188 actually moving a page 189 @signal: C{moved-page (oldpath, newpath)}: emitted after 190 moving the page 191 @signal: C{delete-page (path)}: emitted before deleting a page 192 @signal: C{deleted-page (path)}: emitted after deleting a page 193 means that the preferences need to be loaded again as well 194 @signal: C{suggest-link (path, text)}: hook that is called when trying 195 to resolve links 196 @signal: C{get-page-template (path)}: emitted before 197 when a template for a new page is requested, intended for plugins that 198 want to customize a namespace 199 @signal: C{init-page-template (path, template)}: emitted before 200 evaluating a template for a new page, intended for plugins that want 201 to extend page templates 202 203 @ivar name: The name of the notebook (string) 204 @ivar icon: The path for the notebook icon (if any) 205 # FIXME should be L{File} object 206 @ivar document_root: The L{Dir} object for the X{document root} (if any) 207 @ivar dir: Optional L{Dir} object for the X{notebook folder} 208 @ivar file: Optional L{File} object for the X{notebook file} 209 @ivar cache_dir: A L{Dir} object for the folder used to cache notebook state 210 @ivar config: A L{SectionedConfigDict} for the notebook config 211 (the C{X{notebook.zim}} config file in the notebook folder) 212 @ivar index: The L{Index} object used by the notebook 213 ''' 214 215 # define signals we want to use - (closure type, return type and arg types) 216 __signals__ = { 217 'store-page': (SIGNAL_NORMAL, None, (object,)), 218 'stored-page': (SIGNAL_NORMAL, None, (object,)), 219 'move-page': (SIGNAL_NORMAL, None, (object, object)), 220 'moved-page': (SIGNAL_NORMAL, None, (object, object)), 221 'delete-page': (SIGNAL_NORMAL, None, (object,)), 222 'deleted-page': (SIGNAL_NORMAL, None, (object,)), 223 'page-info-changed': (SIGNAL_NORMAL, None, (object,)), 224 'get-page-template': (SIGNAL_NORMAL, str, (object,)), 225 'init-page-template': (SIGNAL_NORMAL, None, (object, object)), 226 227 # Hooks 228 'suggest-link': (SIGNAL_NORMAL, object, (object, object)), 229 } 230 231 @classmethod 232 def new_from_dir(klass, dir): 233 '''Constructor to create a notebook based on a specific 234 file system location. 235 Since the file system is an external resource, this method 236 will return unique objects per location and keep (weak) 237 references for re-use. 238 239 @param dir: a L{Dir} object 240 @returns: a L{Notebook} object 241 ''' 242 dir = adapt_from_newfs(dir) 243 assert isinstance(dir, Dir) 244 245 nb = _NOTEBOOK_CACHE.get(dir.uri) 246 if nb: 247 return nb 248 249 from .index import Index 250 from .layout import FilesLayout 251 252 config = NotebookConfig(dir.file('notebook.zim')) 253 254 if config['Notebook']['shared']: 255 cache_dir = _cache_dir_for_dir(dir) 256 else: 257 cache_dir = dir.subdir('.zim') 258 cache_dir.touch() 259 if not (cache_dir.exists() and _iswritable(cache_dir)): 260 cache_dir = _cache_dir_for_dir(dir) 261 262 folder = LocalFolder(dir.path) 263 if config['Notebook']['notebook_layout'] == 'files': 264 layout = FilesLayout( 265 folder, 266 config['Notebook']['endofline'], 267 config['Notebook']['default_file_format'], 268 config['Notebook']['default_file_extension'] 269 ) 270 else: 271 raise ValueError('Unkonwn notebook layout: %s' % config['Notebook']['notebook_layout']) 272 273 cache_dir.touch() # must exist for index to work 274 index = Index(cache_dir.file('index.db').path, layout) 275 276 nb = klass(cache_dir, config, folder, layout, index) 277 _NOTEBOOK_CACHE[dir.uri] = nb 278 return nb 279 280 def __init__(self, cache_dir, config, folder, layout, index): 281 '''Constructor 282 @param cache_dir: a L{Folder} object used for caching the notebook state 283 @param config: a L{NotebookConfig} object 284 @param folder: a L{Folder} object for the notebook location 285 @param layout: a L{NotebookLayout} object 286 @param index: an L{Index} object 287 ''' 288 self.folder = folder 289 self.cache_dir = cache_dir 290 self.state = INIConfigFile(cache_dir.file('state.conf')) 291 self.config = config 292 self.properties = config['Notebook'] 293 self.layout = layout 294 self.index = index 295 self._operation_check = NOOP 296 297 self.readonly = not _iswritable(folder) 298 299 if self.readonly: 300 logger.info('Notebook read-only: %s', folder.path) 301 302 self._page_cache = weakref.WeakValueDictionary() 303 304 self.name = None 305 self.icon = None 306 self.document_root = None 307 self.interwiki = None 308 309 if folder.watcher is None: 310 from zim.newfs.helpers import FileTreeWatcher 311 folder.watcher = FileTreeWatcher() 312 313 from .index import PagesView, LinksView, TagsView 314 self.pages = PagesView.new_from_index(self.index) 315 self.links = LinksView.new_from_index(self.index) 316 self.tags = TagsView.new_from_index(self.index) 317 318 def on_page_row_changed(o, row, oldrow): 319 if row['name'] in self._page_cache: 320 self._page_cache[row['name']].haschildren = row['n_children'] > 0 321 self.emit('page-info-changed', self._page_cache[row['name']]) 322 323 def on_page_row_deleted(o, row): 324 if row['name'] in self._page_cache: 325 self._page_cache[row['name']].haschildren = False 326 self.emit('page-info-changed', self._page_cache[row['name']]) 327 328 self.index.update_iter.pages.connect('page-row-changed', on_page_row_changed) 329 self.index.update_iter.pages.connect('page-row-deleted', on_page_row_deleted) 330 331 self.connectto(self.properties, 'changed', self.on_properties_changed) 332 self.on_properties_changed(self.properties) 333 334 def __repr__(self): 335 return '<%s: %s>' % (self.__class__.__name__, self.name) 336 337 def _reload_pages_in_cache(self, path): 338 p = path.name 339 ns = path.name + ':' 340 for name, page in self._page_cache.items(): 341 if name == p or name.startswith(ns): 342 if page.modified: 343 logger.error('Page with unsaved changes in cache while modifying notebook') 344 else: 345 page.reload_textbuffer() 346 # "page.haschildren" may also have changed, will be updated 347 # by signal handlers for index 348 349 @property 350 def uri(self): 351 '''Returns a file:// uri for this notebook that can be opened by zim''' 352 return self.layout.root.uri 353 354 @property 355 def info(self): 356 '''The L{NotebookInfo} object for this notebook''' 357 try: 358 uri = self.uri 359 except AssertionError: 360 uri = None 361 362 return NotebookInfo(uri, **self.config['Notebook']) 363 364 def on_properties_changed(self, properties): 365 dir = Dir(self.layout.root.path) # XXX 366 367 self.name = properties['name'] or self.folder.basename 368 icon, document_root = _resolve_relative_config(dir, properties) 369 if icon: 370 self.icon = icon.path # FIXME rewrite to use File object 371 else: 372 self.icon = None 373 self.document_root = document_root 374 375 self.interwiki = valid_interwiki_key(properties['interwiki'] or self.name) 376 377 def suggest_link(self, source, word): 378 '''Suggest a link Path for 'word' or return None if no suggestion is 379 found. By default we do not do any suggestion but plugins can 380 register handlers to add suggestions using the 'C{suggest-link}' 381 signal. 382 ''' 383 return self.emit_return_first('suggest-link', source, word) 384 385 def get_page(self, path): 386 '''Get a L{Page} object for a given path 387 388 Typically a Page object will be returned even when the page 389 does not exist. In this case the C{hascontent} attribute of 390 the Page will be C{False} and C{get_parsetree()} will return 391 C{None}. This means that you do not have to create a page 392 explicitly, just get the Page object and store it with new 393 content (if it is not read-only of course). 394 395 However in some cases this method will return C{None}. This 396 means that not only does the page not exist, but also that it 397 can not be created. This should only occur for certain special 398 pages and depends on the store implementation. 399 400 @param path: a L{Path} object 401 @returns: a L{Page} object or C{None} 402 ''' 403 # As a special case, using an invalid page as the argument should 404 # return a valid page object. 405 assert isinstance(path, Path) 406 if path.name in self._page_cache: 407 page = self._page_cache[path.name] 408 assert isinstance(page, Page) 409 page.check_source_changed() 410 return page 411 else: 412 file, folder = self.layout.map_page(path) 413 if file.exists() and not self.layout.is_source_file(file): 414 raise PageNotAvailableError(path, file) 415 416 folder = self.layout.get_attachments_folder(path) 417 format = self.layout.get_format(file) 418 page = Page(path, False, file, folder, format) 419 if self.readonly: 420 page._readonly = True # XXX 421 try: 422 indexpath = self.pages.lookup_by_pagename(path) 423 except IndexNotFoundError: 424 pass 425 # TODO trigger indexer here if page exists ! 426 else: 427 if indexpath and indexpath.haschildren: 428 page.haschildren = True 429 # page might be the parent of a placeholder, in that case 430 # the index knows it has children, but the store does not 431 432 # TODO - set haschildren if page maps to a store namespace 433 self._page_cache[path.name] = page 434 return page 435 436 def get_new_page(self, path): 437 '''Like get_page() but guarantees the page does not yet exist 438 by adding a number to the name to make it unique. 439 440 This method is intended for cases where e.g. a automatic script 441 wants to store a new page without user interaction. Conflicts 442 are resolved automatically by appending a number to the name 443 if the page already exists. Be aware that the resulting Page 444 object may not match the given Path object because of this. 445 446 @param path: a L{Path} object 447 @returns: a L{Page} object 448 ''' 449 i = 0 450 base = path.name 451 while True: 452 try: 453 page = self.get_page(path) 454 except PageNotAvailableError: 455 pass 456 else: 457 if not (page.hascontent or page.haschildren): 458 return page 459 finally: 460 i += 1 461 path = Path(base + ' %i' % i) 462 463 def get_home_page(self): 464 '''Returns a L{Page} object for the home page''' 465 return self.get_page(self.config['Notebook']['home']) 466 467 @notebook_state 468 def store_page(self, page): 469 '''Save the data from the page in the storage backend 470 471 @param page: a L{Page} object 472 @emits: store-page before storing the page 473 @emits: stored-page on success 474 ''' 475 logger.debug('Store page: %s', page) 476 self.emit('store-page', page) 477 page._store() 478 file, folder = self.layout.map_page(page) 479 self.index.update_file(file) 480 page.set_modified(False) 481 self.emit('stored-page', page) 482 483 @notebook_state 484 def store_page_async(self, page, parsetree): 485 logger.debug('Store page in background: %s', page) 486 self.emit('store-page', page) 487 error = threading.Event() 488 thread = threading.Thread( 489 target=partial(self._store_page_async_thread_main, page, parsetree, error) 490 ) 491 thread.start() 492 pre_modified = page.modified 493 op = SimpleAsyncOperation( 494 notebook=self, 495 message='Store page in progress', 496 thread=thread, 497 post_handler=partial(self._store_page_async_finished, page, error, pre_modified) 498 ) 499 op.error_event = error 500 op.run_on_idle() 501 return op 502 503 def _store_page_async_thread_main(self, page, parsetree, error): 504 try: 505 page._store_tree(parsetree) 506 except: 507 error.set() 508 logger.exception('Error in background save') 509 510 def _store_page_async_finished(self, page, error, pre_modified): 511 if not error.is_set(): 512 file, folder = self.layout.map_page(page) 513 self.index.update_file(file) 514 if page.modified == pre_modified: 515 # HACK: Checking modified state protects against race condition 516 # in async store. Works because pageview sets "page.modified" 517 # to a counter rather than a boolean 518 page.set_modified(False) 519 self.emit('stored-page', page) 520 521 def wait_for_store_page_async(self): 522 op = ongoing_operation(self) 523 if isinstance(op, SimpleAsyncOperation): 524 op() 525 526 def move_page(self, path, newpath, update_links=True, update_heading=False): 527 '''Move and/or rename a page in the notebook 528 529 @param path: a L{Path} object for the old/current page name 530 @param newpath: a L{Path} object for the new page name 531 @param update_links: if C{True} all links B{from} and B{to} this 532 page and any of it's children will be updated to reflect the 533 new page name 534 @param update_heading: if C{True} the heading of the page will be 535 changed to the basename of the new path 536 537 The original page C{path} does not have to exist, in this case 538 only the link update will done. This is useful to update links 539 for a placeholder. 540 541 @raises PageExistsError: if C{newpath} already exists 542 543 @emits: move-page before the move 544 @emits: moved-page after successfull move 545 ''' 546 for p in self.move_page_iter(path, newpath, update_links, update_heading): 547 pass 548 549 @assert_index_uptodate 550 @notebook_state 551 def move_page_iter(self, path, newpath, update_links=True, update_heading=False): 552 '''Like L{move_page()} but yields pages that are being updated 553 if C{update_links} is C{True} 554 ''' 555 logger.debug('Move page %s to %s', path, newpath) 556 557 self.emit('move-page', path, newpath) 558 try: 559 n_links = self.links.n_list_links_section(path, LINK_DIR_BACKWARD) 560 except IndexNotFoundError: 561 raise PageNotFoundError(path) 562 563 file, folder = self.layout.map_page(path) 564 if (file.exists() or folder.exists()): 565 self._move_file_and_folder(path, newpath) 566 self._reload_pages_in_cache(path) 567 self._reload_pages_in_cache(newpath) 568 self.emit('moved-page', path, newpath) 569 570 if update_links: 571 for p in self._update_links_in_moved_page(path, newpath): 572 yield p 573 574 if update_links: 575 for p in self._update_links_to_moved_page(path, newpath): 576 yield p 577 578 new_n_links = self.links.n_list_links_section(newpath, LINK_DIR_BACKWARD) 579 if new_n_links != n_links: 580 logger.warn('Number of links after move (%i) does not match number before move (%i)', new_n_links, n_links) 581 else: 582 logger.debug('Number of links after move does match number before move (%i)', new_n_links) 583 584 if update_heading: 585 page = self.get_page(newpath) 586 tree = page.get_parsetree() 587 if not tree is None: 588 tree.set_heading_text(newpath.basename) 589 page.set_parsetree(tree) 590 self.store_page(page) 591 592 def _move_file_and_folder(self, path, newpath): 593 file, folder = self.layout.map_page(path) 594 if not (file.exists() or folder.exists()): 595 raise PageNotFoundError(path) 596 597 newfile, newfolder = self.layout.map_page(newpath) 598 if file.path.lower() == newfile.path.lower(): 599 if newfile.isequal(file) or newfolder.isequal(folder): 600 pass # renaming on case-insensitive filesystem 601 elif newfile.exists() or newfolder.exists(): 602 raise PageExistsError(newpath) 603 elif newfile.exists(): 604 if self.layout.is_source_file(newfile): 605 raise PageExistsError(newpath) 606 else: 607 raise PageNotAvailableError(newpath, newfile) 608 elif newfolder.exists(): 609 raise PageExistsError(newpath) 610 611 # First move the dir - if it fails due to some file being locked 612 # the whole move is cancelled. Chance is bigger than the other 613 # way around, e.g. attachment open in external program. 614 615 changes = [] 616 617 if folder.exists(): 618 if newfolder.ischild(folder): 619 # special case where we want to move a page down 620 # into it's own namespace 621 parent = folder.parent() 622 tmp = parent.new_folder(folder.basename) 623 folder.moveto(tmp) 624 tmp.moveto(newfolder) 625 else: 626 folder.moveto(newfolder) 627 628 changes.append((folder, newfolder)) 629 630 # check if we also moved the file inadvertently 631 if file.ischild(folder): 632 rel = file.relpath(folder) 633 movedfile = newfolder.file(rel) 634 if movedfile.exists() and movedfile.path != newfile.path: 635 movedfile.moveto(newfile) 636 changes.append((movedfile, newfile)) 637 elif file.exists(): 638 file.moveto(newfile) 639 changes.append((file, newfile)) 640 641 elif file.exists(): 642 file.moveto(newfile) 643 changes.append((file, newfile)) 644 645 # Process index changes after all fs changes 646 # more robust if anything goes wrong in index update 647 for old, new in changes: 648 self.index.file_moved(old, new) 649 650 651 def _update_links_in_moved_page(self, oldroot, newroot): 652 # Find (floating) links that originate from the moved page 653 # check if they would resolve different from the old location 654 seen = set() 655 for link in list(self.links.list_links_section(newroot)): 656 if link.source.name not in seen: 657 if link.source == newroot: 658 oldpath = oldroot 659 else: 660 oldpath = oldroot + link.source.relname(newroot) 661 662 yield link.source 663 self._update_moved_page(link.source, oldpath, newroot, oldroot) 664 seen.add(link.source.name) 665 666 def _update_moved_page(self, path, oldpath, newroot, oldroot): 667 logger.debug('Updating links in page moved from %s to %s', oldpath, path) 668 page = self.get_page(path) 669 tree = page.get_parsetree() 670 if not tree: 671 return 672 673 def replacefunc(elt): 674 text = elt.attrib['href'] 675 if link_type(text) != 'page': 676 raise zim.formats.VisitorSkip 677 678 href = HRef.new_from_wiki_link(text) 679 if href.rel == HREF_REL_RELATIVE: 680 raise zim.formats.VisitorSkip 681 elif href.rel == HREF_REL_ABSOLUTE: 682 oldtarget = self.pages.resolve_link(page, href) 683 if oldtarget == oldroot: 684 return self._update_link_tag(elt, page, newroot, href) 685 elif oldtarget.ischild(oldroot): 686 newtarget = newroot + oldtarget.relname(oldroot) 687 return self._update_link_tag(elt, page, newtarget, href) 688 else: 689 raise zim.formats.VisitorSkip 690 else: 691 assert href.rel == HREF_REL_FLOATING 692 newtarget = self.pages.resolve_link(page, href) 693 oldtarget = self.pages.resolve_link(oldpath, href) 694 695 if oldtarget == oldroot: 696 return self._update_link_tag(elt, page, newroot, href) 697 elif oldtarget.ischild(oldroot): 698 oldanchor = self.pages.resolve_link(oldpath, HRef(HREF_REL_FLOATING, href.parts()[0])) 699 if oldanchor.ischild(oldroot): 700 raise zim.formats.VisitorSkip # oldtarget cannot be trusted 701 else: 702 newtarget = newroot + oldtarget.relname(oldroot) 703 return self._update_link_tag(elt, page, newtarget, href) 704 elif newtarget != oldtarget: 705 # Redirect back to old target 706 return self._update_link_tag(elt, page, oldtarget, href) 707 else: 708 raise zim.formats.VisitorSkip 709 710 tree.replace(zim.formats.LINK, replacefunc) 711 page.set_parsetree(tree) 712 self.store_page(page) 713 714 def _update_links_to_moved_page(self, oldroot, newroot): 715 # 1. Check remaining placeholders, update pages causing them 716 seen = set() 717 try: 718 oldroot = self.pages.lookup_by_pagename(oldroot) 719 except IndexNotFoundError: 720 pass 721 else: 722 for link in list(self.links.list_links_section(oldroot, LINK_DIR_BACKWARD)): 723 if link.source.name not in seen: 724 yield link.source 725 self._move_links_in_page(link.source, oldroot, newroot) 726 seen.add(link.source.name) 727 728 # 2. Check for links that have anchor of same name as the moved page 729 # and originate from a (grand)child of the parent of the moved page 730 # and no longer resolve to the moved page 731 parent = oldroot.parent 732 for link in list(self.links.list_floating_links(oldroot.basename)): 733 if link.source.name not in seen \ 734 and link.source.ischild(parent) \ 735 and not ( 736 link.target == newroot 737 or link.target.ischild(newroot) 738 ): 739 yield link.source 740 self._move_links_in_page(link.source, oldroot, newroot) 741 seen.add(link.source.name) 742 743 def _move_links_in_page(self, path, oldroot, newroot): 744 logger.debug('Updating page %s to move link from %s to %s', path, oldroot, newroot) 745 page = self.get_page(path) 746 tree = page.get_parsetree() 747 if not tree: 748 return 749 750 def replacefunc(elt): 751 text = elt.attrib['href'] 752 if link_type(text) != 'page': 753 raise zim.formats.VisitorSkip 754 755 href = HRef.new_from_wiki_link(text) 756 target = self.pages.resolve_link(page, href) 757 758 if target == oldroot: 759 return self._update_link_tag(elt, page, newroot, href) 760 elif target.ischild(oldroot): 761 newtarget = newroot.child(target.relname(oldroot)) 762 return self._update_link_tag(elt, page, newtarget, href) 763 764 elif href.rel == HREF_REL_FLOATING \ 765 and natural_sort_key(href.parts()[0]) == natural_sort_key(oldroot.basename) \ 766 and page.ischild(oldroot.parent): 767 try: 768 targetrecord = self.pages.lookup_by_pagename(target) 769 except IndexNotFoundError: 770 targetrecord = None # technically this is a bug, but let's be robust 771 772 if not target.ischild(oldroot.parent) \ 773 or targetrecord is None or not targetrecord.exists(): 774 # An link that was anchored to the moved page, 775 # but now resolves somewhere higher in the tree 776 # Or a link that no longer resolves 777 if len(href.parts()) == 1: 778 return self._update_link_tag(elt, page, newroot, href) 779 else: 780 mynewroot = newroot.child(':'.join(href.parts()[1:])) 781 return self._update_link_tag(elt, page, mynewroot, href) 782 783 raise zim.formats.VisitorSkip 784 785 tree.replace(zim.formats.LINK, replacefunc) 786 page.set_parsetree(tree) 787 self.store_page(page) 788 789 def _update_link_tag(self, elt, source, target, oldhref): 790 if oldhref.rel == HREF_REL_ABSOLUTE: # prefer to keep absolute links 791 newhref = HRef(HREF_REL_ABSOLUTE, target.name) 792 elif source == target and oldhref.anchor: 793 newhref = HRef(HREF_REL_FLOATING, '', oldhref.anchor) 794 else: 795 newhref = self.pages.create_link(source, target) 796 797 newhref.anchor = oldhref.anchor 798 799 link = newhref.to_wiki_link() 800 801 if elt.gettext() == elt.get('href'): 802 elt[:] = [link] 803 elif elt.gettext() == oldhref.parts()[-1] and len(elt) == 1: 804 # We are using short links and the link text was short link 805 # and there were no sub-node (like bold text) that would be cancelled. 806 # Related to 'short_links' but not checking the property here. 807 short = newhref.parts()[-1] 808 if newhref.anchor: 809 short += '#' + newhref.anchor 810 elt[:] = [short] # 'Journal:2020:01:20' -> '20' 811 812 elt.set('href', link) 813 814 return elt 815 816 @assert_index_uptodate 817 @notebook_state 818 def delete_page(self, path, update_links=True): 819 '''Delete a page from the notebook 820 821 @param path: a L{Path} object 822 @param update_links: if C{True} pages linking to the 823 deleted page will be updated and the link are removed. 824 825 @returns: C{True} when the page existed and was deleted, 826 C{False} when the page did not exist in the first place. 827 828 Raises an error when delete failed. 829 830 @emits: delete-page before the actual delete 831 @emits: deleted-page after successfull deletion 832 ''' 833 existed = self._delete_page(path) 834 835 for p in self._deleted_page(path, update_links): 836 pass 837 838 return existed 839 840 @assert_index_uptodate 841 @notebook_state 842 def delete_page_iter(self, path, update_links=True): 843 '''Like L{delete_page()}''' 844 self._delete_page(path) 845 846 for p in self._deleted_page(path, update_links): 847 yield p 848 849 def _delete_page(self, path): 850 logger.debug('Delete page: %s', path) 851 self.emit('delete-page', path) 852 853 file, folder = self.layout.map_page(path) 854 assert file.path.startswith(self.folder.path) 855 assert folder.path.startswith(self.folder.path) 856 857 if not (file.exists() or folder.exists()): 858 return False 859 else: 860 if folder.exists(): 861 folder.remove_children() 862 folder.remove() 863 if file.exists(): 864 file.remove() 865 866 self.index.update_file(file) 867 self.index.update_file(folder) 868 869 return True 870 871 @assert_index_uptodate 872 @notebook_state 873 def trash_page(self, path, update_links=True): 874 '''Move a page to Trash 875 876 Like L{delete_page()} but will use the system Trash (which may 877 depend on the OS we are running on). This is used in the 878 interface as a more user friendly version of delete as it is 879 undoable. 880 881 @param path: a L{Path} object 882 @param update_links: if C{True} pages linking to the 883 deleted page will be updated and the link are removed. 884 885 @returns: C{True} when the page existed and was deleted, 886 C{False} when the page did not exist in the first place. 887 888 Raises an error when trashing failed. 889 890 @raises TrashNotSupportedError: if trashing is not supported by 891 the storage backend or when trashing is explicitly disabled 892 for this notebook. 893 894 @emits: delete-page before the actual delete 895 @emits: deleted-page after successfull deletion 896 ''' 897 existed = self._trash_page(path) 898 899 for p in self._deleted_page(path, update_links): 900 pass 901 902 return existed 903 904 @assert_index_uptodate 905 @notebook_state 906 def trash_page_iter(self, path, update_links=True): 907 '''Like L{trash_page()}''' 908 self._trash_page(path) 909 910 for p in self._deleted_page(path, update_links): 911 yield p 912 913 def _trash_page(self, path): 914 from zim.newfs.helpers import TrashHelper 915 916 logger.debug('Trash page: %s', path) 917 918 if self.config['Notebook']['disable_trash']: 919 raise TrashNotSupportedError('disable_trash is set') 920 921 self.emit('delete-page', path) 922 923 file, folder = self.layout.map_page(path) 924 helper = TrashHelper() 925 926 re = False 927 if folder.exists(): 928 re = helper.trash(folder) 929 if isinstance(path, Page): 930 path.haschildren = False 931 932 if file.exists(): 933 re = helper.trash(file) or re 934 935 self.index.update_file(file) 936 self.index.update_file(folder) 937 938 return re 939 940 def _deleted_page(self, path, update_links): 941 self._reload_pages_in_cache(path) 942 path = Path(path.name) 943 944 if update_links: 945 # remove persisting links 946 try: 947 indexpath = self.pages.lookup_by_pagename(path) 948 except IndexNotFoundError: 949 pass 950 else: 951 pages = set( 952 l.source for l in self.links.list_links_section(path, LINK_DIR_BACKWARD)) 953 954 for p in pages: 955 yield p 956 page = self.get_page(p) 957 self._remove_links_in_page(page, path) 958 self.store_page(page) 959 960 # let everybody know what happened 961 self.emit('deleted-page', path) 962 963 def _remove_links_in_page(self, page, path): 964 logger.debug('Removing links in %s to %s', page, path) 965 tree = page.get_parsetree() 966 if not tree: 967 return 968 969 def replacefunc(elt): 970 href = elt.attrib['href'] 971 type = link_type(href) 972 if type != 'page': 973 raise zim.formats.VisitorSkip 974 975 hrefpath = self.pages.lookup_from_user_input(href, page) 976 #~ print('LINK', hrefpath) 977 if hrefpath == path \ 978 or hrefpath.ischild(path): 979 # Replace the link by it's text 980 return zim.formats.DocumentFragment(*elt) 981 else: 982 raise zim.formats.VisitorSkip 983 984 tree.replace(zim.formats.LINK, replacefunc) 985 page.set_parsetree(tree) 986 987 def resolve_file(self, filename, path=None): 988 '''Resolve a file or directory path relative to a page or 989 Notebook 990 991 This method is intended to lookup file links found in pages and 992 turn resolve the absolute path of those files. 993 994 File URIs and paths that start with '~/' or '~user/' are 995 considered absolute paths. Also windows path names like 996 'C:\\user' are recognized as absolute paths. 997 998 Paths that starts with a '/' are taken relative to the 999 to the I{document root} - this can e.g. be a parent directory 1000 of the notebook. Defaults to the filesystem root when no document 1001 root is set. (So can be relative or absolute depending on the 1002 notebook settings.) 1003 1004 Paths starting with any other character are considered 1005 attachments. If C{path} is given they are resolved relative to 1006 the I{attachment folder} of that page, otherwise they are 1007 resolved relative to the I{notebook folder} - if any. 1008 1009 The file is resolved purely based on the path, it does not have 1010 to exist at all. 1011 1012 @param filename: the (relative) file path or uri as string 1013 @param path: a L{Path} object for the page 1014 @returns: a L{File} object. 1015 ''' 1016 assert isinstance(filename, str) 1017 filename = filename.replace('\\', '/') 1018 if filename.startswith('~') or filename.startswith('file:/'): 1019 return File(filename) 1020 elif filename.startswith('/'): 1021 dir = self.document_root or Dir('/') 1022 return dir.file(filename) 1023 elif is_win32_path_re.match(filename): 1024 if not filename.startswith('/'): 1025 filename = '/' + filename 1026 # make absolute on Unix 1027 return File(filename) 1028 else: 1029 if path: 1030 dir = self.get_attachments_dir(path) 1031 return File((dir.path, filename)) # XXX LocalDir --> File -- will need get_abspath to resolve 1032 else: 1033 dir = Dir(self.layout.root.path) # XXX 1034 return File((dir, filename)) 1035 1036 def relative_filepath(self, file, path=None): 1037 '''Get a file path relative to the notebook or page 1038 1039 Intended as the counter part of L{resolve_file()}. Typically 1040 this function is used to present the user with readable paths or to 1041 shorten the paths inserted in the wiki code. It is advised to 1042 use file URIs for links that can not be made relative with 1043 this method. 1044 1045 The link can be relative: 1046 - to the I{document root} (link will start with "/") 1047 - the attachments dir (if a C{path} is given) or the notebook 1048 (links starting with "./" or "../") 1049 - or the users home dir (link like "~/user/") 1050 1051 Relative file paths are always given with Unix path semantics 1052 (so "/" even on windows). But a leading "/" does not mean the 1053 path is absolute, but rather that it is relative to the 1054 X{document root}. 1055 1056 @param file: L{File} object we want to link 1057 @keyword path: L{Path} object for the page where we want to 1058 link this file 1059 1060 @returns: relative file path as string, or C{None} when no 1061 relative path was found 1062 ''' 1063 from zim.newfs import LocalFile, LocalFolder 1064 file = LocalFile(file.path) # XXX 1065 notebook_root = self.layout.root 1066 document_root = LocalFolder(self.document_root.path) if self.document_root else None# XXX 1067 1068 rootdir = '/' 1069 mydir = '.' + SEP 1070 updir = '..' + SEP 1071 1072 # Look within the notebook 1073 if path: 1074 attachments_dir = self.get_attachments_dir(path) 1075 1076 if file.ischild(attachments_dir): 1077 return mydir + file.relpath(attachments_dir) 1078 elif document_root and notebook_root \ 1079 and document_root.ischild(notebook_root) \ 1080 and file.ischild(document_root) \ 1081 and not attachments_dir.ischild(document_root): 1082 # special case when document root is below notebook root 1083 # the case where document_root == attachment_folder is 1084 # already caught by above if clause 1085 return rootdir + file.relpath(document_root) 1086 elif notebook_root \ 1087 and file.ischild(notebook_root) \ 1088 and attachments_dir.ischild(notebook_root): 1089 parent = file.commonparent(attachments_dir) 1090 uppath = attachments_dir.relpath(parent) 1091 downpath = file.relpath(parent) 1092 up = 1 + uppath.replace('\\', '/').count('/') 1093 return updir * up + downpath 1094 else: 1095 if document_root and notebook_root \ 1096 and document_root.ischild(notebook_root) \ 1097 and file.ischild(document_root): 1098 # special case when document root is below notebook root 1099 return rootdir + file.relpath(document_root) 1100 elif notebook_root and file.ischild(notebook_root): 1101 return mydir + file.relpath(notebook_root) 1102 1103 # If that fails look for global folders 1104 if document_root and file.ischild(document_root): 1105 return rootdir + file.relpath(document_root) 1106 1107 # Finally check HOME or give up 1108 path = file.userpath 1109 return path if path.startswith('~') else None 1110 1111 def get_attachments_dir(self, path): 1112 '''Get the X{attachment folder} for a specific page 1113 1114 @param path: a L{Path} object 1115 @returns: a L{Dir} object or C{None} 1116 1117 Always returns a Dir object when the page can have an attachment 1118 folder, even when the folder does not (yet) exist. However when 1119 C{None} is returned the store implementation does not support 1120 an attachments folder for this page. 1121 ''' 1122 return self.layout.get_attachments_folder(path) 1123 1124 def get_template(self, path): 1125 '''Get a template for the intial text on new pages 1126 @param path: a L{Path} object 1127 @returns: a L{ParseTree} object 1128 ''' 1129 # FIXME hardcoded that template must be wiki format 1130 1131 template = self.get_page_template_name(path) 1132 logger.debug('Got page template \'%s\' for %s', template, path) 1133 template = zim.templates.get_template('wiki', template) 1134 return self.eval_new_page_template(path, template) 1135 1136 def get_page_template_name(self, path=None): 1137 '''Returns the name of the template to use for a new page. 1138 (To get the contents of the template directly, see L{get_template()}) 1139 ''' 1140 return self.emit_return_first('get-page-template', path or Path(':')) or 'Default' 1141 1142 def eval_new_page_template(self, path, template): 1143 lines = [] 1144 context = { 1145 'page': { 1146 'name': path.name, 1147 'basename': path.basename, 1148 'section': path.namespace, 1149 'namespace': path.namespace, # backward compat 1150 } 1151 } 1152 self.emit('init-page-template', path, template) # plugin hook 1153 template.process(lines, context) 1154 1155 parser = zim.formats.get_parser('wiki') 1156 return parser.parse(lines) 1157