From f9c2481499b0b51cd417143e1c22136c89442cba Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Fri, 28 Mar 2014 01:23:12 -0700 Subject: [PATCH] Add the web blog views/models/controllers --- config-sample.py | 10 +- rophako/__init__.py | 7 +- rophako/model/blog.py | 230 +++++++++++++++++++ rophako/modules/blog.py | 300 +++++++++++++++++++++++++ rophako/modules/kirsle_legacy.py | 24 +- rophako/utils.py | 31 ++- rophako/www/blog/delete.html | 17 ++ rophako/www/blog/entry.html | 10 + rophako/www/blog/entry.inc.html | 69 ++++++ rophako/www/blog/index.html | 13 ++ rophako/www/blog/index.inc.html | 13 ++ rophako/www/blog/nav-links.inc.html | 27 +++ rophako/www/blog/update.html | 152 +++++++++++++ rophako/www/index.html | 4 +- rophako/www/static/avatars/default.png | Bin 0 -> 7121 bytes scripts/siikir-blog-migrate.py | 88 ++++++++ 16 files changed, 981 insertions(+), 14 deletions(-) create mode 100644 rophako/model/blog.py create mode 100644 rophako/modules/blog.py create mode 100644 rophako/www/blog/delete.html create mode 100644 rophako/www/blog/entry.html create mode 100644 rophako/www/blog/entry.inc.html create mode 100644 rophako/www/blog/index.html create mode 100644 rophako/www/blog/index.inc.html create mode 100644 rophako/www/blog/nav-links.inc.html create mode 100644 rophako/www/blog/update.html create mode 100644 rophako/www/static/avatars/default.png create mode 100644 scripts/siikir-blog-migrate.py diff --git a/config-sample.py b/config-sample.py index 50448f1..0b7fa04 100644 --- a/config-sample.py +++ b/config-sample.py @@ -31,4 +31,12 @@ DB_ROOT = "db" REDIS_HOST = "localhost" REDIS_PORT = 6379 REDIS_DB = 0 -REDIS_PREFIX = "rophako:" \ No newline at end of file +REDIS_PREFIX = "rophako:" + +# Blog settings +BLOG_ENTRIES_PER_PAGE = 5 # Number of entries to show per page +BLOG_ENTRIES_PER_RSS = 5 # The same, but for the RSS feed +BLOG_DEFAULT_CATEGORY = "Uncategorized" +BLOG_DEFAULT_PRIVACY = "public" +BLOG_TIME_FORMAT = "%A, %B %d %Y @ %I:%M:%S %p" # "Weekday, Month dd yyyy @ hh:mm:ss AM" +BLOG_ALLOW_COMMENTS = True \ No newline at end of file diff --git a/rophako/__init__.py b/rophako/__init__.py index e55ed25..ca98c6e 100644 --- a/rophako/__init__.py +++ b/rophako/__init__.py @@ -17,8 +17,10 @@ app.secret_key = config.SECRET_KEY # Load all the blueprints! from rophako.modules.admin import mod as AdminModule from rophako.modules.account import mod as AccountModule +from rophako.modules.blog import mod as BlogModule app.register_blueprint(AdminModule) app.register_blueprint(AccountModule) +app.register_blueprint(BlogModule) # Custom Jinja handler to support custom- and default-template folders for # rendering templates. @@ -28,6 +30,7 @@ app.jinja_loader = jinja2.ChoiceLoader([ ]) app.jinja_env.globals["csrf_token"] = rophako.utils.generate_csrf_token +app.jinja_env.globals["include_page"] = rophako.utils.include @app.before_request @@ -85,7 +88,6 @@ def before_request(): @app.context_processor def after_request(): """Called just before render_template. Inject g.info into the template vars.""" - g.info["time_elapsed"] = "%.03f" % (time.time() - g.info["time"]) return g.info @@ -100,14 +102,13 @@ def catchall(path): if os.path.isfile(abspath): return send_file(abspath) elif not "." in path and os.path.isfile(abspath + ".html"): - return render_template(path + ".html") + return rophako.utils.template(path + ".html") return not_found("404") @app.route("/") def index(): - print "INDEX PAGE" return catchall("index") diff --git a/rophako/model/blog.py b/rophako/model/blog.py new file mode 100644 index 0000000..dea01f0 --- /dev/null +++ b/rophako/model/blog.py @@ -0,0 +1,230 @@ +# -*- coding: utf-8 -*- + +"""Blog models.""" + +from flask import g +import time +import re +import glob + +import config +import rophako.jsondb as JsonDB +from rophako.log import logger + +def get_index(): + """Get the blog index. + + The index is the cache of available blog posts. It has the format: + + ``` + { + 'post_id': { + fid: Friendly ID for the blog post (for URLs) + time: epoch time of the post + sticky: the stickiness of the post (shows first on global views) + author: the author user ID of the post + categories: [ list of categories ] + privacy: the privacy setting + subject: the post subject + }, + ... + } + ``` + """ + + # Index doesn't exist? + if not JsonDB.exists("blog/index"): + return {} + db = JsonDB.get("blog/index") + + # Hide any private posts if we aren't logged in. + if not g.info["session"]["login"]: + for post_id, data in db.iteritems(): + if data["privacy"] == "private": + del db[post_id] + + return db + + +def __get_categories(): + """Get the blog categories cache. + + The category cache is in the following format: + + ``` + { + 'category_name': { + 'post_id': 'friendly_id', + ... + }, + ... + } + ``` + """ + + # Index doesn't exist? + if not JsonDB.exists("blog/tags"): + return {} + return JsonDB.get("blog/tags") + + +def get_entry(post_id): + """Load a full blog entry.""" + if not JsonDB.exists("blog/entries/{}".format(post_id)): + return None + + db = JsonDB.get("blog/entries/{}".format(post_id)) + + # If no FID, set it to the ID. + if len(db["fid"]) == 0: + db["fid"] = str(post_id) + + return db + + +def post_entry(post_id, fid, epoch, author, subject, avatar, categories, + privacy, ip, emoticons, comments, body): + """Post (or update) a blog entry.""" + + # Fetch the index. + index = get_index() + + # Editing an existing post? + if not post_id: + post_id = get_next_id(index) + + logger.debug("Posting blog post ID {}".format(post_id)) + + # Get a unique friendly ID. + if not fid: + # The default friendly ID = the subject. + fid = subject.lower() + fid = re.sub(r'[^A-Za-z0-9]', '-', fid) + fid = re.sub(r'\-+', '-', fid) + fid = fid.strip("-") + logger.debug("Chosen friendly ID: {}".format(fid)) + + # Make sure the friendly ID is unique! + if len(fid): + test = fid + loop = 1 + logger.debug("Verifying the friendly ID is unique: {}".format(fid)) + while True: + collision = False + + for k, v in index.iteritems(): + # Skip the same post, for updates. + if k == post_id: continue + + if v["fid"] == test: + # Not unique. + loop += 1 + test = fid + "_" + unicode(loop) + collision = True + logger.debug("Collision with existing post {}: {}".format(k, v["fid"])) + break + + # Was there a collision? + if collision: + continue # Try again. + + # Nope! + break + fid = test + + # Write the post. + JsonDB.commit("blog/entries/{}".format(post_id), dict( + fid = fid, + ip = ip, + time = epoch or int(time.time()), + categories = categories, + sticky = False, # TODO: implement sticky + comments = comments, + emoticons = emoticons, + avatar = avatar, + privacy = privacy or "public", + author = author, + subject = subject, + body = body, + )) + + # Update the index cache. + index[post_id] = dict( + fid = fid, + time = epoch or int(time.time()), + categories = categories, + sticky = False, # TODO + author = author, + privacy = privacy or "public", + subject = subject, + ) + JsonDB.commit("blog/index", index) + + return post_id, fid + + +def delete_entry(post_id): + """Remove a blog entry.""" + # Fetch the blog information. + index = get_index() + post = get_entry(post_id) + if post is None: + logger.warning("Can't delete post {}, it doesn't exist!".format(post_id)) + + # Delete the post. + JsonDB.delete("blog/entries/{}".format(post_id)) + + # Update the index cache. + del index[str(post_id)] # Python JSON dict keys must be strings, never ints + JsonDB.commit("blog/index", index) + + +def resolve_id(fid): + """Resolve a friendly ID to the blog ID number.""" + index = get_index() + + # If the ID is all numeric, it's the blog post ID directly. + if re.match(r'^\d+$', fid): + if fid in index: + return int(fid) + else: + logger.error("Tried resolving blog post ID {} as an EntryID, but it wasn't there!".format(fid)) + return None + + # It's a friendly ID. Scan for it. + for post_id, data in index.iteritems(): + if data["fid"] == fid: + return int(post_id) + + logger.error("Friendly post ID {} wasn't found!".format(fid)) + return None + + +def list_avatars(): + """Get a list of all the available blog avatars.""" + avatars = set() + paths = [ + # Load avatars from both locations. We check the built-in set first, + # so if you have matching names in your local site those will override. + "rophako/www/static/avatars/*.*", + "site/www/static/avatars/*.*", + ] + for path in paths: + for filename in glob.glob(path): + filename = filename.split("/")[-1] + avatars.add(filename) + + return sorted(avatars, key=lambda x: x.lower()) + + +def get_next_id(index): + """Get the next free ID for a blog post.""" + logger.debug("Getting next available blog ID number") + sort = sorted(index.keys(), key=lambda x: int(x)) + logger.debug("Highest post ID is: {}".format(sort[-1])) + next_id = int(sort[-1]) + 1 + + # Sanity check! + if next_id in index: + raise Exception("Failed to get_next_id for the blog. Chosen ID is still in the index!") + return next_id \ No newline at end of file diff --git a/rophako/modules/blog.py b/rophako/modules/blog.py new file mode 100644 index 0000000..6c755e6 --- /dev/null +++ b/rophako/modules/blog.py @@ -0,0 +1,300 @@ +# -*- coding: utf-8 -*- + +"""Endpoints for the web blog.""" + +from flask import Blueprint, g, request, redirect, url_for, session, flash +import re +import datetime +import calendar + +import rophako.model.user as User +import rophako.model.blog as Blog +from rophako.utils import template, pretty_time, admin_required +from rophako.log import logger +from config import * + +mod = Blueprint("blog", __name__, url_prefix="/blog") + +@mod.route("/") +def index(): + return template("blog/index.html") + + +@mod.route("/category/") +def category(category): + g.info["url_category"] = category + return template("blog/index.html") + + +@mod.route("/entry/") +def entry(fid): + """Endpoint to view a specific blog entry.""" + + # Resolve the friendly ID to a real ID. + post_id = Blog.resolve_id(fid) + if not post_id: + flash("That blog post wasn't found.") + return redirect(url_for(".index")) + + # Look up the post. + post = Blog.get_entry(post_id) + post["post_id"] = post_id + + # Get the author's information. + post["profile"] = User.get_user(uid=post["author"]) + + # Pretty-print the time. + post["pretty_time"] = pretty_time(BLOG_TIME_FORMAT, post["time"]) + + # TODO: count the comments for this post + post["comment_count"] = 0 + + g.info["post"] = post + return template("blog/entry.html") + + +@mod.route("/entry") +@mod.route("/index") +def dummy(): + return redirect(url_for(".index")) + + +@mod.route("/update", methods=["GET", "POST"]) +@admin_required +def update(): + """Post/edit a blog entry.""" + + # Get our available avatars. + g.info["avatars"] = Blog.list_avatars() + + # Default vars. + g.info.update(dict( + post_id="", + fid="", + author=g.info["session"]["uid"], + subject="", + body="", + avatar="", + categories="", + privacy=BLOG_DEFAULT_PRIVACY, + emoticons=True, + comments=BLOG_ALLOW_COMMENTS, + month="", + day="", + year="", + hour="", + min="", + sec="", + preview=False, + )) + + # Editing an existing post? + post_id = request.args.get("id", None) + if post_id: + post_id = Blog.resolve_id(post_id) + if post_id: + logger.info("Editing existing blog post {}".format(post_id)) + post = Blog.get_entry(post_id) + g.info["post_id"] = post_id + g.info["post"] = post + + # Copy fields. + for field in ["author", "fid", "subject", "body", "avatar", + "categories", "privacy", "emoticons", "comments"]: + g.info[field] = post[field] + + # Dissect the time. + date = datetime.datetime.fromtimestamp(post["time"]) + g.info.update(dict( + month="{:02d}".format(date.month), + day="{:02d}".format(date.day), + year=date.year, + hour="{:02d}".format(date.hour), + min="{:02d}".format(date.minute), + sec="{:02d}".format(date.second), + )) + + # Are we SUBMITTING the form? + if request.method == "POST": + action = request.form.get("action") + + # Get all the fields from the posted params. + g.info["post_id"] = request.form.get("id") + for field in ["fid", "subject", "body", "avatar", "categories", "privacy"]: + g.info[field] = request.form.get(field) + for boolean in ["emoticons", "comments"]: + print "BOOL:", boolean, request.form.get(boolean) + g.info[boolean] = True if request.form.get(boolean, None) == "true" else False + print g.info[boolean] + for number in ["author", "month", "day", "year", "hour", "min", "sec"]: + g.info[number] = int(request.form.get(number, 0)) + + # What action are they doing? + if action == "preview": + g.info["preview"] = True + elif action == "publish": + # Publishing! Validate inputs first. + invalid = False + if len(g.info["body"]) == 0: + invalid = True + flash("You must enter a body for your blog post.") + if len(g.info["subject"]) == 0: + invalid = True + flash("You must enter a subject for your blog post.") + + # Make sure the times are valid. + date = None + try: + date = datetime.datetime( + g.info["year"], + g.info["month"], + g.info["day"], + g.info["hour"], + g.info["min"], + g.info["sec"], + ) + except ValueError, e: + invalid = True + flash("Invalid date/time: " + str(e)) + + # Format the categories. + tags = [] + for tag in g.info["categories"].split(","): + tags.append(tag.strip()) + + # Okay to update? + if invalid is False: + # Convert the date into a Unix time stamp. + epoch = float(date.strftime("%s")) + + new_id, new_fid = Blog.post_entry( + post_id = g.info["post_id"], + epoch = epoch, + author = g.info["author"], + subject = g.info["subject"], + fid = g.info["fid"], + avatar = g.info["avatar"], + categories = tags, + privacy = g.info["privacy"], + ip = request.remote_addr, + emoticons = g.info["emoticons"], + comments = g.info["comments"], + body = g.info["body"], + ) + + return redirect(url_for(".entry", fid=new_fid)) + + + if type(g.info["categories"]) is list: + g.info["categories"] = ", ".join(g.info["categories"]) + + return template("blog/update.html") + + +@mod.route("/delete", methods=["GET", "POST"]) +def delete(): + """Delete a blog post.""" + post_id = request.args.get("id") + + # Resolve the post ID. + post_id = Blog.resolve_id(post_id) + if not post_id: + flash("That blog post wasn't found.") + return redirect(url_for(".index")) + + if request.method == "POST": + confirm = request.form.get("confirm") + if confirm == "true": + Blog.delete_entry(post_id) + flash("The blog entry has been deleted.") + return redirect(url_for(".index")) + + # Get the entry's subject. + post = Blog.get_entry(post_id) + g.info["subject"] = post["subject"] + g.info["post_id"] = post_id + + return template("blog/delete.html") + +def partial_index(): + """Partial template for including the index view of the blog.""" + + # Get the blog index. + index = Blog.get_index() + pool = {} # The set of blog posts to show. + + category = g.info.get("url_category", None) + + # Are we narrowing by category? + if category: + # Narrow down the index to just those that match the category. + for post_id, data in index.iteritems(): + if not category in data["categories"]: + continue + pool[post_id] = data + + # No such category? + if len(pool) == 0: + flash("There are no posts with that category.") + return redirect(url_for(".index")) + else: + pool = index + + # Separate the sticky posts from the normal ones. + sticky, normal = set(), set() + for post_id, data in pool.iteritems(): + if data["sticky"]: + sticky.add(post_id) + else: + normal.add(post_id) + + # Sort the blog IDs by published time. + posts = [] + posts.extend(sorted(sticky, key=lambda x: pool[x]["time"], reverse=True)) + posts.extend(sorted(normal, key=lambda x: pool[x]["time"], reverse=True)) + + # Handle pagination. + offset = request.args.get("skip", 0) + try: offset = int(offset) + except: offset = 0 + + # Handle the offsets, and get those for the "older" and "earlier" posts. + # "earlier" posts count down (towards index 0), "older" counts up. + g.info["offset"] = offset + g.info["earlier"] = offset - BLOG_ENTRIES_PER_PAGE if offset > 0 else 0 + g.info["older"] = offset + BLOG_ENTRIES_PER_PAGE + if g.info["earlier"] < 0: + g.info["earlier"] = 0 + if g.info["older"] < 0 or g.info["older"] > len(posts): + g.info["older"] = 0 + g.info["count"] = 0 + + # Can we go to other pages? + g.info["can_earlier"] = True if offset > 0 else False + g.info["can_older"] = False if g.info["older"] == 0 else True + + # Load the selected posts. + selected = [] + stop = offset + BLOG_ENTRIES_PER_PAGE + if stop > len(posts): stop = len(posts) + for i in range(offset, stop): + post_id = posts[i] + post = Blog.get_entry(post_id) + + post["post_id"] = post_id + + # Get the author's information. + post["profile"] = User.get_user(uid=post["author"]) + + post["pretty_time"] = pretty_time(BLOG_TIME_FORMAT, post["time"]) + + # TODO: count the comments for this post + post["comment_count"] = 0 + + selected.append(post) + g.info["count"] += 1 + + g.info["category"] = category + g.info["posts"] = selected + + return template("blog/index.inc.html") diff --git a/rophako/modules/kirsle_legacy.py b/rophako/modules/kirsle_legacy.py index ee57a16..3a20f48 100644 --- a/rophako/modules/kirsle_legacy.py +++ b/rophako/modules/kirsle_legacy.py @@ -2,21 +2,31 @@ # Legacy endpoint compatibility from kirsle.net. -from flask import request, redirect +from flask import request, redirect, url_for from rophako import app +import rophako.model.blog as Blog @app.route("/+") def google_plus(): return redirect("https://plus.google.com/+NoahPetherbridge/posts") @app.route("/blog.html") -def legacy_blog(): - post_id = request.args.get("id", "") +def ancient_legacy_blog(): + post_id = request.args.get("id", None) + if post_id is None: + return redirect(url_for("blog.index")) - # All of this is TO-DO. - # friendly_id = get friendly ID - # return redirect(...) - return "TO-DO" + # Look up the friendly ID. + post = Blog.get_entry(post_id) + if not post: + flash("That blog entry wasn't found.") + return redirect(url_for("blog.index")) + + return redirect(url_for("blog.entry", fid=post["fid"])) + +@app.route("/blog/kirsle/") +def legacy_blog(fid): + return redirect(url_for("blog.entry", fid=fid)) @app.route("/.html") def legacy_url(page): diff --git a/rophako/utils.py b/rophako/utils.py index 624be3c..a552510 100644 --- a/rophako/utils.py +++ b/rophako/utils.py @@ -1,8 +1,12 @@ # -*- coding: utf-8 -*- -from flask import g, session, request, render_template +from flask import g, session, request, render_template, flash, redirect, url_for from functools import wraps import uuid +import datetime +import time +import re +import importlib from rophako.log import logger @@ -41,6 +45,10 @@ def template(name, **kwargs): """Render a template to the browser.""" html = render_template(name, **kwargs) + + # Get the elapsed time for the request. + time_elapsed = "%.03f" % (time.time() - g.info["time"]) + html = re.sub(r'\%time_elapsed\%', time_elapsed, html) return html @@ -48,4 +56,23 @@ def generate_csrf_token(): """Generator for CSRF tokens.""" if "_csrf" not in session: session["_csrf"] = str(uuid.uuid4()) - return session["_csrf"] \ No newline at end of file + return session["_csrf"] + + +def include(endpoint, *args, **kwargs): + """Include another sub-page inside a template.""" + + # The 'endpoint' should be in the format 'module.function', i.e. 'blog.index'. + module, function = endpoint.split(".") + + # Dynamically import the module and call its function. + m = importlib.import_module("rophako.modules.{}".format(module)) + html = getattr(m, function)(*args, **kwargs) + + return html + + +def pretty_time(time_format, unix): + """Pretty-print a time stamp.""" + date = datetime.datetime.fromtimestamp(unix) + return date.strftime(time_format) \ No newline at end of file diff --git a/rophako/www/blog/delete.html b/rophako/www/blog/delete.html new file mode 100644 index 0000000..f7efe00 --- /dev/null +++ b/rophako/www/blog/delete.html @@ -0,0 +1,17 @@ +{% extends "layout.html" %} +{% block title %}Delete Entry{% endblock %} +{% block content %} + +

