def __init__(self, notebook, page, config, navigation): Window.__init__(self) self.navigation = navigation self.set_title(page.name + ' - Zim') #if ui.notebook.icon: # try: # self.set_icon_from_file(ui.notebook.icon) # except GObject.GError: # logger.exception('Could not load icon %s', ui.notebook.icon) page = notebook.get_page(page) if hasattr(config, 'uistate'): self.uistate = config.uistate['PageWindow'] else: self.uistate = ConfigDict() self.uistate.setdefault('windowsize', (500, 400), check=value_is_coord) w, h = self.uistate['windowsize'] self.set_default_size(w, h) self.pageview = PageView(notebook, config, navigation, secondary=True) self.pageview.set_page(page) self.add(self.pageview)
def parse_attrib(self, attrib): '''Convenience method to enforce the supported attributes and their types. @returns: a L{ConfigDict} using the C{object_attr} dict as definition ''' if not isinstance(attrib, ConfigDict): attrib = ConfigDict(attrib) attrib.define(self.object_attr) return attrib
def setUp(self): self.PATHS = ('Parent:Daughter:Granddaughter', 'Test:tags', 'Test:foo', 'Books') self.LEN_PATHS = len(self.PATHS) self.PATHS_NAMES = {self.PATHS[0]:'name 1', self.PATHS[1]:'name 2', self.PATHS[2]:'name 3'} self.uistate = ConfigDict() self.uistate.setdefault('bookmarks', []) self.uistate.setdefault('bookmarks_names', {}) self.uistate.setdefault('show_full_page_name', True)
def __init__(self, dir=None, file=None, config=None, index=None): assert not (dir and file), 'BUG: can not provide both dir and file ' gobject.GObject.__init__(self) self._namespaces = [] # list used to resolve stores self._stores = {} # dict mapping namespaces to stores self.namespace_properties = HierarchicDict() self._page_cache = weakref.WeakValueDictionary() self.dir = None self.file = None self.cache_dir = None self.name = None self.icon = None self.config = config if dir: assert isinstance(dir, Dir) self.dir = dir self.readonly = not dir.iswritable() self.cache_dir = dir.subdir('.zim') if self.readonly or not self.cache_dir.iswritable(): self.cache_dir = self._cache_dir(dir) logger.debug('Cache dir: %s', self.cache_dir) if self.config is None: self.config = ConfigDictFile(dir.file('notebook.zim')) # TODO check if config defined root namespace self.add_store(Path(':'), 'files') # set root # TODO add other namespaces from config elif file: assert isinstance(file, File) self.file = file self.readonly = not file.iswritable() assert False, 'TODO: support for single file notebooks' if index is None: import zim.index # circular import self.index = zim.index.Index(notebook=self) else: self.index = index self.index.set_notebook(self) if self.config is None: self.config = ConfigDict() self.config['Notebook'].setdefault('name', None, klass=basestring) self.config['Notebook'].setdefault('home', ':Home', klass=basestring) self.config['Notebook'].setdefault('icon', None, klass=basestring) self.config['Notebook'].setdefault('document_root', None, klass=basestring) self.config['Notebook'].setdefault('slow_fs', False) self.do_properties_changed()
def __init__(self): tests.MockObject.__init__(self) self.pageview = setUpPageView() self.uimanager = tests.MockObject() self.ui = tests.MockObject() self.ui.uistate = ConfigDict()
def runTest(self): window = tests.MockObject() window.pageview = setUpPageView() window.ui = tests.MockObject() window.ui.uimanager = tests.MockObject() window.ui.uistate = ConfigDict() window.ui.mainwindow = window # XXX plugin = TableEditorPlugin() extension = MainWindowExtension(plugin, window) with tests.DialogContext(self.checkInsertTableDialog): extension.insert_table() tree = window.pageview.get_parsetree() #~ print tree.tostring() obj = tree.find('table') self.assertTrue(obj.attrib['aligns'] == 'left') self.assertTrue(obj.attrib['wraps'] == '0') # Parses tree to a table object tabledata = tree.tostring().replace("<?xml version='1.0' encoding='utf-8'?>", '')\ .replace('<zim-tree>', '').replace('</zim-tree>', '')\ .replace('<td> </td>', '<td>text</td>') table = plugin.create_table({'type': 'table'}, ElementTree.fromstring(tabledata)) self.assertTrue(isinstance(table, TableViewObject))
def runTest(self): ui = MockUI() ui.notebook = tests.new_notebook() uistate = ConfigDict() widget = TagsPluginWidget(ui.notebook.index, uistate, ui) # Excersize all model switches and check we still have a sane state widget.toggle_treeview() widget.toggle_treeview() path = Path('Test:tags') ui.notebook.pages.lookup_by_pagename(path) treepath = widget.treeview.get_model().find(path) widget.disconnect_model() widget.reconnect_model() path = Path('Test:tags') treepath = widget.treeview.get_model().find(path) # Check signals widget.treeview.emit('populate-popup', gtk.Menu()) widget.treeview.emit('insert-link', path) # Toggles in popup widget.toggle_show_full_page_name() widget.toggle_show_full_page_name() # Check tag filtering cloud = widget.tagcloud self.assertFalse(cloud.get_tag_filter()) tag = None for button in cloud.get_children(): if button.indextag.name == 'tags': tag = button.indextag button.clicked() break else: raise AssertionError('No button for @tags ?') selected = cloud.get_tag_filter() self.assertEqual(selected, [tag]) model = widget.treeview.get_model() self.assertIsInstance(model, TaggedPageTreeStore) self.assertEqual(model.tags, [tag.name]) # check menu and sorting of tag cloud cloud.emit('populate-popup', gtk.Menu()) mockaction = tests.MockObject() mockaction.get_active = lambda: True cloud._switch_sorting(mockaction) mockaction.get_active = lambda: False cloud._switch_sorting(mockaction)
def testSerialize(self): '''Test parsing the history from the state file''' uistate = ConfigDict() history = History(self.notebook, uistate) for page in self.pages: history.append(page) self.assertHistoryEquals(history, self.pages) self.assertCurrentEquals(history, self.pages[-1]) # rewind 2 for i in range(2): prev = history.get_previous() history.set_current(prev) # check state #~ import pprint #~ pprint.pprint(uistate) self.assertHistoryEquals(history, uistate['History']['list']) self.assertRecentEquals(history, uistate['History']['recent']) self.assertEqual(uistate['History']['current'], len(self.pages) - 3) # clone uistate by text lines = uistate.dump() newuistate = ConfigDict() newuistate.parse(lines) # check new state self.assertHistoryEquals(history, [Path(t[0]) for t in newuistate['History']['list']]) self.assertRecentEquals(history, [Path(t[0]) for t in newuistate['History']['recent']]) self.assertEqual(newuistate['History']['current'], len(self.pages) - 3) # and compare resulting history object newhistory = History(self.notebook, newuistate) self.assertEqual(list(newhistory.get_history()), list(history.get_history())) self.assertEqual(list(newhistory.get_recent()), list(history.get_recent())) self.assertEqual(newhistory.get_current(), history.get_current()) # Check recent is initialized if needed newuistate = ConfigDict() newuistate.parse(lines) newuistate['History'].pop('recent') newhistory = History(self.notebook, newuistate) self.assertEqual(list(newhistory.get_history()), list(history.get_history())) self.assertEqual(list(newhistory.get_recent()), list(history.get_recent())) self.assertEqual(newhistory.get_current(), history.get_current())
def testSerialize(self): '''Test parsing the history from the state file''' uistate = ConfigDict() history = History(self.notebook, uistate) for page in self.pages: history.append(page) self.assertEqual(len(history.history), len(self.pages)) self._assertCurrent(history, self.pages[-1]) # rewind 2 for i in range(2): prev = history.get_previous() history.set_current(prev) # check state #~ import pprint #~ pprint.pprint(uistate) self.assertEqual(len(uistate['History']['history']), len(history.history)) #~ self.assertEqual(len(uistate['History']['pages']), len(history.history)) self.assertEqual(uistate['History']['current'], len(history.history)-3) # clone uistate by text lines = uistate.dump() newuistate = ConfigDict() newuistate.parse(lines) # check new state self.assertEqual(len(uistate['History']['history']), len(history.history)) #~ self.assertEqual(len(newuistate['History']['pages']), len(history.history)) self.assertEqual(newuistate['History']['current'], len(history.history)-3) # and compare resulting history object newhistory = History(self.notebook, newuistate) self.assertEqual(newhistory.history, history.history) self.assertEqual(newhistory.current, history.current)
def runTest(self): window = tests.MockObject() window.pageview = setUpPageView() window.ui = tests.MockObject() window.ui.uimanager = tests.MockObject() window.ui.uistate = ConfigDict() window.ui.mainwindow = window # XXX plugin = SourceViewPlugin() extension = MainWindowExtension(plugin, window) with tests.DialogContext(self.checkInsertCodeBlockDialog): extension.insert_sourceview() tree = window.pageview.get_parsetree() #~ print tree.tostring() obj = tree.find('object') self.assertTrue(obj.attrib['type'] == 'code')
def runTest(self): ui = MockUI() ui.notebook = tests.new_notebook() uistate = ConfigDict() widget = TagsPluginWidget(ui.notebook.index, uistate, ui) # Excersize all model switches and check we still have a sane state widget.toggle_treeview() widget.toggle_treeview() path = Path('Test:foo') treepath = widget.treeview.get_model().get_treepath(path) self.assertTrue(not treepath is None) widget.disconnect_model() widget.reload_model() path = Path('Test:foo') treepath = widget.treeview.get_model().get_treepath(path) self.assertTrue(not treepath is None) # Check signals #~ widget.treeview.emit('popup-menu') widget.treeview.emit('insert-link', path) # Check tag filtering cloud = widget.tagcloud self.assertEqual(cloud.get_tag_filter(), None) tag = None for button in cloud.get_children(): if button.indextag.name == 'tags': tag = button.indextag button.clicked() break else: raise AssertionError, 'No button for @tags ?' selected, filtered = cloud.get_tag_filter() self.assertEqual(selected, [tag]) self.assertTrue(len(filtered) > 3) self.assertTrue(tag in filtered) self.assertTrue(not widget.treeview._tag_filter is None) # check filtering in treestore tagfilter = (selected, filtered) selected = frozenset(selected) filtered = frozenset(filtered) def toplevel(model): iter = model.get_iter_first() assert not iter is None while not iter is None: yield iter iter = model.iter_next(iter) def childiter(model, iter): iter = model.iter_children(iter) assert not iter is None while not iter is None: yield iter iter = model.iter_next(iter) self.assertEqual(uistate['treeview'], 'tagged') filteredmodel = widget.treeview.get_model() for iter in toplevel(filteredmodel): path = filteredmodel.get_indexpath(iter) self.assertTrue(not path is None) tags = list(ui.notebook.index.list_tags(path)) tags = frozenset(tags) self.assertTrue( selected.issubset(tags)) # Needs to contains selected tags self.assertTrue(tags.issubset( filtered)) # All other tags should be in the filter selection treepaths = filteredmodel.get_treepaths(path) self.assertTrue(filteredmodel.get_path(iter) in treepaths) widget.toggle_treeview() self.assertEqual(uistate['treeview'], 'tags') filteredmodel = widget.treeview.get_model() for iter in toplevel(filteredmodel): self.assertEqual(filteredmodel.get_indexpath(iter), None) # toplevel has tags, not pages tag = filteredmodel[iter][PATH_COL] self.assertTrue(tag in filtered) for iter in childiter(filteredmodel, iter): path = filteredmodel.get_indexpath(iter) self.assertTrue(not path is None) tags = list(ui.notebook.index.list_tags(path)) tags = frozenset(tags) self.assertTrue( selected.issubset(tags)) # Needs to contains selected tags self.assertTrue( tags.issubset(filtered) ) # All other tags should be in the filter selection treepaths = filteredmodel.get_treepaths(path) self.assertTrue(filteredmodel.get_path(iter) in treepaths)
class TestBookmarksBar(tests.TestCase): @classmethod def setUpClass(cls): cls.notebook = tests.new_notebook() cls.index = cls.notebook.index cls.ui = MockUI() cls.ui.notebook = cls.notebook cls.ui.page = Path('Test:foo') def setUp(self): self.PATHS = ('Parent:Daughter:Granddaughter', 'Test:tags', 'Test:foo', 'Books') self.LEN_PATHS = len(self.PATHS) self.PATHS_NAMES = {self.PATHS[0]:'name 1', self.PATHS[1]:'name 2', self.PATHS[2]:'name 3'} self.uistate = ConfigDict() self.uistate.setdefault('bookmarks', []) self.uistate.setdefault('bookmarks_names', {}) self.uistate.setdefault('show_full_page_name', True) def testGeneral(self): '''Test general functions: add, delete bookmarks.''' self.assertTrue(self.notebook.get_page(self.ui.page).exists()) Bar = BookmarkBar(self.ui, self.uistate, get_page_func = lambda: '') Bar.max_bookmarks = 15 # set maximum number of bookmarks # Add paths to the beginning of the bar. for i, path in enumerate(self.PATHS): Bar._add_new(path, add_bookmarks_to_beginning = True) self.assertEqual(len(Bar.paths), i + 1) self.assertTrue(Bar.paths == list(reversed(self.PATHS))) # Add paths to the end of the bar. Bar.paths = [] for i, path in enumerate(self.PATHS): Bar._add_new(path, add_bookmarks_to_beginning = False) self.assertEqual(len(Bar.paths), i + 1) self.assertEqual(Bar.paths, list(self.PATHS)) # Check that the same path can't be added to the bar. Bar._add_new(self.PATHS[0]) Bar._add_new(self.PATHS[1]) self.assertEqual(Bar.paths, list(self.PATHS)) # Delete paths from the bar. for i, button in enumerate(Bar.container.get_children()[2:]): path = button.zim_path self.assertTrue(path in Bar.paths) Bar.delete(button.zim_path) self.assertEqual(len(Bar.paths), self.LEN_PATHS - i - 1) self.assertTrue(path not in Bar.paths) self.assertEqual(Bar.paths, []) # Delete all bookmarks from the bar. Bar.delete_all() self.assertEqual(Bar.paths, []) def testDeletePages(self): '''Check deleting a bookmark after deleting a page in the notebook.''' notebook = tests.new_notebook() ui = MockUI() ui.notebook = notebook self.uistate['bookmarks'] = list(self.PATHS) Bar = BookmarkBar(ui, self.uistate, get_page_func = lambda: '') for i, path in enumerate(self.PATHS): self.assertTrue(path in Bar.paths) notebook.delete_page(Path(path)) self.assertTrue(path not in Bar.paths) self.assertEqual(len(Bar.paths), self.LEN_PATHS - i - 1) self.assertEqual(Bar.paths, []) def testFunctions(self): '''Test bookmark functions: changing, reordering, ranaming.''' Bar = BookmarkBar(self.ui, self.uistate, get_page_func = lambda: '') Bar.max_bookmarks = 15 # set maximum number of bookmarks # Check changing a bookmark. for i, path in enumerate(self.PATHS): Bar._add_new(path, add_bookmarks_to_beginning = False) self.assertTrue('Test' not in Bar.paths) self.assertTrue('Books' in Bar.paths) Bar.change_bookmark('Books', 'Books') self.assertEqual(Bar.paths, list(self.PATHS)) Bar.change_bookmark('Books', 'Test') self.assertTrue('Test' in Bar.paths) self.assertTrue('Books' not in Bar.paths) _result = [a if a != 'Books' else 'Test' for a in self.PATHS] self.assertEqual(Bar.paths, _result) Bar.change_bookmark('Test', 'Books') self.assertEqual(Bar.paths, list(self.PATHS)) # Check reordering bookmarks. new_paths = ('1','2','3','4','5') Bar.paths = list(new_paths) Bar.move_bookmark(new_paths[2], new_paths[2], 'left') self.assertEqual(Bar.paths, list(new_paths)) Bar.move_bookmark(new_paths[3], new_paths[3], 'right') self.assertEqual(Bar.paths, list(new_paths)) Bar.move_bookmark('3', '1', 'left') self.assertEqual(Bar.paths, ['3','1','2','4','5']) Bar.move_bookmark('5', '1', 'left') self.assertEqual(Bar.paths, ['3','5','1','2','4']) Bar.move_bookmark('5', '1', 'right') self.assertEqual(Bar.paths, ['3','1','5','2','4']) Bar.move_bookmark('3', '4', 'right') self.assertEqual(Bar.paths, ['1','5','2','4','3']) Bar.move_bookmark('5', '4', '-') self.assertEqual(Bar.paths, ['1','5','2','4','3']) # Check rename_bookmark and save options. preferences_changed = lambda save: Bar.on_preferences_changed({'save': save, 'add_bookmarks_to_beginning': False, 'max_bookmarks': 15}) new_path_names = {new_paths[0]:'11', new_paths[1]:'22', new_paths[2]:'33'} Bar.paths = list(new_paths) preferences_changed(True) Bar._reload_bar() def rename_check(label, path, paths_names, path_names_uistate): self.assertEqual(button.get_label(), label) self.assertEqual(button.zim_path, path) self.assertEqual(Bar.paths_names, paths_names) self.assertEqual(self.uistate['bookmarks_names'], path_names_uistate) button = gtk.Button(label = new_paths[0], use_underline = False) button.zim_path = new_paths[0] rename_check(new_paths[0], new_paths[0], {}, {}) Clipboard.set_text('new name') Bar.rename_bookmark(button) rename_check('new name', new_paths[0], {new_paths[0]:'new name'}, {new_paths[0]:'new name'}) preferences_changed(False) rename_check('new name', new_paths[0], {new_paths[0]:'new name'}, {}) preferences_changed(True) rename_check('new name', new_paths[0], {new_paths[0]:'new name'}, {new_paths[0]:'new name'}) Bar.rename_bookmark(button) rename_check(new_paths[0], new_paths[0], {}, {}) # Check delete with renaming. preferences_changed(True) paths_names_copy = dict(new_path_names) Bar.paths_names = dict(new_path_names) for key in new_path_names: Bar.delete(key) del paths_names_copy[key] self.assertEqual(Bar.paths_names, paths_names_copy) self.assertEqual(self.uistate['bookmarks_names'], Bar.paths_names) # Check delete all with renaming. Bar.paths_names = dict(new_path_names) Bar.delete_all() self.assertEqual(Bar.paths_names, {}) self.assertEqual(self.uistate['bookmarks_names'], {}) # Check change bookmark with renaming. new_path_names = {new_paths[0]:'11', new_paths[1]:'22', new_paths[2]:'33'} Bar.paths = list(new_paths) Bar.paths_names = dict(new_path_names) paths_names_copy = dict(new_path_names) _name = paths_names_copy.pop(new_paths[0]) paths_names_copy['new path'] = _name Bar.change_bookmark(new_paths[0], 'new path') self.assertEqual(Bar.paths_names, paths_names_copy) self.assertEqual(Bar.paths, ['new path'] + list(new_paths[1:])) def testPreferences(self): '''Check preferences: full/short page names, save option, max number of bookmarks.''' # Check short page names. Bar = BookmarkBar(self.ui, self.uistate, get_page_func = lambda: '') self.uistate['show_full_page_name'] = False for path in self.PATHS: Bar._add_new(path) self.assertEqual(Bar.paths, list(self.PATHS)) for i, button in enumerate(Bar.container.get_children()[2:]): self.assertEqual(self.PATHS[i], button.zim_path) self.assertEqual(Path(self.PATHS[i]).basename, button.get_label()) # Show full page names. Bar.toggle_show_full_page_name() self.assertEqual(Bar.paths, list(self.PATHS)) for i, button in enumerate(Bar.container.get_children()[2:]): self.assertEqual(self.PATHS[i], button.zim_path) self.assertEqual(self.PATHS[i], button.get_label()) # Check save option. self.uistate['bookmarks'] = list(self.PATHS) self.uistate['bookmarks_names'] = dict(self.PATHS_NAMES) Bar = BookmarkBar(self.ui, self.uistate, get_page_func = lambda: '') self.assertEqual(Bar.paths, list(self.PATHS)) self.assertEqual(Bar.paths_names, self.PATHS_NAMES) self.uistate['bookmarks'] = [] self.uistate['bookmarks_names'] = {} Bar = BookmarkBar(self.ui, self.uistate, get_page_func = lambda: '') self.assertEqual(Bar.paths, []) self.assertEqual(Bar.paths_names, {}) # Get pages to check max number of bookmarks. pagelist = set(self.index.list_pages(None)) _enhanced_pagelist = set() for page in pagelist: _enhanced_pagelist.update( set(self.index.list_pages(page)) ) if len(_enhanced_pagelist) > 20: break pagelist.update(_enhanced_pagelist) pagelist = [a.name for a in pagelist if a.exists()] self.assertTrue(len(pagelist) > 20) def preferences_changed(save, max_b): Bar.on_preferences_changed({ 'save': save, 'add_bookmarks_to_beginning': False, 'max_bookmarks': max_b}) # Check that more than max bookmarks can be loaded at start. self.uistate['bookmarks'] = pagelist Bar = BookmarkBar(self.ui, self.uistate, get_page_func = lambda: '') self.assertEqual(pagelist, Bar.paths) preferences_changed(True, 5) self.assertEqual(pagelist, Bar.paths) self.assertEqual(pagelist, self.uistate['bookmarks']) # Set maximum number of bookmarks. self.uistate['bookmarks'] = [] Bar = BookmarkBar(self.ui, self.uistate, get_page_func = lambda: '') for max_bookmarks in (5, 10, 15, 20): preferences_changed(False, max_bookmarks) for page in pagelist: Bar._add_new(page) self.assertEqual(len(Bar.paths), max_bookmarks) self.assertEqual(Bar.paths, pagelist[:max_bookmarks]) Bar.delete_all() # Check 'save' option in preferences. for i, path in enumerate(self.PATHS): preferences_changed(False, 15) Bar._add_new(path) self.assertEqual(self.uistate['bookmarks'], []) preferences_changed(True, 15) self.assertEqual(self.uistate['bookmarks'], list(self.PATHS[:i+1])) self.assertEqual(self.uistate['bookmarks'], list(self.PATHS))
class DumperClass(Visitor): '''Base class for dumper classes. Dumper classes serialize the content of a parse tree back to a text representation of the page content. Therefore this class implements the visitor API, so it can be used with any parse tree implementation or parser object that supports this API. To implement a dumper class, you need to define handlers for all tags that can appear in a page. Tags that are represented by a simple prefix and postfix string can be defined in the dictionary C{TAGS}. For example to define the italic tag in html output the dictionary should contain a definition like: C{EMPHASIS: ('<i>', '</i>')}. For tags that require more complex logic you can define a method to format the tag. Typical usage is to format link attributes in such a method. The method name should be C{dump_} + the name of the tag, e.g. C{dump_link()} for links (see the constants with tag names for the other tags). Such a sump method will get 3 arguments: the tag name itself, a dictionary with the tag attributes and a list of strings that form the tag content. The method should return a list of strings that represents the formatted text. This base class takes care of a stack of nested formatting tags and when a tag is closed either picks the appropriate prefix and postfix from C{TAGS} or calls the corresponding C{dump_} method. As a result tags are serialized depth-first. @ivar linker: the (optional) L{Linker} object, used to resolve links @ivar template_options: a L{ConfigDict} with options that may be set in a template (so inherently not safe !) to control the output style. Formats using this need to define the supported keys in the dict C{TEMPLATE_OPTIONS}. @ivar context: the stack of open tags maintained by this class. Can be used in C{dump_} methods to inspect the parent scope of the format. Elements on this stack have "tag", "attrib" and "text" attributes. Keep in mind that the parent scope is not yet complete when a tag is serialized. ''' TAGS = {} #: dict mapping formatting tags to 2-tuples of a prefix and a postfix string TEMPLATE_OPTIONS = {} #: dict mapping ConfigDefinitions for template options def __init__(self, linker=None, template_options=None): self.linker = linker self.template_options = ConfigDict(template_options) self.template_options.define(self.TEMPLATE_OPTIONS) self.context = [] self._text = [] def dump(self, tree): '''Format a parsetree to text @param tree: a parse tree object that supports a C{visit()} method @returns: a list of lines ''' # FIXME - issue here is that we need to reset state - should be in __init__ self._text = [] self.context = [DumperContextElement(None, None, self._text)] tree.visit(self) if len(self.context) != 1: raise AssertionError('Unclosed tags on tree: %s' % self.context[-1].tag) #~ import pprint; pprint.pprint(self._text) return self.get_lines() # FIXME - maybe just return text ? def get_lines(self): '''Return the dumped content as a list of lines Should only be called after closing the top level element ''' return ''.join(self._text).splitlines(1) def start(self, tag, attrib=None): if attrib: attrib = attrib.copy() # Ensure dumping does not change tree self.context.append(DumperContextElement(tag, attrib, [])) def text(self, text): assert not text is None if self.context[-1].tag != OBJECT: text = self.encode_text(self.context[-1].tag, text) self.context[-1].text.append(text) def end(self, tag): if not tag or tag != self.context[-1].tag: raise AssertionError('Unexpected tag closed: %s' % tag) _, attrib, strings = self.context.pop() if tag in self.TAGS: assert strings, 'Can not append empty %s element' % tag start, end = self.TAGS[tag] strings.insert(0, start) strings.append(end) elif tag == FORMATTEDTEXT: pass else: try: method = getattr(self, 'dump_' + tag) except AttributeError: raise AssertionError('BUG: Unknown tag: %s' % tag) strings = method(tag, attrib, strings) #~ try: #~ u''.join(strings) #~ except: #~ print("BUG: %s returned %s" % ('dump_'+tag, strings)) if strings is not None: self.context[-1].text.extend(strings) def append(self, tag, attrib=None, text=None): strings = None if tag in self.TAGS: assert text is not None, 'Can not append empty %s element' % tag start, end = self.TAGS[tag] text = self.encode_text(tag, text) strings = [start, text, end] elif tag == FORMATTEDTEXT: if text is not None: strings = [self.encode_text(tag, text)] else: if attrib: attrib = attrib.copy() # Ensure dumping does not change tree try: method = getattr(self, 'dump_' + tag) except AttributeError: raise AssertionError('BUG: Unknown tag: %s' % tag) if text is None: strings = method(tag, attrib, []) elif tag == OBJECT: strings = method(tag, attrib, [text]) else: strings = method(tag, attrib, [self.encode_text(tag, text)]) if strings is not None: self.context[-1].text.extend(strings) def encode_text(self, tag, text): '''Optional method to encode text elements in the output @note: Do not apply text encoding in the C{dump_} methods, the list of strings given there may contain prefix and postfix formatting of nested tags. @param tag: formatting tag @param text: text to be encoded @returns: encoded text @implementation: optional, default just returns unmodified input ''' return text def prefix_lines(self, prefix, strings): '''Convenience method to wrap a number of lines with e.g. an indenting sequence. @param prefix: a string to prefix each line @param strings: a list of pieces of text @returns: a new list of lines, each starting with prefix ''' lines = ''.join(strings).splitlines(1) return [prefix + l for l in lines] def dump_object(self, tag, attrib, strings=[]): '''Dumps objects defined by L{InsertedObjectType}''' format = str(self.__class__.__module__).split('.')[-1] try: obj = PluginManager.insertedobjects[attrib['type']] except KeyError: pass else: try: output = obj.format(format, self, attrib, ''.join(strings)) except ValueError: pass else: assert isinstance(output, (list, tuple)), "Invalid output: %r" % output return output if attrib['type'].startswith('image+'): # Fallback for backward compatibility of image generators < zim 0.70 attrib = attrib.copy() attrib['type'] = attrib['type'][6:] return self.dump_img(IMAGE, attrib, None) else: return self.dump_object_fallback(tag, attrib, strings) def dump_object_fallback(self, tag, attrib, strings=None): '''Method to serialize objects that do not have their own handler for this format. @implementation: must be implemented in sub-classes ''' raise NotImplementedError def isrtl(self, text): '''Check for Right To Left script @param text: the text to check @returns: C{True} if C{text} starts with characters in a RTL script, or C{None} if direction is not determined. ''' if Pango is None: return None # It seems the find_base_dir() function is not documented in the # python language bindings. The Gtk C code shows the signature: # # Pango.find_base_dir(text, length) # # It either returns a direction, or NEUTRAL if e.g. text only # contains punctuation but no real characters. dir = Pango.find_base_dir(text, len(text)) if dir == Pango.Direction.NEUTRAL: return None else: return dir == Pango.Direction.RTL
def __init__(self, linker=None, template_options=None): self.linker = linker self.template_options = ConfigDict(template_options) self.template_options.define(self.TEMPLATE_OPTIONS) self.context = [] self._text = []
class DumperClass(Visitor): '''Base class for dumper classes. Dumper classes serialize the content of a parse tree back to a text representation of the page content. Therefore this class implements the visitor API, so it can be used with any parse tree implementation or parser object that supports this API. To implement a dumper class, you need to define handlers for all tags that can appear in a page. Tags that are represented by a simple prefix and postfix string can be defined in the dictionary C{TAGS}. For example to define the italic tag in html output the dictionary should contain a definition like: C{EMPHASIS: ('<i>', '</i>')}. For tags that require more complex logic you can define a method to format the tag. Typical usage is to format link attributes in such a method. The method name should be C{dump_} + the name of the tag, e.g. C{dump_link()} for links (see the constants with tag names for the other tags). Such a sump method will get 3 arguments: the tag name itself, a dictionary with the tag attributes and a list of strings that form the tag content. The method should return a list of strings that represents the formatted text. This base class takes care of a stack of nested formatting tags and when a tag is closed either picks the appropriate prefix and postfix from C{TAGS} or calls the corresponding C{dump_} method. As a result tags are serialized depth-first. @ivar linker: the (optional) L{Linker} object, used to resolve links @ivar template_options: a L{ConfigDict} with options that may be set in a template (so inherently not safe !) to control the output style. Formats using this need to define the supported keys in the dict C{TEMPLATE_OPTIONS}. @ivar context: the stack of open tags maintained by this class. Can be used in C{dump_} methods to inspect the parent scope of the format. Elements on this stack have "tag", "attrib" and "text" attributes. Keep in mind that the parent scope is not yet complete when a tag is serialized. ''' TAGS = {} #: dict mapping formatting tags to 2-tuples of a prefix and a postfix string TEMPLATE_OPTIONS = {} #: dict mapping ConfigDefinitions for template options def __init__(self, linker=None, template_options=None): self.linker = linker self.template_options = ConfigDict(template_options) self.template_options.define(self.TEMPLATE_OPTIONS) self.context = [] self._text = [] def dump(self, tree): '''Convenience methods to dump a given tree. @param tree: a parse tree object that supports a C{visit()} method ''' # FIXME - issue here is that we need to reset state - should be in __init__ self._text = [] self.context = [DumperContextElement(None, None, self._text)] tree.visit(self) if len(self.context) != 1: raise AssertionError, 'Unclosed tags on tree: %s' % self.context[-1].tag #~ import pprint; pprint.pprint(self._text) return self.get_lines() # FIXME - maybe just return text ? def get_lines(self): '''Return the dumped content as a list of lines Should only be called after closing the top level element ''' return u''.join(self._text).splitlines(1) def start(self, tag, attrib=None): if attrib: attrib = attrib.copy() # Ensure dumping does not change tree self.context.append(DumperContextElement(tag, attrib, [])) def text(self, text): assert not text is None if self.context[-1].tag != OBJECT: text = self.encode_text(self.context[-1].tag, text) self.context[-1].text.append(text) def end(self, tag): if not tag or tag != self.context[-1].tag: raise AssertionError, 'Unexpected tag closed: %s' % tag _, attrib, strings = self.context.pop() if tag in self.TAGS: assert strings, 'Can not append empty %s element' % tag start, end = self.TAGS[tag] strings.insert(0, start) strings.append(end) elif tag == FORMATTEDTEXT: pass else: try: method = getattr(self, 'dump_'+tag) except AttributeError: raise AssertionError, 'BUG: Unknown tag: %s' % tag strings = method(tag, attrib, strings) #~ try: #~ u''.join(strings) #~ except: #~ print "BUG: %s returned %s" % ('dump_'+tag, strings) if strings is not None: self.context[-1].text.extend(strings) def append(self, tag, attrib=None, text=None): strings = None if tag in self.TAGS: assert text is not None, 'Can not append empty %s element' % tag start, end = self.TAGS[tag] text = self.encode_text(tag, text) strings = [start, text, end] elif tag == FORMATTEDTEXT: if text is not None: strings = [self.encode_text(tag, text)] else: if attrib: attrib = attrib.copy() # Ensure dumping does not change tree try: method = getattr(self, 'dump_'+tag) except AttributeError: raise AssertionError, 'BUG: Unknown tag: %s' % tag if text is None: strings = method(tag, attrib, []) elif tag == OBJECT: strings = method(tag, attrib, [text]) else: strings = method(tag, attrib, [self.encode_text(tag, text)]) if strings is not None: self.context[-1].text.extend(strings) def encode_text(self, tag, text): '''Optional method to encode text elements in the output @note: Do not apply text encoding in the C{dump_} methods, the list of strings given there may contain prefix and postfix formatting of nested tags. @param tag: formatting tag @param text: text to be encoded @returns: encoded text @implementation: optional, default just returns unmodified input ''' return text def prefix_lines(self, prefix, strings): '''Convenience method to wrap a number of lines with e.g. an indenting sequence. @param prefix: a string to prefix each line @param strings: a list of pieces of text @returns: a new list of lines, each starting with prefix ''' lines = u''.join(strings).splitlines(1) return [prefix + l for l in lines] def dump_object(self, tag, attrib, strings=None): '''Dumps object using proper ObjectManager''' format = str(self.__class__.__module__).split('.')[-1] if 'type' in attrib: obj = ObjectManager.get_object(attrib['type'], attrib, u''.join(strings)) output = obj.dump(format, self, self.linker) if isinstance(output, basestring): return [output] elif output is not None: return output return self.dump_object_fallback(tag, attrib, strings) # TODO put content in attrib, use text for caption (with full recursion) # See img def dump_object_fallback(self, tag, attrib, strings=None): '''Method to serialize objects that do not have their own handler for this format. @implementation: must be implemented in sub-classes ''' raise NotImplementedError def isrtl(self, text): '''Check for Right To Left script @param text: the text to check @returns: C{True} if C{text} starts with characters in a RTL script, or C{None} if direction is not determined. ''' if pango is None: return None # It seems the find_base_dir() function is not documented in the # python language bindings. The Gtk C code shows the signature: # # pango.find_base_dir(text, length) # # It either returns a direction, or NEUTRAL if e.g. text only # contains punctuation but no real characters. dir = pango.find_base_dir(text, len(text)) if dir == pango.DIRECTION_NEUTRAL: return None else: return dir == pango.DIRECTION_RTL
def runTest(self): '''There is one long test.''' ui = MockUI() ui.notebook = self.notebook ui.page = Path('Test:foo') uistate = ConfigDict() self.assertTrue(self.notebook.get_page(ui.page).exists()) PATHS = ('Parent:Daughter:Granddaughter', 'Test:tags', 'Test:foo', 'Books') LEN_PATHS = len(PATHS) PATHS_NAMES = {PATHS[0]:'name 1', PATHS[1]:'name 2', PATHS[2]:'name 3'} # Check correctness of reading uistate. uistate.setdefault('bookmarks', []) uistate.setdefault('bookmarks_names', {}) uistate['bookmarks'] = list(PATHS) uistate['bookmarks_names'] = dict(PATHS_NAMES) Bar = BookmarkBar(ui, uistate, get_page_func = lambda: '') self.assertTrue(Bar.paths == list(PATHS)) self.assertTrue(Bar.paths_names == PATHS_NAMES) uistate['bookmarks'] = [] uistate['bookmarks_names'] = {} Bar = BookmarkBar(ui, uistate, get_page_func = lambda: '') self.assertTrue(Bar.paths == []) self.assertTrue(Bar.paths_names == {}) # Add paths to the beginning of the bar. for i, path in enumerate(PATHS): Bar._add_new(path, add_bookmarks_to_beginning = True) self.assertTrue(len(Bar.paths) == i + 1) self.assertTrue(Bar.paths == list(reversed(PATHS))) # Add paths to the end of the bar. Bar.paths = [] for i, path in enumerate(PATHS): Bar._add_new(path, add_bookmarks_to_beginning = False) self.assertTrue(len(Bar.paths) == i + 1) self.assertTrue(Bar.paths == list(PATHS)) # Check that the same path can't be added to the bar. Bar._add_new(PATHS[0]) Bar._add_new(PATHS[1]) self.assertTrue(Bar.paths == list(PATHS)) # Delete paths from the bar. for i, button in enumerate(Bar.container.get_children()[2:]): path = button.zim_path self.assertTrue(path in Bar.paths) Bar.delete(button.zim_path) self.assertTrue(len(Bar.paths) == LEN_PATHS - i - 1) self.assertTrue(path not in Bar.paths) self.assertTrue(Bar.paths == []) # Check short page names. uistate['show_full_page_name'] = False for path in PATHS: Bar._add_new(path) self.assertTrue(Bar.paths == list(PATHS)) for i, button in enumerate(Bar.container.get_children()[2:]): self.assertTrue(PATHS[i] == button.zim_path) self.assertTrue(Path(PATHS[i]).basename == button.get_label()) uistate['show_full_page_name'] = True # Delete all bookmarks from the bar. Bar.delete_all() self.assertTrue(Bar.paths == []) # Check restriction of max bookmarks in the bar. pagelist = set(self.index.list_pages(None)) _enhanced_pagelist = set() for page in pagelist: _enhanced_pagelist.update( set(self.index.list_pages(page)) ) if len(_enhanced_pagelist) > MAX_BOOKMARKS: break pagelist.update(_enhanced_pagelist) self.assertTrue(len(pagelist) > MAX_BOOKMARKS) pagelist = list(pagelist) for page in pagelist: Bar._add_new(page.name) self.assertTrue(len(Bar.paths) == MAX_BOOKMARKS) self.assertTrue(Bar.paths == [a.name for a in pagelist[:MAX_BOOKMARKS]]) Bar.delete_all() # Check 'save' option in preferences. for i, path in enumerate(PATHS): Bar.on_preferences_changed({'save':False, 'add_bookmarks_to_beginning':False}) Bar._add_new(path) self.assertTrue(uistate['bookmarks'] == []) Bar.on_preferences_changed({'save':True, 'add_bookmarks_to_beginning':False}) self.assertTrue(uistate['bookmarks'] == list(PATHS[:i+1])) self.assertTrue(uistate['bookmarks'] == list(PATHS)) # Check changing a bookmark. self.assertTrue('Test' not in Bar.paths) self.assertTrue('Books' in Bar.paths) Bar.change_bookmark('Books', 'Books') self.assertTrue(Bar.paths == list(PATHS)) _b_paths = [a for a in Bar.paths if a != 'Books'] Bar.change_bookmark('Books', 'Test') self.assertTrue('Test' in Bar.paths) self.assertTrue('Books' not in Bar.paths) _e_paths = [a for a in Bar.paths if a != 'Test'] self.assertTrue(_b_paths == _e_paths) Bar.change_bookmark('Test', 'Books') self.assertTrue(Bar.paths == list(PATHS)) # Check deleting a bookmark after deleting a page in the notebook. self.assertTrue(len(Bar.paths) == LEN_PATHS) for i, path in enumerate(PATHS): self.assertTrue(path in Bar.paths) self.notebook.delete_page(Path(path)) self.assertTrue(path not in Bar.paths) self.assertTrue(len(Bar.paths) == LEN_PATHS - i - 1) self.assertTrue(Bar.paths == []) # Check reordering bookmarks. PATHS_2 = ('1','2','3','4','5') PATHS_NAMES_2 = {PATHS_2[0]:'11', PATHS_2[1]:'22', PATHS_2[2]:'33'} Bar.paths = list(PATHS_2) Bar.move_bookmark(PATHS_2[2], PATHS_2[2], 'left') self.assertTrue(Bar.paths == list(PATHS_2)) Bar.move_bookmark(PATHS_2[3], PATHS_2[3], 'right') self.assertTrue(Bar.paths == list(PATHS_2)) Bar.move_bookmark('3', '1', 'left') self.assertTrue(Bar.paths == ['3','1','2','4','5']) Bar.move_bookmark('5', '1', 'left') self.assertTrue(Bar.paths == ['3','5','1','2','4']) Bar.move_bookmark('5', '1', 'right') self.assertTrue(Bar.paths == ['3','1','5','2','4']) Bar.move_bookmark('3', '4', 'right') self.assertTrue(Bar.paths == ['1','5','2','4','3']) Bar.move_bookmark('5', '4', '-') self.assertTrue(Bar.paths == ['1','5','2','4','3']) # CHECK RENAMING # Check rename_bookmark and save options. Bar.paths = list(PATHS_2) button = gtk.Button(label = PATHS_2[0], use_underline = False) button.zim_path = PATHS_2[0] Bar.on_preferences_changed({'save':True, 'add_bookmarks_to_beginning':False}) Bar._reload_bar() def rename_check(label, path, paths_names, path_names_uistate): self.assertTrue(button.get_label() == label) self.assertTrue(button.zim_path == path) self.assertTrue(Bar.paths_names == paths_names) self.assertTrue(uistate['bookmarks_names'] == path_names_uistate) rename_check(PATHS_2[0], PATHS_2[0], {}, {}) Clipboard.set_text('new name') Bar.rename_bookmark(button) rename_check('new name', PATHS_2[0], {PATHS_2[0]:'new name'}, {PATHS_2[0]:'new name'}) Bar.on_preferences_changed({'save':False, 'add_bookmarks_to_beginning':False}) rename_check('new name', PATHS_2[0], {PATHS_2[0]:'new name'}, {}) Bar.on_preferences_changed({'save':True, 'add_bookmarks_to_beginning':False}) rename_check('new name', PATHS_2[0], {PATHS_2[0]:'new name'}, {PATHS_2[0]:'new name'}) Bar.rename_bookmark(button) rename_check(PATHS_2[0], PATHS_2[0], {}, {}) # Check delete with renaming. Bar.on_preferences_changed({'save':True, 'add_bookmarks_to_beginning':False}) paths_names_copy = dict(PATHS_NAMES_2) Bar.paths_names = dict(PATHS_NAMES_2) for key in PATHS_NAMES_2: Bar.delete(key) del paths_names_copy[key] self.assertTrue(Bar.paths_names == paths_names_copy) self.assertTrue(uistate['bookmarks_names'] == Bar.paths_names) # Check delete all with renaming. Bar.paths_names = dict(PATHS_NAMES_2) Bar.delete_all() self.assertTrue(Bar.paths_names == {}) self.assertTrue(uistate['bookmarks_names'] == {}) # Check change bookmark with renaming. Bar.paths = list(PATHS_2) Bar.paths_names = dict(PATHS_NAMES_2) paths_names_copy = dict(PATHS_NAMES_2) paths_names_copy.pop(PATHS_2[0], None) Bar.change_bookmark(PATHS_2[0], 'new path') self.assertTrue(Bar.paths_names == paths_names_copy) self.assertTrue(Bar.paths == ['new path'] + list(PATHS_2[1:])) # Check that paths and paths_names didn't change in the process. self.assertTrue(PATHS_2 == ('1','2','3','4','5')) self.assertTrue(PATHS_NAMES_2 == {PATHS_2[0]:'11', PATHS_2[1]:'22', PATHS_2[2]:'33'})
class TestBookmarksBar(tests.TestCase): def setUp(self): self.PATHS = ('Parent:Daughter:Granddaughter', 'Test:tags', 'Test:foo', 'Books') self.LEN_PATHS = len(self.PATHS) self.PATHS_NAMES = {self.PATHS[0]: 'name 1', self.PATHS[1]: 'name 2', self.PATHS[2]: 'name 3'} self.notebook = self.setUpNotebook(content=self.PATHS) self.uistate = ConfigDict() self.uistate.setdefault('bookmarks', []) self.uistate.setdefault('bookmarks_names', {}) self.uistate.setdefault('show_full_page_name', True) def testGeneral(self): '''Test general functions: add, delete bookmarks.''' navigation = tests.MockObject() bar = BookmarkBar(self.notebook, navigation, self.uistate, get_page_func = lambda: '') bar.max_bookmarks = 15 # set maximum number of bookmarks # Add paths to the beginning of the bar. for i, path in enumerate(self.PATHS): bar._add_new(path, add_bookmarks_to_beginning = True) self.assertEqual(len(bar.paths), i + 1) self.assertTrue(bar.paths == list(reversed(self.PATHS))) # Add paths to the end of the bar. bar.paths = [] for i, path in enumerate(self.PATHS): bar._add_new(path, add_bookmarks_to_beginning = False) self.assertEqual(len(bar.paths), i + 1) self.assertEqual(bar.paths, list(self.PATHS)) # Check that the same path can't be added to the bar. bar._add_new(self.PATHS[0]) bar._add_new(self.PATHS[1]) self.assertEqual(bar.paths, list(self.PATHS)) # Delete paths from the bar. for i, button in enumerate(bar.scrolledbox.get_scrolled_children()): path = button.zim_path self.assertTrue(path in bar.paths) bar.delete(button.zim_path) self.assertEqual(len(bar.paths), self.LEN_PATHS - i - 1) self.assertTrue(path not in bar.paths) self.assertEqual(bar.paths, []) # Delete all bookmarks from the bar. bar.delete_all() self.assertEqual(bar.paths, []) def testDeletePages(self): '''Check deleting a bookmark after deleting a page in the notebook.''' self.uistate['bookmarks'] = list(self.PATHS) navigation = tests.MockObject() bar = BookmarkBar(self.notebook, navigation, self.uistate, get_page_func = lambda: '') for i, path in enumerate(self.PATHS): self.assertTrue(path in bar.paths) self.notebook.delete_page(Path(path)) self.assertTrue(path not in bar.paths) self.assertEqual(len(bar.paths), self.LEN_PATHS - i - 1) self.assertEqual(bar.paths, []) def testFunctions(self): '''Test bookmark functions: changing, reordering, ranaming.''' navigation = tests.MockObject() bar = BookmarkBar(self.notebook, navigation, self.uistate, get_page_func = lambda: '') bar.max_bookmarks = 15 # set maximum number of bookmarks # Check changing a bookmark. for i, path in enumerate(self.PATHS): bar._add_new(path, add_bookmarks_to_beginning = False) self.assertTrue('Test' not in bar.paths) self.assertTrue('Books' in bar.paths) bar.change_bookmark('Books', 'Books') self.assertEqual(bar.paths, list(self.PATHS)) bar.change_bookmark('Books', 'Test') self.assertTrue('Test' in bar.paths) self.assertTrue('Books' not in bar.paths) _result = [a if a != 'Books' else 'Test' for a in self.PATHS] self.assertEqual(bar.paths, _result) bar.change_bookmark('Test', 'Books') self.assertEqual(bar.paths, list(self.PATHS)) # Check reordering bookmarks. new_paths = ('1', '2', '3', '4', '5') bar.paths = list(new_paths) bar.move_bookmark(new_paths[2], new_paths[2], 'left') self.assertEqual(bar.paths, list(new_paths)) bar.move_bookmark(new_paths[3], new_paths[3], 'right') self.assertEqual(bar.paths, list(new_paths)) bar.move_bookmark('3', '1', 'left') self.assertEqual(bar.paths, ['3', '1', '2', '4', '5']) bar.move_bookmark('5', '1', 'left') self.assertEqual(bar.paths, ['3', '5', '1', '2', '4']) bar.move_bookmark('5', '1', 'right') self.assertEqual(bar.paths, ['3', '1', '5', '2', '4']) bar.move_bookmark('3', '4', 'right') self.assertEqual(bar.paths, ['1', '5', '2', '4', '3']) bar.move_bookmark('5', '4', '-') self.assertEqual(bar.paths, ['1', '5', '2', '4', '3']) # Check rename_bookmark and save options. preferences_changed = lambda save: bar.on_preferences_changed({'save': save, 'add_bookmarks_to_beginning': False, 'max_bookmarks': 15}) new_path_names = {new_paths[0]: '11', new_paths[1]: '22', new_paths[2]: '33'} bar.paths = list(new_paths) preferences_changed(True) bar._reload_bar() def rename_check(label, path, paths_names, path_names_uistate): self.assertEqual(button.get_label(), label) self.assertEqual(button.zim_path, path) self.assertEqual(bar.paths_names, paths_names) self.assertEqual(self.uistate['bookmarks_names'], path_names_uistate) button = Gtk.Button(label = new_paths[0], use_underline = False) button.zim_path = new_paths[0] rename_check(new_paths[0], new_paths[0], {}, {}) Clipboard.set_text('new name') bar.rename_bookmark(button) rename_check('new name', new_paths[0], {new_paths[0]: 'new name'}, {new_paths[0]: 'new name'}) preferences_changed(False) rename_check('new name', new_paths[0], {new_paths[0]: 'new name'}, {}) preferences_changed(True) rename_check('new name', new_paths[0], {new_paths[0]: 'new name'}, {new_paths[0]: 'new name'}) bar.rename_bookmark(button) rename_check(new_paths[0], new_paths[0], {}, {}) # Check delete with renaming. preferences_changed(True) paths_names_copy = dict(new_path_names) bar.paths_names = dict(new_path_names) for key in new_path_names: bar.delete(key) del paths_names_copy[key] self.assertEqual(bar.paths_names, paths_names_copy) self.assertEqual(self.uistate['bookmarks_names'], bar.paths_names) # Check delete all with renaming. bar.paths_names = dict(new_path_names) bar.delete_all() self.assertEqual(bar.paths_names, {}) self.assertEqual(self.uistate['bookmarks_names'], {}) # Check change bookmark with renaming. new_path_names = {new_paths[0]: '11', new_paths[1]: '22', new_paths[2]: '33'} bar.paths = list(new_paths) bar.paths_names = dict(new_path_names) paths_names_copy = dict(new_path_names) _name = paths_names_copy.pop(new_paths[0]) paths_names_copy['new path'] = _name bar.change_bookmark(new_paths[0], 'new path') self.assertEqual(bar.paths_names, paths_names_copy) self.assertEqual(bar.paths, ['new path'] + list(new_paths[1:])) def testPreferences(self): '''Check preferences: full/short page names, save option, max number of bookmarks.''' # Check short page names. navigation = tests.MockObject() bar = BookmarkBar(self.notebook, navigation, self.uistate, get_page_func = lambda: '') self.uistate['show_full_page_name'] = False for path in self.PATHS: bar._add_new(path) self.assertEqual(bar.paths, list(self.PATHS)) for i, button in enumerate(bar.scrolledbox.get_scrolled_children()): self.assertEqual(self.PATHS[i], button.zim_path) self.assertEqual(Path(self.PATHS[i]).basename, button.get_label()) # Show full page names. bar.toggle_show_full_page_name() self.assertEqual(bar.paths, list(self.PATHS)) for i, button in enumerate(bar.scrolledbox.get_scrolled_children()): self.assertEqual(self.PATHS[i], button.zim_path) self.assertEqual(self.PATHS[i], button.get_label()) # Check save option. self.uistate['bookmarks'] = list(self.PATHS) self.uistate['bookmarks_names'] = dict(self.PATHS_NAMES) bar = BookmarkBar(self.notebook, navigation, self.uistate, get_page_func = lambda: '') self.assertEqual(bar.paths, list(self.PATHS)) self.assertEqual(bar.paths_names, self.PATHS_NAMES) self.uistate['bookmarks'] = [] self.uistate['bookmarks_names'] = {} bar = BookmarkBar(self.notebook, navigation, self.uistate, get_page_func = lambda: '') self.assertEqual(bar.paths, []) self.assertEqual(bar.paths_names, {}) # Get pages to check max number of bookmarks. pagelist = [] for path in [Path('Page %i' % i) for i in range(25)]: page = self.notebook.get_page(path) page.parse('wiki', 'TEst 123') self.notebook.store_page(page) pagelist.append(path.name) self.assertTrue(len(pagelist) > 20) def preferences_changed(save, max_b): bar.on_preferences_changed({ 'save': save, 'add_bookmarks_to_beginning': False, 'max_bookmarks': max_b}) # Check that more than max bookmarks can be loaded at start. self.uistate['bookmarks'] = pagelist bar = BookmarkBar(self.notebook, navigation, self.uistate, get_page_func = lambda: '') self.assertEqual(pagelist, bar.paths) preferences_changed(True, 5) self.assertEqual(pagelist, bar.paths) self.assertEqual(pagelist, self.uistate['bookmarks']) # Set maximum number of bookmarks. self.uistate['bookmarks'] = [] bar = BookmarkBar(self.notebook, navigation, self.uistate, get_page_func = lambda: '') for max_bookmarks in (5, 10, 15, 20): preferences_changed(False, max_bookmarks) for page in pagelist: bar._add_new(page) self.assertEqual(len(bar.paths), max_bookmarks) self.assertEqual(bar.paths, pagelist[:max_bookmarks]) bar.delete_all() # Check 'save' option in preferences. for i, path in enumerate(self.PATHS): preferences_changed(False, 15) bar._add_new(path) self.assertEqual(self.uistate['bookmarks'], []) preferences_changed(True, 15) self.assertEqual(self.uistate['bookmarks'], list(self.PATHS[:i + 1])) self.assertEqual(self.uistate['bookmarks'], list(self.PATHS))
class Notebook(gobject.GObject): '''Main class to access a notebook. Proxies between backend Store and Index objects on the one hand and the gui application on the other This class has the following signals: * store-page (page) * move-page (oldpath, newpath, update_links) * delete-page (path) * properties-changed () All signals are defined with the SIGNAL_RUN_LAST type, so any handler connected normally will run before the actual action. Use "connect_after()" to install handlers after storing, moving or deleting a page. ''' # TODO add checks for read-only page in much more methods # define signals we want to use - (closure type, return type and arg types) __gsignals__ = { 'store-page': (gobject.SIGNAL_RUN_LAST, None, (object,)), 'move-page': (gobject.SIGNAL_RUN_LAST, None, (object, object, bool)), 'delete-page': (gobject.SIGNAL_RUN_LAST, None, (object,)), 'properties-changed': (gobject.SIGNAL_RUN_FIRST, None, ()), } properties = ( ('name', 'string', _('Name')), # T: label for properties dialog ('home', 'page', _('Home Page')), # T: label for properties dialog ('icon', 'image', _('Icon')), # T: label for properties dialog ('document_root', 'dir', _('Document Root')), # T: label for properties dialog ('slow_fs', 'bool', _('Slow file system')), # T: label for properties dialog #~ ('autosave', 'bool', _('Auto-version when closing the notebook')), # T: label for properties dialog ) def __init__(self, dir=None, file=None, config=None, index=None): assert not (dir and file), 'BUG: can not provide both dir and file ' gobject.GObject.__init__(self) self._namespaces = [] # list used to resolve stores self._stores = {} # dict mapping namespaces to stores self.namespace_properties = HierarchicDict() self._page_cache = weakref.WeakValueDictionary() self.dir = None self.file = None self.cache_dir = None self.name = None self.icon = None self.config = config if dir: assert isinstance(dir, Dir) self.dir = dir self.readonly = not dir.iswritable() self.cache_dir = dir.subdir('.zim') if self.readonly or not self.cache_dir.iswritable(): self.cache_dir = self._cache_dir(dir) logger.debug('Cache dir: %s', self.cache_dir) if self.config is None: self.config = ConfigDictFile(dir.file('notebook.zim')) # TODO check if config defined root namespace self.add_store(Path(':'), 'files') # set root # TODO add other namespaces from config elif file: assert isinstance(file, File) self.file = file self.readonly = not file.iswritable() assert False, 'TODO: support for single file notebooks' if index is None: import zim.index # circular import self.index = zim.index.Index(notebook=self) else: self.index = index self.index.set_notebook(self) if self.config is None: self.config = ConfigDict() self.config['Notebook'].setdefault('name', None, klass=basestring) self.config['Notebook'].setdefault('home', ':Home', klass=basestring) self.config['Notebook'].setdefault('icon', None, klass=basestring) self.config['Notebook'].setdefault('document_root', None, klass=basestring) self.config['Notebook'].setdefault('slow_fs', False) self.do_properties_changed() @property def uri(self): '''Returns a file:// uri for this notebook that can be opened by zim''' assert self.dir or self.file, 'Notebook does not have a dir or file' if self.dir: return self.dir.uri else: return self.file.uri def _cache_dir(self, dir): from zim.config import XDG_CACHE_HOME path = 'notebook-' + dir.path.replace('/', '_').strip('_') return XDG_CACHE_HOME.subdir(('zim', path)) def save_properties(self, **properties): # Check if icon is relative if 'icon' in properties and properties['icon'] \ and self.dir and properties['icon'].startswith(self.dir.path): i = len(self.dir.path) path = './' + properties['icon'][i:].lstrip('/\\') # TODO use proper fs routine(s) for this substitution properties['icon'] = path # Set home page as string if 'home' in properties and isinstance(properties['home'], Path): properties['home'] = properties['home'].name self.config['Notebook'].update(properties) self.config.write() self.emit('properties-changed') def do_properties_changed(self): #~ import pprint #~ pprint.pprint(self.config) config = self.config['Notebook'] # Set a name for ourselves if config['name']: self.name = config['name'] elif self.dir: self.name = self.dir.basename elif self.file: self.name = self.file.basename else: self.name = 'Unnamed Notebook' # We should always have a home config.setdefault('home', ':Home') # Resolve icon, can be relative # TODO proper FS routine to check abs path - also allowed without the "./" - so e.g. icon.png should be resolved as well if self.dir and config['icon'] and config['icon'].startswith('.'): self.icon = self.dir.file(config['icon']).path elif config['icon']: self.icon = File(config['icon']).path else: self.icon = None # Set FS property if config['slow_fs']: print 'TODO: hook slow_fs property' def add_store(self, path, store, **args): '''Add a store to the notebook to handle a specific path and all it's sub-pages. Needs a Path and a store name, all other args will be passed to the store. Alternatively you can pass a store object but in that case no arguments are allowed. Returns the store object. ''' assert not path.name in self._stores, 'Store for "%s" exists' % path if isinstance(store, basestring): mod = zim.stores.get_store(store) mystore = mod.Store(notebook=self, path=path, **args) else: assert not args mystore = store self._stores[path.name] = mystore self._namespaces.append(path.name) # keep order correct for lookup self._namespaces.sort(reverse=True) return mystore def get_store(self, path): '''Returns the store object to handle a page or namespace.''' for namespace in self._namespaces: # longest match first because of reverse sorting if namespace == '' \ or page.name == namespace \ or page.name.startswith(namespace+':'): return self._stores[namespace] else: raise LookupError, 'Could not find store for: %s' % name def get_stores(self): return self._stores.values() def resolve_path(self, name, source=None, index=None): '''Returns a proper path name for page names given in links or from user input. The optional argument 'source' is the path for the refering page, if any, or the path of the "current" page in the user interface. The 'index' argument allows specifying an index object, if none is given the default index for this notebook is used. If no source path is given or if the page name starts with a ':' the name is considered an absolute name and only case is resolved. If the page does not exist the last part(s) of the name will remain in the case as given. If a source path is given and the page name starts with '+' it will be resolved as a direct child of the source. Else we first look for a match of the first part of the name in the source path. If that fails we do a search for the first part of the name through all namespaces in the source path, starting with pages below the namespace of the source. If no existing page was found in this search we default to a new page below this namespace. So if we for example look for "baz" with as source ":foo:bar:dus" the following pages will be checked in a case insensitive way: :foo:bar:baz :foo:baz :baz And if none exist we default to ":foo:bar:baz" However if for example we are looking for "bar:bud" with as source ":foo:bar:baz:dus", we only try to resolve the case for ":foo:bar:bud" and default to the given case if it does not yet exist. This method will raise a PageNameError if the name resolves to an empty string. Since all trailing ":" characters are removed there is no way for the name to address the root path in this method - and typically user input should not need to able to address this path. ''' assert name, 'BUG: name is empty string' startswith = name[0] if startswith == '.': startswith = '+' # backward compat if startswith == '+': name = name[1:] name = self.cleanup_pathname(name) if index is None: index = self.index if startswith == ':' or source == None: return index.resolve_case(name) or Path(name) elif startswith == '+': if not source: raise PageNameError, '+'+name return index.resolve_case(source.name+':'+name) \ or Path(source.name+':'+name) # FIXME use parent as argument else: # first check if we see an explicit match in the path assert isinstance(source, Path) anchor = name.split(':')[0].lower() path = source.namespace.lower().split(':') if anchor in path: # ok, so we can shortcut to an absolute path path.reverse() # why is there no rindex or rfind ? i = path.index(anchor) + 1 path = path[i:] path.reverse() path.append( name.lstrip(':') ) name = ':'.join(path) return index.resolve_case(name) or Path(name) # FIXME use parentt as argument # FIXME use short cut when the result is the parent else: # no luck, do a search through the whole path - including root source = index.lookup_path(source) or source for parent in source.parents(): candidate = index.resolve_case(name, namespace=parent) if not candidate is None: return candidate else: # name not found, keep case as is return source.parent + name def relative_link(self, source, href): '''Returns a link for a path 'href' relative to path 'source'. More or less the opposite of resolve_path(). ''' if href == source: return href.basename elif href > source: return '+' + href.relname(source) else: parent = source.commonparent(href) if parent.isroot: return ':' + href.name else: return parent.basename + ':' + href.relname(parent) def register_hook(self, name, handler): '''Register a handler method for a specific hook''' register = '_register_%s' % name if not hasattr(self, register): setattr(self, register, []) getattr(self, register).append(handler) def unregister_hook(self, name, handler): '''Remove a handler method for a specific hook''' register = '_register_%s' % name if hasattr(self, register): getattr(self, register).remove(handler) def suggest_link(self, source, word): '''Suggest a link Path for 'word' or return None if no suggestion is found. By default we do not do any suggestion but plugins can register handlers to add suggestions. See 'register_hook()' to register a handler. ''' if not hasattr(self, '_register_suggest_link'): return None for handler in self._register_suggest_link: link = handler(source, word) if not link is None: return link else: return None @staticmethod def cleanup_pathname(name): '''Returns a safe version of name, used internally by functions like resolve_path() to parse user input. ''' orig = name name = ':'.join( map(unicode.strip, filter(lambda n: len(n)>0, unicode(name).split(':')) ) ) # Reserved characters are: # The ':' is reserrved as seperator # The '?' is reserved to encode url style options # The '#' is reserved as anchor separator # The '/' and '\' are reserved to distinquise file links & urls # First character of each part MUST be alphanumeric # (including utf8 letters / numbers) # Zim version < 0.42 restricted all special charachters but # white listed ".", "-", "_", "(", ")", ":" and "%". # TODO check for illegal characters in the name if not name or name.isspace(): raise PageNameError, orig return name def get_page(self, path): '''Returns a Page object. This method uses a weakref dictionary to ensure that an unique object is being used for each page that is given out. ''' # As a special case, using an invalid page as the argument should # return a valid page object. assert isinstance(path, Path) if path.name in self._page_cache \ and self._page_cache[path.name].valid: return self._page_cache[path.name] else: store = self.get_store(path) page = store.get_page(path) # TODO - set haschildren if page maps to a store namespace self._page_cache[path.name] = page return page def flush_page_cache(self, path): '''Remove a page from the page cache, calling get_page() after this will return a fresh page object. Be aware that the old object may still be around but will have its 'valid' attribute set to False. This function also removes all child pages of path from the cache. ''' names = [path.name] ns = path.name + ':' names.extend(k for k in self._page_cache.keys() if k.startswith(ns)) for name in names: if name in self._page_cache: page = self._page_cache[name] assert not page.modified, 'BUG: Flushing page with unsaved changes' page.valid = False del self._page_cache[name] def get_home_page(self): '''Returns a page object for the home page.''' path = self.resolve_path(self.config['Notebook']['home']) return self.get_page(path) def get_pagelist(self, path): '''Returns a list of page objects.''' store = self.get_store(path) return store.get_pagelist(path) # TODO: add sub-stores in this namespace if any def store_page(self, page): '''Store a page permanently. Commits the parse tree from the page object to the backend store. ''' assert page.valid, 'BUG: page object no longer valid' with SignalExceptionContext(self, 'store-page'): self.emit('store-page', page) def do_store_page(self, page): with SignalRaiseExceptionContext(self, 'store-page'): store = self.get_store(page) store.store_page(page) def revert_page(self, page): '''Reloads the parse tree from the store into the page object. In a sense the opposite to store_page(). Used in the gui to discard changes in a page. ''' # get_page without the cache assert page.valid, 'BUG: page object no longer valid' store = self.get_store(page) storedpage = store.get_page(page) page.set_parsetree(storedpage.get_parsetree()) page.modified = False def move_page(self, path, newpath, update_links=True): '''Move a page from 'path' to 'newpath'. If 'update_links' is True all links from and to the page will be modified as well. ''' if update_links and self.index.updating: raise IndexBusyError, 'Index busy' # Index need to be complete in order to be 100% sure we # know all backlinks, so no way we can update links before. page = self.get_page(path) if not (page.hascontent or page.haschildren): raise LookupError, 'Page does not exist: %s' % path.name assert not page.modified, 'BUG: moving a page with uncomitted changes' with SignalExceptionContext(self, 'move-page'): self.emit('move-page', path, newpath, update_links) def do_move_page(self, path, newpath, update_links): logger.debug('Move %s to %s (%s)', path, newpath, update_links) with SignalRaiseExceptionContext(self, 'move-page'): # Collect backlinks if update_links: from zim.index import LINK_DIR_BACKWARD backlinkpages = set( l.source for l in self.index.list_links(path, LINK_DIR_BACKWARD) ) for child in self.index.walk(path): backlinkpages.update(set( l.source for l in self.index.list_links(path, LINK_DIR_BACKWARD) )) # Do the actual move store = self.get_store(path) newstore = self.get_store(newpath) if newstore == store: store.move_page(path, newpath) else: assert False, 'TODO: move between stores' # recursive + move attachments as well self.flush_page_cache(path) self.flush_page_cache(newpath) # Update links in moved pages page = self.get_page(newpath) if page.hascontent: self._update_links_from(page, path) store = self.get_store(page) store.store_page(page) # do not use self.store_page because it emits signals for child in self._no_index_walk(newpath): if not child.hascontent: continue oldpath = path + child.relname(newpath) self._update_links_from(child, oldpath) store = self.get_store(child) store.store_page(child) # do not use self.store_page because it emits signals # Update links to the moved page tree if update_links: # Need this indexed before we can resolve links to it self.index.delete(path) self.index.update(newpath) #~ print backlinkpages for p in backlinkpages: if p == path or p > path: continue page = self.get_page(p) self._update_links_in_page(page, path, newpath) self.store_page(page) def _no_index_walk(self, path): '''Walking that can be used when the index is not in sync''' # TODO allow this to cross several stores store = self.get_store(path) for page in store.get_pagelist(path): yield page for child in self._no_index_walk(page): # recurs yield child @staticmethod def _update_link_tag(tag, newhref): newhref = str(newhref) haschildren = bool(list(tag.getchildren())) if not haschildren and tag.text == tag.attrib['href']: tag.text = newhref tag.attrib['href'] = newhref def _update_links_from(self, page, oldpath): logger.debug('Updating links in %s (was %s)', page, oldpath) tree = page.get_parsetree() if not tree: return for tag in tree.getiterator('link'): href = tag.attrib['href'] type = link_type(href) if type == 'page': hrefpath = self.resolve_path(href, source=page) oldhrefpath = self.resolve_path(href, source=oldpath) #~ print 'LINK', oldhrefpath, '->', hrefpath if hrefpath != oldhrefpath: if hrefpath >= page and oldhrefpath >= oldpath: #~ print '\t.. Ignore' pass else: newhref = self.relative_link(page, oldhrefpath) #~ print '\t->', newhref self._update_link_tag(tag, newhref) page.set_parsetree(tree) def _update_links_in_page(self, page, oldpath, newpath): # Maybe counter intuitive, but pages below oldpath do not need # to exist anymore while we still try to resolve links to these # pages. The reason is that all pages that could link _upward_ # to these pages are below and are moved as well. logger.debug('Updating links in %s to %s (was: %s)', page, newpath, oldpath) tree = page.get_parsetree() if not tree: logger.warn('Page turned out to be empty: %s', page) return for tag in tree.getiterator('link'): href = tag.attrib['href'] type = link_type(href) if type == 'page': hrefpath = self.resolve_path(href, source=page) #~ print 'LINK', hrefpath if hrefpath == oldpath: newhrefpath = newpath #~ print '\t==', oldpath, '->', newhrefpath elif hrefpath > oldpath: rel = hrefpath.relname(oldpath) newhrefpath = newpath + rel #~ print '\t>', oldpath, '->', newhrefpath else: continue newhref = self.relative_link(page, newhrefpath) self._update_link_tag(tag, newhref) page.set_parsetree(tree) def rename_page(self, path, newbasename, update_heading=True, update_links=True): '''Rename page to a page in the same namespace but with a new basename. If 'update_heading' is True the first heading in the page will be updated to it's new name. If 'update_links' is True all links from and to the page will be modified as well. ''' logger.debug('Rename %s to "%s" (%s, %s)', path, newbasename, update_heading, update_links) newbasename = self.cleanup_pathname(newbasename) newpath = Path(path.namespace + ':' + newbasename) if newbasename.lower() != path.basename.lower(): # allow explicit case-sensitive renaming newpath = self.index.resolve_case( newbasename, namespace=path.parent) or newpath self.move_page(path, newpath, update_links=update_links) if update_heading: page = self.get_page(newpath) tree = page.get_parsetree() if not tree is None: tree.set_heading(newbasename.title()) page.set_parsetree(tree) self.store_page(page) return newpath def delete_page(self, path): with SignalExceptionContext(self, 'delete-page'): self.emit('delete-page', path) def do_delete_page(self, path): with SignalRaiseExceptionContext(self, 'delete-page'): store = self.get_store(path) store.delete_page(path) self.flush_page_cache(path) def resolve_file(self, filename, path): '''Resolves a file or directory path relative to a page. Returns a File object. However the file does not have to exist. File urls and paths that start with '~/' or '~user/' are considered absolute paths and are returned unmodified. In case the file path starts with '/' the the path is taken relative to the document root - this can e.g. be a parent directory of the notebook. Defaults to the home dir. Other paths are considered attachments and are resolved relative to the namespce below the page. Because this is used to resolve file links and is supposed to be platform independent it tries to convert windows filenames to unix equivalents. ''' filename = filename.replace('\\', '/') if filename.startswith('~') or filename.startswith('file:/'): return File(filename) elif filename.startswith('/'): dir = self.get_document_root() or Dir('~') return dir.file(filename) elif is_win32_path_re.match(filename): if not filename.startswith('/'): filename = '/'+filename # make absolute on unix return File(filename) else: # TODO - how to deal with '..' in the middle of the path ? filepath = [p for p in filename.split('/') if len(p) and p != '.'] if not filepath: # filename is e.g. "." return self.get_attachments_dir(path) pagepath = path.name.split(':') filename = filepath.pop() while filepath and filepath[0] == '..': if not pagepath: print 'TODO: handle paths relative to notebook but outside notebook dir' return File('/TODO') else: filepath.pop(0) pagepath.pop() pagename = ':'+':'.join(pagepath + filepath) dir = self.get_attachments_dir(Path(pagename)) return dir.file(filename) def relative_filepath(self, file, path=None): '''Returns a filepath relative to either the documents dir (/xxx), the attachments dir (if a path is given) (./xxx or ../xxx) or the users home dir (~/xxx). Returns None otherwise. Intended as the counter part of resolve_file(). Typically this function is used to present the user with readable paths or to shorten the paths inserted in the wiki code. It is advised to use file uris for links that can not be made relative. ''' if path: root = self.dir dir = self.get_attachments_dir(path) if file.ischild(dir): return './'+file.relpath(dir) elif root and file.ischild(root) and dir.ischild(root): parent = file.commonparent(dir) uppath = dir.relpath(parent) downpath = file.relpath(parent) up = 1 + uppath.count('/') return '../'*up + downpath dir = self.get_document_root() if dir and file.ischild(dir): return '/'+file.relpath(dir) dir = Dir('~') if file.ischild(dir): return '~/'+file.relpath(dir) return None def get_attachments_dir(self, path): '''Returns a Dir object for the attachments directory for 'path'. The directory does not need to exist. ''' store = self.get_store(path) return store.get_attachments_dir(path) def get_document_root(self): '''Returns the Dir object for the document root or None''' path = self.config['Notebook']['document_root'] if path: return Dir(path) else: return None def get_template(self, path): '''Returns a template object for path. Typically used to set initial content for a new page. ''' from zim.templates import get_template template = self.namespace_properties[path].get('template', '_New') logger.debug('Found template \'%s\' for %s', template, path) return get_template('wiki', template) def walk(self, path=None): '''Generator function which iterates through all pages, depth first. If a path is given, only iterates through sub-pages of that path. If you are only interested in the paths using Index.walk() will be more efficient. ''' if path == None: path = Path(':') for p in self.index.walk(path): page = self.get_page(p) yield page def get_pagelist_indexkey(self, path): store = self.get_store(path) return store.get_pagelist_indexkey(path) def get_page_indexkey(self, path): store = self.get_store(path) return store.get_page_indexkey(path)