* Readonly document view.
[redakcja.git] / lib / vstorage.py
index 7a80c9b..82151da 100644 (file)
@@ -1,4 +1,8 @@
 # -*- coding: utf-8 -*-
 # -*- coding: utf-8 -*-
+#
+# This file is part of FNP-Redakcja, licensed under GNU Affero GPLv3 or later.
+# Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
+#
 import os
 import tempfile
 import datetime
 import os
 import tempfile
 import datetime
@@ -19,9 +23,9 @@ import mercurial.revlog
 import mercurial.util
 
 
 import mercurial.util
 
 
-def urlquote(url, safe = '/'):
-    """Quotes URL 
-    
+def urlquote(url, safe='/'):
+    """Quotes URL
+
     >>> urlquote(u'Za\u017c\xf3\u0142\u0107 g\u0119\u015bl\u0105 ja\u017a\u0144')
     'Za%C5%BC%C3%B3%C5%82%C4%87_g%C4%99%C5%9Bl%C4%85_ja%C5%BA%C5%84'
     """
     >>> urlquote(u'Za\u017c\xf3\u0142\u0107 g\u0119\u015bl\u0105 ja\u017a\u0144')
     'Za%C5%BC%C3%B3%C5%82%C4%87_g%C4%99%C5%9Bl%C4%85_ja%C5%BA%C5%84'
     """
@@ -29,8 +33,8 @@ def urlquote(url, safe = '/'):
 
 
 def urlunquote(url):
 
 
 def urlunquote(url):
-    """Unqotes URL 
-    
+    """Unqotes URL
+
     # >>> urlunquote('Za%C5%BC%C3%B3%C5%82%C4%87_g%C4%99%C5%9Bl%C4%85_ja%C5%BA%C5%84')
     # u'Za\u017c\xf3\u0142\u0107 g\u0119\u015bl\u0105 ja\u017a\u0144'
     """
     # >>> urlunquote('Za%C5%BC%C3%B3%C5%82%C4%87_g%C4%99%C5%9Bl%C4%85_ja%C5%BA%C5%84')
     # u'Za\u017c\xf3\u0142\u0107 g\u0119\u015bl\u0105 ja\u017a\u0144'
     """
@@ -55,10 +59,11 @@ def with_working_copy_locked(func):
         wlock = self.repo.wlock()
         try:
             return func(self, *args, **kwargs)
         wlock = self.repo.wlock()
         try:
             return func(self, *args, **kwargs)
-        finally:            
+        finally:
             wlock.release()
     return wrapped
 
             wlock.release()
     return wrapped
 
+
 def with_storage_locked(func):
     """A decorator for locking the repository when calling a method."""
 
 def with_storage_locked(func):
     """A decorator for locking the repository when calling a method."""
 
@@ -68,10 +73,11 @@ def with_storage_locked(func):
         lock = self.repo.lock()
         try:
             return func(self, *args, **kwargs)
         lock = self.repo.lock()
         try:
             return func(self, *args, **kwargs)
-        finally:            
+        finally:
             lock.release()
     return wrapped
 
             lock.release()
     return wrapped
 
+
 def guess_mime(file_name):
     """
     Guess file's mime type based on extension.
 def guess_mime(file_name):
     """
     Guess file's mime type based on extension.
@@ -91,7 +97,7 @@ def guess_mime(file_name):
     'archive/gzip'
     """
 
     'archive/gzip'
     """
 
-    mime, encoding = mimetypes.guess_type(file_name, strict = False)
+    mime, encoding = mimetypes.guess_type(file_name, strict=False)
     if encoding:
         mime = 'archive/%s' % encoding
     if mime is None:
     if encoding:
         mime = 'archive/%s' % encoding
     if mime is None:
@@ -109,7 +115,7 @@ class VersionedStorage(object):
     change history, using Mercurial repository as the storage method.
     """
 
     change history, using Mercurial repository as the storage method.
     """
 
-    def __init__(self, path, charset = None):
+    def __init__(self, path, charset=None):
         """
         Takes the path to the directory where the pages are to be kept.
         If the directory doen't exist, it will be created. If it's inside
         """
         Takes the path to the directory where the pages are to be kept.
         If the directory doen't exist, it will be created. If it's inside
