aboutsummaryrefslogtreecommitdiffstats
path: root/nikola/plugins/command
diff options
context:
space:
mode:
authorLibravatarAgustin Henze <tin@sluc.org.ar>2015-07-08 07:35:06 -0300
committerLibravatarAgustin Henze <tin@sluc.org.ar>2015-07-08 07:35:06 -0300
commit055d72d76b44b0e627c8a17c48dbecd62e44197b (patch)
treee2c8d5475477c46115461fe9547c1ee797873635 /nikola/plugins/command
parent61f3aad02cd6492cb38e41b66f2ed8ec56e98981 (diff)
parentb0b24795b24ee6809397fbbadf42f31f310a219f (diff)
Merge tag 'upstream/7.6.0'
Upstream version 7.6.0
Diffstat (limited to 'nikola/plugins/command')
-rw-r--r--nikola/plugins/command/__init__.py2
-rw-r--r--nikola/plugins/command/auto.plugin2
-rw-r--r--nikola/plugins/command/auto.py87
-rw-r--r--nikola/plugins/command/auto/__init__.py366
l---------nikola/plugins/command/auto/livereload.js1
-rw-r--r--nikola/plugins/command/bootswatch_theme.plugin2
-rw-r--r--nikola/plugins/command/bootswatch_theme.py20
-rw-r--r--nikola/plugins/command/check.plugin2
-rw-r--r--nikola/plugins/command/check.py134
-rw-r--r--nikola/plugins/command/console.plugin2
-rw-r--r--nikola/plugins/command/console.py6
-rw-r--r--nikola/plugins/command/deploy.plugin2
-rw-r--r--nikola/plugins/command/deploy.py39
-rw-r--r--nikola/plugins/command/github_deploy.plugin2
-rw-r--r--nikola/plugins/command/github_deploy.py220
-rw-r--r--nikola/plugins/command/import_wordpress.plugin2
-rw-r--r--nikola/plugins/command/import_wordpress.py157
-rw-r--r--nikola/plugins/command/init.plugin2
-rw-r--r--nikola/plugins/command/init.py87
-rw-r--r--nikola/plugins/command/install_theme.plugin2
-rw-r--r--nikola/plugins/command/install_theme.py76
-rw-r--r--nikola/plugins/command/new_page.plugin2
-rw-r--r--nikola/plugins/command/new_page.py30
-rw-r--r--nikola/plugins/command/new_post.plugin2
-rw-r--r--nikola/plugins/command/new_post.py188
-rw-r--r--nikola/plugins/command/orphans.plugin2
-rw-r--r--nikola/plugins/command/orphans.py2
-rw-r--r--nikola/plugins/command/plugin.plugin2
-rw-r--r--nikola/plugins/command/plugin.py43
-rw-r--r--nikola/plugins/command/rst2html.plugin9
-rw-r--r--nikola/plugins/command/rst2html/__init__.py69
-rw-r--r--nikola/plugins/command/rst2html/rst2html.tmpl13
-rw-r--r--nikola/plugins/command/serve.plugin2
-rw-r--r--nikola/plugins/command/serve.py44
-rw-r--r--nikola/plugins/command/status.plugin9
-rw-r--r--nikola/plugins/command/status.py140
-rw-r--r--nikola/plugins/command/version.plugin2
-rw-r--r--nikola/plugins/command/version.py27
38 files changed, 1271 insertions, 528 deletions
diff --git a/nikola/plugins/command/__init__.py b/nikola/plugins/command/__init__.py
index 6ad8bac..a1d17a6 100644
--- a/nikola/plugins/command/__init__.py
+++ b/nikola/plugins/command/__init__.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2014 Roberto Alsina and others.
+# Copyright © 2012-2015 Roberto Alsina and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
diff --git a/nikola/plugins/command/auto.plugin b/nikola/plugins/command/auto.plugin
index 87939b2..a1c6820 100644
--- a/nikola/plugins/command/auto.plugin
+++ b/nikola/plugins/command/auto.plugin
@@ -4,6 +4,6 @@ Module = auto
[Documentation]
Author = Roberto Alsina
-Version = 0.2
+Version = 2.1.0
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
deleted file mode 100644
index 7f3f66f..0000000
--- a/nikola/plugins/command/auto.py
+++ /dev/null
@@ -1,87 +0,0 @@
-# -*- coding: utf-8 -*-
-
-# Copyright © 2012-2014 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 subprocess
-
-from nikola.plugin_categories import Command
-from nikola.utils import req_missing
-
-
-class CommandAuto(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 import Server
- except ImportError:
- req_missing(['livereload'], 'use the "auto" command')
- return
-
- # Run an initial build so we are up-to-date
- subprocess.call(("nikola", "build"))
-
- port = options and options.get('port')
-
- server = Server()
- server.watch('conf.py', 'nikola build')
- server.watch('themes/', 'nikola build')
- server.watch('templates/', 'nikola build')
- server.watch(self.site.config['GALLERY_PATH'], 'nikola build')
- for item in self.site.config['post_pages']:
- server.watch(os.path.dirname(item[0]), 'nikola build')
- for item in self.site.config['FILES_FOLDERS']:
- server.watch(item, 'nikola build')
-
- out_folder = self.site.config['OUTPUT_FOLDER']
- if options and options.get('browser'):
- browser = True
- else:
- browser = False
-
- server.serve(port, None, out_folder, True, browser)
diff --git a/nikola/plugins/command/auto/__init__.py b/nikola/plugins/command/auto/__init__.py
new file mode 100644
index 0000000..c25ef8a
--- /dev/null
+++ b/nikola/plugins/command/auto/__init__.py
@@ -0,0 +1,366 @@
+# -*- coding: utf-8 -*-
+
+# Copyright © 2012-2015 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 json
+import mimetypes
+import os
+import re
+import subprocess
+try:
+ from urlparse import urlparse
+except ImportError:
+ from urllib.parse import urlparse # NOQA
+import webbrowser
+from wsgiref.simple_server import make_server
+import wsgiref.util
+
+from blinker import signal
+try:
+ from ws4py.websocket import WebSocket
+ from ws4py.server.wsgirefserver import WSGIServer, WebSocketWSGIRequestHandler
+ from ws4py.server.wsgiutils import WebSocketWSGIApplication
+ from ws4py.messaging import TextMessage
+except ImportError:
+ WebSocket = object
+try:
+ import watchdog
+ from watchdog.observers import Observer
+ from watchdog.events import FileSystemEventHandler, PatternMatchingEventHandler
+except ImportError:
+ watchdog = None
+ FileSystemEventHandler = object
+ PatternMatchingEventHandler = object
+
+
+from nikola.plugin_categories import Command
+from nikola.utils import req_missing, get_logger, get_theme_path
+LRJS_PATH = os.path.join(os.path.dirname(__file__), 'livereload.js')
+error_signal = signal('error')
+refresh_signal = signal('refresh')
+
+ERROR_N = '''<html>
+<head>
+</head>
+<boody>
+ERROR {}
+</body>
+</html>
+'''
+
+
+class CommandAuto(Command):
+ """Start debugging console."""
+ name = "auto"
+ logger = None
+ doc_purpose = "builds and serves a site; automatically detects site changes, rebuilds, and optionally refreshes a browser"
+ 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 – localhost)',
+ },
+ {
+ 'name': 'browser',
+ 'short': 'b',
+ 'long': 'browser',
+ 'type': bool,
+ 'help': 'Start a web browser.',
+ 'default': False,
+ },
+ {
+ 'name': 'ipv6',
+ 'short': '6',
+ 'long': 'ipv6',
+ 'default': False,
+ 'type': bool,
+ 'help': 'Use IPv6',
+ },
+ ]
+
+ def _execute(self, options, args):
+ """Start the watcher."""
+
+ self.logger = get_logger('auto', self.site.loghandlers)
+ LRSocket.logger = self.logger
+
+ if WebSocket is object and watchdog is None:
+ req_missing(['ws4py', 'watchdog'], 'use the "auto" command')
+ elif WebSocket is object:
+ req_missing(['ws4py'], 'use the "auto" command')
+ elif watchdog is None:
+ req_missing(['watchdog'], 'use the "auto" command')
+
+ self.cmd_arguments = ['nikola', 'build']
+ if self.site.configuration_filename != 'conf.py':
+ self.cmd_arguments = ['--conf=' + self.site.configuration_filename] + self.cmd_arguments
+
+ # Run an initial build so we are up-to-date
+ subprocess.call(self.cmd_arguments)
+
+ port = options and options.get('port')
+ self.snippet = '''<script>document.write('<script src="http://'
+ + (location.host || 'localhost').split(':')[0]
+ + ':{0}/livereload.js?snipver=1"></'
+ + 'script>')</script>
+ </head>'''.format(port)
+
+ # Do not duplicate entries -- otherwise, multiple rebuilds are triggered
+ watched = set([
+ 'templates/',
+ ] + [get_theme_path(name) for name in self.site.THEMES])
+ for item in self.site.config['post_pages']:
+ watched.add(os.path.dirname(item[0]))
+ for item in self.site.config['FILES_FOLDERS']:
+ watched.add(item)
+ for item in self.site.config['GALLERY_FOLDERS']:
+ watched.add(item)
+ for item in self.site.config['LISTINGS_FOLDERS']:
+ watched.add(item)
+
+ out_folder = self.site.config['OUTPUT_FOLDER']
+ if options and options.get('browser'):
+ browser = True
+ else:
+ browser = False
+
+ if options['ipv6']:
+ dhost = '::'
+ else:
+ dhost = None
+
+ host = options['address'].strip('[').strip(']') or dhost
+
+ # Instantiate global observer
+ observer = Observer()
+ # Watch output folders and trigger reloads
+ observer.schedule(OurWatchHandler(self.do_refresh), out_folder, recursive=True)
+
+ # Watch input folders and trigger rebuilds
+ for p in watched:
+ if os.path.exists(p):
+ observer.schedule(OurWatchHandler(self.do_rebuild), p, recursive=True)
+
+ # Watch config file (a bit of a hack, but we need a directory)
+ _conf_fn = os.path.abspath(self.site.configuration_filename or 'conf.py')
+ _conf_dn = os.path.dirname(_conf_fn)
+ observer.schedule(ConfigWatchHandler(_conf_fn, self.do_rebuild), _conf_dn, recursive=False)
+
+ observer.start()
+
+ parent = self
+
+ class Mixed(WebSocketWSGIApplication):
+ """A class that supports WS and HTTP protocols in the same port."""
+ def __call__(self, environ, start_response):
+ if environ.get('HTTP_UPGRADE') is None:
+ return parent.serve_static(environ, start_response)
+ return super(Mixed, self).__call__(environ, start_response)
+
+ ws = make_server(
+ host, port, server_class=WSGIServer,
+ handler_class=WebSocketWSGIRequestHandler,
+ app=Mixed(handler_cls=LRSocket)
+ )
+ ws.initialize_websockets_manager()
+ self.logger.info("Serving HTTP on {0} port {1}...".format(host, port))
+ if browser:
+ if options['ipv6'] or '::' in host:
+ server_url = "http://[{0}]:{1}/".format(host, port)
+ else:
+ server_url = "http://{0}:{1}/".format(host, port)
+
+ self.logger.info("Opening {0} in the default web browser...".format(server_url))
+ # Yes, this is racy
+ webbrowser.open('http://{0}:{1}'.format(host, port))
+
+ try:
+ ws.serve_forever()
+ except KeyboardInterrupt:
+ self.logger.info("Server is shutting down.")
+ observer.stop()
+ observer.join()
+
+ def do_rebuild(self, event):
+ self.logger.info('REBUILDING SITE (from {0})'.format(event.src_path))
+ p = subprocess.Popen(self.cmd_arguments, stderr=subprocess.PIPE)
+ if p.wait() != 0:
+ error = p.stderr.read()
+ self.logger.error(error)
+ error_signal.send(error=error)
+ else:
+ error = p.stderr.read()
+ print(error)
+
+ def do_refresh(self, event):
+ self.logger.info('REFRESHING: {0}'.format(event.src_path))
+ p = os.path.relpath(event.src_path, os.path.abspath(self.site.config['OUTPUT_FOLDER']))
+ refresh_signal.send(path=p)
+
+ def serve_static(self, environ, start_response):
+ """Trivial static file server."""
+ uri = wsgiref.util.request_uri(environ)
+ p_uri = urlparse(uri)
+ f_path = os.path.join(self.site.config['OUTPUT_FOLDER'], *p_uri.path.split('/'))
+ mimetype = mimetypes.guess_type(uri)[0] or 'text/html'
+
+ if os.path.isdir(f_path):
+ f_path = os.path.join(f_path, self.site.config['INDEX_FILE'])
+
+ if p_uri.path == '/robots.txt':
+ start_response('200 OK', [('Content-type', 'text/plain')])
+ return ['User-Agent: *\nDisallow: /\n']
+ elif os.path.isfile(f_path):
+ with open(f_path, 'rb') as fd:
+ start_response('200 OK', [('Content-type', mimetype)])
+ return [self.inject_js(mimetype, fd.read())]
+ elif p_uri.path == '/livereload.js':
+ with open(LRJS_PATH, 'rb') as fd:
+ start_response('200 OK', [('Content-type', mimetype)])
+ return [self.inject_js(mimetype, fd.read())]
+ start_response('404 ERR', [])
+ return [self.inject_js('text/html', ERROR_N.format(404).format(uri))]
+
+ def inject_js(self, mimetype, data):
+ """Inject livereload.js in HTML files."""
+ if mimetype == 'text/html':
+ data = re.sub('</head>', self.snippet, data.decode('utf8'), 1, re.IGNORECASE)
+ data = data.encode('utf8')
+ return data
+
+
+pending = []
+
+
+class LRSocket(WebSocket):
+ """Speak Livereload protocol."""
+
+ def __init__(self, *a, **kw):
+ refresh_signal.connect(self.notify)
+ error_signal.connect(self.send_error)
+ super(LRSocket, self).__init__(*a, **kw)
+
+ def received_message(self, message):
+ message = json.loads(message.data.decode('utf8'))
+ self.logger.info('<--- {0}'.format(message))
+ response = None
+ if message['command'] == 'hello': # Handshake
+ response = {
+ 'command': 'hello',
+ 'protocols': [
+ 'http://livereload.com/protocols/official-7',
+ ],
+ 'serverName': 'nikola-livereload',
+ }
+ elif message['command'] == 'info': # Someone connected
+ self.logger.info('****** Browser connected: {0}'.format(message.get('url')))
+ self.logger.info('****** sending {0} pending messages'.format(len(pending)))
+ while pending:
+ msg = pending.pop()
+ self.logger.info('---> {0}'.format(msg.data))
+ self.send(msg, msg.is_binary)
+ else:
+ response = {
+ 'command': 'alert',
+ 'message': 'HEY',
+ }
+ if response is not None:
+ response = json.dumps(response)
+ self.logger.info('---> {0}'.format(response))
+ response = TextMessage(response)
+ self.send(response, response.is_binary)
+
+ def notify(self, sender, path):
+ """Send reload requests to the client."""
+ p = os.path.join('/', path)
+ message = {
+ 'command': 'reload',
+ 'liveCSS': True,
+ 'path': p,
+ }
+ response = json.dumps(message)
+ self.logger.info('---> {0}'.format(p))
+ response = TextMessage(response)
+ if self.stream is None: # No client connected or whatever
+ pending.append(response)
+ else:
+ self.send(response, response.is_binary)
+
+ def send_error(self, sender, error=None):
+ """Send reload requests to the client."""
+ if self.stream is None: # No client connected or whatever
+ return
+ message = {
+ 'command': 'alert',
+ 'message': error,
+ }
+ response = json.dumps(message)
+ response = TextMessage(response)
+ if self.stream is None: # No client connected or whatever
+ pending.append(response)
+ else:
+ self.send(response, response.is_binary)
+
+
+class OurWatchHandler(FileSystemEventHandler):
+
+ """A Nikola-specific handler for Watchdog."""
+
+ def __init__(self, function):
+ """Initialize the handler."""
+ self.function = function
+ super(OurWatchHandler, self).__init__()
+
+ def on_any_event(self, event):
+ """Call the provided function on any event."""
+ self.function(event)
+
+
+class ConfigWatchHandler(FileSystemEventHandler):
+
+ """A Nikola-specific handler for Watchdog that handles the config file (as a workaround)."""
+
+ def __init__(self, configuration_filename, function):
+ """Initialize the handler."""
+ self.configuration_filename = configuration_filename
+ self.function = function
+
+ def on_any_event(self, event):
+ """Call the provided function on any event."""
+ if event._src_path == self.configuration_filename:
+ self.function(event)
diff --git a/nikola/plugins/command/auto/livereload.js b/nikola/plugins/command/auto/livereload.js
new file mode 120000
index 0000000..b4cafb3
--- /dev/null
+++ b/nikola/plugins/command/auto/livereload.js
@@ -0,0 +1 @@
+../../../../bower_components/livereload-js/dist/livereload.js \ No newline at end of file
diff --git a/nikola/plugins/command/bootswatch_theme.plugin b/nikola/plugins/command/bootswatch_theme.plugin
index 7091310..b428da3 100644
--- a/nikola/plugins/command/bootswatch_theme.plugin
+++ b/nikola/plugins/command/bootswatch_theme.plugin
@@ -4,7 +4,7 @@ Module = bootswatch_theme
[Documentation]
Author = Roberto Alsina
-Version = 0.1
+Version = 1.0
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
index e65413b..e19c937 100644
--- a/nikola/plugins/command/bootswatch_theme.py
+++ b/nikola/plugins/command/bootswatch_theme.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2014 Roberto Alsina and others.
+# Copyright © 2012-2015 Roberto Alsina and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
@@ -26,11 +26,7 @@
from __future__ import print_function
import os
-
-try:
- import requests
-except ImportError:
- requests = None # NOQA
+import requests
from nikola.plugin_categories import Command
from nikola import utils
@@ -57,7 +53,7 @@ class CommandBootswatchTheme(Command):
{
'name': 'swatch',
'short': 's',
- 'default': 'slate',
+ 'default': '',
'type': str,
'help': 'Name of the swatch from bootswatch.com.'
},
@@ -72,19 +68,19 @@ class CommandBootswatchTheme(Command):
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']
+ if not swatch:
+ LOGGER.error('The -s option is mandatory')
+ return 1
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 or 'bootstrap3-jinja' not in themes:
+ if 'bootstrap3' not in themes and 'bootstrap3-jinja' not in themes:
version = '2'
- elif 'bootstrap' not in themes or 'bootstrap-jinja' not in themes:
+ elif 'bootstrap' not in themes and 'bootstrap-jinja' not in themes:
LOGGER.warn('"bootswatch_theme" only makes sense for themes that use bootstrap')
elif 'bootstrap3-gradients' in themes or 'bootstrap3-gradients-jinja' in themes:
LOGGER.warn('"bootswatch_theme" doesn\'t work well with the bootstrap3-gradients family')
diff --git a/nikola/plugins/command/check.plugin b/nikola/plugins/command/check.plugin
index 8ceda5f..dd0980e 100644
--- a/nikola/plugins/command/check.plugin
+++ b/nikola/plugins/command/check.plugin
@@ -4,7 +4,7 @@ Module = check
[Documentation]
Author = Roberto Alsina
-Version = 0.1
+Version = 1.0
Website = http://getnikola.com
Description = Check the generated site
diff --git a/nikola/plugins/command/check.py b/nikola/plugins/command/check.py
index bd254f4..a9bc44a 100644
--- a/nikola/plugins/command/check.py
+++ b/nikola/plugins/command/check.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2014 Roberto Alsina and others.
+# Copyright © 2012-2015 Roberto Alsina and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
@@ -25,6 +25,7 @@
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
from __future__ import print_function
+from collections import defaultdict
import os
import re
import sys
@@ -34,21 +35,36 @@ try:
except ImportError:
from urllib.parse import unquote, urlparse, urljoin, urldefrag # NOQA
+from doit.loader import generate_tasks
import lxml.html
+import requests
from nikola.plugin_categories import Command
from nikola.utils import get_logger
+def _call_nikola_list(site):
+ files = []
+ deps = defaultdict(list)
+ for task in generate_tasks('render_site', site.gen_tasks('render_site', "Task", '')):
+ files.extend(task.targets)
+ for target in task.targets:
+ deps[target].extend(task.file_dep)
+ for task in generate_tasks('post_render', site.gen_tasks('render_site', "LateTask", '')):
+ files.extend(task.targets)
+ for target in task.targets:
+ deps[target].extend(task.file_dep)
+ return files, deps
+
+
def real_scan_files(site):
task_fnames = set([])
real_fnames = set([])
output_folder = 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]
+ for fname in _call_nikola_list(site)[0]:
+ fname = fname.strip()
+ if fname.startswith(output_folder):
task_fnames.add(fname)
# And now check that there are no non-target files
for root, dirs, files in os.walk(output_folder, followlinks=True):
@@ -68,7 +84,7 @@ def fs_relpath_from_url_path(url_path):
url_path = unquote(url_path)
# in windows relative paths don't begin with os.sep
if sys.platform == 'win32' and len(url_path):
- url_path = url_path[1:].replace('/', '\\')
+ url_path = url_path.replace('/', '\\')
return url_path
@@ -78,7 +94,7 @@ class CommandCheck(Command):
name = "check"
logger = None
- doc_usage = "-l [--find-sources] | -f"
+ doc_usage = "[-v] (-l [--find-sources] [-r] | -f [--clean-files])"
doc_purpose = "check links and files in the generated site"
cmd_options = [
{
@@ -119,11 +135,18 @@ class CommandCheck(Command):
'default': False,
'help': 'Be more verbose.',
},
+ {
+ 'name': 'remote',
+ 'long': 'remote',
+ 'short': 'r',
+ 'type': bool,
+ 'default': False,
+ 'help': 'Check that remote links work.',
+ },
]
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']:
@@ -134,59 +157,103 @@ class CommandCheck(Command):
else:
self.logger.level = 4
if options['links']:
- failure = self.scan_links(options['find_sources'])
+ failure = self.scan_links(options['find_sources'], options['remote'])
if options['files']:
failure = self.scan_files()
if options['clean']:
failure = self.clean_files()
if failure:
- sys.exit(1)
+ return 1
existing_targets = set([])
+ checked_remote_targets = {}
- def analyze(self, task, find_sources=False):
+ def analyze(self, fname, find_sources=False, check_remote=False):
rv = False
self.whitelist = [re.compile(x) for x in self.site.config['LINK_CHECK_WHITELIST']]
base_url = urlparse(self.site.config['BASE_URL'])
self.existing_targets.add(self.site.config['SITE_URL'])
self.existing_targets.add(self.site.config['BASE_URL'])
url_type = self.site.config['URL_TYPE']
- if url_type == 'absolute':
- url_netloc_to_root = urlparse(self.site.config['SITE_URL']).path
+
+ deps = {}
+ if find_sources:
+ deps = _call_nikola_list(self.site)[1]
+
+ if url_type in ('absolute', 'full_path'):
+ url_netloc_to_root = urlparse(self.site.config['BASE_URL']).path
try:
- filename = task.split(":")[-1]
- d = lxml.html.fromstring(open(filename).read())
+ filename = fname
+
+ if filename.startswith(self.site.config['CACHE_FOLDER']):
+ # Do not look at links in the cache, which are not parsed by
+ # anyone and may result in false positives. Problems arise
+ # with galleries, for example. Full rationale: (Issue #1447)
+ self.logger.notice("Ignoring {0} (in cache, links may be incorrect)".format(filename))
+ return False
+
+ if not os.path.exists(fname):
+ # Quietly ignore files that don’t exist; use `nikola check -f` instead (Issue #1831)
+ return False
+
+ d = lxml.html.fromstring(open(filename, 'rb').read())
for l in d.iterlinks():
- target = l[0].attrib[l[1]]
+ target = l[2]
if target == "#":
continue
target, _ = urldefrag(target)
parsed = urlparse(target)
- # Absolute links when using only paths, skip.
- if (parsed.scheme or target.startswith('//')) and url_type in ('rel_path', 'full_path'):
- continue
+ # Warn about links from https to http (mixed-security)
+ if base_url.netloc == parsed.netloc and base_url.scheme == "https" and parsed.scheme == "http":
+ self.logger.warn("Mixed-content security for link in {0}: {1}".format(filename, target))
# Absolute links to other domains, skip
- if (parsed.scheme or target.startswith('//')) and parsed.netloc != base_url.netloc:
+ # Absolute links when using only paths, skip.
+ if ((parsed.scheme or target.startswith('//')) and parsed.netloc != base_url.netloc) or \
+ ((parsed.scheme or target.startswith('//')) and url_type in ('rel_path', 'full_path')):
+ if not check_remote or parsed.scheme not in ["http", "https"]:
+ continue
+ if parsed.netloc == base_url.netloc: # absolute URL to self.site
+ continue
+ if target in self.checked_remote_targets: # already checked this exact target
+ if self.checked_remote_targets[target] > 399:
+ self.logger.warn("Broken link in {0}: {1} [Error {2}]".format(filename, target, self.checked_remote_targets[target]))
+ continue
+ # Check the remote link works
+ req_headers = {'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:45.0) Gecko/20100101 Firefox/45.0 (Nikola)'} # I’m a real boy!
+ resp = requests.head(target, headers=req_headers)
+ self.checked_remote_targets[target] = resp.status_code
+ if resp.status_code > 399: # Error
+ self.logger.warn("Broken link in {0}: {1} [Error {2}]".format(filename, target, resp.status_code))
+ continue
+ elif resp.status_code <= 399: # The address leads *somewhere* that is not an error
+ self.logger.debug("Successfully checked remote link in {0}: {1} [HTTP: {2}]".format(filename, target, resp.status_code))
+ continue
+ self.logger.warn("Could not check remote link in {0}: {1} [Unknown problem]".format(filename, target))
continue
if url_type == 'rel_path':
- target_filename = os.path.abspath(
- os.path.join(os.path.dirname(filename), unquote(target)))
+ if target.startswith('/'):
+ target_filename = os.path.abspath(
+ os.path.join(self.site.config['OUTPUT_FOLDER'], unquote(target.lstrip('/'))))
+ else: # Relative path
+ target_filename = os.path.abspath(
+ os.path.join(os.path.dirname(filename), unquote(target)))
elif url_type in ('full_path', 'absolute'):
if url_type == 'absolute':
# convert to 'full_path' case, ie url relative to root
- url_rel_path = target.path[len(url_netloc_to_root):]
+ url_rel_path = parsed.path[len(url_netloc_to_root):]
else:
- url_rel_path = target.path
+ # convert to relative to base path
+ url_rel_path = target[len(url_netloc_to_root):]
if url_rel_path == '' or url_rel_path.endswith('/'):
url_rel_path = urljoin(url_rel_path, self.site.config['INDEX_FILE'])
fs_rel_path = fs_relpath_from_url_path(url_rel_path)
target_filename = os.path.join(self.site.config['OUTPUT_FOLDER'], fs_rel_path)
- if any(re.match(x, target_filename) for x in self.whitelist):
+ if any(re.search(x, target_filename) for x in self.whitelist):
continue
elif target_filename not in self.existing_targets:
if os.path.exists(target_filename):
@@ -197,25 +264,22 @@ class CommandCheck(Command):
self.logger.warn("Broken link in {0}: {1}".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".join(deps[filename]))
self.logger.warn("===============================\n")
except Exception as exc:
self.logger.error("Error with: {0} {1}".format(filename, exc))
return rv
- def scan_links(self, find_sources=False):
+ def scan_links(self, find_sources=False, check_remote=False):
self.logger.info("Checking Links:")
self.logger.info("===============\n")
self.logger.notice("{0} mode".format(self.site.config['URL_TYPE']))
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):
+ # Maybe we should just examine all HTML files
+ output_folder = self.site.config['OUTPUT_FOLDER']
+ for fname in _call_nikola_list(self.site)[0]:
+ if fname.startswith(output_folder) and '.html' == fname[-5:]:
+ if self.analyze(fname, find_sources, check_remote):
failure = True
if not failure:
self.logger.info("All links checked.")
diff --git a/nikola/plugins/command/console.plugin b/nikola/plugins/command/console.plugin
index 2eeedae..3aef2e7 100644
--- a/nikola/plugins/command/console.plugin
+++ b/nikola/plugins/command/console.plugin
@@ -4,6 +4,6 @@ Module = console
[Documentation]
Author = Chris Warrick, Roberto Alsina
-Version = 0.1
+Version = 1.0
Website = http://getnikola.com
Description = Start a debugging python console
diff --git a/nikola/plugins/command/console.py b/nikola/plugins/command/console.py
index 9dfc975..b8e7825 100644
--- a/nikola/plugins/command/console.py
+++ b/nikola/plugins/command/console.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2014 Chris Warrick, Roberto Alsina and others.
+# Copyright © 2012-2015 Chris Warrick, Roberto Alsina and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
@@ -30,7 +30,7 @@ import os
from nikola import __version__
from nikola.plugin_categories import Command
-from nikola.utils import get_logger, STDERR_HANDLER, req_missing
+from nikola.utils import get_logger, STDERR_HANDLER, req_missing, Commands
LOGGER = get_logger('console', STDERR_HANDLER)
@@ -122,6 +122,8 @@ If there is no console to use specified (as -b, -i, -p) it tries IPython, then f
self.site.scan_posts()
# Create nice object with all commands:
+ self.site.commands = Commands(self.site.doit, self.config, self._doitargs)
+
self.context = {
'conf': self.site.config,
'site': self.site,
diff --git a/nikola/plugins/command/deploy.plugin b/nikola/plugins/command/deploy.plugin
index 10cc796..14fd53f 100644
--- a/nikola/plugins/command/deploy.plugin
+++ b/nikola/plugins/command/deploy.plugin
@@ -4,6 +4,6 @@ Module = deploy
[Documentation]
Author = Roberto Alsina
-Version = 0.1
+Version = 1.0
Website = http://getnikola.com
Description = Deploy the site
diff --git a/nikola/plugins/command/deploy.py b/nikola/plugins/command/deploy.py
index fde43fa..2c44e87 100644
--- a/nikola/plugins/command/deploy.py
+++ b/nikola/plugins/command/deploy.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2014 Roberto Alsina and others.
+# Copyright © 2012-2015 Roberto Alsina and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
@@ -29,23 +29,22 @@ import io
from datetime import datetime
from dateutil.tz import gettz
import os
-import sys
import subprocess
import time
from blinker import signal
from nikola.plugin_categories import Command
-from nikola.utils import get_logger, remove_file, unicode_str
+from nikola.utils import get_logger, remove_file, unicode_str, makedirs
class CommandDeploy(Command):
"""Deploy site."""
name = "deploy"
- doc_usage = ""
+ doc_usage = "[[preset [preset...]]"
doc_purpose = "deploy the site"
-
+ doc_description = "Deploy the site by executing deploy commands from the presets listed on the command line. If no presets are specified, `default` is executed."
logger = None
def _execute(self, command, args):
@@ -74,14 +73,29 @@ class CommandDeploy(Command):
remove_file(os.path.join(out_dir, post.source_path))
undeployed_posts.append(post)
- for command in self.site.config['DEPLOY_COMMANDS']:
- self.logger.info("==> {0}".format(command))
+ if args:
+ presets = args
+ else:
+ presets = ['default']
+
+ # test for preset existence
+ for preset in presets:
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.site.config['DEPLOY_COMMANDS'][preset]
+ except:
+ self.logger.error('No such preset: {0}'.format(preset))
+ return 255
+
+ for preset in presets:
+ self.logger.info("=> preset '{0}'".format(preset))
+ for command in self.site.config['DEPLOY_COMMANDS'][preset]:
+ self.logger.info("==> {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))
+ return e.returncode
self.logger.info("Successful deployment")
try:
@@ -96,6 +110,7 @@ class CommandDeploy(Command):
new_deploy = datetime.utcnow()
self._emit_deploy_event(last_deploy, new_deploy, clean, undeployed_posts)
+ makedirs(self.site.config['CACHE_FOLDER'])
# Store timestamp of successful deployment
with io.open(timestamp_path, 'w+', encoding='utf8') as outf:
outf.write(unicode_str(new_deploy.isoformat()))
diff --git a/nikola/plugins/command/github_deploy.plugin b/nikola/plugins/command/github_deploy.plugin
index 4cbc422..74e7902 100644
--- a/nikola/plugins/command/github_deploy.plugin
+++ b/nikola/plugins/command/github_deploy.plugin
@@ -4,6 +4,6 @@ Module = github_deploy
[Documentation]
Author = Puneeth Chaganti
-Version = 0.1
+Version = 1,0
Website = http://getnikola.com
Description = Deploy the site to GitHub pages.
diff --git a/nikola/plugins/command/github_deploy.py b/nikola/plugins/command/github_deploy.py
index 13da48c..888a4f9 100644
--- a/nikola/plugins/command/github_deploy.py
+++ b/nikola/plugins/command/github_deploy.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2014 Puneeth Chaganti and others.
+# Copyright © 2014-2015 Puneeth Chaganti and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
@@ -25,15 +25,15 @@
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
from __future__ import print_function
+from datetime import datetime
+import io
import os
-import shutil
import subprocess
-import sys
from textwrap import dedent
from nikola.plugin_categories import Command
from nikola.plugins.command.check import real_scan_files
-from nikola.utils import ask_yesno, get_logger
+from nikola.utils import get_logger, req_missing, makedirs, unicode_str
from nikola.__main__ import main
from nikola import __version__
@@ -43,79 +43,53 @@ def uni_check_output(*args, **kwargs):
return o.decode('utf-8')
+def check_ghp_import_installed():
+ try:
+ subprocess.check_output(['ghp-import', '-h'])
+ except OSError:
+ # req_missing defaults to `python=True` — and it’s meant to be like this.
+ # `ghp-import` is installed via pip, but the only way to use it is by executing the script it installs.
+ req_missing(['ghp-import'], 'deploy the site to GitHub Pages')
+
+
class CommandGitHubDeploy(Command):
- """ Deploy site to GitHub pages. """
+ """ Deploy site to GitHub Pages. """
name = 'github_deploy'
doc_usage = ''
- doc_purpose = 'deploy the site to GitHub pages'
+ doc_purpose = 'deploy the site to GitHub Pages'
doc_description = dedent(
"""\
- This command can be used to deploy your site to GitHub pages.
- It performs the following actions:
+ This command can be used to deploy your site to GitHub Pages.
- 1. Ensure that your site is a git repository, and git is on the PATH.
- 2. Ensure that the output directory is not committed on the
- source branch.
- 3. Check for changes, and prompt the user to continue, if required.
- 4. Build the site
- 5. Clean any files that are "unknown" to Nikola.
- 6. Create a deploy branch, if one doesn't exist.
- 7. Commit the output to this branch. (NOTE: Any untracked source
- files, may get committed at this stage, on the wrong branch!)
- 8. Push and deploy!
+ It uses ghp-import to do this task.
- NOTE: This command needs your site to be a git repository, with a
- master branch (or a different branch, configured using
- GITHUB_SOURCE_BRANCH if you are pushing to user.github
- .io/organization.github.io pages) containing the sources of your
- site. You also, obviously, need to have `git` on your PATH,
- and should be able to push to the repository specified as the remote
- (origin, by default).
"""
)
logger = None
- _deploy_branch = ''
- _source_branch = ''
- _remote_name = ''
-
def _execute(self, command, args):
self.logger = get_logger(
CommandGitHubDeploy.name, self.site.loghandlers
)
- self._source_branch = self.site.config.get(
- 'GITHUB_SOURCE_BRANCH', 'master'
- )
- self._deploy_branch = self.site.config.get(
- 'GITHUB_DEPLOY_BRANCH', 'gh-pages'
- )
- self._remote_name = self.site.config.get(
- 'GITHUB_REMOTE_NAME', 'origin'
- )
-
- self._ensure_git_repo()
-
- self._exit_if_output_committed()
- if not self._prompt_continue():
- return
+ # Check if ghp-import is installed
+ check_ghp_import_installed()
+ # Build before deploying
build = main(['build'])
if build != 0:
self.logger.error('Build failed, not deploying to GitHub')
- sys.exit(build)
+ return build
+ # Clean non-target files
only_on_output, _ = real_scan_files(self.site)
for f in only_on_output:
os.unlink(f)
- self._checkout_deploy_branch()
-
- self._copy_output()
-
+ # Commit and push
self._commit_and_push()
return
@@ -123,150 +97,34 @@ class CommandGitHubDeploy(Command):
def _commit_and_push(self):
""" Commit all the files and push. """
- deploy = self._deploy_branch
- source = self._source_branch
- remote = self._remote_name
-
+ source = self.site.config['GITHUB_SOURCE_BRANCH']
+ deploy = self.site.config['GITHUB_DEPLOY_BRANCH']
+ remote = self.site.config['GITHUB_REMOTE_NAME']
source_commit = uni_check_output(['git', 'rev-parse', source])
commit_message = (
'Nikola auto commit.\n\n'
'Source commit: %s'
'Nikola version: %s' % (source_commit, __version__)
)
-
- commands = [
- ['git', 'pull', remote, '%s:%s' % (deploy, deploy)],
- ['git', 'add', '-A'],
- ['git', 'commit', '-m', commit_message],
- ['git', 'push', remote, '%s:%s' % (deploy, deploy)],
- ['git', 'checkout', source],
- ]
-
- for command in commands:
- self.logger.info("==> {0}".format(command))
- try:
- subprocess.check_call(command)
- except subprocess.CalledProcessError as e:
- self.logger.error(
- 'Failed GitHub deployment — command {0} '
- 'returned {1}'.format(e.cmd, e.returncode)
- )
- sys.exit(e.returncode)
-
- def _copy_output(self):
- """ Copy all output to the top level directory. """
output_folder = self.site.config['OUTPUT_FOLDER']
- for each in os.listdir(output_folder):
- if os.path.exists(each):
- if os.path.isdir(each):
- shutil.rmtree(each)
-
- else:
- os.unlink(each)
-
- shutil.move(os.path.join(output_folder, each), '.')
-
- def _checkout_deploy_branch(self):
- """ Check out the deploy branch
-
- Creates an orphan branch if not present.
-
- """
- deploy = self._deploy_branch
+ command = ['ghp-import', '-n', '-m', commit_message, '-p', '-r', remote, '-b', deploy, output_folder]
+ self.logger.info("==> {0}".format(command))
try:
- subprocess.check_call(
- [
- 'git', 'show-ref', '--verify', '--quiet',
- 'refs/heads/%s' % deploy
- ]
- )
- except subprocess.CalledProcessError:
- self._create_orphan_deploy_branch()
- else:
- subprocess.check_call(['git', 'checkout', deploy])
-
- def _create_orphan_deploy_branch(self):
- """ Create an orphan deploy branch """
-
- result = subprocess.check_call(
- ['git', 'checkout', '--orphan', self._deploy_branch]
- )
- if result != 0:
- self.logger.error('Failed to create a deploy branch')
- sys.exit(1)
-
- result = subprocess.check_call(['git', 'rm', '-rf', '.'])
- if result != 0:
- self.logger.error('Failed to create a deploy branch')
- sys.exit(1)
-
- with open('.gitignore', 'w') as f:
- f.write('%s\n' % self.site.config['OUTPUT_FOLDER'])
- f.write('%s\n' % self.site.config['CACHE_FOLDER'])
- f.write('*.pyc\n')
- f.write('*.db\n')
-
- subprocess.check_call(['git', 'add', '.gitignore'])
- subprocess.check_call(['git', 'commit', '-m', 'Add .gitignore'])
-
- def _ensure_git_repo(self):
- """ Ensure that the site is a git-repo.
-
- Also make sure that a remote with the specified name exists.
-
- """
-
- try:
- remotes = uni_check_output(['git', 'remote'])
+ subprocess.check_call(command)
except subprocess.CalledProcessError as e:
- self.logger.notice('github_deploy needs a git repository!')
- sys.exit(e.returncode)
- except OSError as e:
- import errno
- self.logger.error('Running git failed with {0}'.format(e))
- if e.errno == errno.ENOENT:
- self.logger.notice('Is git on the PATH?')
- sys.exit(1)
- else:
- if self._remote_name not in remotes:
- self.logger.error(
- 'Need a remote called "%s" configured' % self._remote_name
- )
- sys.exit(1)
-
- def _exit_if_output_committed(self):
- """ Exit if the output folder is committed on the source branch. """
-
- source = self._source_branch
- subprocess.check_call(['git', 'checkout', source])
-
- output_folder = self.site.config['OUTPUT_FOLDER']
- output_log = uni_check_output(
- ['git', 'ls-files', '--', output_folder]
- )
-
- if len(output_log.strip()) > 0:
self.logger.error(
- 'Output folder is committed on the source branch. '
- 'Cannot proceed until it is removed.'
+ 'Failed GitHub deployment — command {0} '
+ 'returned {1}'.format(e.cmd, e.returncode)
)
- sys.exit(1)
-
- def _prompt_continue(self):
- """ Show uncommitted changes, and ask if user wants to continue. """
+ return e.returncode
- changes = uni_check_output(['git', 'status', '--porcelain'])
- if changes.strip():
- changes = uni_check_output(['git', 'status']).strip()
- message = (
- "You have the following changes:\n%s\n\n"
- "Anything not committed, and unknown to Nikola may be lost, "
- "or committed onto the wrong branch. Do you wish to continue?"
- ) % changes
- proceed = ask_yesno(message, False)
- else:
- proceed = True
+ self.logger.info("Successful deployment")
- return proceed
+ # Store timestamp of successful deployment
+ timestamp_path = os.path.join(self.site.config["CACHE_FOLDER"], "lastdeploy")
+ new_deploy = datetime.utcnow()
+ makedirs(self.site.config["CACHE_FOLDER"])
+ with io.open(timestamp_path, "w+", encoding="utf8") as outf:
+ outf.write(unicode_str(new_deploy.isoformat()))
diff --git a/nikola/plugins/command/import_wordpress.plugin b/nikola/plugins/command/import_wordpress.plugin
index fadc759..e072224 100644
--- a/nikola/plugins/command/import_wordpress.plugin
+++ b/nikola/plugins/command/import_wordpress.plugin
@@ -4,7 +4,7 @@ Module = import_wordpress
[Documentation]
Author = Roberto Alsina
-Version = 0.2
+Version = 1.0
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
index 1af4083..674fc2a 100644
--- a/nikola/plugins/command/import_wordpress.py
+++ b/nikola/plugins/command/import_wordpress.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2014 Roberto Alsina and others.
+# Copyright © 2012-2015 Roberto Alsina and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
@@ -28,6 +28,8 @@ from __future__ import unicode_literals, print_function
import os
import re
import sys
+import datetime
+import requests
from lxml import etree
try:
@@ -37,11 +39,6 @@ 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
@@ -87,6 +84,13 @@ class CommandImportWordpress(Command, ImportMixin):
'help': "Do not try to download files for the import",
},
{
+ 'name': 'download_auth',
+ 'long': 'download-auth',
+ 'default': None,
+ 'type': str,
+ 'help': "Specify username and password for HTTP authentication (separated by ':')",
+ },
+ {
'name': 'separate_qtranslate_content',
'long': 'qtranslate',
'default': False,
@@ -104,6 +108,7 @@ class CommandImportWordpress(Command, ImportMixin):
'help': "The pattern for translation files names",
},
]
+ all_tags = set([])
def _execute(self, options={}, args=[]):
"""Import a WordPress blog from an export file into a Nikola site."""
@@ -133,6 +138,14 @@ class CommandImportWordpress(Command, ImportMixin):
self.exclude_drafts = options.get('exclude_drafts', False)
self.no_downloads = options.get('no_downloads', False)
+ self.auth = None
+ if options.get('download_auth') is not None:
+ username_password = options.get('download_auth')
+ self.auth = tuple(username_password.split(':', 1))
+ if len(self.auth) < 2:
+ print("Please specify HTTP authentication credentials in the form username:password.")
+ return False
+
self.separate_qtranslate_content = options.get('separate_qtranslate_content')
self.translations_pattern = options.get('translations_pattern')
@@ -149,11 +162,7 @@ class CommandImportWordpress(Command, ImportMixin):
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:
+ if phpserialize is None:
req_missing(['phpserialize'], 'import WordPress dumps without --no-downloads')
channel = self.get_channel_from_file(self.wordpress_export_file)
@@ -172,6 +181,19 @@ class CommandImportWordpress(Command, ImportMixin):
self.extra_languages)
self.context['REDIRECTIONS'] = self.configure_redirections(
self.url_map)
+
+ # Add tag redirects
+ for tag in self.all_tags:
+ try:
+ tag_str = tag.decode('utf8')
+ except AttributeError:
+ tag_str = tag
+ tag = utils.slugify(tag_str)
+ src_url = '{}tag/{}'.format(self.context['SITE_URL'], tag)
+ dst_url = self.site.link('tag', tag)
+ if src_url != dst_url:
+ self.url_map[src_url] = dst_url
+
self.write_urlmap_csv(
os.path.join(self.output_folder, 'url_map.csv'), self.url_map)
rendered_template = conf_template.render(**prepare_config(self.context))
@@ -186,26 +208,6 @@ class CommandImportWordpress(Command, ImportMixin):
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 = []
@@ -215,8 +217,7 @@ class CommandImportWordpress(Command, ImportMixin):
if b'<atom:link rel=' in line:
continue
xml.append(line)
-
- return cls._glue_xml_lines(xml)
+ return b'\n'.join(xml)
@classmethod
def get_channel_from_file(cls, filename):
@@ -255,9 +256,15 @@ class CommandImportWordpress(Command, ImportMixin):
'{{{0}}}author_display_name'.format(wordpress_namespace),
"Joe Example")
context['POSTS'] = '''(
+ ("posts/*.rst", "posts", "post.tmpl"),
+ ("posts/*.txt", "posts", "post.tmpl"),
+ ("posts/*.md", "posts", "post.tmpl"),
("posts/*.wp", "posts", "post.tmpl"),
)'''
context['PAGES'] = '''(
+ ("stories/*.rst", "stories", "story.tmpl"),
+ ("stories/*.txt", "stories", "story.tmpl"),
+ ("stories/*.md", "stories", "story.tmpl"),
("stories/*.wp", "stories", "story.tmpl"),
)'''
context['COMPILERS'] = '''{
@@ -274,8 +281,12 @@ class CommandImportWordpress(Command, ImportMixin):
return
try:
+ request = requests.get(url, auth=self.auth)
+ if request.status_code >= 400:
+ LOGGER.warn("Downloading {0} to {1} failed with HTTP status code {2}".format(url, dst_path, request.status_code))
+ return
with open(dst_path, 'wb+') as fd:
- fd.write(requests.get(url).content)
+ fd.write(request.content)
except requests.exceptions.ConnectionError as err:
LOGGER.warn("Downloading {0} to {1} failed: {2}".format(url, dst_path, err))
@@ -285,8 +296,7 @@ class CommandImportWordpress(Command, ImportMixin):
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_path = os.path.join(*([self.output_folder, 'files'] + list(path.split('/'))))
dst_dir = os.path.dirname(dst_path)
utils.makedirs(dst_dir)
LOGGER.info("Downloading {0} => {1}".format(url, dst_path))
@@ -306,7 +316,6 @@ class CommandImportWordpress(Command, ImportMixin):
return
additional_metadata = item.findall('{{{0}}}postmeta'.format(wordpress_namespace))
-
if additional_metadata is None:
return
@@ -341,8 +350,7 @@ class CommandImportWordpress(Command, ImportMixin):
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_path = os.path.join(*([self.output_folder, 'files'] + list(path.split('/'))))
dst_dir = os.path.dirname(dst_path)
utils.makedirs(dst_dir)
LOGGER.info("Downloading {0} => {1}".format(url, dst_path))
@@ -351,13 +359,34 @@ class CommandImportWordpress(Command, ImportMixin):
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
+ code_re1 = re.compile(r'\[code.* lang.*?="(.*?)?".*\](.*?)\[/code\]', re.DOTALL | re.MULTILINE)
+ code_re2 = re.compile(r'\[sourcecode.* lang.*?="(.*?)?".*\](.*?)\[/sourcecode\]', re.DOTALL | re.MULTILINE)
+ code_re3 = re.compile(r'\[code.*?\](.*?)\[/code\]', re.DOTALL | re.MULTILINE)
+ code_re4 = re.compile(r'\[sourcecode.*?\](.*?)\[/sourcecode\]', re.DOTALL | re.MULTILINE)
+
+ def transform_code(self, content):
+ # http://en.support.wordpress.com/code/posting-source-code/. There are
+ # a ton of things not supported here. We only do a basic [code
+ # lang="x"] -> ```x translation, and remove quoted html entities (<,
+ # >, &, and ").
+ def replacement(m, c=content):
+ if len(m.groups()) == 1:
+ language = ''
+ code = m.group(0)
+ else:
+ language = m.group(1) or ''
+ code = m.group(2)
+ code = code.replace('&amp;', '&')
+ code = code.replace('&gt;', '>')
+ code = code.replace('&lt;', '<')
+ code = code.replace('&quot;', '"')
+ return '```{language}\n{code}\n```'.format(language=language, code=code)
+
+ content = self.code_re1.sub(replacement, content)
+ content = self.code_re2.sub(replacement, content)
+ content = self.code_re3.sub(replacement, content)
+ content = self.code_re4.sub(replacement, content)
+ return content
@staticmethod
def transform_caption(content):
@@ -374,10 +403,10 @@ class CommandImportWordpress(Command, ImportMixin):
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
+ content = self.transform_code(content)
+ content = self.transform_caption(content)
+ content = self.transform_multiple_newlines(content)
+ return content
def import_item(self, item, wordpress_namespace, out_folder=None):
"""Takes an item from the feed and creates a post file."""
@@ -391,11 +420,10 @@ class CommandImportWordpress(Command, ImportMixin):
parsed = urlparse(link)
path = unquote(parsed.path.strip('/'))
- # 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):
+ try:
path = path.decode('utf8')
+ except AttributeError:
+ pass
# Cut out the base directory.
if path.startswith(self.base_dir.strip('/')):
@@ -420,7 +448,13 @@ class CommandImportWordpress(Command, ImportMixin):
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)
+ try:
+ dt = utils.to_datetime(post_date)
+ except ValueError:
+ dt = datetime.datetime(1970, 1, 1, 0, 0, 0)
+ LOGGER.error('Malformed date "{0}" in "{1}" [{2}], assuming 1970-01-01 00:00:00 instead.'.format(post_date, title, slug))
+ post_date = dt.strftime('%Y-%m-%d %H:%M:%S')
+
if dt.tzinfo and self.timezone is None:
self.timezone = utils.get_tzname(dt)
status = get_text_tag(
@@ -443,12 +477,20 @@ class CommandImportWordpress(Command, ImportMixin):
if text == 'Uncategorized':
continue
tags.append(text)
+ self.all_tags.add(text)
if '$latex' in content:
tags.append('mathjax')
+ # Find post format if it's there
+ post_format = 'wp'
+ format_tag = [x for x in item.findall('*//{%s}meta_key' % wordpress_namespace) if x.text == '_tc_post_format']
+ if format_tag:
+ post_format = format_tag[0].getparent().find('{%s}meta_value' % wordpress_namespace).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'] +
@@ -475,7 +517,8 @@ class CommandImportWordpress(Command, ImportMixin):
out_meta_filename = slug + '.meta'
out_content_filename = slug + '.wp'
meta_slug = slug
- content = self.transform_content(content)
+ if post_format == 'wp':
+ content = self.transform_content(content)
self.write_metadata(os.path.join(self.output_folder, out_folder,
out_meta_filename),
title, meta_slug, post_date, description, tags)
@@ -510,7 +553,7 @@ def get_text_tag(tag, name, default):
if tag is None:
return default
t = tag.find(name)
- if t is not None:
+ if t is not None and t.text is not None:
return t.text
else:
return default
diff --git a/nikola/plugins/command/init.plugin b/nikola/plugins/command/init.plugin
index a539f51..850dba9 100644
--- a/nikola/plugins/command/init.plugin
+++ b/nikola/plugins/command/init.plugin
@@ -4,6 +4,6 @@ Module = init
[Documentation]
Author = Roberto Alsina
-Version = 0.2
+Version = 1.0
Website = http://getnikola.com
Description = Create a new site.
diff --git a/nikola/plugins/command/init.py b/nikola/plugins/command/init.py
index a8b60db..7a36894 100644
--- a/nikola/plugins/command/init.py
+++ b/nikola/plugins/command/init.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2014 Roberto Alsina and others.
+# Copyright © 2012-2015 Roberto Alsina and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
@@ -33,11 +33,13 @@ import textwrap
import datetime
import unidecode
import dateutil.tz
+import dateutil.zoneinfo
from mako.template import Template
from pkg_resources import resource_filename
+import tarfile
import nikola
-from nikola.nikola import DEFAULT_TRANSLATIONS_PATTERN, DEFAULT_INDEX_READ_MORE_LINK, DEFAULT_RSS_READ_MORE_LINK, LEGAL_VALUES
+from nikola.nikola import DEFAULT_TRANSLATIONS_PATTERN, DEFAULT_INDEX_READ_MORE_LINK, DEFAULT_RSS_READ_MORE_LINK, LEGAL_VALUES, urlsplit, urlunsplit
from nikola.plugin_categories import Command
from nikola.utils import ask, ask_yesno, get_logger, makedirs, STDERR_HANDLER, load_messages
from nikola.packages.tzlocal import get_localzone
@@ -48,9 +50,10 @@ LOGGER = get_logger('init', STDERR_HANDLER)
SAMPLE_CONF = {
'BLOG_AUTHOR': "Your Name",
'BLOG_TITLE': "Demo Site",
- 'SITE_URL': "http://getnikola.com/",
+ 'SITE_URL': "https://example.com/",
'BLOG_EMAIL': "joe@demo.site",
'BLOG_DESCRIPTION': "This is a demo site for Nikola.",
+ 'PRETTY_URLS': False,
'DEFAULT_LANG': "en",
'TRANSLATIONS': """{
DEFAULT_LANG: "",
@@ -186,7 +189,7 @@ def format_navigation_links(additional_languages, default_lang, messages):
pairs.append(f.format('DEFAULT_LANG', '', get_msg(default_lang)))
for l in additional_languages:
- pairs.append(f.format(json.dumps(l), '/' + l, get_msg(l)))
+ pairs.append(f.format(json.dumps(l, ensure_ascii=False), '/' + l, get_msg(l)))
return u'{{\n{0}\n}}'.format('\n\n'.join(pairs))
@@ -196,11 +199,13 @@ def format_navigation_links(additional_languages, default_lang, messages):
def prepare_config(config):
"""Parse sample config with JSON."""
p = config.copy()
- p.update(dict((k, json.dumps(v)) for k, v in p.items()
- if k not in ('POSTS', 'PAGES', 'COMPILERS', 'TRANSLATIONS', 'NAVIGATION_LINKS', '_SUPPORTED_LANGUAGES', '_SUPPORTED_COMMENT_SYSTEMS', 'INDEX_READ_MORE_LINK', 'RSS_READ_MORE_LINK')))
+ p.update(dict((k, json.dumps(v, ensure_ascii=False)) for k, v in p.items()
+ if k not in ('POSTS', 'PAGES', 'COMPILERS', 'TRANSLATIONS', 'NAVIGATION_LINKS', '_SUPPORTED_LANGUAGES', '_SUPPORTED_COMMENT_SYSTEMS', 'INDEX_READ_MORE_LINK', 'RSS_READ_MORE_LINK', 'PRETTY_URLS')))
# READ_MORE_LINKs require some special treatment.
p['INDEX_READ_MORE_LINK'] = "'" + p['INDEX_READ_MORE_LINK'].replace("'", "\\'") + "'"
p['RSS_READ_MORE_LINK'] = "'" + p['RSS_READ_MORE_LINK'].replace("'", "\\'") + "'"
+ # json would make that `true` instead of `True`
+ p['PRETTY_URLS'] = str(p['PRETTY_URLS'])
return p
@@ -237,14 +242,20 @@ class CommandInit(Command):
src = resource_filename('nikola', os.path.join('data', 'samplesite'))
shutil.copytree(src, target)
- @classmethod
- def create_configuration(cls, target):
+ @staticmethod
+ def create_configuration(target):
template_path = resource_filename('nikola', 'conf.py.in')
conf_template = Template(filename=template_path)
conf_path = os.path.join(target, 'conf.py')
with io.open(conf_path, 'w+', encoding='utf8') as fd:
fd.write(conf_template.render(**prepare_config(SAMPLE_CONF)))
+ @staticmethod
+ def create_configuration_to_string():
+ template_path = resource_filename('nikola', 'conf.py.in')
+ conf_template = Template(filename=template_path)
+ return conf_template.render(**prepare_config(SAMPLE_CONF))
+
@classmethod
def create_empty_site(cls, target):
for folder in ('files', 'galleries', 'listings', 'posts', 'stories'):
@@ -253,6 +264,39 @@ class CommandInit(Command):
@staticmethod
def ask_questions(target):
"""Ask some questions about Nikola."""
+ def urlhandler(default, toconf):
+ answer = ask('Site URL', 'https://example.com/')
+ try:
+ answer = answer.decode('utf-8')
+ except (AttributeError, UnicodeDecodeError):
+ pass
+ if not answer.startswith(u'http'):
+ print(" ERROR: You must specify a protocol (http or https).")
+ urlhandler(default, toconf)
+ return
+ if not answer.endswith('/'):
+ print(" The URL does not end in '/' -- adding it.")
+ answer += '/'
+
+ dst_url = urlsplit(answer)
+ try:
+ dst_url.netloc.encode('ascii')
+ except (UnicodeEncodeError, UnicodeDecodeError):
+ # The IDN contains characters beyond ASCII. We must convert it
+ # to Punycode. (Issue #1644)
+ nl = dst_url.netloc.encode('idna')
+ answer = urlunsplit((dst_url.scheme,
+ nl,
+ dst_url.path,
+ dst_url.query,
+ dst_url.fragment))
+ print(" Converting to Punycode:", answer)
+
+ SAMPLE_CONF['SITE_URL'] = answer
+
+ def prettyhandler(default, toconf):
+ SAMPLE_CONF['PRETTY_URLS'] = ask_yesno('Enable pretty URLs (/page/ instead of /page.html) that don’t need web server configuration?', default=True)
+
def lhandler(default, toconf, show_header=True):
if show_header:
print("We will now ask you to provide the list of languages you want to use.")
@@ -297,7 +341,7 @@ class CommandInit(Command):
lhandler(default, toconf, show_header=False)
def tzhandler(default, toconf):
- print("\nPlease choose the correct time zone for your blog. Nikola uses the tz database.")
+ print("\nPlease choose the correct time zone for your blog. Nikola uses the tz database.")
print("You can find your time zone here:")
print("http://en.wikipedia.org/wiki/List_of_tz_database_time_zones")
print("")
@@ -309,12 +353,26 @@ class CommandInit(Command):
lz = None
answer = ask('Time zone', lz if lz else "UTC")
tz = dateutil.tz.gettz(answer)
+
+ if tz is None:
+ print(" WARNING: Time zone not found. Searching list of time zones for a match.")
+ zonesfile = tarfile.open(fileobj=dateutil.zoneinfo.getzoneinfofile_stream())
+ zonenames = [zone for zone in zonesfile.getnames() if answer.lower() in zone.lower()]
+ if len(zonenames) == 1:
+ tz = dateutil.tz.gettz(zonenames[0])
+ answer = zonenames[0]
+ print(" Picking '{0}'.".format(answer))
+ elif len(zonenames) > 1:
+ print(" The following time zones match your query:")
+ print(' ' + '\n '.join(zonenames))
+ continue
+
if tz is not None:
time = datetime.datetime.now(tz).strftime('%H:%M:%S')
print(" Current time in {0}: {1}".format(answer, time))
answered = ask_yesno("Use this time zone?", True)
else:
- print(" ERROR: Time zone not found. Please try again. Time zones are case-sensitive.")
+ print(" ERROR: No matches found. Please try again.")
SAMPLE_CONF['TIMEZONE'] = answer
@@ -353,7 +411,8 @@ class CommandInit(Command):
('Site author', 'Nikola Tesla', True, 'BLOG_AUTHOR'),
('Site author\'s e-mail', 'n.tesla@example.com', True, 'BLOG_EMAIL'),
('Site description', 'This is a demo site for Nikola.', True, 'BLOG_DESCRIPTION'),
- ('Site URL', 'http://getnikola.com/', True, 'SITE_URL'),
+ (urlhandler, None, True, True),
+ (prettyhandler, None, True, True),
('Questions about languages and locales', None, None, None),
(lhandler, None, True, True),
(tzhandler, None, True, True),
@@ -377,6 +436,10 @@ class CommandInit(Command):
query(default, toconf)
else:
answer = ask(query, default)
+ try:
+ answer = answer.decode('utf-8')
+ except (AttributeError, UnicodeDecodeError):
+ pass
if toconf:
SAMPLE_CONF[destination] = answer
if destination == '!target':
@@ -386,7 +449,7 @@ class CommandInit(Command):
STORAGE['target'] = answer
print("\nThat's it, Nikola is now configured. Make sure to edit conf.py to your liking.")
- print("If you are looking for themes and addons, check out http://themes.getnikola.com/ and http://plugins.getnikola.com/.")
+ print("If you are looking for themes and addons, check out https://themes.getnikola.com/ and https://plugins.getnikola.com/.")
print("Have fun!")
return STORAGE
diff --git a/nikola/plugins/command/install_theme.plugin b/nikola/plugins/command/install_theme.plugin
index 84b2623..54a91ff 100644
--- a/nikola/plugins/command/install_theme.plugin
+++ b/nikola/plugins/command/install_theme.plugin
@@ -4,7 +4,7 @@ Module = install_theme
[Documentation]
Author = Roberto Alsina
-Version = 0.1
+Version = 1.0
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
index 5397772..4937509 100644
--- a/nikola/plugins/command/install_theme.py
+++ b/nikola/plugins/command/install_theme.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2014 Roberto Alsina and others.
+# Copyright © 2012-2015 Roberto Alsina and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
@@ -28,42 +28,18 @@ from __future__ import print_function
import os
import io
import json
-import shutil
+import requests
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."""
@@ -86,16 +62,21 @@ class CommandInstallTheme(Command):
'long': 'url',
'type': str,
'help': "URL for the theme repository (default: "
- "http://themes.getnikola.com/v7/themes.json)",
- 'default': 'http://themes.getnikola.com/v7/themes.json'
+ "https://themes.getnikola.com/v7/themes.json)",
+ 'default': 'https://themes.getnikola.com/v7/themes.json'
+ },
+ {
+ 'name': 'getpath',
+ 'short': 'g',
+ 'long': 'get-path',
+ 'type': bool,
+ 'default': False,
+ 'help': "Print the path for installed theme",
},
]
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:
@@ -103,6 +84,14 @@ class CommandInstallTheme(Command):
else:
name = None
+ if options['getpath'] and name:
+ path = utils.get_theme_path(name)
+ if path:
+ print(path)
+ else:
+ print('not installed')
+ return 0
+
if name is None and not listing:
LOGGER.error("This command needs either a theme name or the -l option.")
return False
@@ -135,36 +124,31 @@ class CommandInstallTheme(Command):
def do_install(self, name, data):
if name in data:
utils.makedirs(self.output_dir)
- LOGGER.info('Downloading: ' + data[name])
+ LOGGER.info("Downloading '{0}'".format(data[name]))
zip_file = io.BytesIO()
zip_file.write(requests.get(data[name]).content)
- LOGGER.info('Extracting: {0} into themes'.format(name))
+ LOGGER.info("Extracting '{0}' into themes/".format(name))
utils.extract_all(zip_file)
- dest_path = os.path.join('themes', name)
+ dest_path = os.path.join(self.output_dir, name)
else:
+ dest_path = os.path.join(self.output_dir, name)
try:
theme_path = utils.get_theme_path(name)
- except:
- LOGGER.error("Can't find theme " + name)
- return False
+ LOGGER.error("Theme '{0}' is already installed in {1}".format(name, theme_path))
+ except Exception:
+ LOGGER.error("Can't find theme {0}".format(name))
- 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
+ return False
- LOGGER.info('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 theme has a sample config file. Integrate it with yours in order to make this theme work!')
print('Contents of the conf.py.sample file:\n')
with io.open(confpypath, 'r', encoding='utf-8') as fh:
if self.site.colorful:
- print(indent(pygments.highlight(
+ print(utils.indent(pygments.highlight(
fh.read(), PythonLexer(), TerminalFormatter()),
4 * ' '))
else:
- print(indent(fh.read(), 4 * ' '))
+ print(utils.indent(fh.read(), 4 * ' '))
return True
diff --git a/nikola/plugins/command/new_page.plugin b/nikola/plugins/command/new_page.plugin
index 1f1c84c..f078dd6 100644
--- a/nikola/plugins/command/new_page.plugin
+++ b/nikola/plugins/command/new_page.plugin
@@ -4,6 +4,6 @@ Module = new_page
[Documentation]
Author = Roberto Alsina, Chris Warrick
-Version = 0.1
+Version = 1.0
Website = http://getnikola.com
Description = Create a new page.
diff --git a/nikola/plugins/command/new_page.py b/nikola/plugins/command/new_page.py
index f07ba39..39a85bd 100644
--- a/nikola/plugins/command/new_page.py
+++ b/nikola/plugins/command/new_page.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2014 Roberto Alsina, Chris Warrick and others.
+# Copyright © 2012-2015 Roberto Alsina, Chris Warrick and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
@@ -45,6 +45,14 @@ class CommandNewPage(Command):
'help': 'Title for the page.'
},
{
+ 'name': 'author',
+ 'short': 'a',
+ 'long': 'author',
+ 'type': str,
+ 'default': '',
+ 'help': 'Author of the post.'
+ },
+ {
'name': 'onefile',
'short': '1',
'type': bool,
@@ -71,13 +79,29 @@ class CommandNewPage(Command):
'long': 'format',
'type': str,
'default': '',
- 'help': 'Markup format for the page, one of rest, markdown, wiki, '
- 'bbcode, html, textile, txt2tags',
+ 'help': 'Markup format for the page (use --available-formats for list)',
+ },
+ {
+ 'name': 'available-formats',
+ 'short': 'F',
+ 'long': 'available-formats',
+ 'type': bool,
+ 'default': False,
+ 'help': 'List all available input formats'
+ },
+ {
+ 'name': 'import',
+ 'short': 'i',
+ 'long': 'import',
+ 'type': str,
+ 'default': '',
+ 'help': 'Import an existing file instead of creating a placeholder'
},
]
def _execute(self, options, args):
"""Create a new page."""
+ # Defaults for some values that don’t apply to pages and the is_page option (duh!)
options['tags'] = ''
options['schedule'] = False
options['is_page'] = True
diff --git a/nikola/plugins/command/new_post.plugin b/nikola/plugins/command/new_post.plugin
index ec35c35..fec4b1d 100644
--- a/nikola/plugins/command/new_post.plugin
+++ b/nikola/plugins/command/new_post.plugin
@@ -4,7 +4,7 @@ Module = new_post
[Documentation]
Author = Roberto Alsina
-Version = 0.1
+Version = 1.0
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
index 24c09d0..5141c7e 100644
--- a/nikola/plugins/command/new_post.py
+++ b/nikola/plugins/command/new_post.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2014 Roberto Alsina and others.
+# Copyright © 2012-2015 Roberto Alsina and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
@@ -30,6 +30,7 @@ import datetime
import os
import sys
import subprocess
+import operator
from blinker import signal
import dateutil.tz
@@ -37,12 +38,13 @@ import dateutil.tz
from nikola.plugin_categories import Command
from nikola import utils
+COMPILERS_DOC_LINK = 'https://getnikola.com/handbook.html#configuring-other-input-formats'
POSTLOGGER = utils.get_logger('new_post', utils.STDERR_HANDLER)
PAGELOGGER = utils.get_logger('new_page', utils.STDERR_HANDLER)
LOGGER = POSTLOGGER
-def filter_post_pages(compiler, is_post, compilers, post_pages):
+def filter_post_pages(compiler, is_post, compilers, post_pages, compiler_objs, compilers_raw):
"""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."""
@@ -51,7 +53,15 @@ def filter_post_pages(compiler, is_post, compilers, post_pages):
filtered = [entry for entry in post_pages if entry[3] == is_post]
# These are the extensions supported by the required format
- extensions = compilers[compiler]
+ extensions = compilers.get(compiler)
+ if extensions is None:
+ if compiler in compiler_objs:
+ LOGGER.error("There is a {0} compiler available, but it's not set in your COMPILERS option.".format(compiler))
+ LOGGER.info("Read more: {0}".format(COMPILERS_DOC_LINK))
+ else:
+ LOGGER.error('Unknown format {0}'.format(compiler))
+ print_compilers(compilers_raw, post_pages, compiler_objs)
+ return False
# Throw away the post_pages with the wrong extensions
filtered = [entry for entry in filtered if any([ext in entry[0] for ext in
@@ -59,13 +69,77 @@ def filter_post_pages(compiler, is_post, compilers, post_pages):
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 {2}S in conf.py".format(
- type_name, compiler, type_name.upper()))
+ LOGGER.error("Can't find a way, using your configuration, to create "
+ "a {0} in format {1}. You may want to tweak "
+ "COMPILERS or {2}S in conf.py".format(
+ type_name, compiler, type_name.upper()))
+ LOGGER.info("Read more: {0}".format(COMPILERS_DOC_LINK))
+
+ return False
return filtered[0]
+def print_compilers(compilers_raw, post_pages, compiler_objs):
+ """
+ List all available compilers in a human-friendly format.
+
+ :param compilers_raw: The compilers dict, mapping compiler names to tuples of extensions
+ :param post_pages: The post_pages structure
+ :param compilers_objs: Compiler objects
+ """
+
+ # We use compilers_raw, because the normal dict can contain
+ # garbage coming from the translation candidate implementation.
+ # Entries are in format: (name, extensions, used_in_post_pages)
+ parsed_compilers = {'used': [], 'unused': [], 'disabled': []}
+
+ for compiler_name, compiler_obj in compiler_objs.items():
+ fname = compiler_obj.friendly_name or compiler_name
+ if compiler_name not in compilers_raw:
+ parsed_compilers['disabled'].append((compiler_name, fname, (), False))
+ else:
+ # stolen from filter_post_pages
+ extensions = compilers_raw[compiler_name]
+ filtered = [entry for entry in post_pages if any(
+ [ext in entry[0] for ext in extensions])]
+ if filtered:
+ parsed_compilers['used'].append((compiler_name, fname, extensions, True))
+ else:
+ parsed_compilers['unused'].append((compiler_name, fname, extensions, False))
+
+ # Sort compilers alphabetically by name, just so it’s prettier (and
+ # deterministic)
+ parsed_compilers['used'].sort(key=operator.itemgetter(0))
+ parsed_compilers['unused'].sort(key=operator.itemgetter(0))
+ parsed_compilers['disabled'].sort(key=operator.itemgetter(0))
+
+ # We also group the compilers by status for readability.
+ parsed_list = parsed_compilers['used'] + parsed_compilers['unused'] + parsed_compilers['disabled']
+
+ print("Available input formats:\n")
+
+ name_width = max([len(i[0]) for i in parsed_list] + [4]) # 4 == len('NAME')
+ fname_width = max([len(i[1]) for i in parsed_list] + [11]) # 11 == len('DESCRIPTION')
+
+ print((' {0:<' + str(name_width) + '} {1:<' + str(fname_width) + '} EXTENSIONS\n').format('NAME', 'DESCRIPTION'))
+
+ for name, fname, extensions, used in parsed_list:
+ flag = ' ' if used else '!'
+ flag = flag if extensions else '~'
+
+ extensions = ', '.join(extensions) if extensions else '(disabled: not in COMPILERS)'
+
+ print(('{flag}{name:<' + str(name_width) + '} {fname:<' + str(fname_width) + '} {extensions}').format(flag=flag, name=name, fname=fname, extensions=extensions))
+
+ print("""
+More compilers are available in the Plugins Index.
+
+Compilers marked with ! and ~ require additional configuration:
+ ! not in the PAGES/POSTS tuples (unused)
+ ~ not in the COMPILERS dict (disabled)
+Read more: {0}""".format(COMPILERS_DOC_LINK))
+
+
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.
@@ -116,7 +190,7 @@ def get_date(schedule=False, rule=None, last_date=None, tz=None, iso8601=False):
rrule = None # NOQA
if schedule and rrule and rule:
try:
- rule_ = rrule.rrulestr(rule, dtstart=last_date)
+ rule_ = rrule.rrulestr(rule, dtstart=last_date or date)
except Exception:
LOGGER.error('Unable to parse rule string, using current time.')
else:
@@ -161,6 +235,14 @@ class CommandNewPost(Command):
'help': 'Title for the post.'
},
{
+ 'name': 'author',
+ 'short': 'a',
+ 'long': 'author',
+ 'type': str,
+ 'default': '',
+ 'help': 'Author of the post.'
+ },
+ {
'name': 'tags',
'long': 'tags',
'type': str,
@@ -194,8 +276,15 @@ class CommandNewPost(Command):
'long': 'format',
'type': str,
'default': '',
- 'help': 'Markup format for the post, one of rest, markdown, wiki, '
- 'bbcode, html, textile, txt2tags',
+ 'help': 'Markup format for the post (use --available-formats for list)',
+ },
+ {
+ 'name': 'available-formats',
+ 'short': 'F',
+ 'long': 'available-formats',
+ 'type': bool,
+ 'default': False,
+ 'help': 'List all available input formats'
},
{
'name': 'schedule',
@@ -204,6 +293,14 @@ class CommandNewPost(Command):
'default': False,
'help': 'Schedule the post based on recurrence rule'
},
+ {
+ 'name': 'import',
+ 'short': 'i',
+ 'long': 'import',
+ 'type': str,
+ 'default': '',
+ 'help': 'Import an existing file instead of creating a placeholder'
+ },
]
@@ -228,9 +325,16 @@ class CommandNewPost(Command):
is_post = not is_page
content_type = 'page' if is_page else 'post'
title = options['title'] or None
+ author = options['author'] or ''
tags = options['tags']
onefile = options['onefile']
twofile = options['twofile']
+ import_file = options['import']
+ wants_available = options['available-formats']
+
+ if wants_available:
+ print_compilers(self.site.config['_COMPILERS_RAW'], self.site.config['post_pages'], self.site.compilers)
+ return
if is_page:
LOGGER = PAGELOGGER
@@ -243,6 +347,10 @@ class CommandNewPost(Command):
onefile = self.site.config.get('ONE_FILE_POSTS', True)
content_format = options['content_format']
+ content_subformat = None
+
+ if "@" in content_format:
+ content_format, content_subformat = content_format.split("@")
if not content_format: # Issue #400
content_format = get_default_compiler(
@@ -251,7 +359,8 @@ class CommandNewPost(Command):
self.site.config['post_pages'])
if content_format not in compiler_names:
- LOGGER.error("Unknown {0} format {1}".format(content_type, content_format))
+ LOGGER.error("Unknown {0} format {1}, maybe you need to install a plugin?".format(content_type, content_format))
+ print_compilers(self.site.config['_COMPILERS_RAW'], self.site.config['post_pages'], self.site.compilers)
return
compiler_plugin = self.site.plugin_manager.getPluginByName(
content_format, "PageCompiler").plugin_object
@@ -259,10 +368,19 @@ class CommandNewPost(Command):
# Guess where we should put this
entry = filter_post_pages(content_format, is_post,
self.site.config['COMPILERS'],
- self.site.config['post_pages'])
+ self.site.config['post_pages'],
+ self.site.compilers,
+ self.site.config['_COMPILERS_RAW'])
+
+ if entry is False:
+ return 1
- print("Creating New {0}".format(content_type.title()))
- print("-----------------\n")
+ if import_file:
+ print("Importing Existing {xx}".format(xx=content_type.title()))
+ print("-----------------------\n")
+ else:
+ print("Creating New {xx}".format(xx=content_type.title()))
+ print("-----------------\n")
if title is not None:
print("Title:", title)
else:
@@ -272,7 +390,7 @@ class CommandNewPost(Command):
if isinstance(title, utils.bytes_str):
try:
title = title.decode(sys.stdin.encoding)
- except AttributeError: # for tests
+ except (AttributeError, TypeError): # for tests
title = title.decode('utf-8')
title = title.strip()
@@ -282,9 +400,16 @@ class CommandNewPost(Command):
if isinstance(path, utils.bytes_str):
try:
path = path.decode(sys.stdin.encoding)
- except AttributeError: # for tests
+ except (AttributeError, TypeError): # for tests
path = path.decode('utf-8')
slug = utils.slugify(os.path.splitext(os.path.basename(path))[0])
+
+ if isinstance(author, utils.bytes_str):
+ try:
+ author = author.decode(sys.stdin.encoding)
+ except (AttributeError, TypeError): # for tests
+ author = author.decode('utf-8')
+
# Calculate the date to use for the content
schedule = options['schedule'] or self.site.config['SCHEDULE_ALL']
rule = self.site.config['SCHEDULE_RULE']
@@ -308,23 +433,46 @@ class CommandNewPost(Command):
if not path:
txt_path = os.path.join(output_path, slug + suffix)
else:
- txt_path = path
+ txt_path = os.path.join(self.site.original_cwd, path)
if (not onefile and os.path.isfile(meta_path)) or \
os.path.isfile(txt_path):
+
+ # Emit an event when a post exists
+ event = dict(path=txt_path)
+ if not onefile: # write metadata file
+ event['meta_path'] = meta_path
+ signal('existing_' + content_type).send(self, **event)
+
LOGGER.error("The title already exists!")
- exit()
+ return 8
d_name = os.path.dirname(txt_path)
utils.makedirs(d_name)
- metadata = self.site.config['ADDITIONAL_METADATA']
+ metadata = {}
+ if author:
+ metadata['author'] = author
+ metadata.update(self.site.config['ADDITIONAL_METADATA'])
+ data.update(metadata)
+
+ # ipynb plugin needs the ipython kernel info. We get the kernel name
+ # from the content_subformat and pass it to the compiler in the metadata
+ if content_format == "ipynb" and content_subformat is not None:
+ metadata["ipython_kernel"] = content_subformat
# Override onefile if not really supported.
if not compiler_plugin.supports_onefile and onefile:
onefile = False
LOGGER.warn('This compiler does not support one-file posts.')
- content = "Write your {0} here.".format('page' if is_page else 'post')
+ if import_file:
+ with io.open(import_file, 'r', encoding='utf-8') as fh:
+ content = fh.read()
+ else:
+ if is_page:
+ content = self.site.MESSAGES[self.site.default_lang]["Write your page here."]
+ else:
+ content = self.site.MESSAGES[self.site.default_lang]["Write your post here."]
compiler_plugin.create_post(
txt_path, content=content, onefile=onefile, title=title,
slug=slug, date=date, tags=tags, is_page=is_page, **metadata)
diff --git a/nikola/plugins/command/orphans.plugin b/nikola/plugins/command/orphans.plugin
index 408578b..f491eaf 100644
--- a/nikola/plugins/command/orphans.plugin
+++ b/nikola/plugins/command/orphans.plugin
@@ -4,7 +4,7 @@ Module = orphans
[Documentation]
Author = Roberto Alsina, Chris Warrick
-Version = 0.1
+Version = 1.0
Website = http://getnikola.com
Description = List all orphans
diff --git a/nikola/plugins/command/orphans.py b/nikola/plugins/command/orphans.py
index ff114b4..f550e17 100644
--- a/nikola/plugins/command/orphans.py
+++ b/nikola/plugins/command/orphans.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2014 Roberto Alsina, Chris Warrick and others.
+# Copyright © 2012-2015 Roberto Alsina, Chris Warrick and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
diff --git a/nikola/plugins/command/plugin.plugin b/nikola/plugins/command/plugin.plugin
index d2bca92..2815caa 100644
--- a/nikola/plugins/command/plugin.plugin
+++ b/nikola/plugins/command/plugin.plugin
@@ -4,7 +4,7 @@ Module = plugin
[Documentation]
Author = Roberto Alsina and Chris Warrick
-Version = 0.2
+Version = 1.0
Website = http://getnikola.com
Description = Manage Nikola plugins
diff --git a/nikola/plugins/command/plugin.py b/nikola/plugins/command/plugin.py
index 71901b8..56eb1d7 100644
--- a/nikola/plugins/command/plugin.py
+++ b/nikola/plugins/command/plugin.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2014 Roberto Alsina and others.
+# Copyright © 2012-2015 Roberto Alsina and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
@@ -30,41 +30,18 @@ import os
import shutil
import subprocess
import sys
+import requests
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('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 CommandPlugin(Command):
"""Manage plugins."""
@@ -105,8 +82,8 @@ class CommandPlugin(Command):
'long': 'url',
'type': str,
'help': "URL for the plugin repository (default: "
- "http://plugins.getnikola.com/v7/plugins.json)",
- 'default': 'http://plugins.getnikola.com/v7/plugins.json'
+ "https://plugins.getnikola.com/v7/plugins.json)",
+ 'default': 'https://plugins.getnikola.com/v7/plugins.json'
},
{
'name': 'user',
@@ -258,7 +235,7 @@ class CommandPlugin(Command):
LOGGER.error('Could not install the dependencies.')
print('Contents of the requirements.txt file:\n')
with io.open(reqpath, 'r', encoding='utf-8') as fh:
- print(indent(fh.read(), 4 * ' '))
+ print(utils.indent(fh.read(), 4 * ' '))
print('You have to install those yourself or through a '
'package manager.')
else:
@@ -272,8 +249,8 @@ class CommandPlugin(Command):
with io.open(reqnpypath, 'r', encoding='utf-8') as fh:
for l in fh.readlines():
i, j = l.split('::')
- print(indent(i.strip(), 4 * ' '))
- print(indent(j.strip(), 8 * ' '))
+ print(utils.indent(i.strip(), 4 * ' '))
+ print(utils.indent(j.strip(), 8 * ' '))
print()
print('You have to install those yourself or through a package '
@@ -284,11 +261,11 @@ class CommandPlugin(Command):
print('Contents of the conf.py.sample file:\n')
with io.open(confpypath, 'r', encoding='utf-8') as fh:
if self.site.colorful:
- print(indent(pygments.highlight(
+ print(utils.indent(pygments.highlight(
fh.read(), PythonLexer(), TerminalFormatter()),
4 * ' '))
else:
- print(indent(fh.read(), 4 * ' '))
+ print(utils.indent(fh.read(), 4 * ' '))
return True
def do_uninstall(self, name):
@@ -311,8 +288,6 @@ class CommandPlugin(Command):
return False
def get_json(self, url):
- if requests is None:
- utils.req_missing(['requests'], 'install or list available plugins', python=True, optional=False)
if self.json is None:
self.json = requests.get(url).json()
return self.json
diff --git a/nikola/plugins/command/rst2html.plugin b/nikola/plugins/command/rst2html.plugin
new file mode 100644
index 0000000..0d0d3b0
--- /dev/null
+++ b/nikola/plugins/command/rst2html.plugin
@@ -0,0 +1,9 @@
+[Core]
+Name = rst2html
+Module = rst2html
+
+[Documentation]
+Author = Chris Warrick
+Version = 1.0
+Website = http://getnikola.com
+Description = Compile reStructuredText to HTML using the Nikola architecture
diff --git a/nikola/plugins/command/rst2html/__init__.py b/nikola/plugins/command/rst2html/__init__.py
new file mode 100644
index 0000000..342aaeb
--- /dev/null
+++ b/nikola/plugins/command/rst2html/__init__.py
@@ -0,0 +1,69 @@
+# -*- coding: utf-8 -*-
+
+# Copyright © 2015 Chris Warrick 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 io
+import lxml.html
+from pkg_resources import resource_filename
+from mako.template import Template
+from nikola.plugin_categories import Command
+
+
+class CommandRst2Html(Command):
+ """Compile reStructuredText to HTML, using Nikola architecture."""
+
+ name = "rst2html"
+ doc_usage = "infile"
+ doc_purpose = "compile reStructuredText to HTML files"
+ needs_config = False
+
+ def _execute(self, options, args):
+ """Compile reStructuredText to standalone HTML files."""
+ compiler = self.site.plugin_manager.getPluginByName('rest', 'PageCompiler').plugin_object
+ if len(args) != 1:
+ print("This command takes only one argument (input file name).")
+ return 2
+ source = args[0]
+ with io.open(source, "r", encoding="utf8") as in_file:
+ data = in_file.read()
+ output, error_level, deps = compiler.compile_html_string(data, source, True)
+
+ rstcss_path = resource_filename('nikola', 'data/themes/base/assets/css/rst.css')
+ with io.open(rstcss_path, "r", encoding="utf8") as fh:
+ rstcss = fh.read()
+
+ template_path = resource_filename('nikola', 'plugins/command/rst2html/rst2html.tmpl')
+ template = Template(filename=template_path)
+ template_output = template.render(rstcss=rstcss, output=output)
+ parser = lxml.html.HTMLParser(remove_blank_text=True)
+ doc = lxml.html.document_fromstring(template_output, parser)
+ html = b'<!DOCTYPE html>\n' + lxml.html.tostring(doc, encoding='utf8', method='html', pretty_print=True)
+ print(html)
+ if error_level < 3:
+ return 0
+ else:
+ return 1
diff --git a/nikola/plugins/command/rst2html/rst2html.tmpl b/nikola/plugins/command/rst2html/rst2html.tmpl
new file mode 100644
index 0000000..5a892ea
--- /dev/null
+++ b/nikola/plugins/command/rst2html/rst2html.tmpl
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<style class="text/css">
+${rstcss}
+</style>
+</head>
+
+<body>
+${output}
+</body>
+</html>
diff --git a/nikola/plugins/command/serve.plugin b/nikola/plugins/command/serve.plugin
index e663cc6..0c1176d 100644
--- a/nikola/plugins/command/serve.plugin
+++ b/nikola/plugins/command/serve.plugin
@@ -4,7 +4,7 @@ Module = serve
[Documentation]
Author = Roberto Alsina
-Version = 0.1
+Version = 1.0
Website = http://getnikola.com
Description = Start test server.
diff --git a/nikola/plugins/command/serve.py b/nikola/plugins/command/serve.py
index de4f6e2..0e4d01f 100644
--- a/nikola/plugins/command/serve.py
+++ b/nikola/plugins/command/serve.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2014 Roberto Alsina and others.
+# Copyright © 2012-2015 Roberto Alsina and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
@@ -26,6 +26,7 @@
from __future__ import print_function
import os
+import socket
import webbrowser
try:
from BaseHTTPServer import HTTPServer
@@ -38,6 +39,11 @@ from nikola.plugin_categories import Command
from nikola.utils import get_logger
+class IPv6Server(HTTPServer):
+ """An IPv6 HTTPServer."""
+ address_family = socket.AF_INET6
+
+
class CommandServe(Command):
"""Start test server."""
@@ -53,7 +59,7 @@ class CommandServe(Command):
'long': 'port',
'default': 8000,
'type': int,
- 'help': 'Port nummber (default: 8000)',
+ 'help': 'Port number (default: 8000)',
},
{
'name': 'address',
@@ -61,7 +67,7 @@ class CommandServe(Command):
'long': 'address',
'type': str,
'default': '',
- 'help': 'Address to bind (default: 0.0.0.0 – all local interfaces)',
+ 'help': 'Address to bind (default: 0.0.0.0 – all local IPv4 interfaces)',
},
{
'name': 'browser',
@@ -70,7 +76,15 @@ class CommandServe(Command):
'type': bool,
'default': False,
'help': 'Open the test server in a web browser',
- }
+ },
+ {
+ 'name': 'ipv6',
+ 'short': '6',
+ 'long': 'ipv6',
+ 'type': bool,
+ 'default': False,
+ 'help': 'Use IPv6',
+ },
)
def _execute(self, options, args):
@@ -81,19 +95,33 @@ class CommandServe(Command):
self.logger.error("Missing '{0}' folder?".format(out_dir))
else:
os.chdir(out_dir)
- httpd = HTTPServer((options['address'], options['port']),
- OurHTTPRequestHandler)
+ if '[' in options['address']:
+ options['address'] = options['address'].strip('[').strip(']')
+ ipv6 = True
+ OurHTTP = IPv6Server
+ elif options['ipv6']:
+ ipv6 = True
+ OurHTTP = IPv6Server
+ else:
+ ipv6 = False
+ OurHTTP = HTTPServer
+
+ httpd = OurHTTP((options['address'], options['port']),
+ OurHTTPRequestHandler)
sa = httpd.socket.getsockname()
self.logger.info("Serving HTTP on {0} port {1}...".format(*sa))
if options['browser']:
- server_url = "http://{0}:{1}/".format(*sa)
+ if ipv6:
+ server_url = "http://[{0}]:{1}/".format(*sa)
+ else:
+ server_url = "http://{0}:{1}/".format(*sa)
self.logger.info("Opening {0} in the default web browser...".format(server_url))
webbrowser.open(server_url)
try:
httpd.serve_forever()
except KeyboardInterrupt:
self.logger.info("Server is shutting down.")
- exit(130)
+ return 130
class OurHTTPRequestHandler(SimpleHTTPRequestHandler):
diff --git a/nikola/plugins/command/status.plugin b/nikola/plugins/command/status.plugin
new file mode 100644
index 0000000..e02da8b
--- /dev/null
+++ b/nikola/plugins/command/status.plugin
@@ -0,0 +1,9 @@
+[Core]
+Name = status
+Module = status
+
+[Documentation]
+Author = Daniel Aleksandersen
+Version = 1.0
+Website = https://getnikola.com
+Description = Site status
diff --git a/nikola/plugins/command/status.py b/nikola/plugins/command/status.py
new file mode 100644
index 0000000..b8a6a60
--- /dev/null
+++ b/nikola/plugins/command/status.py
@@ -0,0 +1,140 @@
+# -*- coding: utf-8 -*-
+
+# Copyright © 2012-2015 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 io
+import os
+from datetime import datetime
+from dateutil.tz import gettz, tzlocal
+
+from nikola.plugin_categories import Command
+
+
+class CommandDeploy(Command):
+ """ Site status. """
+ name = "status"
+
+ doc_purpose = "display site status"
+ doc_description = "Show information about the posts and site deployment."
+ doc_usage = '[-l|--list-drafts] [-m|--list-modified] [-s|--list-scheduled]'
+ logger = None
+ cmd_options = [
+ {
+ 'name': 'list_drafts',
+ 'short': 'd',
+ 'long': 'list-drafts',
+ 'type': bool,
+ 'default': False,
+ 'help': 'List all drafts',
+ },
+ {
+ 'name': 'list_modified',
+ 'short': 'm',
+ 'long': 'list-modified',
+ 'type': bool,
+ 'default': False,
+ 'help': 'List all modified files since last deployment',
+ },
+ {
+ 'name': 'list_scheduled',
+ 'short': 's',
+ 'long': 'list-scheduled',
+ 'type': bool,
+ 'default': False,
+ 'help': 'List all scheduled posts',
+ },
+ ]
+
+ def _execute(self, options, args):
+
+ self.site.scan_posts()
+
+ timestamp_path = os.path.join(self.site.config["CACHE_FOLDER"], "lastdeploy")
+
+ last_deploy = None
+
+ try:
+ with io.open(timestamp_path, "r", encoding="utf8") as inf:
+ last_deploy = datetime.strptime(inf.read().strip(), "%Y-%m-%dT%H:%M:%S.%f")
+ last_deploy_offset = datetime.utcnow() - last_deploy
+ except (IOError, Exception):
+ print("It does not seem like you’ve ever deployed the site (or cache missing).")
+
+ if last_deploy:
+
+ fmod_since_deployment = []
+ for root, dirs, files in os.walk(self.site.config["OUTPUT_FOLDER"], followlinks=True):
+ if not dirs and not files:
+ continue
+ for fname in files:
+ fpath = os.path.join(root, fname)
+ fmodtime = datetime.fromtimestamp(os.stat(fpath).st_mtime)
+ if fmodtime.replace(tzinfo=tzlocal()) > last_deploy.replace(tzinfo=gettz("UTC")).astimezone(tz=tzlocal()):
+ fmod_since_deployment.append(fpath)
+
+ if len(fmod_since_deployment) > 0:
+ print("{0} output files modified since last deployment {1} ago.".format(str(len(fmod_since_deployment)), self.human_time(last_deploy_offset)))
+ if options['list_modified']:
+ for fpath in fmod_since_deployment:
+ print("Modified: '{0}'".format(fpath))
+ else:
+ print("Last deployment {0} ago.".format(self.human_time(last_deploy_offset)))
+
+ now = datetime.utcnow().replace(tzinfo=gettz("UTC"))
+
+ posts_count = len(self.site.all_posts)
+
+ # find all drafts
+ posts_drafts = [post for post in self.site.all_posts if post.is_draft]
+ posts_drafts = sorted(posts_drafts, key=lambda post: post.source_path)
+
+ # find all scheduled posts with offset from now until publishing time
+ posts_scheduled = [(post.date - now, post) for post in self.site.all_posts if post.publish_later]
+ posts_scheduled = sorted(posts_scheduled, key=lambda offset_post: (offset_post[0], offset_post[1].source_path))
+
+ if len(posts_scheduled) > 0:
+ if options['list_scheduled']:
+ for offset, post in posts_scheduled:
+ print("Scheduled: '{1}' ({2}; source: {3}) in {0}".format(self.human_time(offset), post.meta('title'), post.permalink(), post.source_path))
+ else:
+ offset, post = posts_scheduled[0]
+ print("{0} to next scheduled post ('{1}'; {2}; source: {3}).".format(self.human_time(offset), post.meta('title'), post.permalink(), post.source_path))
+ if options['list_drafts']:
+ for post in posts_drafts:
+ print("Draft: '{0}' ({1}; source: {2})".format(post.meta('title'), post.permalink(), post.source_path))
+ print("{0} posts in total, {1} scheduled, and {2} drafts.".format(posts_count, len(posts_scheduled), len(posts_drafts)))
+
+ def human_time(self, dt):
+ days = dt.days
+ hours = dt.seconds / 60 // 60
+ minutes = dt.seconds / 60 - (hours * 60)
+ if days > 0:
+ return "{0:.0f} days and {1:.0f} hours".format(days, hours)
+ elif hours > 0:
+ return "{0:.0f} hours and {1:.0f} minutes".format(hours, minutes)
+ elif minutes:
+ return "{0:.0f} minutes".format(minutes)
+ return False
diff --git a/nikola/plugins/command/version.plugin b/nikola/plugins/command/version.plugin
index 3c1ae95..a3f58e8 100644
--- a/nikola/plugins/command/version.plugin
+++ b/nikola/plugins/command/version.plugin
@@ -4,6 +4,6 @@ Module = version
[Documentation]
Author = Roberto Alsina
-Version = 0.2
+Version = 1.0
Website = http://getnikola.com
Description = Show nikola version
diff --git a/nikola/plugins/command/version.py b/nikola/plugins/command/version.py
index 9b42423..b6520d7 100644
--- a/nikola/plugins/command/version.py
+++ b/nikola/plugins/command/version.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-# Copyright © 2012-2014 Roberto Alsina and others.
+# Copyright © 2012-2015 Roberto Alsina and others.
# Permission is hereby granted, free of charge, to any
# person obtaining a copy of this software and associated
@@ -26,19 +26,42 @@
from __future__ import print_function
+import lxml
+import requests
+
from nikola.plugin_categories import Command
from nikola import __version__
+URL = 'https://pypi.python.org/pypi?:action=doap&name=Nikola'
+
class CommandVersion(Command):
"""Print the version."""
name = "version"
- doc_usage = ""
+ doc_usage = "[--check]"
needs_config = False
doc_purpose = "print the Nikola version number"
+ cmd_options = [
+ {
+ 'name': 'check',
+ 'long': 'check',
+ 'short': '',
+ 'default': False,
+ 'type': bool,
+ 'help': "Check for new versions.",
+ }
+ ]
def _execute(self, options={}, args=None):
"""Print the version number."""
print("Nikola v" + __version__)
+ if options.get('check'):
+ data = requests.get(URL).text
+ doc = lxml.etree.fromstring(data.encode('utf8'))
+ revision = doc.findall('*//{http://usefulinc.com/ns/doap#}revision')[0].text
+ if revision == __version__:
+ print("Nikola is up-to-date")
+ else:
+ print("The latest version of Nikola is v{0} -- please upgrade using `pip install --upgrade Nikola=={0}` or your system package manager".format(revision))