summaryrefslogtreecommitdiffstats
path: root/nikola/plugins/command
diff options
context:
space:
mode:
Diffstat (limited to 'nikola/plugins/command')
-rw-r--r--nikola/plugins/command/__init__.py25
-rw-r--r--nikola/plugins/command/auto.plugin9
-rw-r--r--nikola/plugins/command/auto.py103
-rw-r--r--nikola/plugins/command/bootswatch_theme.plugin10
-rw-r--r--nikola/plugins/command/bootswatch_theme.py103
-rw-r--r--nikola/plugins/command/check.plugin10
-rw-r--r--nikola/plugins/command/check.py204
-rw-r--r--nikola/plugins/command/console.plugin9
-rw-r--r--nikola/plugins/command/console.py110
-rw-r--r--nikola/plugins/command/deploy.plugin9
-rw-r--r--nikola/plugins/command/deploy.py141
-rw-r--r--nikola/plugins/command/import_blogger.plugin10
-rw-r--r--nikola/plugins/command/import_blogger.py229
-rw-r--r--nikola/plugins/command/import_feed.plugin10
-rw-r--r--nikola/plugins/command/import_feed.py197
-rw-r--r--nikola/plugins/command/import_wordpress.plugin10
-rw-r--r--nikola/plugins/command/import_wordpress.py443
-rw-r--r--nikola/plugins/command/init.plugin9
-rw-r--r--nikola/plugins/command/init.py137
-rw-r--r--nikola/plugins/command/install_plugin.plugin10
-rw-r--r--nikola/plugins/command/install_plugin.py185
-rw-r--r--nikola/plugins/command/install_theme.plugin10
-rw-r--r--nikola/plugins/command/install_theme.py163
-rw-r--r--nikola/plugins/command/mincss.plugin10
-rw-r--r--nikola/plugins/command/mincss.py75
-rw-r--r--nikola/plugins/command/new_post.plugin10
-rw-r--r--nikola/plugins/command/new_post.py291
-rw-r--r--nikola/plugins/command/planetoid.plugin9
-rw-r--r--nikola/plugins/command/planetoid/__init__.py289
-rw-r--r--nikola/plugins/command/serve.plugin10
-rw-r--r--nikola/plugins/command/serve.py153
-rw-r--r--nikola/plugins/command/version.plugin9
-rw-r--r--nikola/plugins/command/version.py44
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__)