@@ -122,38 +128,38 @@ class VersionedStorage(object):
         if not os.path.exists(self.path):
             os.makedirs(self.path)
         self.repo_path = find_repo_path(self.path)
         if not os.path.exists(self.path):
             os.makedirs(self.path)
         self.repo_path = find_repo_path(self.path)
-        
+
         self.ui = mercurial.ui.ui()
         self.ui.quiet = True
         self.ui._report_untrusted = False
         self.ui.setconfig('ui', 'interactive', False)
         self.ui = mercurial.ui.ui()
         self.ui.quiet = True
         self.ui._report_untrusted = False
         self.ui.setconfig('ui', 'interactive', False)
-        
+
         if self.repo_path is None:
             self.repo_path = self.path
             create = True
         else:
             create = False
         if self.repo_path is None:
             self.repo_path = self.path
             create = True
         else:
             create = False
-            
+
         self.repo_prefix = self.path[len(self.repo_path):].strip('/')
         self.repo = mercurial.hg.repository(self.ui, self.repo_path,
         self.repo_prefix = self.path[len(self.repo_path):].strip('/')
         self.repo = mercurial.hg.repository(self.ui, self.repo_path,
-                                            create = create)
+                                            create=create)
 
     def reopen(self):
         """Close and reopen the repo, to make sure we are up to date."""
         self.repo = mercurial.hg.repository(self.ui, self.repo_path)
 
     def _file_path(self, title):
 
     def reopen(self):
         """Close and reopen the repo, to make sure we are up to date."""
         self.repo = mercurial.hg.repository(self.ui, self.repo_path)
 
     def _file_path(self, title):
-        return os.path.join(self.path, urlquote(title, safe = ''))
+        return os.path.join(self.path, urlquote(title, safe=''))
 
     def _title_to_file(self, title):
 
     def _title_to_file(self, title):
-        return os.path.join(self.repo_prefix, urlquote(title, safe = ''))
+        return os.path.join(self.repo_prefix, urlquote(title, safe=''))
 
     def _file_to_title(self, filename):
         assert filename.startswith(self.repo_prefix)
         name = filename[len(self.repo_prefix):].strip('/')
         return urlunquote(name)
 
 
     def _file_to_title(self, filename):
         assert filename.startswith(self.repo_prefix)
         name = filename[len(self.repo_prefix):].strip('/')
         return urlunquote(name)
 
-    def __contains__(self, title):                        
+    def __contains__(self, title):
         return urlquote(title) in self.repo['tip']
 
     def __iter__(self):
         return urlquote(title) in self.repo['tip']
 
     def __iter__(self):
@@ -191,40 +197,42 @@ class VersionedStorage(object):
 
     @with_working_copy_locked
     @with_storage_locked
 
     @with_working_copy_locked
     @with_storage_locked
-    def save_file(self, title, file_name, author = u'', comment = u'', parent = None):
+    def save_file(self, title, file_name, **kwargs):
         """Save an existing file as specified page."""
         """Save an existing file as specified page."""
-        user = author.encode('utf-8') or u'anonymous'.encode('utf-8')
-        text = comment.encode('utf-8') or u'comment'.encode('utf-8')
-        
+
+        author = kwargs.get('author', u'anonymous').encode('utf-8')
+        comment = kwargs.get('comment', u'Empty comment.').encode('utf-8')
+        parent = kwargs.get('parent', None)
+
         repo_file = self._title_to_file(title)
         file_path = self._file_path(title)
         mercurial.util.rename(file_name, file_path)
         changectx = self._changectx()
         repo_file = self._title_to_file(title)
         file_path = self._file_path(title)
         mercurial.util.rename(file_name, file_path)
         changectx = self._changectx()
-        
+
         try:
             filectx_tip = changectx[repo_file]
             current_page_rev = filectx_tip.filerev()
         except mercurial.revlog.LookupError:
             self.repo.add([repo_file])
             current_page_rev = -1
         try:
             filectx_tip = changectx[repo_file]
             current_page_rev = filectx_tip.filerev()
         except mercurial.revlog.LookupError:
             self.repo.add([repo_file])
             current_page_rev = -1
