From b60949dd255429498dd09c175727839520da8b83 Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Sun, 8 Jan 2017 11:58:00 +0100 Subject: [PATCH] Added static_comments plugin. --- v7/static_comments/README.md | 66 +++++ v7/static_comments/static_comments.plugin | 12 + v7/static_comments/static_comments.py | 246 ++++++++++++++++++ .../jinja/static_comments_helper.tmpl | 34 +++ .../template/mako/static_comments_helper.tmpl | 34 +++ 5 files changed, 392 insertions(+) create mode 100644 v7/static_comments/README.md create mode 100644 v7/static_comments/static_comments.plugin create mode 100644 v7/static_comments/static_comments.py create mode 100644 v7/static_comments/template/jinja/static_comments_helper.tmpl create mode 100644 v7/static_comments/template/mako/static_comments_helper.tmpl diff --git a/v7/static_comments/README.md b/v7/static_comments/README.md new file mode 100644 index 00000000..f8d182b9 --- /dev/null +++ b/v7/static_comments/README.md @@ -0,0 +1,66 @@ +This plugin allows to add static comments to your theme. Static comments are taken from files `/..wpcomment`, where `/.` is the post's main file name. Such comments are for example written by the `import_wordpress` plugin when specifying the `--export-comments` argument on import. + +You must use a theme which supports static comments for them to be visible (see below for instructions on how to adjust a theme). + + +Why use static comments? +------------------------ + +Static comments allow you to avoid using a dynamic (JavaScript-based) comment system. If you want users to be able to still comment things, you need to provide a form which could send the comment as an email to you, so you can create the comment files manually (or with a script). + +Static comments also allow you to import a legacy WordPress blog and convert it to a completely static Nikola blog, without having to use some external service for handling the comments. + + +Comment files +------------- + +Comment files are of the following form:: + + .. id: 10 + .. approved: True + .. author: felix + .. author_email: felix@fontein.de + .. author_IP: 1.2.3.4 + .. author_url: https://felix.fontein.de + .. date_utc: 2017-01-06 11:23:55 + .. parent_id: 8 + .. wordpress_user_id: 1 + .. compiler: rest + + this is a test comment. + + the content spans the rest of the file. + +Most header fields are optional. `compiler` must specify a page compiler which allows to compile a content given as a string to a string; these are currently the restructured text compiler (`rest`), the `ipynb` compiler, and the [WordPress](https://plugins.getnikola.com/#wordpress_compiler) (`wordpress`) compiler. Comments can form a hierarchy; `parent_id` must be the comment ID of the parent comment, or left away if there's no parent. + + +Inclusion in theme +------------------ + +You need a static comments aware theme to be able to actually see the comments. To modify a theme accordingly, some helper functions are provided in `templates/*/static_comment_helpers.tmpl`. They can be used as follows. + +The plugin defines a variable `site_has_static_comments` with value `True`, so themes can detect the presence of static comments in general. + +In templates which show the post contents (`post.tmpl` and `index.tmpl`), you can get the comments shown as follows (with jinja2 templates; adjust accordingly for mako templates):: + + [...] + {% import 'static_comments_helper.tmpl' as static_comments with context %} + [...] + {% if not post.meta('nocomments') and (site_has_comments or site_has_static_comments) %} +
+

{{ messages("Comments", lang) }}

