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