-        
+
         if parent is not None and current_page_rev != parent:
         if parent is not None and current_page_rev != parent:
-            msg = self.merge_changes(changectx, repo_file, text, user, parent)
-            user = '<wiki>'
-            text = msg.encode('utf-8')
-            
-        self._commit([repo_file], text, user)
-        
-        
+            msg = self.merge_changes(changectx, repo_file, comment, author, parent)
+            author = '<wiki>'
+            comment = msg.encode('utf-8')
+
+        self._commit([repo_file], comment, author)
+
     def save_data(self, title, data, **kwargs):
         """Save data as specified page."""
         try:
     def save_data(self, title, data, **kwargs):
         """Save data as specified page."""
         try:
-            temp_path = tempfile.mkdtemp(dir = self.path)
+            temp_path = tempfile.mkdtemp(dir=self.path)
             file_path = os.path.join(temp_path, 'saved')
             f = open(file_path, "wb")
             f.write(data)
             f.close()
             file_path = os.path.join(temp_path, 'saved')
             f = open(file_path, "wb")
             f.write(data)
             f.close()
-            self.save_file(title = title, file_name = file_path, **kwargs)
+
+            return self.save_file(title=title, file_name=file_path, **kwargs)
         finally:
             try:
                 os.unlink(file_path)
         finally:
             try:
                 os.unlink(file_path)
@@ -235,28 +243,18 @@ class VersionedStorage(object):
             except OSError:
                 pass
 
             except OSError:
                 pass
 
-    def save_text(self, text, **kwargs):
-        """Save text as specified page, encoded to charset.""" 
-        self.save_data(data = text.encode(self.charset), **kwargs)
-
+    def save_text(self, **kwargs):
+        """Save text as specified page, encoded to charset."""
+        text = kwargs.pop('text')
+        return self.save_data(data=text.encode(self.charset), **kwargs)
 
 
-    def _commit(self, files, text, user):
+    def _commit(self, files, comment, user):
         match = mercurial.match.exact(self.repo_path, '', list(files))
         match = mercurial.match.exact(self.repo_path, '', list(files))
-        return self.repo.commit(match = match, text = text, user = user, force = True)
-    def page_text(self, title):
-        """Read unicode text of a page."""
-        data = self.open_page(title).read()
-        text = unicode(data, self.charset, 'replace')
-        return text
-
-    def page_lines(self, page):
-        for data in page:
-            yield unicode(data, self.charset, 'replace')
+        return self.repo.commit(match=match, text=comment, user=user, force=True)
 
     @with_working_copy_locked
     @with_storage_locked
 
     @with_working_copy_locked
     @with_storage_locked
-    def delete_page(self, title, author = u'', comment = u''):
+    def delete_page(self, title, author=u'', comment=u''):
         user = author.encode('utf-8') or 'anon'
         text = comment.encode('utf-8') or 'deleted'
         repo_file = self._title_to_file(title)
         user = author.encode('utf-8') or 'anon'
         text = comment.encode('utf-8') or 'deleted'
         repo_file = self._title_to_file(title)
@@ -268,49 +266,54 @@ class VersionedStorage(object):
         self.repo.remove([repo_file])
         self._commit([repo_file], text, user)
 
         self.repo.remove([repo_file])
         self._commit([repo_file], text, user)
 
-    @with_working_copy_locked
-    def open_page(self, title):
-        if title not in self:
-            raise DocumentNotFound()
-        
-        path = self._title_to_file(title)
-        logger.debug("Opening page %s", path)      
+    def page_text(self, title, revision=None):
+        """Read unicode text of a page."""
+        ctx = self._find_filectx(title, revision)
+
+        if ctx is None:
+            raise DocumentNotFound(title)
+
+        return ctx.data().decode(self.charset, 'replace'), ctx.filerev()
+
+    def page_text_by_tag(self, title, tag):
+        """Read unicode text of a taged page."""
+        fname = self._title_to_file(title)
+        tag = u"{fname}#{tag}".format(**locals()).encode('utf-8')
+
         try:
         try:
