diff options
Diffstat (limited to 'nikola/plugins/command')
33 files changed, 3046 insertions, 0 deletions
diff --git a/nikola/plugins/command/__init__.py b/nikola/plugins/command/__init__.py new file mode 100644 index 0000000..9be4d63 --- /dev/null +++ b/nikola/plugins/command/__init__.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2013 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/nikola/plugins/command/auto.plugin b/nikola/plugins/command/auto.plugin new file mode 100644 index 0000000..87939b2 --- /dev/null +++ b/nikola/plugins/command/auto.plugin @@ -0,0 +1,9 @@ +[Core] +Name = auto +Module = auto + +[Documentation] +Author = Roberto Alsina +Version = 0.2 +Website = http://getnikola.com +Description = Automatically detect site changes, rebuild and optionally refresh a browser. diff --git a/nikola/plugins/command/auto.py b/nikola/plugins/command/auto.py new file mode 100644 index 0000000..cb726d9 --- /dev/null +++ b/nikola/plugins/command/auto.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2013 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +from __future__ import print_function, unicode_literals + +import codecs +import json +import os +import subprocess + +from nikola.plugin_categories import Command +from nikola.utils import req_missing + +GUARDFILE = """#!/usr/bin/env python +# -*- coding: utf-8 -*- +from livereload.task import Task +import json +import subprocess + +def f(): + import subprocess + subprocess.call(("nikola", "build")) + +fdata = json.loads('''{0}''') + +for watch in fdata: + Task.add(watch, f) +""" + + +class Auto(Command): + """Start debugging console.""" + name = "auto" + doc_purpose = "automatically detect site changes, rebuild and optionally refresh a browser" + cmd_options = [ + { + 'name': 'browser', + 'short': 'b', + 'type': bool, + 'help': 'Start a web browser.', + 'default': False, + }, + { + 'name': 'port', + 'short': 'p', + 'long': 'port', + 'default': 8000, + 'type': int, + 'help': 'Port nummber (default: 8000)', + }, + ] + + def _execute(self, options, args): + """Start the watcher.""" + try: + from livereload.server import start + except ImportError: + req_missing(['livereload'], 'use the "auto" command') + return + + # Run an initial build so we are uptodate + subprocess.call(("nikola", "build")) + + port = options and options.get('port') + + # Create a Guardfile + with codecs.open("Guardfile", "wb+", "utf8") as guardfile: + l = ["conf.py", "themes", "templates", self.site.config['GALLERY_PATH']] + for item in self.site.config['post_pages']: + l.append(os.path.dirname(item[0])) + for item in self.site.config['FILES_FOLDERS']: + l.append(os.path.dirname(item)) + data = GUARDFILE.format(json.dumps(l)) + guardfile.write(data) + + out_folder = self.site.config['OUTPUT_FOLDER'] + + os.chmod("Guardfile", 0o755) + + start(port, out_folder, options and options.get('browser')) diff --git a/nikola/plugins/command/bootswatch_theme.plugin b/nikola/plugins/command/bootswatch_theme.plugin new file mode 100644 index 0000000..7091310 --- /dev/null +++ b/nikola/plugins/command/bootswatch_theme.plugin @@ -0,0 +1,10 @@ +[Core] +Name = bootswatch_theme +Module = bootswatch_theme + +[Documentation] +Author = Roberto Alsina +Version = 0.1 +Website = http://getnikola.com +Description = Given a swatch name and a parent theme, creates a custom theme. + diff --git a/nikola/plugins/command/bootswatch_theme.py b/nikola/plugins/command/bootswatch_theme.py new file mode 100644 index 0000000..eb27f94 --- /dev/null +++ b/nikola/plugins/command/bootswatch_theme.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2013 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +from __future__ import print_function +import os + +try: + import requests +except ImportError: + requests = None # NOQA + +from nikola.plugin_categories import Command +from nikola import utils + +LOGGER = utils.get_logger('bootswatch_theme', utils.STDERR_HANDLER) + + +class CommandBootswatchTheme(Command): + """Given a swatch name from bootswatch.com and a parent theme, creates a custom theme.""" + + name = "bootswatch_theme" + doc_usage = "[options]" + doc_purpose = "given a swatch name from bootswatch.com and a parent theme, creates a custom"\ + " theme" + cmd_options = [ + { + 'name': 'name', + 'short': 'n', + 'long': 'name', + 'default': 'custom', + 'type': str, + 'help': 'New theme name (default: custom)', + }, + { + 'name': 'swatch', + 'short': 's', + 'default': 'slate', + 'type': str, + 'help': 'Name of the swatch from bootswatch.com.' + }, + { + 'name': 'parent', + 'short': 'p', + 'long': 'parent', + 'default': 'bootstrap3', + 'help': 'Parent theme name (default: bootstrap3)', + }, + ] + + def _execute(self, options, args): + """Given a swatch name and a parent theme, creates a custom theme.""" + if requests is None: + utils.req_missing(['requests'], 'install Bootswatch themes') + + name = options['name'] + swatch = options['swatch'] + parent = options['parent'] + version = '' + + # See if we need bootswatch for bootstrap v2 or v3 + themes = utils.get_theme_chain(parent) + if 'bootstrap3' not in themes: + version = '2' + elif 'bootstrap' not in themes: + LOGGER.warn('"bootswatch_theme" only makes sense for themes that use bootstrap') + + LOGGER.notice("Creating '{0}' theme from '{1}' and '{2}'".format(name, swatch, parent)) + utils.makedirs(os.path.join('themes', name, 'assets', 'css')) + for fname in ('bootstrap.min.css', 'bootstrap.css'): + url = '/'.join(('http://bootswatch.com', version, swatch, fname)) + LOGGER.notice("Downloading: " + url) + data = requests.get(url).text + with open(os.path.join('themes', name, 'assets', 'css', fname), + 'wb+') as output: + output.write(data.encode('utf-8')) + + with open(os.path.join('themes', name, 'parent'), 'wb+') as output: + output.write(parent.encode('utf-8')) + LOGGER.notice('Theme created. Change the THEME setting to "{0}" to use ' + 'it.'.format(name)) diff --git a/nikola/plugins/command/check.plugin b/nikola/plugins/command/check.plugin new file mode 100644 index 0000000..8ceda5f --- /dev/null +++ b/nikola/plugins/command/check.plugin @@ -0,0 +1,10 @@ +[Core] +Name = check +Module = check + +[Documentation] +Author = Roberto Alsina +Version = 0.1 +Website = http://getnikola.com +Description = Check the generated site + diff --git a/nikola/plugins/command/check.py b/nikola/plugins/command/check.py new file mode 100644 index 0000000..5c7e49a --- /dev/null +++ b/nikola/plugins/command/check.py @@ -0,0 +1,204 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2013 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +from __future__ import print_function +import os +import re +import sys +try: + from urllib import unquote + from urlparse import urlparse +except ImportError: + from urllib.parse import unquote, urlparse # NOQA + +import lxml.html + +from nikola.plugin_categories import Command +from nikola.utils import get_logger + + +class CommandCheck(Command): + """Check the generated site.""" + + name = "check" + logger = None + + doc_usage = "-l [--find-sources] | -f" + doc_purpose = "check links and files in the generated site" + cmd_options = [ + { + 'name': 'links', + 'short': 'l', + 'long': 'check-links', + 'type': bool, + 'default': False, + 'help': 'Check for dangling links', + }, + { + 'name': 'files', + 'short': 'f', + 'long': 'check-files', + 'type': bool, + 'default': False, + 'help': 'Check for unknown files', + }, + { + 'name': 'clean', + 'long': 'clean-files', + 'type': bool, + 'default': False, + 'help': 'Remove all unknown files, use with caution', + }, + { + 'name': 'find_sources', + 'long': 'find-sources', + 'type': bool, + 'default': False, + 'help': 'List possible source files for files with broken links.', + }, + ] + + def _execute(self, options, args): + """Check the generated site.""" + + self.logger = get_logger('check', self.site.loghandlers) + + if not options['links'] and not options['files'] and not options['clean']: + print(self.help()) + return False + if options['links']: + failure = self.scan_links(options['find_sources']) + if options['files']: + failure = self.scan_files() + if options['clean']: + failure = self.clean_files() + if failure: + sys.exit(1) + + existing_targets = set([]) + + def analyze(self, task, find_sources=False): + rv = False + self.whitelist = [re.compile(x) for x in self.site.config['LINK_CHECK_WHITELIST']] + try: + filename = task.split(":")[-1] + d = lxml.html.fromstring(open(filename).read()) + for l in d.iterlinks(): + target = l[0].attrib[l[1]] + if target == "#": + continue + parsed = urlparse(target) + if parsed.scheme or target.startswith('//'): + continue + if parsed.fragment: + target = target.split('#')[0] + target_filename = os.path.abspath( + os.path.join(os.path.dirname(filename), unquote(target))) + if any(re.match(x, target_filename) for x in self.whitelist): + continue + elif target_filename not in self.existing_targets: + if os.path.exists(target_filename): + self.existing_targets.add(target_filename) + else: + rv = True + self.logger.warn("Broken link in {0}: ".format(filename), target) + if find_sources: + self.logger.warn("Possible sources:") + self.logger.warn(os.popen('nikola list --deps ' + task, 'r').read()) + self.logger.warn("===============================\n") + except Exception as exc: + self.logger.error("Error with:", filename, exc) + return rv + + def scan_links(self, find_sources=False): + self.logger.notice("Checking Links:") + self.logger.notice("===============") + failure = False + for task in os.popen('nikola list --all', 'r').readlines(): + task = task.strip() + if task.split(':')[0] in ( + 'render_tags', 'render_archive', + 'render_galleries', 'render_indexes', + 'render_pages' + 'render_site') and '.html' in task: + if self.analyze(task, find_sources): + failure = True + if not failure: + self.logger.notice("All links checked.") + return failure + + def scan_files(self): + failure = False + self.logger.notice("Checking Files:") + self.logger.notice("===============\n") + only_on_output, only_on_input = self.real_scan_files() + + # Ignore folders + only_on_output = [p for p in only_on_output if not os.path.isdir(p)] + only_on_input = [p for p in only_on_input if not os.path.isdir(p)] + + if only_on_output: + only_on_output.sort() + self.logger.warn("Files from unknown origins:") + for f in only_on_output: + self.logger.warn(f) + failure = True + if only_on_input: + only_on_input.sort() + self.logger.warn("Files not generated:") + for f in only_on_input: + self.logger.warn(f) + if not failure: + self.logger.notice("All files checked.") + return failure + + def clean_files(self): + only_on_output, _ = self.real_scan_files() + for f in only_on_output: + os.unlink(f) + return True + + def real_scan_files(self): + task_fnames = set([]) + real_fnames = set([]) + output_folder = self.site.config['OUTPUT_FOLDER'] + # First check that all targets are generated in the right places + for task in os.popen('nikola list --all', 'r').readlines(): + task = task.strip() + if output_folder in task and ':' in task: + fname = task.split(':', 1)[-1] + task_fnames.add(fname) + # And now check that there are no non-target files + for root, dirs, files in os.walk(output_folder): + for src_name in files: + fname = os.path.join(root, src_name) + real_fnames.add(fname) + + only_on_output = list(real_fnames - task_fnames) + + only_on_input = list(task_fnames - real_fnames) + + return (only_on_output, only_on_input) diff --git a/nikola/plugins/command/console.plugin b/nikola/plugins/command/console.plugin new file mode 100644 index 0000000..a2be9ca --- /dev/null +++ b/nikola/plugins/command/console.plugin @@ -0,0 +1,9 @@ +[Core] +Name = console +Module = console + +[Documentation] +Author = Roberto Alsina +Version = 0.1 +Website = http://getnikola.com +Description = Start a debugging python console diff --git a/nikola/plugins/command/console.py b/nikola/plugins/command/console.py new file mode 100644 index 0000000..fe17dfc --- /dev/null +++ b/nikola/plugins/command/console.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2013 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +from __future__ import print_function, unicode_literals + +import os + +from nikola import __version__ +from nikola.plugin_categories import Command +from nikola.utils import get_logger, STDERR_HANDLER + +LOGGER = get_logger('console', STDERR_HANDLER) + + +class Console(Command): + """Start debugging console.""" + name = "console" + shells = ['ipython', 'bpython', 'plain'] + doc_purpose = "Start an interactive Python (IPython->bpython->plain) console with access to your site and configuration" + header = "Nikola v" + __version__ + " -- {0} Console (conf = configuration, SITE = site engine)" + + def ipython(self): + """IPython shell.""" + from nikola import Nikola + try: + import conf + except ImportError: + LOGGER.error("No configuration found, cannot run the console.") + else: + import IPython + SITE = Nikola(**conf.__dict__) + SITE.scan_posts() + IPython.embed(header=self.header.format('IPython')) + + def bpython(self): + """bpython shell.""" + from nikola import Nikola + try: + import conf + except ImportError: + LOGGER.error("No configuration found, cannot run the console.") + else: + import bpython + SITE = Nikola(**conf.__dict__) + SITE.scan_posts() + gl = {'conf': conf, 'SITE': SITE, 'Nikola': Nikola} + bpython.embed(banner=self.header.format( + 'bpython (Slightly Deprecated)'), locals_=gl) + + def plain(self): + """Plain Python shell.""" + from nikola import Nikola + try: + import conf + SITE = Nikola(**conf.__dict__) + SITE.scan_posts() + gl = {'conf': conf, 'SITE': SITE, 'Nikola': Nikola} + except ImportError: + LOGGER.error("No configuration found, cannot run the console.") + else: + import code + try: + import readline + except ImportError: + pass + else: + import rlcompleter + readline.set_completer(rlcompleter.Completer(gl).complete) + readline.parse_and_bind("tab:complete") + + pythonrc = os.environ.get("PYTHONSTARTUP") + if pythonrc and os.path.isfile(pythonrc): + try: + execfile(pythonrc) # NOQA + except NameError: + pass + + code.interact(local=gl, banner=self.header.format('Python')) + + def _execute(self, options, args): + """Start the console.""" + for shell in self.shells: + try: + return getattr(self, shell)() + except ImportError: + pass + raise ImportError diff --git a/nikola/plugins/command/deploy.plugin b/nikola/plugins/command/deploy.plugin new file mode 100644 index 0000000..10cc796 --- /dev/null +++ b/nikola/plugins/command/deploy.plugin @@ -0,0 +1,9 @@ +[Core] +Name = deploy +Module = deploy + +[Documentation] +Author = Roberto Alsina +Version = 0.1 +Website = http://getnikola.com +Description = Deploy the site diff --git a/nikola/plugins/command/deploy.py b/nikola/plugins/command/deploy.py new file mode 100644 index 0000000..efb909d --- /dev/null +++ b/nikola/plugins/command/deploy.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2013 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +from __future__ import print_function +from ast import literal_eval +import codecs +from datetime import datetime +import os +import sys +import subprocess +import time +import pytz + +from blinker import signal + +from nikola.plugin_categories import Command +from nikola.utils import remove_file, get_logger + + +class Deploy(Command): + """Deploy site. """ + name = "deploy" + + doc_usage = "" + doc_purpose = "deploy the site" + + logger = None + + def _execute(self, command, args): + self.logger = get_logger('deploy', self.site.loghandlers) + # Get last successful deploy date + timestamp_path = os.path.join(self.site.config['CACHE_FOLDER'], 'lastdeploy') + if self.site.config['COMMENT_SYSTEM_ID'] == 'nikolademo': + self.logger.warn("\nWARNING WARNING WARNING WARNING\n" + "You are deploying using the nikolademo Disqus account.\n" + "That means you will not be able to moderate the comments in your own site.\n" + "And is probably not what you want to do.\n" + "Think about it for 5 seconds, I'll wait :-)\n\n") + time.sleep(5) + + deploy_drafts = self.site.config.get('DEPLOY_DRAFTS', True) + deploy_future = self.site.config.get('DEPLOY_FUTURE', False) + if not (deploy_drafts and deploy_future): + # Remove drafts and future posts + out_dir = self.site.config['OUTPUT_FOLDER'] + undeployed_posts = [] + self.site.scan_posts() + for post in self.site.timeline: + if (not deploy_drafts and post.is_draft) or \ + (not deploy_future and post.publish_later): + remove_file(os.path.join(out_dir, post.destination_path())) + remove_file(os.path.join(out_dir, post.source_path)) + undeployed_posts.append(post) + + for command in self.site.config['DEPLOY_COMMANDS']: + self.logger.notice("==> {0}".format(command)) + try: + subprocess.check_call(command, shell=True) + except subprocess.CalledProcessError as e: + self.logger.error('Failed deployment — command {0} ' + 'returned {1}'.format(e.cmd, e.returncode)) + sys.exit(e.returncode) + + self.logger.notice("Successful deployment") + if self.site.config['TIMEZONE'] is not None: + tzinfo = pytz.timezone(self.site.config['TIMEZONE']) + else: + tzinfo = pytz.UTC + try: + with open(timestamp_path, 'rb') as inf: + last_deploy = literal_eval(inf.read().strip()) + # this might ignore DST + last_deploy = last_deploy.replace(tzinfo=tzinfo) + clean = False + except Exception: + last_deploy = datetime(1970, 1, 1).replace(tzinfo=tzinfo) + clean = True + + new_deploy = datetime.now() + self._emit_deploy_event(last_deploy, new_deploy, clean, undeployed_posts) + + # Store timestamp of successful deployment + with codecs.open(timestamp_path, 'wb+', 'utf8') as outf: + outf.write(repr(new_deploy)) + + def _emit_deploy_event(self, last_deploy, new_deploy, clean=False, undeployed=None): + """ Emit events for all timeline entries newer than last deploy. + + last_deploy: datetime + Time stamp of the last successful deployment. + + new_deploy: datetime + Time stamp of the current deployment. + + clean: bool + True when it appears like deploy is being run after a clean. + + """ + + if undeployed is None: + undeployed = [] + + event = { + 'last_deploy': last_deploy, + 'new_deploy': new_deploy, + 'clean': clean, + 'undeployed': undeployed + } + + deployed = [ + entry for entry in self.site.timeline + if entry.date > last_deploy and entry not in undeployed + ] + + event['deployed'] = deployed + + if len(deployed) > 0 or len(undeployed) > 0: + signal('deployed').send(event) diff --git a/nikola/plugins/command/import_blogger.plugin b/nikola/plugins/command/import_blogger.plugin new file mode 100644 index 0000000..91a7cb6 --- /dev/null +++ b/nikola/plugins/command/import_blogger.plugin @@ -0,0 +1,10 @@ +[Core] +Name = import_blogger +Module = import_blogger + +[Documentation] +Author = Roberto Alsina +Version = 0.2 +Website = http://getnikola.com +Description = Import a blogger site from a XML dump. + diff --git a/nikola/plugins/command/import_blogger.py b/nikola/plugins/command/import_blogger.py new file mode 100644 index 0000000..53618b4 --- /dev/null +++ b/nikola/plugins/command/import_blogger.py @@ -0,0 +1,229 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2013 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +from __future__ import unicode_literals, print_function +import datetime +import os +import time + +try: + from urlparse import urlparse +except ImportError: + from urllib.parse import urlparse # NOQA + +try: + import feedparser +except ImportError: + feedparser = None # NOQA + +from nikola.plugin_categories import Command +from nikola import utils +from nikola.utils import req_missing +from nikola.plugins.basic_import import ImportMixin + +LOGGER = utils.get_logger('import_blogger', utils.STDERR_HANDLER) + + +class CommandImportBlogger(Command, ImportMixin): + """Import a blogger dump.""" + + name = "import_blogger" + needs_config = False + doc_usage = "[options] blogger_export_file" + doc_purpose = "import a blogger dump" + cmd_options = ImportMixin.cmd_options + [ + { + 'name': 'exclude_drafts', + 'long': 'no-drafts', + 'short': 'd', + 'default': False, + 'type': bool, + 'help': "Don't import drafts", + }, + ] + + def _execute(self, options, args): + """Import a Blogger blog from an export file into a Nikola site.""" + # Parse the data + if feedparser is None: + req_missing(['feedparser'], 'import Blogger dumps') + return + + if not args: + print(self.help()) + return + + options['filename'] = args[0] + self.blogger_export_file = options['filename'] + self.output_folder = options['output_folder'] + self.import_into_existing_site = False + self.exclude_drafts = options['exclude_drafts'] + self.url_map = {} + channel = self.get_channel_from_file(self.blogger_export_file) + self.context = self.populate_context(channel) + conf_template = self.generate_base_site() + self.context['REDIRECTIONS'] = self.configure_redirections( + self.url_map) + + self.import_posts(channel) + self.write_urlmap_csv( + os.path.join(self.output_folder, 'url_map.csv'), self.url_map) + + conf_out_path = self.get_configuration_output_path() + # if it tracebacks here, look a comment in + # basic_import.Import_Mixin.generate_base_site + conf_termplate_render = conf_template.render(**self.context) + self.write_configuration(conf_out_path, conf_termplate_render) + + @classmethod + def get_channel_from_file(cls, filename): + if not os.path.isfile(filename): + raise Exception("Missing file: %s" % filename) + return feedparser.parse(filename) + + @staticmethod + def populate_context(channel): + # may need changes when the template conf.py.in changes + context = {} + context['DEFAULT_LANG'] = 'en' # blogger doesn't include the language + # in the dump + context['BLOG_TITLE'] = channel.feed.title + + context['BLOG_DESCRIPTION'] = '' # Missing in the dump + context['SITE_URL'] = channel.feed.link + context['BLOG_EMAIL'] = channel.feed.author_detail.email + context['BLOG_AUTHOR'] = channel.feed.author_detail.name + context['POSTS'] = '''( + ("posts/*.txt", "posts", "post.tmpl"), + ("posts/*.rst", "posts", "post.tmpl"), + ("posts/*.html", "posts", "post.tmpl"), + )''' + context['PAGES'] = '''( + ("articles/*.txt", "articles", "story.tmpl"), + ("articles/*.rst", "articles", "story.tmpl"), + )''' + context['COMPILERS'] = '''{ + "rest": ('.txt', '.rst'), + "markdown": ('.md', '.mdown', '.markdown', '.wp'), + "html": ('.html', '.htm') + } + ''' + context['THEME'] = 'bootstrap3' + + return context + + def import_item(self, item, out_folder=None): + """Takes an item from the feed and creates a post file.""" + if out_folder is None: + out_folder = 'posts' + + # link is something like http://foo.com/2012/09/01/hello-world/ + # So, take the path, utils.slugify it, and that's our slug + link = item.link + link_path = urlparse(link).path + + title = item.title + + # blogger supports empty titles, which Nikola doesn't + if not title: + LOGGER.warn("Empty title in post with URL {0}. Using NO_TITLE " + "as placeholder, please fix.".format(link)) + title = "NO_TITLE" + + if link_path.lower().endswith('.html'): + link_path = link_path[:-5] + + slug = utils.slugify(link_path) + + if not slug: # should never happen + LOGGER.error("Error converting post:", title) + return + + description = '' + post_date = datetime.datetime.fromtimestamp(time.mktime( + item.published_parsed)) + + for candidate in item.content: + if candidate.type == 'text/html': + content = candidate.value + break + # FIXME: handle attachments + + tags = [] + for tag in item.tags: + if tag.scheme == 'http://www.blogger.com/atom/ns#': + tags.append(tag.term) + + if item.get('app_draft'): + tags.append('draft') + is_draft = True + else: + is_draft = False + + self.url_map[link] = self.context['SITE_URL'] + '/' + \ + out_folder + '/' + slug + '.html' + + if is_draft and self.exclude_drafts: + LOGGER.notice('Draft "{0}" will not be imported.'.format(title)) + elif content.strip(): + # If no content is found, no files are written. + content = self.transform_content(content) + + self.write_metadata(os.path.join(self.output_folder, out_folder, + slug + '.meta'), + title, slug, post_date, description, tags) + self.write_content( + os.path.join(self.output_folder, out_folder, slug + '.html'), + content) + else: + LOGGER.warn('Not going to import "{0}" because it seems to contain' + ' no content.'.format(title)) + + def process_item(self, item): + post_type = item.tags[0].term + + if post_type == 'http://schemas.google.com/blogger/2008/kind#post': + self.import_item(item, 'posts') + elif post_type == 'http://schemas.google.com/blogger/2008/kind#page': + self.import_item(item, 'stories') + elif post_type == ('http://schemas.google.com/blogger/2008/kind' + '#settings'): + # Ignore settings + pass + elif post_type == ('http://schemas.google.com/blogger/2008/kind' + '#template'): + # Ignore template + pass + elif post_type == ('http://schemas.google.com/blogger/2008/kind' + '#comment'): + # FIXME: not importing comments. Does blogger support "pages"? + pass + else: + LOGGER.warn("Unknown post_type:", post_type) + + def import_posts(self, channel): + for item in channel.entries: + self.process_item(item) diff --git a/nikola/plugins/command/import_feed.plugin b/nikola/plugins/command/import_feed.plugin new file mode 100644 index 0000000..26e570a --- /dev/null +++ b/nikola/plugins/command/import_feed.plugin @@ -0,0 +1,10 @@ +[Core] +Name = import_feed +Module = import_feed + +[Documentation] +Author = Grzegorz Śliwiński +Version = 0.1 +Website = http://www.fizyk.net.pl/ +Description = Import a blog posts from a RSS/Atom dump + diff --git a/nikola/plugins/command/import_feed.py b/nikola/plugins/command/import_feed.py new file mode 100644 index 0000000..b25d9ec --- /dev/null +++ b/nikola/plugins/command/import_feed.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2013 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +from __future__ import unicode_literals, print_function +import datetime +import os +import time + +try: + from urlparse import urlparse +except ImportError: + from urllib.parse import urlparse # NOQA + +try: + import feedparser +except ImportError: + feedparser = None # NOQA + +from nikola.plugin_categories import Command +from nikola import utils +from nikola.utils import req_missing +from nikola.plugins.basic_import import ImportMixin + +LOGGER = utils.get_logger('import_feed', utils.STDERR_HANDLER) + + +class CommandImportFeed(Command, ImportMixin): + """Import a feed dump.""" + + name = "import_feed" + needs_config = False + doc_usage = "[options] feed_file" + doc_purpose = "import a RSS/Atom dump" + cmd_options = ImportMixin.cmd_options + + def _execute(self, options, args): + ''' + Import Atom/RSS feed + ''' + if feedparser is None: + req_missing(['feedparser'], 'import feeds') + return + + if not args: + print(self.help()) + return + + options['filename'] = args[0] + self.feed_export_file = options['filename'] + self.output_folder = options['output_folder'] + self.import_into_existing_site = False + self.url_map = {} + channel = self.get_channel_from_file(self.feed_export_file) + self.context = self.populate_context(channel) + conf_template = self.generate_base_site() + self.context['REDIRECTIONS'] = self.configure_redirections( + self.url_map) + + self.import_posts(channel) + + self.write_configuration(self.get_configuration_output_path( + ), conf_template.render(**self.context)) + + @classmethod + def get_channel_from_file(cls, filename): + return feedparser.parse(filename) + + @staticmethod + def populate_context(channel): + context = {} + context['DEFAULT_LANG'] = channel.feed.title_detail.language \ + if channel.feed.title_detail.language else 'en' + context['BLOG_TITLE'] = channel.feed.title + + context['BLOG_DESCRIPTION'] = channel.feed.get('subtitle', '') + context['SITE_URL'] = channel.feed.get('link', '').rstrip('/') + context['BLOG_EMAIL'] = channel.feed.author_detail.get('email', '') if 'author_detail' in channel.feed else '' + context['BLOG_AUTHOR'] = channel.feed.author_detail.get('name', '') if 'author_detail' in channel.feed else '' + + context['POST_PAGES'] = '''( + ("posts/*.html", "posts", "post.tmpl", True), + ("stories/*.html", "stories", "story.tmpl", False), + )''' + context['COMPILERS'] = '''{ + "rest": ('.txt', '.rst'), + "markdown": ('.md', '.mdown', '.markdown', '.wp'), + "html": ('.html', '.htm') + } + ''' + + return context + + def import_posts(self, channel): + for item in channel.entries: + self.process_item(item) + + def process_item(self, item): + self.import_item(item, 'posts') + + def import_item(self, item, out_folder=None): + """Takes an item from the feed and creates a post file.""" + if out_folder is None: + out_folder = 'posts' + + # link is something like http://foo.com/2012/09/01/hello-world/ + # So, take the path, utils.slugify it, and that's our slug + link = item.link + link_path = urlparse(link).path + + title = item.title + + # blogger supports empty titles, which Nikola doesn't + if not title: + LOGGER.warn("Empty title in post with URL {0}. Using NO_TITLE " + "as placeholder, please fix.".format(link)) + title = "NO_TITLE" + + if link_path.lower().endswith('.html'): + link_path = link_path[:-5] + + slug = utils.slugify(link_path) + + if not slug: # should never happen + LOGGER.error("Error converting post:", title) + return + + description = '' + post_date = datetime.datetime.fromtimestamp(time.mktime( + item.published_parsed)) + if item.get('content'): + for candidate in item.get('content', []): + content = candidate.value + break + # FIXME: handle attachments + elif item.get('summary'): + content = item.get('summary') + + tags = [] + for tag in item.get('tags', []): + tags.append(tag.term) + + if item.get('app_draft'): + tags.append('draft') + is_draft = True + else: + is_draft = False + + self.url_map[link] = self.context['SITE_URL'] + '/' + \ + out_folder + '/' + slug + '.html' + + if is_draft and self.exclude_drafts: + LOGGER.notice('Draft "{0}" will not be imported.'.format(title)) + elif content.strip(): + # If no content is found, no files are written. + content = self.transform_content(content) + + self.write_metadata(os.path.join(self.output_folder, out_folder, + slug + '.meta'), + title, slug, post_date, description, tags) + self.write_content( + os.path.join(self.output_folder, out_folder, slug + '.html'), + content) + else: + LOGGER.warn('Not going to import "{0}" because it seems to contain' + ' no content.'.format(title)) + + @staticmethod + def write_metadata(filename, title, slug, post_date, description, tags): + ImportMixin.write_metadata(filename, + title, + slug, + post_date.strftime(r'%Y/%m/%d %H:%m:%S'), + description, + tags) diff --git a/nikola/plugins/command/import_wordpress.plugin b/nikola/plugins/command/import_wordpress.plugin new file mode 100644 index 0000000..fadc759 --- /dev/null +++ b/nikola/plugins/command/import_wordpress.plugin @@ -0,0 +1,10 @@ +[Core] +Name = import_wordpress +Module = import_wordpress + +[Documentation] +Author = Roberto Alsina +Version = 0.2 +Website = http://getnikola.com +Description = Import a wordpress site from a XML dump (requires markdown). + diff --git a/nikola/plugins/command/import_wordpress.py b/nikola/plugins/command/import_wordpress.py new file mode 100644 index 0000000..4f32198 --- /dev/null +++ b/nikola/plugins/command/import_wordpress.py @@ -0,0 +1,443 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2013 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +from __future__ import unicode_literals, print_function +import os +import re +import sys +from lxml import etree + +try: + from urlparse import urlparse + from urllib import unquote +except ImportError: + from urllib.parse import urlparse, unquote # NOQA + +try: + import requests +except ImportError: + requests = None # NOQA + +try: + import phpserialize +except ImportError: + phpserialize = None # NOQA + +from nikola.plugin_categories import Command +from nikola import utils +from nikola.utils import req_missing +from nikola.plugins.basic_import import ImportMixin, links + +LOGGER = utils.get_logger('import_wordpress', utils.STDERR_HANDLER) + + +class CommandImportWordpress(Command, ImportMixin): + """Import a WordPress dump.""" + + name = "import_wordpress" + needs_config = False + doc_usage = "[options] wordpress_export_file" + doc_purpose = "import a WordPress dump" + cmd_options = ImportMixin.cmd_options + [ + { + 'name': 'exclude_drafts', + 'long': 'no-drafts', + 'short': 'd', + 'default': False, + 'type': bool, + 'help': "Don't import drafts", + }, + { + 'name': 'squash_newlines', + 'long': 'squash-newlines', + 'default': False, + 'type': bool, + 'help': "Shorten multiple newlines in a row to only two newlines", + }, + { + 'name': 'no_downloads', + 'long': 'no-downloads', + 'default': False, + 'type': bool, + 'help': "Do not try to download files for the import", + }, + ] + + def _execute(self, options={}, args=[]): + """Import a WordPress blog from an export file into a Nikola site.""" + if not args: + print(self.help()) + return + + options['filename'] = args.pop(0) + + if args and ('output_folder' not in args or + options['output_folder'] == 'new_site'): + options['output_folder'] = args.pop(0) + + if args: + LOGGER.warn('You specified additional arguments ({0}). Please consider ' + 'putting these arguments before the filename if you ' + 'are running into problems.'.format(args)) + + self.import_into_existing_site = False + self.url_map = {} + self.timezone = None + + self.wordpress_export_file = options['filename'] + self.squash_newlines = options.get('squash_newlines', False) + self.output_folder = options.get('output_folder', 'new_site') + + self.exclude_drafts = options.get('exclude_drafts', False) + self.no_downloads = options.get('no_downloads', False) + + if not self.no_downloads: + def show_info_about_mising_module(modulename): + LOGGER.error( + 'To use the "{commandname}" command, you have to install ' + 'the "{package}" package or supply the "--no-downloads" ' + 'option.'.format( + commandname=self.name, + package=modulename) + ) + + if requests is None and phpserialize is None: + req_missing(['requests', 'phpserialize'], 'import WordPress dumps without --no-downloads') + elif requests is None: + req_missing(['requests'], 'import WordPress dumps without --no-downloads') + elif phpserialize is None: + req_missing(['phpserialize'], 'import WordPress dumps without --no-downloads') + + channel = self.get_channel_from_file(self.wordpress_export_file) + self.context = self.populate_context(channel) + conf_template = self.generate_base_site() + + self.import_posts(channel) + + self.context['REDIRECTIONS'] = self.configure_redirections( + self.url_map) + self.write_urlmap_csv( + os.path.join(self.output_folder, 'url_map.csv'), self.url_map) + rendered_template = conf_template.render(**self.context) + rendered_template = re.sub('# REDIRECTIONS = ', 'REDIRECTIONS = ', + rendered_template) + if self.timezone: + rendered_template = re.sub('# TIMEZONE = \'Europe/Zurich\'', + 'TIMEZONE = \'' + self.timezone + '\'', + rendered_template) + self.write_configuration(self.get_configuration_output_path(), + rendered_template) + + @classmethod + def _glue_xml_lines(cls, xml): + new_xml = xml[0] + previous_line_ended_in_newline = new_xml.endswith(b'\n') + previous_line_was_indentet = False + for line in xml[1:]: + if (re.match(b'^[ \t]+', line) and previous_line_ended_in_newline): + new_xml = b''.join((new_xml, line)) + previous_line_was_indentet = True + elif previous_line_was_indentet: + new_xml = b''.join((new_xml, line)) + previous_line_was_indentet = False + else: + new_xml = b'\n'.join((new_xml, line)) + previous_line_was_indentet = False + + previous_line_ended_in_newline = line.endswith(b'\n') + + return new_xml + + @classmethod + def read_xml_file(cls, filename): + xml = [] + + with open(filename, 'rb') as fd: + for line in fd: + # These explode etree and are useless + if b'<atom:link rel=' in line: + continue + xml.append(line) + + return cls._glue_xml_lines(xml) + + @classmethod + def get_channel_from_file(cls, filename): + tree = etree.fromstring(cls.read_xml_file(filename)) + channel = tree.find('channel') + return channel + + @staticmethod + def populate_context(channel): + wordpress_namespace = channel.nsmap['wp'] + + context = {} + context['DEFAULT_LANG'] = get_text_tag(channel, 'language', 'en')[:2] + context['BLOG_TITLE'] = get_text_tag(channel, 'title', + 'PUT TITLE HERE') + context['BLOG_DESCRIPTION'] = get_text_tag( + channel, 'description', 'PUT DESCRIPTION HERE') + context['BASE_URL'] = get_text_tag(channel, 'link', '#') + if not context['BASE_URL']: + base_site_url = channel.find('{{{0}}}author'.format(wordpress_namespace)) + context['BASE_URL'] = get_text_tag(base_site_url, + None, + "http://foo.com") + context['SITE_URL'] = context['BASE_URL'] + context['THEME'] = 'bootstrap3' + + author = channel.find('{{{0}}}author'.format(wordpress_namespace)) + context['BLOG_EMAIL'] = get_text_tag( + author, + '{{{0}}}author_email'.format(wordpress_namespace), + "joe@example.com") + context['BLOG_AUTHOR'] = get_text_tag( + author, + '{{{0}}}author_display_name'.format(wordpress_namespace), + "Joe Example") + context['POSTS'] = '''( + ("posts/*.wp", "posts", "post.tmpl"), + )''' + context['PAGES'] = '''( + ("stories/*.wp", "stories", "story.tmpl"), + )''' + context['COMPILERS'] = '''{ + "rest": ('.txt', '.rst'), + "markdown": ('.md', '.mdown', '.markdown', '.wp'), + "html": ('.html', '.htm') + } + ''' + + return context + + def download_url_content_to_file(self, url, dst_path): + if self.no_downloads: + return + + try: + with open(dst_path, 'wb+') as fd: + fd.write(requests.get(url).content) + except requests.exceptions.ConnectionError as err: + LOGGER.warn("Downloading {0} to {1} failed: {2}".format(url, dst_path, err)) + + def import_attachment(self, item, wordpress_namespace): + url = get_text_tag( + item, '{{{0}}}attachment_url'.format(wordpress_namespace), 'foo') + link = get_text_tag(item, '{{{0}}}link'.format(wordpress_namespace), + 'foo') + path = urlparse(url).path + dst_path = os.path.join(*([self.output_folder, 'files'] + + list(path.split('/')))) + dst_dir = os.path.dirname(dst_path) + utils.makedirs(dst_dir) + LOGGER.notice("Downloading {0} => {1}".format(url, dst_path)) + self.download_url_content_to_file(url, dst_path) + dst_url = '/'.join(dst_path.split(os.sep)[2:]) + links[link] = '/' + dst_url + links[url] = '/' + dst_url + + self.download_additional_image_sizes( + item, + wordpress_namespace, + os.path.dirname(url) + ) + + def download_additional_image_sizes(self, item, wordpress_namespace, source_path): + if phpserialize is None: + return + + additional_metadata = item.findall('{{{0}}}postmeta'.format(wordpress_namespace)) + + if additional_metadata is None: + return + + for element in additional_metadata: + meta_key = element.find('{{{0}}}meta_key'.format(wordpress_namespace)) + if meta_key is not None and meta_key.text == '_wp_attachment_metadata': + meta_value = element.find('{{{0}}}meta_value'.format(wordpress_namespace)) + + if meta_value is None: + continue + + # Someone from Wordpress thought it was a good idea + # serialize PHP objects into that metadata field. Given + # that the export should give you the power to insert + # your blogging into another site or system its not. + # Why don't they just use JSON? + if sys.version_info[0] == 2: + metadata = phpserialize.loads(meta_value.text) + size_key = 'sizes' + file_key = 'file' + else: + metadata = phpserialize.loads(meta_value.text.encode('UTF-8')) + size_key = b'sizes' + file_key = b'file' + + if not size_key in metadata: + continue + + for filename in [metadata[size_key][size][file_key] for size in metadata[size_key]]: + url = '/'.join([source_path, filename.decode('utf-8')]) + + path = urlparse(url).path + dst_path = os.path.join(*([self.output_folder, 'files'] + + list(path.split('/')))) + dst_dir = os.path.dirname(dst_path) + utils.makedirs(dst_dir) + LOGGER.notice("Downloading {0} => {1}".format(url, dst_path)) + self.download_url_content_to_file(url, dst_path) + dst_url = '/'.join(dst_path.split(os.sep)[2:]) + links[url] = '/' + dst_url + links[url] = '/' + dst_url + + @staticmethod + def transform_sourcecode(content): + new_content = re.sub('\[sourcecode language="([^"]+)"\]', + "\n~~~~~~~~~~~~{.\\1}\n", content) + new_content = new_content.replace('[/sourcecode]', + "\n~~~~~~~~~~~~\n") + return new_content + + @staticmethod + def transform_caption(content): + new_caption = re.sub(r'\[/caption\]', '', content) + new_caption = re.sub(r'\[caption.*\]', '', new_caption) + + return new_caption + + def transform_multiple_newlines(self, content): + """Replaces multiple newlines with only two.""" + if self.squash_newlines: + return re.sub(r'\n{3,}', r'\n\n', content) + else: + return content + + def transform_content(self, content): + new_content = self.transform_sourcecode(content) + new_content = self.transform_caption(new_content) + new_content = self.transform_multiple_newlines(new_content) + return new_content + + def import_item(self, item, wordpress_namespace, out_folder=None): + """Takes an item from the feed and creates a post file.""" + if out_folder is None: + out_folder = 'posts' + + title = get_text_tag(item, 'title', 'NO TITLE') + # link is something like http://foo.com/2012/09/01/hello-world/ + # So, take the path, utils.slugify it, and that's our slug + link = get_text_tag(item, 'link', None) + path = unquote(urlparse(link).path) + + # In python 2, path is a str. slug requires a unicode + # object. According to wikipedia, unquoted strings will + # usually be UTF8 + if isinstance(path, utils.bytes_str): + path = path.decode('utf8') + slug = utils.slugify(path) + if not slug: # it happens if the post has no "nice" URL + slug = get_text_tag( + item, '{{{0}}}post_name'.format(wordpress_namespace), None) + if not slug: # it *may* happen + slug = get_text_tag( + item, '{{{0}}}post_id'.format(wordpress_namespace), None) + if not slug: # should never happen + LOGGER.error("Error converting post:", title) + return + + description = get_text_tag(item, 'description', '') + post_date = get_text_tag( + item, '{{{0}}}post_date'.format(wordpress_namespace), None) + dt = utils.to_datetime(post_date) + if dt.tzinfo and self.timezone is None: + self.timezone = utils.get_tzname(dt) + status = get_text_tag( + item, '{{{0}}}status'.format(wordpress_namespace), 'publish') + content = get_text_tag( + item, '{http://purl.org/rss/1.0/modules/content/}encoded', '') + + tags = [] + if status == 'trash': + LOGGER.warn('Trashed post "{0}" will not be imported.'.format(title)) + return + elif status != 'publish': + tags.append('draft') + is_draft = True + else: + is_draft = False + + for tag in item.findall('category'): + text = tag.text + if text == 'Uncategorized': + continue + tags.append(text) + + if is_draft and self.exclude_drafts: + LOGGER.notice('Draft "{0}" will not be imported.'.format(title)) + elif content.strip(): + # If no content is found, no files are written. + self.url_map[link] = self.context['SITE_URL'] + '/' + \ + out_folder + '/' + slug + '.html' + + content = self.transform_content(content) + + self.write_metadata(os.path.join(self.output_folder, out_folder, + slug + '.meta'), + title, slug, post_date, description, tags) + self.write_content( + os.path.join(self.output_folder, out_folder, slug + '.wp'), + content) + else: + LOGGER.warn('Not going to import "{0}" because it seems to contain' + ' no content.'.format(title)) + + def process_item(self, item): + # The namespace usually is something like: + # http://wordpress.org/export/1.2/ + wordpress_namespace = item.nsmap['wp'] + post_type = get_text_tag( + item, '{{{0}}}post_type'.format(wordpress_namespace), 'post') + + if post_type == 'attachment': + self.import_attachment(item, wordpress_namespace) + elif post_type == 'post': + self.import_item(item, wordpress_namespace, 'posts') + else: + self.import_item(item, wordpress_namespace, 'stories') + + def import_posts(self, channel): + for item in channel.findall('item'): + self.process_item(item) + + +def get_text_tag(tag, name, default): + if tag is None: + return default + t = tag.find(name) + if t is not None: + return t.text + else: + return default diff --git a/nikola/plugins/command/init.plugin b/nikola/plugins/command/init.plugin new file mode 100644 index 0000000..a539f51 --- /dev/null +++ b/nikola/plugins/command/init.plugin @@ -0,0 +1,9 @@ +[Core] +Name = init +Module = init + +[Documentation] +Author = Roberto Alsina +Version = 0.2 +Website = http://getnikola.com +Description = Create a new site. diff --git a/nikola/plugins/command/init.py b/nikola/plugins/command/init.py new file mode 100644 index 0000000..1873ec4 --- /dev/null +++ b/nikola/plugins/command/init.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2013 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +from __future__ import print_function +import os +import shutil +import codecs + +from mako.template import Template + +import nikola +from nikola.plugin_categories import Command +from nikola.utils import get_logger, makedirs, STDERR_HANDLER +from nikola.winutils import fix_git_symlinked + +LOGGER = get_logger('init', STDERR_HANDLER) + + +class CommandInit(Command): + """Create a new site.""" + + name = "init" + + doc_usage = "[--demo] folder" + needs_config = False + doc_purpose = "create a Nikola site in the specified folder" + cmd_options = [ + { + 'name': 'demo', + 'long': 'demo', + 'default': False, + 'type': bool, + 'help': "Create a site filled with example data.", + } + ] + + SAMPLE_CONF = { + 'BLOG_AUTHOR': "Your Name", + 'BLOG_TITLE': "Demo Site", + 'SITE_URL': "http://getnikola.com/", + 'BLOG_EMAIL': "joe@demo.site", + 'BLOG_DESCRIPTION': "This is a demo site for Nikola.", + 'DEFAULT_LANG': "en", + 'THEME': 'bootstrap3', + + 'POSTS': """( + ("posts/*.rst", "posts", "post.tmpl"), + ("posts/*.txt", "posts", "post.tmpl"), +)""", + 'PAGES': """( + ("stories/*.rst", "stories", "story.tmpl"), + ("stories/*.txt", "stories", "story.tmpl"), +)""", + 'COMPILERS': """{ + "rest": ('.rst', '.txt'), + "markdown": ('.md', '.mdown', '.markdown'), + "textile": ('.textile',), + "txt2tags": ('.t2t',), + "bbcode": ('.bb',), + "wiki": ('.wiki',), + "ipynb": ('.ipynb',), + "html": ('.html', '.htm'), + # Pandoc detects the input from the source filename + # but is disabled by default as it would conflict + # with many of the others. + # "pandoc": ('.rst', '.md', '.txt'), +}""", + 'REDIRECTIONS': '[]', + } + + @classmethod + def copy_sample_site(cls, target): + lib_path = cls.get_path_to_nikola_modules() + src = os.path.join(lib_path, 'data', 'samplesite') + shutil.copytree(src, target) + fix_git_symlinked(src, target) + + @classmethod + def create_configuration(cls, target): + lib_path = cls.get_path_to_nikola_modules() + template_path = os.path.join(lib_path, 'conf.py.in') + conf_template = Template(filename=template_path) + conf_path = os.path.join(target, 'conf.py') + with codecs.open(conf_path, 'w+', 'utf8') as fd: + fd.write(conf_template.render(**cls.SAMPLE_CONF)) + + @classmethod + def create_empty_site(cls, target): + for folder in ('files', 'galleries', 'listings', 'posts', 'stories'): + makedirs(os.path.join(target, folder)) + + @staticmethod + def get_path_to_nikola_modules(): + return os.path.dirname(nikola.__file__) + + def _execute(self, options={}, args=None): + """Create a new site.""" + if not args: + print("Usage: nikola init folder [options]") + return False + target = args[0] + if target is None: + print(self.usage) + else: + if not options or not options.get('demo'): + self.create_empty_site(target) + LOGGER.notice('Created empty site at {0}.'.format(target)) + else: + self.copy_sample_site(target) + LOGGER.notice("A new site with example data has been created at " + "{0}.".format(target)) + LOGGER.notice("See README.txt in that folder for more information.") + + self.create_configuration(target) diff --git a/nikola/plugins/command/install_plugin.plugin b/nikola/plugins/command/install_plugin.plugin new file mode 100644 index 0000000..3dbabd8 --- /dev/null +++ b/nikola/plugins/command/install_plugin.plugin @@ -0,0 +1,10 @@ +[Core] +Name = install_plugin +Module = install_plugin + +[Documentation] +Author = Roberto Alsina and Chris Warrick +Version = 0.1 +Website = http://getnikola.com +Description = Install a plugin into the current site. + diff --git a/nikola/plugins/command/install_plugin.py b/nikola/plugins/command/install_plugin.py new file mode 100644 index 0000000..fdbd0b7 --- /dev/null +++ b/nikola/plugins/command/install_plugin.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2013 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +from __future__ import print_function +import codecs +import os +import json +import shutil +import subprocess +from io import BytesIO + +import pygments +from pygments.lexers import PythonLexer +from pygments.formatters import TerminalFormatter + +try: + import requests +except ImportError: + requests = None # NOQA + +from nikola.plugin_categories import Command +from nikola import utils + +LOGGER = utils.get_logger('install_plugin', utils.STDERR_HANDLER) + + +# Stolen from textwrap in Python 3.3.2. +def indent(text, prefix, predicate=None): # NOQA + """Adds 'prefix' to the beginning of selected lines in 'text'. + + If 'predicate' is provided, 'prefix' will only be added to the lines + where 'predicate(line)' is True. If 'predicate' is not provided, + it will default to adding 'prefix' to all non-empty lines that do not + consist solely of whitespace characters. + """ + if predicate is None: + def predicate(line): + return line.strip() + + def prefixed_lines(): + for line in text.splitlines(True): + yield (prefix + line if predicate(line) else line) + return ''.join(prefixed_lines()) + + +class CommandInstallPlugin(Command): + """Install a plugin.""" + + name = "install_plugin" + doc_usage = "[[-u] plugin_name] | [[-u] -l]" + doc_purpose = "install plugin into current site" + output_dir = 'plugins' + cmd_options = [ + { + 'name': 'list', + 'short': 'l', + 'long': 'list', + 'type': bool, + 'default': False, + 'help': 'Show list of available plugins.' + }, + { + 'name': 'url', + 'short': 'u', + 'long': 'url', + 'type': str, + 'help': "URL for the plugin repository (default: " + "http://plugins.getnikola.com/v6/plugins.json)", + 'default': 'http://plugins.getnikola.com/v6/plugins.json' + }, + ] + + def _execute(self, options, args): + """Install plugin into current site.""" + if requests is None: + utils.req_missing(['requests'], 'install plugins') + + listing = options['list'] + url = options['url'] + if args: + name = args[0] + else: + name = None + + if name is None and not listing: + LOGGER.error("This command needs either a plugin name or the -l option.") + return False + data = requests.get(url).text + data = json.loads(data) + if listing: + print("Plugins:") + print("--------") + for plugin in sorted(data.keys()): + print(plugin) + return True + else: + self.do_install(name, data) + + def do_install(self, name, data): + if name in data: + utils.makedirs(self.output_dir) + LOGGER.notice('Downloading: ' + data[name]) + zip_file = BytesIO() + zip_file.write(requests.get(data[name]).content) + LOGGER.notice('Extracting: {0} into plugins'.format(name)) + utils.extract_all(zip_file, 'plugins') + dest_path = os.path.join('plugins', name) + else: + try: + plugin_path = utils.get_plugin_path(name) + except: + LOGGER.error("Can't find plugin " + name) + return False + + utils.makedirs(self.output_dir) + dest_path = os.path.join(self.output_dir, name) + if os.path.exists(dest_path): + LOGGER.error("{0} is already installed".format(name)) + return False + + LOGGER.notice('Copying {0} into plugins'.format(plugin_path)) + shutil.copytree(plugin_path, dest_path) + + reqpath = os.path.join(dest_path, 'requirements.txt') + print(reqpath) + if os.path.exists(reqpath): + LOGGER.notice('This plugin has Python dependencies.') + LOGGER.notice('Installing dependencies with pip...') + try: + subprocess.check_call(('pip', 'install', '-r', reqpath)) + except subprocess.CalledProcessError: + LOGGER.error('Could not install the dependencies.') + print('Contents of the requirements.txt file:\n') + with codecs.open(reqpath, 'rb', 'utf-8') as fh: + print(indent(fh.read(), 4 * ' ')) + print('You have to install those yourself or through a ' + 'package manager.') + else: + LOGGER.notice('Dependency installation succeeded.') + reqnpypath = os.path.join(dest_path, 'requirements-nonpy.txt') + if os.path.exists(reqnpypath): + LOGGER.notice('This plugin has third-party ' + 'dependencies you need to install ' + 'manually.') + print('Contents of the requirements-nonpy.txt file:\n') + with codecs.open(reqnpypath, 'rb', 'utf-8') as fh: + for l in fh.readlines(): + i, j = l.split('::') + print(indent(i.strip(), 4 * ' ')) + print(indent(j.strip(), 8 * ' ')) + print() + + print('You have to install those yourself or through a package ' + 'manager.') + confpypath = os.path.join(dest_path, 'conf.py.sample') + if os.path.exists(confpypath): + LOGGER.notice('This plugin has a sample config file.') + print('Contents of the conf.py.sample file:\n') + with codecs.open(confpypath, 'rb', 'utf-8') as fh: + print(indent(pygments.highlight( + fh.read(), PythonLexer(), TerminalFormatter()), 4 * ' ')) + return True diff --git a/nikola/plugins/command/install_theme.plugin b/nikola/plugins/command/install_theme.plugin new file mode 100644 index 0000000..84b2623 --- /dev/null +++ b/nikola/plugins/command/install_theme.plugin @@ -0,0 +1,10 @@ +[Core] +Name = install_theme +Module = install_theme + +[Documentation] +Author = Roberto Alsina +Version = 0.1 +Website = http://getnikola.com +Description = Install a theme into the current site. + diff --git a/nikola/plugins/command/install_theme.py b/nikola/plugins/command/install_theme.py new file mode 100644 index 0000000..a9d835a --- /dev/null +++ b/nikola/plugins/command/install_theme.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2013 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +from __future__ import print_function +import os +import json +import shutil +import codecs +from io import BytesIO + +import pygments +from pygments.lexers import PythonLexer +from pygments.formatters import TerminalFormatter + +try: + import requests +except ImportError: + requests = None # NOQA + +from nikola.plugin_categories import Command +from nikola import utils + +LOGGER = utils.get_logger('install_theme', utils.STDERR_HANDLER) + + +# Stolen from textwrap in Python 3.3.2. +def indent(text, prefix, predicate=None): # NOQA + """Adds 'prefix' to the beginning of selected lines in 'text'. + + If 'predicate' is provided, 'prefix' will only be added to the lines + where 'predicate(line)' is True. If 'predicate' is not provided, + it will default to adding 'prefix' to all non-empty lines that do not + consist solely of whitespace characters. + """ + if predicate is None: + def predicate(line): + return line.strip() + + def prefixed_lines(): + for line in text.splitlines(True): + yield (prefix + line if predicate(line) else line) + return ''.join(prefixed_lines()) + + +class CommandInstallTheme(Command): + """Install a theme.""" + + name = "install_theme" + doc_usage = "[[-u] theme_name] | [[-u] -l]" + doc_purpose = "install theme into current site" + output_dir = 'themes' + cmd_options = [ + { + 'name': 'list', + 'short': 'l', + 'long': 'list', + 'type': bool, + 'default': False, + 'help': 'Show list of available themes.' + }, + { + 'name': 'url', + 'short': 'u', + 'long': 'url', + 'type': str, + 'help': "URL for the theme repository (default: " + "http://themes.getnikola.com/v6/themes.json)", + 'default': 'http://themes.getnikola.com/v6/themes.json' + }, + ] + + def _execute(self, options, args): + """Install theme into current site.""" + if requests is None: + utils.req_missing(['requests'], 'install themes') + + listing = options['list'] + url = options['url'] + if args: + name = args[0] + else: + name = None + + if name is None and not listing: + LOGGER.error("This command needs either a theme name or the -l option.") + return False + data = requests.get(url).text + data = json.loads(data) + if listing: + print("Themes:") + print("-------") + for theme in sorted(data.keys()): + print(theme) + return True + else: + self.do_install(name, data) + # See if the theme's parent is available. If not, install it + while True: + parent_name = utils.get_parent_theme_name(name) + if parent_name is None: + break + try: + utils.get_theme_path(parent_name) + break + except: # Not available + self.do_install(parent_name, data) + name = parent_name + + def do_install(self, name, data): + if name in data: + utils.makedirs(self.output_dir) + LOGGER.notice('Downloading: ' + data[name]) + zip_file = BytesIO() + zip_file.write(requests.get(data[name]).content) + LOGGER.notice('Extracting: {0} into themes'.format(name)) + utils.extract_all(zip_file) + dest_path = os.path.join('themes', name) + else: + try: + theme_path = utils.get_theme_path(name) + except: + LOGGER.error("Can't find theme " + name) + return False + + utils.makedirs(self.output_dir) + dest_path = os.path.join(self.output_dir, name) + if os.path.exists(dest_path): + LOGGER.error("{0} is already installed".format(name)) + return False + + LOGGER.notice('Copying {0} into themes'.format(theme_path)) + shutil.copytree(theme_path, dest_path) + confpypath = os.path.join(dest_path, 'conf.py.sample') + if os.path.exists(confpypath): + LOGGER.notice('This plugin has a sample config file.') + print('Contents of the conf.py.sample file:\n') + with codecs.open(confpypath, 'rb', 'utf-8') as fh: + print(indent(pygments.highlight( + fh.read(), PythonLexer(), TerminalFormatter()), 4 * ' ')) + return True diff --git a/nikola/plugins/command/mincss.plugin b/nikola/plugins/command/mincss.plugin new file mode 100644 index 0000000..d394d06 --- /dev/null +++ b/nikola/plugins/command/mincss.plugin @@ -0,0 +1,10 @@ +[Core] +Name = mincss +Module = mincss + +[Documentation] +Author = Roberto Alsina +Version = 0.1 +Website = http://getnikola.com +Description = Apply mincss to the generated site + diff --git a/nikola/plugins/command/mincss.py b/nikola/plugins/command/mincss.py new file mode 100644 index 0000000..5c9a7cb --- /dev/null +++ b/nikola/plugins/command/mincss.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2013 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +from __future__ import print_function, unicode_literals +import os +import sys + +try: + from mincss.processor import Processor +except ImportError: + Processor = None + +from nikola.plugin_categories import Command +from nikola.utils import req_missing, get_logger, STDERR_HANDLER + + +class CommandMincss(Command): + """Check the generated site.""" + name = "mincss" + + doc_usage = "" + doc_purpose = "apply mincss to the generated site" + + logger = get_logger('mincss', STDERR_HANDLER) + + def _execute(self, options, args): + """Apply mincss the generated site.""" + output_folder = self.site.config['OUTPUT_FOLDER'] + if Processor is None: + req_missing(['mincss'], 'use the "mincss" command') + return + + p = Processor(preserve_remote_urls=False) + urls = [] + css_files = {} + for root, dirs, files in os.walk(output_folder): + for f in files: + url = os.path.join(root, f) + if url.endswith('.css'): + fname = os.path.basename(url) + if fname in css_files: + self.logger.error("You have two CSS files with the same name and that confuses me.") + sys.exit(1) + css_files[fname] = url + if not f.endswith('.html'): + continue + urls.append(url) + p.process(*urls) + for inline in p.links: + fname = os.path.basename(inline.href) + with open(css_files[fname], 'wb+') as outf: + outf.write(inline.after) diff --git a/nikola/plugins/command/new_post.plugin b/nikola/plugins/command/new_post.plugin new file mode 100644 index 0000000..ec35c35 --- /dev/null +++ b/nikola/plugins/command/new_post.plugin @@ -0,0 +1,10 @@ +[Core] +Name = new_post +Module = new_post + +[Documentation] +Author = Roberto Alsina +Version = 0.1 +Website = http://getnikola.com +Description = Create a new post. + diff --git a/nikola/plugins/command/new_post.py b/nikola/plugins/command/new_post.py new file mode 100644 index 0000000..ea0f3de --- /dev/null +++ b/nikola/plugins/command/new_post.py @@ -0,0 +1,291 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2013 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +from __future__ import unicode_literals, print_function +import codecs +import datetime +import os +import sys + +from blinker import signal + +from nikola.plugin_categories import Command +from nikola import utils + +LOGGER = utils.get_logger('new_post', utils.STDERR_HANDLER) + + +def filter_post_pages(compiler, is_post, compilers, post_pages): + """Given a compiler ("markdown", "rest"), and whether it's meant for + a post or a page, and compilers, return the correct entry from + post_pages.""" + + # First throw away all the post_pages with the wrong is_post + filtered = [entry for entry in post_pages if entry[3] == is_post] + + # These are the extensions supported by the required format + extensions = compilers[compiler] + + # Throw away the post_pages with the wrong extensions + filtered = [entry for entry in filtered if any([ext in entry[0] for ext in + extensions])] + + if not filtered: + type_name = "post" if is_post else "page" + raise Exception("Can't find a way, using your configuration, to create " + "a {0} in format {1}. You may want to tweak " + "COMPILERS or POSTS/PAGES in conf.py".format( + type_name, compiler)) + return filtered[0] + + +def get_default_compiler(is_post, compilers, post_pages): + """Given compilers and post_pages, return a reasonable + default compiler for this kind of post/page. + """ + + # First throw away all the post_pages with the wrong is_post + filtered = [entry for entry in post_pages if entry[3] == is_post] + + # Get extensions in filtered post_pages until one matches a compiler + for entry in filtered: + extension = os.path.splitext(entry[0])[-1] + for compiler, extensions in compilers.items(): + if extension in extensions: + return compiler + # No idea, back to default behaviour + return 'rest' + + +def get_date(schedule=False, rule=None, last_date=None, force_today=False): + """Returns a date stamp, given a recurrence rule. + + schedule - bool: + whether to use the recurrence rule or not + + rule - str: + an iCal RRULE string that specifies the rule for scheduling posts + + last_date - datetime: + timestamp of the last post + + force_today - bool: + tries to schedule a post to today, if possible, even if the scheduled + time has already passed in the day. + """ + + date = now = datetime.datetime.now() + if schedule: + try: + from dateutil import rrule + except ImportError: + LOGGER.error('To use the --schedule switch of new_post, ' + 'you have to install the "dateutil" package.') + rrule = None + if schedule and rrule and rule: + if last_date and last_date.tzinfo: + # strip tzinfo for comparisons + last_date = last_date.replace(tzinfo=None) + try: + rule_ = rrule.rrulestr(rule, dtstart=last_date) + except Exception: + LOGGER.error('Unable to parse rule string, using current time.') + else: + # Try to post today, instead of tomorrow, if no other post today. + if force_today: + now = now.replace(hour=0, minute=0, second=0, microsecond=0) + date = rule_.after(max(now, last_date or now), last_date is None) + return date.strftime('%Y/%m/%d %H:%M:%S') + + +class CommandNewPost(Command): + """Create a new post.""" + + name = "new_post" + doc_usage = "[options] [path]" + doc_purpose = "create a new blog post or site page" + cmd_options = [ + { + 'name': 'is_page', + 'short': 'p', + 'long': 'page', + 'type': bool, + 'default': False, + 'help': 'Create a page instead of a blog post.' + }, + { + 'name': 'title', + 'short': 't', + 'long': 'title', + 'type': str, + 'default': '', + 'help': 'Title for the page/post.' + }, + { + 'name': 'tags', + 'long': 'tags', + 'type': str, + 'default': '', + 'help': 'Comma-separated tags for the page/post.' + }, + { + 'name': 'onefile', + 'short': '1', + 'type': bool, + 'default': False, + 'help': 'Create post with embedded metadata (single file format)' + }, + { + 'name': 'twofile', + 'short': '2', + 'type': bool, + 'default': False, + 'help': 'Create post with separate metadata (two file format)' + }, + { + 'name': 'post_format', + 'short': 'f', + 'long': 'format', + 'type': str, + 'default': '', + 'help': 'Markup format for post, one of rest, markdown, wiki, ' + 'bbcode, html, textile, txt2tags', + }, + { + 'name': 'schedule', + 'short': 's', + 'type': bool, + 'default': False, + 'help': 'Schedule post based on recurrence rule' + }, + + ] + + def _execute(self, options, args): + """Create a new post or page.""" + compiler_names = [p.name for p in + self.site.plugin_manager.getPluginsOfCategory( + "PageCompiler")] + + if len(args) > 1: + print(self.help()) + return False + elif args: + path = args[0] + else: + path = None + + is_page = options.get('is_page', False) + is_post = not is_page + title = options['title'] or None + tags = options['tags'] + onefile = options['onefile'] + twofile = options['twofile'] + + if twofile: + onefile = False + if not onefile and not twofile: + onefile = self.site.config.get('ONE_FILE_POSTS', True) + + post_format = options['post_format'] + + if not post_format: # Issue #400 + post_format = get_default_compiler( + is_post, + self.site.config['COMPILERS'], + self.site.config['post_pages']) + + if post_format not in compiler_names: + LOGGER.error("Unknown post format " + post_format) + return + compiler_plugin = self.site.plugin_manager.getPluginByName( + post_format, "PageCompiler").plugin_object + + # Guess where we should put this + entry = filter_post_pages(post_format, is_post, + self.site.config['COMPILERS'], + self.site.config['post_pages']) + + print("Creating New Post") + print("-----------------\n") + if title is None: + print("Enter title: ", end='') + # WHY, PYTHON3???? WHY? + sys.stdout.flush() + title = sys.stdin.readline() + else: + print("Title:", title) + if isinstance(title, utils.bytes_str): + title = title.decode(sys.stdin.encoding) + title = title.strip() + if not path: + slug = utils.slugify(title) + else: + if isinstance(path, utils.bytes_str): + path = path.decode(sys.stdin.encoding) + slug = utils.slugify(os.path.splitext(os.path.basename(path))[0]) + # Calculate the date to use for the post + schedule = options['schedule'] or self.site.config['SCHEDULE_ALL'] + rule = self.site.config['SCHEDULE_RULE'] + force_today = self.site.config['SCHEDULE_FORCE_TODAY'] + self.site.scan_posts() + timeline = self.site.timeline + last_date = None if not timeline else timeline[0].date + date = get_date(schedule, rule, last_date, force_today) + data = [title, slug, date, tags] + output_path = os.path.dirname(entry[0]) + meta_path = os.path.join(output_path, slug + ".meta") + pattern = os.path.basename(entry[0]) + suffix = pattern[1:] + if not path: + txt_path = os.path.join(output_path, slug + suffix) + else: + txt_path = path + + if (not onefile and os.path.isfile(meta_path)) or \ + os.path.isfile(txt_path): + LOGGER.error("The title already exists!") + exit() + + d_name = os.path.dirname(txt_path) + utils.makedirs(d_name) + metadata = self.site.config['ADDITIONAL_METADATA'] + compiler_plugin.create_post( + txt_path, onefile, title=title, + slug=slug, date=date, tags=tags, **metadata) + + event = dict(path=txt_path) + + if not onefile: # write metadata file + with codecs.open(meta_path, "wb+", "utf8") as fd: + fd.write('\n'.join(data)) + with codecs.open(txt_path, "wb+", "utf8") as fd: + fd.write("Write your post here.") + LOGGER.notice("Your post's metadata is at: {0}".format(meta_path)) + event['meta_path'] = meta_path + LOGGER.notice("Your post's text is at: {0}".format(txt_path)) + + signal('new_post').send(self, **event) diff --git a/nikola/plugins/command/planetoid.plugin b/nikola/plugins/command/planetoid.plugin new file mode 100644 index 0000000..e767f31 --- /dev/null +++ b/nikola/plugins/command/planetoid.plugin @@ -0,0 +1,9 @@ +[Core] +Name = planetoid +Module = planetoid + +[Documentation] +Author = Roberto Alsina +Version = 0.1 +Website = http://getnikola.com +Description = Maintain a planet-like site diff --git a/nikola/plugins/command/planetoid/__init__.py b/nikola/plugins/command/planetoid/__init__.py new file mode 100644 index 0000000..369862b --- /dev/null +++ b/nikola/plugins/command/planetoid/__init__.py @@ -0,0 +1,289 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2013 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +from __future__ import print_function, unicode_literals +import codecs +import datetime +import hashlib +from optparse import OptionParser +import os +import sys + +from doit.tools import timeout +from nikola.plugin_categories import Command, Task +from nikola.utils import config_changed, req_missing, get_logger, STDERR_HANDLER + +LOGGER = get_logger('planetoid', STDERR_HANDLER) + +try: + import feedparser +except ImportError: + feedparser = None # NOQA + +try: + import peewee +except ImportError: + peewee = None + + +if peewee is not None: + class Feed(peewee.Model): + name = peewee.CharField() + url = peewee.CharField(max_length=200) + last_status = peewee.CharField(null=True) + etag = peewee.CharField(max_length=200) + last_modified = peewee.DateTimeField() + + class Entry(peewee.Model): + date = peewee.DateTimeField() + feed = peewee.ForeignKeyField(Feed) + content = peewee.TextField(max_length=20000) + link = peewee.CharField(max_length=200) + title = peewee.CharField(max_length=200) + guid = peewee.CharField(max_length=200) + + +class Planetoid(Command, Task): + """Maintain a planet-like thing.""" + name = "planetoid" + + def init_db(self): + # setup database + Feed.create_table(fail_silently=True) + Entry.create_table(fail_silently=True) + + def gen_tasks(self): + if peewee is None or sys.version_info[0] == 3: + if sys.version_info[0] == 3: + message = 'Peewee, a requirement of the "planetoid" command, is currently incompatible with Python 3.' + else: + req_missing('peewee', 'use the "planetoid" command') + message = '' + yield { + 'basename': self.name, + 'name': '', + 'verbosity': 2, + 'actions': ['echo "%s"' % message] + } + else: + self.init_db() + self.load_feeds() + for task in self.task_update_feeds(): + yield task + for task in self.task_generate_posts(): + yield task + yield { + 'basename': self.name, + 'name': '', + 'actions': [], + 'file_dep': ['feeds'], + 'task_dep': [ + self.name + "_fetch_feed", + self.name + "_generate_posts", + ] + } + + def run(self, *args): + parser = OptionParser(usage="nikola %s [options]" % self.name) + (options, args) = parser.parse_args(list(args)) + + def load_feeds(self): + "Read the feeds file, add it to the database." + feeds = [] + feed = name = None + for line in codecs.open('feeds', 'r', 'utf-8'): + line = line.strip() + if line.startswith("#"): + continue + elif line.startswith('http'): + feed = line + elif line: + name = line + if feed and name: + feeds.append([feed, name]) + feed = name = None + + def add_feed(name, url): + f = Feed.create( + name=name, + url=url, + etag='foo', + last_modified=datetime.datetime(1970, 1, 1), + ) + f.save() + + def update_feed_url(feed, url): + feed.url = url + feed.save() + + for feed, name in feeds: + f = Feed.select().where(Feed.name == name) + if not list(f): + add_feed(name, feed) + elif list(f)[0].url != feed: + update_feed_url(list(f)[0], feed) + + def task_update_feeds(self): + """Download feed contents, add entries to the database.""" + def update_feed(feed): + modified = feed.last_modified.timetuple() + etag = feed.etag + try: + parsed = feedparser.parse( + feed.url, + etag=etag, + modified=modified + ) + feed.last_status = str(parsed.status) + except: # Probably a timeout + # TODO: log failure + return + if parsed.feed.get('title'): + LOGGER.notice(parsed.feed.title) + else: + LOGGER.notice(feed.url) + feed.etag = parsed.get('etag', 'foo') + modified = tuple(parsed.get('date_parsed', (1970, 1, 1)))[:6] + LOGGER.notice("==========>", modified) + modified = datetime.datetime(*modified) + feed.last_modified = modified + feed.save() + # No point in adding items from missinfg feeds + if parsed.status > 400: + # TODO log failure + return + for entry_data in parsed.entries: + LOGGER.notice("=========================================") + date = entry_data.get('published_parsed', None) + if date is None: + date = entry_data.get('updated_parsed', None) + if date is None: + LOGGER.error("Can't parse date from:\n", entry_data) + return False + LOGGER.notice("DATE:===>", date) + date = datetime.datetime(*(date[:6])) + title = "%s: %s" % (feed.name, entry_data.get('title', 'Sin título')) + content = entry_data.get('content', None) + if content: + content = content[0].value + if not content: + content = entry_data.get('description', None) + if not content: + content = entry_data.get('summary', 'Sin contenido') + guid = str(entry_data.get('guid', entry_data.link)) + link = entry_data.link + LOGGER.notice(repr([date, title])) + e = list(Entry.select().where(Entry.guid == guid)) + LOGGER.notice( + repr(dict( + date=date, + title=title, + content=content, + guid=guid, + feed=feed, + link=link, + )) + ) + if not e: + entry = Entry.create( + date=date, + title=title, + content=content, + guid=guid, + feed=feed, + link=link, + ) + else: + entry = e[0] + entry.date = date + entry.title = title + entry.content = content + entry.link = link + entry.save() + flag = False + for feed in Feed.select(): + flag = True + task = { + 'basename': self.name + "_fetch_feed", + 'name': str(feed.url), + 'actions': [(update_feed, (feed, ))], + 'uptodate': [timeout(datetime.timedelta(minutes= + self.site.config.get('PLANETOID_REFRESH', 60)))], + } + yield task + if not flag: + yield { + 'basename': self.name + "_fetch_feed", + 'name': '', + 'actions': [], + } + + def task_generate_posts(self): + """Generate post files for the blog entries.""" + def gen_id(entry): + h = hashlib.md5() + h.update(entry.feed.name.encode('utf8')) + h.update(entry.guid) + return h.hexdigest() + + def generate_post(entry): + unique_id = gen_id(entry) + meta_path = os.path.join('posts', unique_id + '.meta') + post_path = os.path.join('posts', unique_id + '.txt') + with codecs.open(meta_path, 'wb+', 'utf8') as fd: + fd.write('%s\n' % entry.title.replace('\n', ' ')) + fd.write('%s\n' % unique_id) + fd.write('%s\n' % entry.date.strftime('%Y/%m/%d %H:%M')) + fd.write('\n') + fd.write('%s\n' % entry.link) + with codecs.open(post_path, 'wb+', 'utf8') as fd: + fd.write('.. raw:: html\n\n') + content = entry.content + if not content: + content = 'Sin contenido' + for line in content.splitlines(): + fd.write(' %s\n' % line) + + if not os.path.isdir('posts'): + os.mkdir('posts') + flag = False + for entry in Entry.select().order_by(Entry.date.desc()): + flag = True + entry_id = gen_id(entry) + yield { + 'basename': self.name + "_generate_posts", + 'targets': [os.path.join('posts', entry_id + '.meta'), os.path.join('posts', entry_id + '.txt')], + 'name': entry_id, + 'actions': [(generate_post, (entry,))], + 'uptodate': [config_changed({1: entry})], + 'task_dep': [self.name + "_fetch_feed"], + } + if not flag: + yield { + 'basename': self.name + "_generate_posts", + 'name': '', + 'actions': [], + } diff --git a/nikola/plugins/command/serve.plugin b/nikola/plugins/command/serve.plugin new file mode 100644 index 0000000..e663cc6 --- /dev/null +++ b/nikola/plugins/command/serve.plugin @@ -0,0 +1,10 @@ +[Core] +Name = serve +Module = serve + +[Documentation] +Author = Roberto Alsina +Version = 0.1 +Website = http://getnikola.com +Description = Start test server. + diff --git a/nikola/plugins/command/serve.py b/nikola/plugins/command/serve.py new file mode 100644 index 0000000..07403d4 --- /dev/null +++ b/nikola/plugins/command/serve.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2013 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +from __future__ import print_function +import os +try: + from BaseHTTPServer import HTTPServer + from SimpleHTTPServer import SimpleHTTPRequestHandler +except ImportError: + from http.server import HTTPServer # NOQA + from http.server import SimpleHTTPRequestHandler # NOQA + +from nikola.plugin_categories import Command +from nikola.utils import get_logger + + +class CommandBuild(Command): + """Start test server.""" + + name = "serve" + doc_usage = "[options]" + doc_purpose = "start the test webserver" + logger = None + + cmd_options = ( + { + 'name': 'port', + 'short': 'p', + 'long': 'port', + 'default': 8000, + 'type': int, + 'help': 'Port nummber (default: 8000)', + }, + { + 'name': 'address', + 'short': 'a', + 'long': '--address', + 'type': str, + 'default': '127.0.0.1', + 'help': 'Address to bind (default: 127.0.0.1)', + }, + ) + + def _execute(self, options, args): + """Start test server.""" + self.logger = get_logger('serve', self.site.loghandlers) + out_dir = self.site.config['OUTPUT_FOLDER'] + if not os.path.isdir(out_dir): + self.logger.error("Missing '{0}' folder?".format(out_dir)) + else: + os.chdir(out_dir) + httpd = HTTPServer((options['address'], options['port']), + OurHTTPRequestHandler) + sa = httpd.socket.getsockname() + self.logger.notice("Serving HTTP on {0} port {1} ...".format(*sa)) + httpd.serve_forever() + + +class OurHTTPRequestHandler(SimpleHTTPRequestHandler): + extensions_map = dict(SimpleHTTPRequestHandler.extensions_map) + extensions_map[""] = "text/plain" + + # NOTICE: this is a patched version of send_head() to disable all sorts of + # caching. `nikola serve` is a development server, hence caching should + # not happen to have access to the newest resources. + # + # The original code was copy-pasted from Python 2.7. Python 3.3 contains + # the same code, missing the binary mode comment. + # + # Note that it might break in future versions of Python, in which case we + # would need to do even more magic. + def send_head(self): + """Common code for GET and HEAD commands. + + This sends the response code and MIME headers. + + Return value is either a file object (which has to be copied + to the outputfile by the caller unless the command was HEAD, + and must be closed by the caller under all circumstances), or + None, in which case the caller has nothing further to do. + + """ + path = self.translate_path(self.path) + f = None + if os.path.isdir(path): + if not self.path.endswith('/'): + # redirect browser - doing basically what apache does + self.send_response(301) + self.send_header("Location", self.path + "/") + # begin no-cache patch + # For redirects. With redirects, caching is even worse and can + # break more. Especially with 301 Moved Permanently redirects, + # like this one. + self.send_header("Cache-Control", "no-cache, no-store, " + "must-revalidate") + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + # end no-cache patch + self.end_headers() + return None + for index in "index.html", "index.htm": + index = os.path.join(path, index) + if os.path.exists(index): + path = index + break + else: + return self.list_directory(path) + ctype = self.guess_type(path) + try: + # Always read in binary mode. Opening files in text mode may cause + # newline translations, making the actual size of the content + # transmitted *less* than the content-length! + f = open(path, 'rb') + except IOError: + self.send_error(404, "File not found") + return None + self.send_response(200) + self.send_header("Content-type", ctype) + fs = os.fstat(f.fileno()) + self.send_header("Content-Length", str(fs[6])) + self.send_header("Last-Modified", self.date_time_string(fs.st_mtime)) + # begin no-cache patch + # For standard requests. + self.send_header("Cache-Control", "no-cache, no-store, " + "must-revalidate") + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + # end no-cache patch + self.end_headers() + return f diff --git a/nikola/plugins/command/version.plugin b/nikola/plugins/command/version.plugin new file mode 100644 index 0000000..3c1ae95 --- /dev/null +++ b/nikola/plugins/command/version.plugin @@ -0,0 +1,9 @@ +[Core] +Name = version +Module = version + +[Documentation] +Author = Roberto Alsina +Version = 0.2 +Website = http://getnikola.com +Description = Show nikola version diff --git a/nikola/plugins/command/version.py b/nikola/plugins/command/version.py new file mode 100644 index 0000000..65896e9 --- /dev/null +++ b/nikola/plugins/command/version.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2013 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +from __future__ import print_function + +from nikola.plugin_categories import Command +from nikola import __version__ + + +class CommandVersion(Command): + """Print the version.""" + + name = "version" + + doc_usage = "" + needs_config = False + doc_purpose = "print the Nikola version number" + + def _execute(self, options={}, args=None): + """Print the version number.""" + print("Nikola version " + __version__) |
