"Fossies" - the Fresh Open Source Software Archive

Member "zim-0.71.1/zim/notebook/notebook.py" (21 May 2019, 38135 Bytes) of package /linux/privat/zim-0.71.1.tar.gz:


As a special service "Fossies" has tried to format the requested source page into HTML format using (guessed) Python source code syntax highlighting (style: standard) with prefixed line numbers. Alternatively you can here view or download the uninterpreted source code file. For more information about "notebook.py" see the Fossies "Dox" file reference documentation and the last Fossies "Diffs" side-by-side code changes report: 0.70_vs_0.71.0.

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