-            return self.repo.wfile(path, 'rb')            
-        except IOError:
-            logger.exception("Failed to open page %s", title)
-            raise DocumentNotFound()
+            ctx = self.repo[tag][fname]
+            return ctx.data().decode(self.charset, 'replace'), ctx.filerev()
+        except IndexError:
+            raise DocumentNotFound(fname)
 
     @with_working_copy_locked
     def page_file_meta(self, title):
         """Get page's inode number, size and last modification time."""
         try:
 
     @with_working_copy_locked
     def page_file_meta(self, title):
         """Get page's inode number, size and last modification time."""
         try:
-            (st_mode, st_ino, st_dev, st_nlink, st_uid, st_gid, st_size,
-             st_atime, st_mtime, st_ctime) = os.stat(self._file_path(title))
+            (_st_mode, st_ino, _st_dev, _st_nlink, _st_uid, _st_gid, st_size,
+             _st_atime, st_mtime, _st_ctime) = os.stat(self._file_path(title))
         except OSError:
             return 0, 0, 0
         return st_ino, st_size, st_mtime
         except OSError:
             return 0, 0, 0
         return st_ino, st_size, st_mtime
-    
+
     @with_working_copy_locked
     @with_working_copy_locked
-    def page_meta(self, title):
+    def page_meta(self, title, revision=None):
         """Get page's revision, date, last editor and his edit comment."""
         """Get page's revision, date, last editor and his edit comment."""
-        if not title in self:
-            raise DocumentNotFound()
+        fctx = self._find_filectx(title, revision)
 
 
-        filectx_tip = self._find_filectx(title)
-        if filectx_tip is None:
-            raise DocumentNotFound()
-        rev = filectx_tip.filerev()
-        filectx = filectx_tip.filectx(rev)
-        date = datetime.datetime.fromtimestamp(filectx.date()[0])
-        author = unicode(filectx.user(), "utf-8",
-                         'replace').split('<')[0].strip()
-        comment = unicode(filectx.description(), "utf-8", 'replace')
-        return rev, date, author, comment
+        if fctx is None:
+            raise DocumentNotFound(title)
+
+        return {
+            "revision": fctx.filerev(),
+            "date": datetime.datetime.fromtimestamp(fctx.date()[0]),
+            "author": fctx.user().decode("utf-8", 'replace'),
+            "comment": fctx.description().decode("utf-8", 'replace'),
+        }
 
     def repo_revision(self):
         return self.repo['tip'].rev()
 
     def repo_revision(self):
         return self.repo['tip'].rev()
-    
+
     def _changectx(self):
         return self.repo['tip']
 
     def _changectx(self):
         return self.repo['tip']
 
@@ -321,53 +324,68 @@ class VersionedStorage(object):
         """
         return guess_mime(self._file_path(title))
 
         """
         return guess_mime(self._file_path(title))
 
-    def _find_filectx(self, title):
+    def _find_filectx(self, title, rev=None):
         """Find the last revision in which the file existed."""
         """Find the last revision in which the file existed."""
-
         repo_file = self._title_to_file(title)
         repo_file = self._title_to_file(title)
-        changectx = self._changectx()
+        changectx = self._changectx()  # start with tip
         stack = [changectx]
         stack = [changectx]
+
         while repo_file not in changectx:
             if not stack:
                 return None
         while repo_file not in changectx:
             if not stack:
                 return None
+
             changectx = stack.pop()
             for parent in changectx.parents():
                 if parent != changectx:
                     stack.append(parent)
             changectx = stack.pop()
             for parent in changectx.parents():
                 if parent != changectx:
                     stack.append(parent)
-        return changectx[repo_file]
+
+        try:
+            fctx = changectx[repo_file]
+
+            if rev is not None:
+                fctx = fctx.filectx(rev)
+                fctx.filerev()
+
+            return fctx
+        except (IndexError, LookupError) as e:
+            raise DocumentNotFound(title)
 
     def page_history(self, title):
         """Iterate over the page's history."""
 
         filectx_tip = self._find_filectx(title)
 
     def page_history(self, title):
         """Iterate over the page's history."""
 
         filectx_tip = self._find_filectx(title)