+ {{ static_comments.add_static_comments(post.comments, lang) }} + [...] +
+ {% endif %} + [...] + +In templates which list the posts (`list_post.tmpl`, `post_list_directive.tmpl` etc.), you can get the static comment count shown as follows:: + + [...] + {% import 'static_comments_helper.tmpl' as static_comments with context %} + [...] + {% if not post.meta('nocomments') and site_has_static_comments %} + {{ static_comments.add_static_comment_count(post.comments, lang) }} + {% endif %} + [...] diff --git a/v7/static_comments/static_comments.plugin b/v7/static_comments/static_comments.plugin new file mode 100644 index 00000000..141295d2 --- /dev/null +++ b/v7/static_comments/static_comments.plugin @@ -0,0 +1,12 @@ +[Core] +Name = static_comments +Module = static_comments + +[Nikola] +PluginCategory = SignalHandler + +[Documentation] +Author = Felix Fontein +Version = 1.0 +Website = https://felix.fontein.de +Description = Static comments for Nikola diff --git a/v7/static_comments/static_comments.py b/v7/static_comments/static_comments.py new file mode 100644 index 00000000..22697f54 --- /dev/null +++ b/v7/static_comments/static_comments.py @@ -0,0 +1,246 @@ +from __future__ import unicode_literals, print_function, absolute_import + +from nikola.plugin_categories import SignalHandler +from nikola import utils + +import blinker +import hashlib +import os +import re + +__all__ = [] + +_LOGGER = utils.get_logger('static_comments', utils.STDERR_HANDLER) + + +class Comment(object): + """Represents a comment for a post, story or gallery.""" + + # set by constructor + id = None + parent_id = None + + # set by creator + content = '' # should be a properly escaped HTML fragment + author = None + author_email = None # should not be published by default + author_url = None + author_ip = None # should not be published by default + date_utc = None # should be set via set_utc_date() + date_localized = None # should be set via set_utc_date() + + # set by _process_comments(): + indent_levels = None # use for formatting comments as tree + indent_change_before = 0 # use for formatting comments as tree + indent_change_after = 0 # use for formatting comments as tree + + # The meaning of indent_levels, indent_change_before and + # indent_change_after are the same as the values in utils.TreeNode. + + def __init__(self, site, owner, id, parent_id=None): + """Initialize comment. + + site: Nikola site object; + owner: post which 'owns' this comment; + id: ID of comment; + parent_id: ID of comment's parent, or None if it has none. + """ + self._owner = owner + self._config = site.config + self.id = id + self.parent_id = parent_id + + def set_utc_date(self, date_utc): + """Set the date (in UTC). Automatically updates the localized date.""" + self.date_utc = utils.to_datetime(date_utc) + self.date_localized = utils.to_datetime(date_utc, self._config['__tzinfo__']) + + def formatted_date(self, date_format): + """Return the formatted localized date.""" + return utils.LocaleBorg().formatted_date(date_format, self.date_localized) + + def hash_values(self): + """Return tuple of values whose hash to consider for computing the hash of this comment.""" + return (self.id, self.parent_id, self.content, self.author, self.author_url, self.date_utc) + + def __repr__(self): + """Returns string representation for comment.""" + return ''.format(self.id, self._owner, self.indent_levels) + + +class StaticComments(SignalHandler): + """Add static comments to posts.""" + + # Used to parse comment headers + _header_regex = re.compile('^\.\. (.*?): (.*)') + + def _compile_content(self, compiler_name, content, filename): + """Compile comment content with """ + if compiler_name not in self.site.compilers: + _LOGGER.error("Cannot find page compiler '{0}' for comment {1}!".format(compiler_name, filename)) + exit(1) + compiler = self.site.compilers[compiler_name] + if compiler_name == 'rest': + content, error_level, _ = compiler.compile_string(content) + if error_level >= 3: + _LOGGER.error("Restructured text page compiler ({0}) failed to compile comment {1}!".format(compiler_name, filename)) + exit(1) + return content + elif compiler_name == 'ipynb': + return compiler.compile_string(content) + else: + try: + return compiler.compile_to_string(content) # this is a non-standard function! must not be available with any page compiler! + except AttributeError: + try: + return compiler.compile_string(content) # this is a non-standard function! must not be available with any page compiler! + except AttributeError: + _LOGGER.error("Page compiler plugin '{0}' has no compile_to_string or compile_string function (comment {1})!".format(compiler_name, filename)) + exit(1) + + def _read_comment(self, filename, owner, id): + """Read a comment from a file.""" + with open(filename, "r") as f: + lines = f.readlines() + start = 0 + # create comment object + comment = Comment(self.site, owner, id) + # parse headers + compiler_name = None + while start < len(lines): + # on empty line, header is definitely done + if len(lines[start].strip()) == 0: + break + # try to check if line fits header regex + result = self._header_regex.findall(lines[start].strip()) + if not result: + break + # parse header line + header = result[0][0] + value = result[0][1] + if header == 'id': + comment.id = value + elif header == 'status': + pass + elif header == 'approved': + if value != 'True': + return None + elif header == 'author': + comment.author = value + elif header == 'author_email': + comment.author_email = value + elif header == 'author_url': + comment.author_url = value + elif header == 'author_IP': + comment.author_ip = value + elif header == 'date_utc': + comment.set_utc_date(value) + elif header == 'parent_id': + if value != 'None': + comment.parent_id = value + elif header == 'wordpress_user_id': + pass + elif header == 'post_language': + pass + elif header == 'compiler': + compiler_name = value + else: + _LOGGER.error("Unknown comment header: '{0}' (in file {1})".format(header, filename)) + exit(1) + # go to next line + start += 1 + # skip empty lines and re-combine content + while start < len(lines) and len(lines[start]) == 0: + start += 1 + content = '\n'.join(lines[start:]) + # check compiler name + if compiler_name is None: + _LOGGER.warn("Comment file '{0}' doesn't specify compiler! Using default 'wordpress'.".format(filename)) + compiler_name = 'wordpress' + # compile content + comment.content = self._compile_content(compiler_name, content, filename) + return comment + + def _scan_comments(self, path, file, owner): + """Scan comments for post.""" + comments = {} + for dirpath, dirnames, filenames in os.walk(path, followlinks=True): + if dirpath != path: + continue + for filename in filenames: + if not filename.startswith(file + '.'): + continue + rest = filename[len(file):].split('.') + if len(rest) != 3: + continue + if rest[0] != '' or rest[2] != 'wpcomment': + continue + try: + comment = self._read_comment(os.path.join(dirpath, filename), owner, rest[1]) + if comment is not None: + # _LOGGER.info("Found comment '{0}' with ID {1}".format(os.path.join(dirpath, filename), comment.id)) + comments[comment.id] = comment + except ValueError as e: + _LOGGER.warn("Exception '{1}' while reading file '{0}'!".format(os.path.join(dirpath, filename), e)) + pass + return sorted(list(comments.values()), key=lambda c: c.date_utc) + + def _hash_post_comments(self, post): + """Compute hash of all comments for this post.""" + # compute hash of comments + hash = hashlib.md5() + c = 0 + for comment in post.comments: + c += 1 + for part in comment.hash_values(): + hash.update(str(part).encode('utf-8')) + return hash.hexdigest() + + def _process_comments(self, comments): + """Given a list of comments, rearranges them according to hierarchy and returns ordered list with indentation information.""" + # First, build tree structure out of TreeNode with comments attached + root_list = [] + comment_nodes = dict() + for comment in comments: + node = utils.TreeNode(comment.id) + node.comment = comment + comment_nodes[comment.id] = node + for comment in comments: + node = comment_nodes[comment.id] + parent_node = comment_nodes.get(node.comment.parent_id) + if parent_node is not None: + parent_node.children.append(node) + else: + root_list.append(node) + # Then flatten structure and add indent information + comment_nodes = utils.flatten_tree_structure(root_list) + for node in comment_nodes: + comment = node.comment + comment.indent_levels = node.indent_levels + comment.indent_change_before = node.indent_change_before + comment.indent_change_after = node.indent_change_after + return [node.comment for node in comment_nodes] + + def _process_post_object(self, post): + """Add comments to a post object.""" + # Get all comments + path, ext = os.path.splitext(post.source_path) + path, file = os.path.split(path) + comments = self._scan_comments(path, file, post) + # Add ordered comment list to post + post.comments = self._process_comments(comments) + # Add dependency to post + digest = self._hash_post_comments(post) + post.add_dependency_uptodate(utils.config_changed({1: digest}, 'nikola.plugins.comments.static_comments:' + post.base_path), is_callable=False, add='page') + + def _process_posts_and_stories(self, site): + """Add comments to all posts.""" + if site is self.site: + for post in site.timeline: + self._process_post_object(post) + + def set_site(self, site): + """Set Nikola site object.""" + super(StaticComments, self).set_site(site) + site._GLOBAL_CONTEXT['site_has_static_comments'] = True + blinker.signal("scanned").connect(self._process_posts_and_stories) diff --git a/v7/static_comments/template/jinja/static_comments_helper.tmpl b/v7/static_comments/template/jinja/static_comments_helper.tmpl new file mode 100644 index 00000000..385ddc37 --- /dev/null +++ b/v7/static_comments/template/jinja/static_comments_helper.tmpl @@ -0,0 +1,34 @@ +{# -*- coding: utf-8 -*- #} + +{% macro add_static_comments(static_comment_list, lang) %} + {%if static_comment_list|length == 0 %} +
{{ messages("No comments.", lang) }}
+ {% else %} + {%for comment in static_comment_list %} + {%for i in range(comment.indent_change_before) %} +
+ {% endfor %} +
+
+ + {%if comment.author is not none %} + {{ messages("{0} wrote on {1}:", lang).format( + '' ~ ('{1}'.format(comment.author_url|e, comment.author|e) if comment.author_url is not none else (comment.author|e)) ~ '', + '' + comment.formatted_date(date_format) + '' + ) }} + {% endif %} +
+
+ {{ comment.content }} +
+
+ {%for i in range(-comment.indent_change_after) %} +
+ {% endfor %} + {% endfor %} + {% endif %} +{% endmacro %} + +{% macro add_static_comment_count(static_comment_list, lang) %} + {{ messages("No comments" if static_comment_list|length == 0 else ("{0} comments" if static_comment_list|length != 1 else "{0} comment"), lang).format(static_comment_list|length) }} +{% endmacro %} diff --git a/v7/static_comments/template/mako/static_comments_helper.tmpl b/v7/static_comments/template/mako/static_comments_helper.tmpl new file mode 100644 index 00000000..8c97abc8 --- /dev/null +++ b/v7/static_comments/template/mako/static_comments_helper.tmpl @@ -0,0 +1,34 @@ +## -*- coding: utf-8 -*- + +<%def name="add_static_comments(static_comment_list, lang)"> + % if len(static_comment_list) == 0: +
${ messages("No comments.", lang) }
+ % else: + % for comment in static_comment_list: + % for i in range(comment.indent_change_before): +
+ % endfor +
+
+ + % if comment.author is not none: + ${ messages("{0} wrote on {1}:", lang).format( + '' + ('{1}'.format(comment.author_url|h, comment.author|h) if comment.author_url is not none else (comment.author|h)) + '', + '' + comment.formatted_date(date_format) + '' + ) } + % endif +
+
+ ${ comment.content } +
+
+ % for i in range(-comment.indent_change_after): +
+ % endfor + % endfor + % endif + + +<%def name="add_static_comment_count(static_comment_list, lang)"> + ${ messages("No comments" if len(static_comment_list) == 0 else ("{0} comments" if len(static_comment_list) != 1 else "{0} comment"), lang).format(len(static_comment_list)) } +