Delete Entry

+ +
+ + + + Are you sure you want to delete the blog post, + "{{ subject }}"?

+ + +

+ +{% endblock %} \ No newline at end of file diff --git a/rophako/www/blog/entry.html b/rophako/www/blog/entry.html new file mode 100644 index 0000000..805738c --- /dev/null +++ b/rophako/www/blog/entry.html @@ -0,0 +1,10 @@ +{% extends "layout.html" %} +{% block title %}{{ post["subject"] }}{% endblock %} +{% block content %} + +

{{ post["subject"] }}

+ +{% from "blog/entry.inc.html" import blog_entry %} +{{ blog_entry(post) }} + +{% endblock %} \ No newline at end of file diff --git a/rophako/www/blog/entry.inc.html b/rophako/www/blog/entry.inc.html new file mode 100644 index 0000000..2d7b007 --- /dev/null +++ b/rophako/www/blog/entry.inc.html @@ -0,0 +1,69 @@ +{# Reusable template for showing a blog post content #} + +{% macro blog_entry(post, from=None) %} + + {% if from == "index" %} + + {{ post["subject"] }} +

+ {% endif %} + +

+ +
+ Posted by {{ post["profile"]["name"] }} + on {{ post["pretty_time"] }} +
+ + {{ post["body"] | safe }} + +

+

+ Categories: + {% if post["categories"]|length == 0 %} + Uncategorized{# TODO hardcoded name #} + {% else %} +
    + {% for tag in post["categories"] %} +
  • {{ tag }}
  • + {% endfor %} +
+ {% endif %} +

+ + [ + {% if from == "index" %} + {% if post["comments"] %}{# Allowed comments #} + {{ post["comment_count"] }} comment{% if post["comment_count"] != 1 %}s{% endif %} + | + Add comment + | + {% endif %} + + Permalink + {% else %} + Blog + {% endif %} + + {% if session["login"] %} + | + Edit + | + Delete + {% endif %} + ] +

+ +{% endmacro %} \ No newline at end of file diff --git a/rophako/www/blog/index.html b/rophako/www/blog/index.html new file mode 100644 index 0000000..b6314c4 --- /dev/null +++ b/rophako/www/blog/index.html @@ -0,0 +1,13 @@ +{% extends "layout.html" %} +{% block title %}Blog{% endblock %} +{% block content %} + +{% if url_category %} +

Category: {{ url_category }}

+{% else %} +

My Blog

+{% endif %} + +{{ include_page("blog.partial_index") | safe }} + +{% endblock %} \ No newline at end of file diff --git a/rophako/www/blog/index.inc.html b/rophako/www/blog/index.inc.html new file mode 100644 index 0000000..8e7c700 --- /dev/null +++ b/rophako/www/blog/index.inc.html @@ -0,0 +1,13 @@ +{% from "blog/entry.inc.html" import blog_entry %} + +{% include "blog/nav-links.inc.html" %} + +{% if count == 0 %} + There are no blog posts yet. +{% else %} + {% for post in posts %} + {{ blog_entry(post, from="index") }} + {% endfor %} +{% endif %} + +{% include "blog/nav-links.inc.html" %} \ No newline at end of file diff --git a/rophako/www/blog/nav-links.inc.html b/rophako/www/blog/nav-links.inc.html new file mode 100644 index 0000000..c71fb94 --- /dev/null +++ b/rophako/www/blog/nav-links.inc.html @@ -0,0 +1,27 @@ +{# Older/Newer links #} + +{% if can_older or can_newer %} +
+ [ + RSS Feed | {# TODO! #} + {% if can_earlier %} + {% if category %} + < Newer + {% else %} + < Newer + {% endif %} + + {% if can_older %} | {% endif %} + {% endif %} + + {% if can_older %} + {% if category %} + Older > + {% else %} + Older > + {% endif %} + {% endif %} + + ] +
+{% endif %} \ No newline at end of file diff --git a/rophako/www/blog/update.html b/rophako/www/blog/update.html new file mode 100644 index 0000000..dcc3ad3 --- /dev/null +++ b/rophako/www/blog/update.html @@ -0,0 +1,152 @@ +{% extends "layout.html" %} +{% block title %}Update Blog{% endblock %} +{% block content %} + +{% if preview %} +

Preview: {{ subject }}

+ + {{ body|safe }} + +
+{% endif %} + +

Update Blog

+ +
+ + + + + Subject:
+

+ + Friendly ID:
+ You can leave this blank if this is a new post. It defaults to be based + on the subject.
+

+ + Body:
+
+ Emoticon reference (opens in new window)

+ + Avatar:
+ +

+ + Categories:
+ Comma-separated list, e.g. General, HTML, Perl, Web Design
+

+ + Privacy:
+

+ + Options:
+
+

+ + Time Stamp:
+ / + / + @ + : + : +
+ mm / dd / yyyy @ hh:mm:ss
+

+ + + +

+ +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/rophako/www/index.html b/rophako/www/index.html index b4b48c0..76fd760 100644 --- a/rophako/www/index.html +++ b/rophako/www/index.html @@ -4,6 +4,8 @@

Welcome!

-This is the Rophako CMS! +This is the Rophako CMS!

+ +{{ include_page("blog.partial_index") | safe }} {% endblock %} \ No newline at end of file diff --git a/rophako/www/static/avatars/default.png b/rophako/www/static/avatars/default.png new file mode 100644 index 0000000000000000000000000000000000000000..b4e203cbfee690c5d8d6af27b7aa0664a80ca323 GIT binary patch literal 7121 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 O0cK}?$m*#jE$)AITHfOT literal 0 HcmV?d00001 diff --git a/scripts/siikir-blog-migrate.py b/scripts/siikir-blog-migrate.py new file mode 100644 index 0000000..a639d4c --- /dev/null +++ b/scripts/siikir-blog-migrate.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python + +"""Migrate blog database files from a PerlSiikir site to Rophako. + +Usage: scripts/siikir-blog-migrate.py + +Rophako supports one global blog, so the blog of UserID 1 in Siikir is used.""" + +import sys +import os +import codecs +import json +import glob + +sys.path.append(".") +import rophako.jsondb as JsonDB + +# Path to Siikir DB root. +siikir = None + +def main(): + if len(sys.argv) == 1: + print "Usage: {} ".format(__file__) + sys.exit(1) + + global siikir + siikir = sys.argv[1] + print "Siikir DB:", siikir + if raw_input("Confirm? [yN] ") != "y": + sys.exit(1) + + convert_index() + #convert_tags() + convert_posts() + + +def convert_index(): + print "Converting blog index" + index = json_get("blog/index/1.json") + new = {} + for post_id, data in index.iteritems(): + del data["id"] + + # Enforce data types. + data["author"] = int(data["author"]) + data["time"] = int(data["time"]) + data["sticky"] = bool(data["sticky"]) + + new[post_id] = data + + JsonDB.commit("blog/index", new) + + +def convert_tags(): + print "Converting tag index" + index = json_get("blog/tags/1.json") + JsonDB.commit("blog/tags", index) + + +def convert_posts(): + print "Converting blog entries..." + + for name in glob.glob(os.path.join(siikir, "blog/entries/1/*.json")): + name = name.split("/")[-1] + post = json_get("blog/entries/1/{}".format(name)) + post_id = post["id"] + del post["id"] + + # Enforce data types. + post["time"] = int(post["time"]) + post["author"] = int(post["author"]) + post["comments"] = bool(post["comments"]) + post["sticky"] = bool(post["sticky"]) + post["emoticons"] = bool(post["emoticons"]) + + print "*", post["subject"] + JsonDB.commit("blog/entries/{}".format(post_id), post) + + +def json_get(document): + fh = codecs.open(os.path.join(siikir, document), 'r', 'utf-8') + text = fh.read() + fh.close() + return json.loads(text) + + +if __name__ == "__main__": + main() \ No newline at end of file