-        if filectx_tip is None:
-            return
+
         maxrev = filectx_tip.filerev()
         minrev = 0
         for rev in range(maxrev, minrev - 1, -1):
             filectx = filectx_tip.filectx(rev)
             date = datetime.datetime.fromtimestamp(filectx.date()[0])
         maxrev = filectx_tip.filerev()
         minrev = 0
         for rev in range(maxrev, minrev - 1, -1):
             filectx = filectx_tip.filectx(rev)
             date = datetime.datetime.fromtimestamp(filectx.date()[0])
-            author = unicode(filectx.user(), "utf-8",
-                             'replace').split('<')[0].strip()
-            comment = unicode(filectx.description(), "utf-8", 'replace')
-            yield rev, date, author, comment
+            author = filectx.user().decode('utf-8', 'replace')
+            comment = filectx.description().decode("utf-8", 'replace')
+            tags = [t.rsplit('#', 1)[-1] for t in filectx.changectx().tags() if '#' in t]
+
+            yield {
+                "version": rev,
+                "date": date,
+                "author": author,
+                "description": comment,
+                "tag": tags,
+            }
 
 
-    def page_revision(self, title, rev):
-        """Get unicode contents of specified revision of the page."""
+    @with_working_copy_locked
+    def add_page_tag(self, title, rev, tag, user, doctag=True):
+        ctitle = self._title_to_file(title)
 
 
-        filectx_tip = self._find_filectx(title)
-        if filectx_tip is None:
-            raise DocumentNotFound()
-        try:
-            data = filectx_tip.filectx(rev).data()
-        except IndexError:
-            raise DocumentNotFound()
-        return data
+        if doctag:
+            tag = u"{ctitle}#{tag}".format(**locals()).encode('utf-8')
+
+        message = u"Assigned tag {tag!r} to version {rev!r} of {ctitle!r}".format(**locals()).encode('utf-8')
 
 
-    def revision_text(self, title, rev):
-        data = self.page_revision(title, rev)
-        text = unicode(data, self.charset, 'replace')
-        return text
+        fctx = self._find_filectx(title, rev)
+        self.repo.tag(
+            names=tag, node=fctx.node(), local=False,
+            user=user, message=message, date=None,
+        )
 
     def history(self):
         """Iterate over the history of entire wiki."""
 
     def history(self):
         """Iterate over the history of entire wiki."""
@@ -378,9 +396,8 @@ class VersionedStorage(object):
         for wiki_rev in range(maxrev, minrev - 1, -1):
             change = self.repo.changectx(wiki_rev)
             date = datetime.datetime.fromtimestamp(change.date()[0])
         for wiki_rev in range(maxrev, minrev - 1, -1):
             change = self.repo.changectx(wiki_rev)
             date = datetime.datetime.fromtimestamp(change.date()[0])
-            author = unicode(change.user(), "utf-8",
-                             'replace').split('<')[0].strip()
-            comment = unicode(change.description(), "utf-8", 'replace')
+            author = change.user().decode('utf-8', 'replace')
+            comment = change.description().decode("utf-8", 'replace')
             for repo_file in change.files():
                 if repo_file.startswith(self.repo_prefix):
                     title = self._file_to_title(repo_file)
             for repo_file in change.files():
                 if repo_file.startswith(self.repo_prefix):
                     title = self._file_to_title(repo_file)
@@ -393,7 +410,7 @@ class VersionedStorage(object):
     def all_pages(self):
         tip = self.repo['tip']
         """Iterate over the titles of all pages in the wiki."""
     def all_pages(self):
         tip = self.repo['tip']
         """Iterate over the titles of all pages in the wiki."""
-        return [ urlunquote(filename) for filename in tip ]        
+        return [urlunquote(filename) for filename in tip]
 
     def changed_since(self, rev):
         """Return all pages that changed since specified repository revision."""
 
     def changed_since(self, rev):
         """Return all pages that changed since specified repository revision."""