diff options
Diffstat (limited to 'nikola/plugins/command')
25 files changed, 1099 insertions, 1164 deletions
diff --git a/nikola/plugins/command/auto.py b/nikola/plugins/command/auto.py index d707d53..c46e0a3 100644 --- a/nikola/plugins/command/auto.py +++ b/nikola/plugins/command/auto.py @@ -73,11 +73,11 @@ class CommandAuto(Command): server.watch('conf.py', 'nikola build') server.watch('themes/', 'nikola build') server.watch('templates/', 'nikola build') - server.watch(self.site.config['GALLERY_PATH']) + 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(os.path.dirname(item), 'nikola build') + server.watch(item, 'nikola build') out_folder = self.site.config['OUTPUT_FOLDER'] if options and options.get('browser'): diff --git a/nikola/plugins/command/bootswatch_theme.py b/nikola/plugins/command/bootswatch_theme.py index 82c47d2..871a5ce 100644 --- a/nikola/plugins/command/bootswatch_theme.py +++ b/nikola/plugins/command/bootswatch_theme.py @@ -92,7 +92,10 @@ class CommandBootswatchTheme(Command): LOGGER.info("Creating '{0}' theme from '{1}' and '{2}'".format(name, swatch, parent)) utils.makedirs(os.path.join('themes', name, 'assets', 'css')) for fname in ('bootstrap.min.css', 'bootstrap.css'): - url = '/'.join(('http://bootswatch.com', version, swatch, fname)) + url = 'http://bootswatch.com' + if version: + url += '/' + version + url = '/'.join((url, swatch, fname)) LOGGER.info("Downloading: " + url) data = requests.get(url).text with open(os.path.join('themes', name, 'assets', 'css', fname), diff --git a/nikola/plugins/command/check.py b/nikola/plugins/command/check.py index 26db321..76571a0 100644 --- a/nikola/plugins/command/check.py +++ b/nikola/plugins/command/check.py @@ -51,7 +51,7 @@ def real_scan_files(site): fname = task.split(':', 1)[-1] task_fnames.add(fname) # And now check that there are no non-target files - for root, dirs, files in os.walk(output_folder): + for root, dirs, files in os.walk(output_folder, followlinks=True): for src_name in files: fname = os.path.join(root, src_name) real_fnames.add(fname) @@ -139,6 +139,8 @@ class CommandCheck(Command): 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'] try: filename = task.split(":")[-1] @@ -166,11 +168,15 @@ class CommandCheck(Command): elif url_type in ('full_path', 'absolute'): target_filename = os.path.abspath( os.path.join(os.path.dirname(filename), parsed.path)) - if parsed.path.endswith('/'): # abspath removes trailing slashes + if parsed.path in ['', '/']: + target_filename = os.path.join(self.site.config['OUTPUT_FOLDER'], self.site.config['INDEX_FILE']) + elif parsed.path.endswith('/'): # abspath removes trailing slashes target_filename += '/{0}'.format(self.site.config['INDEX_FILE']) if target_filename.startswith(base_url.path): target_filename = target_filename[len(base_url.path):] target_filename = os.path.join(self.site.config['OUTPUT_FOLDER'], target_filename) + if parsed.path in ['', '/']: + target_filename = os.path.join(self.site.config['OUTPUT_FOLDER'], self.site.config['INDEX_FILE']) if any(re.match(x, target_filename) for x in self.whitelist): continue @@ -233,7 +239,7 @@ class CommandCheck(Command): return failure def clean_files(self): - only_on_output, _ = self.real_scan_files() + only_on_output, _ = real_scan_files(self.site) for f in only_on_output: os.unlink(f) return True diff --git a/nikola/plugins/command/console.py b/nikola/plugins/command/console.py index b0a8958..9dfc975 100644 --- a/nikola/plugins/command/console.py +++ b/nikola/plugins/command/console.py @@ -30,7 +30,7 @@ import os from nikola import __version__ from nikola.plugin_categories import Command -from nikola.utils import get_logger, STDERR_HANDLER +from nikola.utils import get_logger, STDERR_HANDLER, req_missing LOGGER = get_logger('console', STDERR_HANDLER) @@ -41,86 +41,102 @@ class CommandConsole(Command): shells = ['ipython', 'bpython', 'plain'] doc_purpose = "start an interactive Python console with access to your site" doc_description = """\ -Order of resolution: IPython → bpython [deprecated] → plain Python interpreter -The site engine is accessible as `SITE`, and the config as `conf`.""" - header = "Nikola v" + __version__ + " -- {0} Console (conf = configuration, SITE = site engine)" +The site engine is accessible as `site`, the config file as `conf`, and commands are available as `commands`. +If there is no console to use specified (as -b, -i, -p) it tries IPython, then falls back to bpython, and finally falls back to the plain Python console.""" + header = "Nikola v" + __version__ + " -- {0} Console (conf = configuration file, site = site engine, commands = nikola commands)" cmd_options = [ { + 'name': 'bpython', + 'short': 'b', + 'long': 'bpython', + 'type': bool, + 'default': False, + 'help': 'Use bpython', + }, + { + 'name': 'ipython', + 'short': 'i', + 'long': 'plain', + 'type': bool, + 'default': False, + 'help': 'Use IPython', + }, + { 'name': 'plain', 'short': 'p', 'long': 'plain', 'type': bool, 'default': False, - 'help': 'Force the plain Python console', - } + 'help': 'Use the plain Python interpreter', + }, ] - def ipython(self): + def ipython(self, willful=True): """IPython shell.""" - from nikola import Nikola try: - import conf - except ImportError: - LOGGER.error("No configuration found, cannot run the console.") - else: import IPython - SITE = Nikola(**conf.__dict__) - SITE.scan_posts() + except ImportError as e: + if willful: + req_missing(['IPython'], 'use the IPython console') + raise e # That’s how _execute knows whether to try something else. + else: + site = self.context['site'] # NOQA + conf = self.context['conf'] # NOQA + commands = self.context['commands'] # NOQA IPython.embed(header=self.header.format('IPython')) - def bpython(self): + def bpython(self, willful=True): """bpython shell.""" - from nikola import Nikola try: - import conf - except ImportError: - LOGGER.error("No configuration found, cannot run the console.") - else: import bpython - SITE = Nikola(**conf.__dict__) - SITE.scan_posts() - gl = {'conf': conf, 'SITE': SITE, 'Nikola': Nikola} - bpython.embed(banner=self.header.format( - 'bpython (Slightly Deprecated)'), locals_=gl) + except ImportError as e: + if willful: + req_missing(['bpython'], 'use the bpython console') + raise e # That’s how _execute knows whether to try something else. + else: + bpython.embed(banner=self.header.format('bpython'), locals_=self.context) - def plain(self): + def plain(self, willful=True): """Plain Python shell.""" - from nikola import Nikola + import code try: - import conf - SITE = Nikola(**conf.__dict__) - SITE.scan_posts() - gl = {'conf': conf, 'SITE': SITE, 'Nikola': Nikola} + import readline except ImportError: - LOGGER.error("No configuration found, cannot run the console.") + pass else: - import code + import rlcompleter + readline.set_completer(rlcompleter.Completer(self.context).complete) + readline.parse_and_bind("tab:complete") + + pythonrc = os.environ.get("PYTHONSTARTUP") + if pythonrc and os.path.isfile(pythonrc): try: - import readline - except ImportError: + execfile(pythonrc) # NOQA + except NameError: pass - else: - import rlcompleter - readline.set_completer(rlcompleter.Completer(gl).complete) - readline.parse_and_bind("tab:complete") - pythonrc = os.environ.get("PYTHONSTARTUP") - if pythonrc and os.path.isfile(pythonrc): - try: - execfile(pythonrc) # NOQA - except NameError: - pass - - code.interact(local=gl, banner=self.header.format('Python')) + code.interact(local=self.context, banner=self.header.format('Python')) def _execute(self, options, args): """Start the console.""" - if options['plain']: - self.plain() + self.site.scan_posts() + # Create nice object with all commands: + + self.context = { + 'conf': self.site.config, + 'site': self.site, + 'commands': self.site.commands, + } + if options['bpython']: + self.bpython(True) + elif options['ipython']: + self.ipython(True) + elif options['plain']: + self.plain(True) else: for shell in self.shells: try: - return getattr(self, shell)() + return getattr(self, shell)(False) except ImportError: pass raise ImportError diff --git a/nikola/plugins/command/deploy.py b/nikola/plugins/command/deploy.py index bd1c15f..1bec1d3 100644 --- a/nikola/plugins/command/deploy.py +++ b/nikola/plugins/command/deploy.py @@ -31,7 +31,6 @@ import os import sys import subprocess import time -import pytz from blinker import signal @@ -62,10 +61,10 @@ class CommandDeploy(Command): deploy_drafts = self.site.config.get('DEPLOY_DRAFTS', True) deploy_future = self.site.config.get('DEPLOY_FUTURE', False) + undeployed_posts = [] if not (deploy_drafts and deploy_future): # Remove drafts and future posts out_dir = self.site.config['OUTPUT_FOLDER'] - undeployed_posts = [] self.site.scan_posts() for post in self.site.timeline: if (not deploy_drafts and post.is_draft) or \ @@ -114,9 +113,6 @@ class CommandDeploy(Command): """ - if undeployed is None: - undeployed = [] - event = { 'last_deploy': last_deploy, 'new_deploy': new_deploy, @@ -124,11 +120,9 @@ class CommandDeploy(Command): 'undeployed': undeployed } - tzinfo = pytz.timezone(self.site.config['TIMEZONE']) - deployed = [ entry for entry in self.site.timeline - if entry.date > (last_deploy.replace(tzinfo=tzinfo) if tzinfo else last_deploy) and entry not in undeployed + if entry.date > last_deploy.replace(tzinfo=self.site.tzinfo) and entry not in undeployed ] event['deployed'] = deployed diff --git a/nikola/plugins/command/github_deploy.plugin b/nikola/plugins/command/github_deploy.plugin new file mode 100644 index 0000000..4cbc422 --- /dev/null +++ b/nikola/plugins/command/github_deploy.plugin @@ -0,0 +1,9 @@ +[Core] +Name = github_deploy +Module = github_deploy + +[Documentation] +Author = Puneeth Chaganti +Version = 0.1 +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 new file mode 100644 index 0000000..d4dd8c5 --- /dev/null +++ b/nikola/plugins/command/github_deploy.py @@ -0,0 +1,271 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2014 Puneeth Chaganti and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +from __future__ import print_function +import os +import shutil +import 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.__main__ import main +from nikola import __version__ + + +def uni_check_output(*args, **kwargs): + o = subprocess.check_output(*args, **kwargs) + return o.decode('utf-8') + + +class CommandGitHubDeploy(Command): + """ Deploy site to GitHub pages. """ + name = 'github_deploy' + + doc_usage = '' + 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: + + 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! + + 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 + + build = main(['build']) + if build != 0: + self.logger.error('Build failed, not deploying to GitHub') + sys.exit(build) + + only_on_output, _ = real_scan_files(self.site) + for f in only_on_output: + os.unlink(f) + + self._checkout_deploy_branch() + + self._copy_output() + + self._commit_and_push() + + return + + def _commit_and_push(self): + """ Commit all the files and push. """ + + deploy = self._deploy_branch + source = self._source_branch + remote = self._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', 'add', '-A'], + ['git', 'commit', '-m', commit_message], + ['git', 'push', '-f', 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 + + 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']) + 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.' + ) + sys.exit(1) + + def _prompt_continue(self): + """ Show uncommitted changes, and ask if user wants to continue. """ + + 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 + + return proceed diff --git a/nikola/plugins/command/import_blogger.plugin b/nikola/plugins/command/import_blogger.plugin deleted file mode 100644 index 91a7cb6..0000000 --- a/nikola/plugins/command/import_blogger.plugin +++ /dev/null @@ -1,10 +0,0 @@ -[Core] -Name = import_blogger -Module = import_blogger - -[Documentation] -Author = Roberto Alsina -Version = 0.2 -Website = http://getnikola.com -Description = Import a blogger site from a XML dump. - diff --git a/nikola/plugins/command/import_blogger.py b/nikola/plugins/command/import_blogger.py deleted file mode 100644 index dd629c4..0000000 --- a/nikola/plugins/command/import_blogger.py +++ /dev/null @@ -1,228 +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 unicode_literals, print_function -import datetime -import os -import time - -try: - from urlparse import urlparse -except ImportError: - from urllib.parse import urlparse # NOQA - -try: - import feedparser -except ImportError: - feedparser = None # NOQA - -from nikola.plugin_categories import Command -from nikola import utils -from nikola.utils import req_missing -from nikola.plugins.basic_import import ImportMixin -from nikola.plugins.command.init import SAMPLE_CONF, prepare_config - -LOGGER = utils.get_logger('import_blogger', utils.STDERR_HANDLER) - - -class CommandImportBlogger(Command, ImportMixin): - """Import a blogger dump.""" - - name = "import_blogger" - needs_config = False - doc_usage = "[options] blogger_export_file" - doc_purpose = "import a blogger dump" - cmd_options = ImportMixin.cmd_options + [ - { - 'name': 'exclude_drafts', - 'long': 'no-drafts', - 'short': 'd', - 'default': False, - 'type': bool, - 'help': "Don't import drafts", - }, - ] - - def _execute(self, options, args): - """Import a Blogger blog from an export file into a Nikola site.""" - # Parse the data - if feedparser is None: - req_missing(['feedparser'], 'import Blogger dumps') - return - - if not args: - print(self.help()) - return - - options['filename'] = args[0] - self.blogger_export_file = options['filename'] - self.output_folder = options['output_folder'] - self.import_into_existing_site = False - self.exclude_drafts = options['exclude_drafts'] - self.url_map = {} - channel = self.get_channel_from_file(self.blogger_export_file) - self.context = self.populate_context(channel) - conf_template = self.generate_base_site() - self.context['REDIRECTIONS'] = self.configure_redirections( - self.url_map) - - self.import_posts(channel) - self.write_urlmap_csv( - os.path.join(self.output_folder, 'url_map.csv'), self.url_map) - - conf_out_path = self.get_configuration_output_path() - # if it tracebacks here, look a comment in - # basic_import.Import_Mixin.generate_base_site - conf_template_render = conf_template.render(**prepare_config(self.context)) - self.write_configuration(conf_out_path, conf_template_render) - - @classmethod - def get_channel_from_file(cls, filename): - if not os.path.isfile(filename): - raise Exception("Missing file: %s" % filename) - return feedparser.parse(filename) - - @staticmethod - def populate_context(channel): - context = SAMPLE_CONF.copy() - context['DEFAULT_LANG'] = 'en' # blogger doesn't include the language - # in the dump - context['BLOG_TITLE'] = channel.feed.title - - context['BLOG_DESCRIPTION'] = '' # Missing in the dump - context['SITE_URL'] = channel.feed.link - context['BLOG_EMAIL'] = channel.feed.author_detail.email - context['BLOG_AUTHOR'] = channel.feed.author_detail.name - context['POSTS'] = '''( - ("posts/*.txt", "posts", "post.tmpl"), - ("posts/*.rst", "posts", "post.tmpl"), - ("posts/*.html", "posts", "post.tmpl"), - )''' - context['PAGES'] = '''( - ("articles/*.txt", "articles", "story.tmpl"), - ("articles/*.rst", "articles", "story.tmpl"), - )''' - context['COMPILERS'] = '''{ - "rest": ('.txt', '.rst'), - "markdown": ('.md', '.mdown', '.markdown', '.wp'), - "html": ('.html', '.htm') - } - ''' - - return context - - def import_item(self, item, out_folder=None): - """Takes an item from the feed and creates a post file.""" - if out_folder is None: - out_folder = 'posts' - - # link is something like http://foo.com/2012/09/01/hello-world/ - # So, take the path, utils.slugify it, and that's our slug - link = item.link - link_path = urlparse(link).path - - title = item.title - - # blogger supports empty titles, which Nikola doesn't - if not title: - LOGGER.warn("Empty title in post with URL {0}. Using NO_TITLE " - "as placeholder, please fix.".format(link)) - title = "NO_TITLE" - - if link_path.lower().endswith('.html'): - link_path = link_path[:-5] - - slug = utils.slugify(link_path) - - if not slug: # should never happen - LOGGER.error("Error converting post:", title) - return - - description = '' - post_date = datetime.datetime.fromtimestamp(time.mktime( - item.published_parsed)) - - for candidate in item.content: - if candidate.type == 'text/html': - content = candidate.value - break - # FIXME: handle attachments - - tags = [] - for tag in item.tags: - if tag.scheme == 'http://www.blogger.com/atom/ns#': - tags.append(tag.term) - - if item.get('app_draft'): - tags.append('draft') - is_draft = True - else: - is_draft = False - - self.url_map[link] = self.context['SITE_URL'] + '/' + \ - out_folder + '/' + slug + '.html' - - if is_draft and self.exclude_drafts: - LOGGER.notice('Draft "{0}" will not be imported.'.format(title)) - elif content.strip(): - # If no content is found, no files are written. - content = self.transform_content(content) - - self.write_metadata(os.path.join(self.output_folder, out_folder, - slug + '.meta'), - title, slug, post_date, description, tags) - self.write_content( - os.path.join(self.output_folder, out_folder, slug + '.html'), - content) - else: - LOGGER.warn('Not going to import "{0}" because it seems to contain' - ' no content.'.format(title)) - - def process_item(self, item): - post_type = item.tags[0].term - - if post_type == 'http://schemas.google.com/blogger/2008/kind#post': - self.import_item(item, 'posts') - elif post_type == 'http://schemas.google.com/blogger/2008/kind#page': - self.import_item(item, 'stories') - elif post_type == ('http://schemas.google.com/blogger/2008/kind' - '#settings'): - # Ignore settings - pass - elif post_type == ('http://schemas.google.com/blogger/2008/kind' - '#template'): - # Ignore template - pass - elif post_type == ('http://schemas.google.com/blogger/2008/kind' - '#comment'): - # FIXME: not importing comments. Does blogger support "pages"? - pass - else: - LOGGER.warn("Unknown post_type:", post_type) - - def import_posts(self, channel): - for item in channel.entries: - self.process_item(item) diff --git a/nikola/plugins/command/import_feed.plugin b/nikola/plugins/command/import_feed.plugin deleted file mode 100644 index 26e570a..0000000 --- a/nikola/plugins/command/import_feed.plugin +++ /dev/null @@ -1,10 +0,0 @@ -[Core] -Name = import_feed -Module = import_feed - -[Documentation] -Author = Grzegorz Śliwiński -Version = 0.1 -Website = http://www.fizyk.net.pl/ -Description = Import a blog posts from a RSS/Atom dump - diff --git a/nikola/plugins/command/import_feed.py b/nikola/plugins/command/import_feed.py deleted file mode 100644 index ee59277..0000000 --- a/nikola/plugins/command/import_feed.py +++ /dev/null @@ -1,200 +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 unicode_literals, print_function -import datetime -import os -import time - -try: - from urlparse import urlparse -except ImportError: - from urllib.parse import urlparse # NOQA - -try: - import feedparser -except ImportError: - feedparser = None # NOQA - -from nikola.plugin_categories import Command -from nikola import utils -from nikola.utils import req_missing -from nikola.plugins.basic_import import ImportMixin -from nikola.plugins.command.init import SAMPLE_CONF, prepare_config - -LOGGER = utils.get_logger('import_feed', utils.STDERR_HANDLER) - - -class CommandImportFeed(Command, ImportMixin): - """Import a feed dump.""" - - name = "import_feed" - needs_config = False - doc_usage = "[options] feed_file" - doc_purpose = "import a RSS/Atom dump" - cmd_options = ImportMixin.cmd_options - - def _execute(self, options, args): - ''' - Import Atom/RSS feed - ''' - if feedparser is None: - req_missing(['feedparser'], 'import feeds') - return - - if not args: - print(self.help()) - return - - options['filename'] = args[0] - self.feed_export_file = options['filename'] - self.output_folder = options['output_folder'] - self.import_into_existing_site = False - self.url_map = {} - channel = self.get_channel_from_file(self.feed_export_file) - self.context = self.populate_context(channel) - conf_template = self.generate_base_site() - self.context['REDIRECTIONS'] = self.configure_redirections( - self.url_map) - - self.import_posts(channel) - - self.write_configuration(self.get_configuration_output_path( - ), conf_template.render(**prepare_config(self.context))) - - @classmethod - def get_channel_from_file(cls, filename): - return feedparser.parse(filename) - - @staticmethod - def populate_context(channel): - context = SAMPLE_CONF.copy() - context['DEFAULT_LANG'] = channel.feed.title_detail.language \ - if channel.feed.title_detail.language else 'en' - context['BLOG_TITLE'] = channel.feed.title - - context['BLOG_DESCRIPTION'] = channel.feed.get('subtitle', '') - context['SITE_URL'] = channel.feed.get('link', '').rstrip('/') - context['BLOG_EMAIL'] = channel.feed.author_detail.get('email', '') if 'author_detail' in channel.feed else '' - context['BLOG_AUTHOR'] = channel.feed.author_detail.get('name', '') if 'author_detail' in channel.feed else '' - - context['POSTS'] = '''( - ("posts/*.html", "posts", "post.tmpl"), - )''' - context['PAGES'] = '''( - ("stories/*.html", "stories", "story.tmpl"), - )''' - context['COMPILERS'] = '''{ - "rest": ('.txt', '.rst'), - "markdown": ('.md', '.mdown', '.markdown', '.wp'), - "html": ('.html', '.htm') - } - ''' - - return context - - def import_posts(self, channel): - for item in channel.entries: - self.process_item(item) - - def process_item(self, item): - self.import_item(item, 'posts') - - def import_item(self, item, out_folder=None): - """Takes an item from the feed and creates a post file.""" - if out_folder is None: - out_folder = 'posts' - - # link is something like http://foo.com/2012/09/01/hello-world/ - # So, take the path, utils.slugify it, and that's our slug - link = item.link - link_path = urlparse(link).path - - title = item.title - - # blogger supports empty titles, which Nikola doesn't - if not title: - LOGGER.warn("Empty title in post with URL {0}. Using NO_TITLE " - "as placeholder, please fix.".format(link)) - title = "NO_TITLE" - - if link_path.lower().endswith('.html'): - link_path = link_path[:-5] - - slug = utils.slugify(link_path) - - if not slug: # should never happen - LOGGER.error("Error converting post:", title) - return - - description = '' - post_date = datetime.datetime.fromtimestamp(time.mktime( - item.published_parsed)) - if item.get('content'): - for candidate in item.get('content', []): - content = candidate.value - break - # FIXME: handle attachments - elif item.get('summary'): - content = item.get('summary') - - tags = [] - for tag in item.get('tags', []): - tags.append(tag.term) - - if item.get('app_draft'): - tags.append('draft') - is_draft = True - else: - is_draft = False - - self.url_map[link] = self.context['SITE_URL'] + '/' + \ - out_folder + '/' + slug + '.html' - - if is_draft and self.exclude_drafts: - LOGGER.notice('Draft "{0}" will not be imported.'.format(title)) - elif content.strip(): - # If no content is found, no files are written. - content = self.transform_content(content) - - self.write_metadata(os.path.join(self.output_folder, out_folder, - slug + '.meta'), - title, slug, post_date, description, tags) - self.write_content( - os.path.join(self.output_folder, out_folder, slug + '.html'), - content) - else: - LOGGER.warn('Not going to import "{0}" because it seems to contain' - ' no content.'.format(title)) - - @staticmethod - def write_metadata(filename, title, slug, post_date, description, tags): - ImportMixin.write_metadata(filename, - title, - slug, - post_date.strftime(r'%Y/%m/%d %H:%m:%S'), - description, - tags) diff --git a/nikola/plugins/command/import_wordpress.py b/nikola/plugins/command/import_wordpress.py index b567c77..8ddc8c7 100644 --- a/nikola/plugins/command/import_wordpress.py +++ b/nikola/plugins/command/import_wordpress.py @@ -51,7 +51,7 @@ from nikola import utils from nikola.utils import req_missing from nikola.plugins.basic_import import ImportMixin, links from nikola.nikola import DEFAULT_TRANSLATIONS_PATTERN -from nikola.plugins.command.init import SAMPLE_CONF, prepare_config +from nikola.plugins.command.init import SAMPLE_CONF, prepare_config, format_default_translations_config LOGGER = utils.get_logger('import_wordpress', utils.STDERR_HANDLER) @@ -136,6 +136,9 @@ class CommandImportWordpress(Command, ImportMixin): self.separate_qtranslate_content = options.get('separate_qtranslate_content') self.translations_pattern = options.get('translations_pattern') + # A place holder where extra language (if detected) will be stored + self.extra_languages = set() + if not self.no_downloads: def show_info_about_mising_module(modulename): LOGGER.error( @@ -164,6 +167,8 @@ class CommandImportWordpress(Command, ImportMixin): self.import_posts(channel) + self.context['TRANSLATIONS'] = format_default_translations_config( + self.extra_languages) self.context['REDIRECTIONS'] = self.configure_redirections( self.url_map) self.write_urlmap_csv( @@ -326,7 +331,7 @@ class CommandImportWordpress(Command, ImportMixin): size_key = b'sizes' file_key = b'file' - if not size_key in metadata: + if size_key not in metadata: continue for filename in [metadata[size_key][size][file_key] for size in metadata[size_key]]: @@ -452,6 +457,7 @@ class CommandImportWordpress(Command, ImportMixin): out_content_filename \ = utils.get_translation_candidate(self.context, slug + ".wp", lang) + self.extra_languages.add(lang) meta_slug = slug else: out_meta_filename = slug + '.meta' diff --git a/nikola/plugins/command/init.py b/nikola/plugins/command/init.py index d7eeed7..8fb15e0 100644 --- a/nikola/plugins/command/init.py +++ b/nikola/plugins/command/init.py @@ -24,19 +24,24 @@ # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -from __future__ import print_function +from __future__ import print_function, unicode_literals import os import shutil import codecs import json - +import textwrap +import datetime +import unidecode +import dateutil.tz from mako.template import Template +from pkg_resources import resource_filename import nikola -from nikola.nikola import DEFAULT_TRANSLATIONS_PATTERN +from nikola.nikola import DEFAULT_TRANSLATIONS_PATTERN, DEFAULT_INDEX_READ_MORE_LINK, DEFAULT_RSS_READ_MORE_LINK, LEGAL_VALUES from nikola.plugin_categories import Command -from nikola.utils import get_logger, makedirs, STDERR_HANDLER -from nikola.winutils import fix_git_symlinked +from nikola.utils import ask, ask_yesno, get_logger, makedirs, STDERR_HANDLER, load_messages +from nikola.packages.tzlocal import get_localzone + LOGGER = get_logger('init', STDERR_HANDLER) @@ -47,39 +52,144 @@ SAMPLE_CONF = { 'BLOG_EMAIL': "joe@demo.site", 'BLOG_DESCRIPTION': "This is a demo site for Nikola.", 'DEFAULT_LANG': "en", + 'TRANSLATIONS': """{ + DEFAULT_LANG: "", + # Example for another language: + # "es": "./es", +}""", 'THEME': 'bootstrap3', + 'TIMEZONE': 'UTC', 'COMMENT_SYSTEM': 'disqus', 'COMMENT_SYSTEM_ID': 'nikolademo', 'TRANSLATIONS_PATTERN': DEFAULT_TRANSLATIONS_PATTERN, + 'INDEX_READ_MORE_LINK': DEFAULT_INDEX_READ_MORE_LINK, + 'RSS_READ_MORE_LINK': DEFAULT_RSS_READ_MORE_LINK, 'POSTS': """( -("posts/*.rst", "posts", "post.tmpl"), -("posts/*.txt", "posts", "post.tmpl"), + ("posts/*.rst", "posts", "post.tmpl"), + ("posts/*.txt", "posts", "post.tmpl"), )""", 'PAGES': """( -("stories/*.rst", "stories", "story.tmpl"), -("stories/*.txt", "stories", "story.tmpl"), + ("stories/*.rst", "stories", "story.tmpl"), + ("stories/*.txt", "stories", "story.tmpl"), )""", 'COMPILERS': """{ -"rest": ('.rst', '.txt'), -"markdown": ('.md', '.mdown', '.markdown'), -"textile": ('.textile',), -"txt2tags": ('.t2t',), -"bbcode": ('.bb',), -"wiki": ('.wiki',), -"ipynb": ('.ipynb',), -"html": ('.html', '.htm'), -# PHP files are rendered the usual way (i.e. with the full templates). -# The resulting files have .php extensions, making it possible to run -# them without reconfiguring your server to recognize them. -"php": ('.php',), -# Pandoc detects the input from the source filename -# but is disabled by default as it would conflict -# with many of the others. -# "pandoc": ('.rst', '.md', '.txt'), + "rest": ('.rst', '.txt'), + "markdown": ('.md', '.mdown', '.markdown'), + "textile": ('.textile',), + "txt2tags": ('.t2t',), + "bbcode": ('.bb',), + "wiki": ('.wiki',), + "ipynb": ('.ipynb',), + "html": ('.html', '.htm'), + # PHP files are rendered the usual way (i.e. with the full templates). + # The resulting files have .php extensions, making it possible to run + # them without reconfiguring your server to recognize them. + "php": ('.php',), + # Pandoc detects the input from the source filename + # but is disabled by default as it would conflict + # with many of the others. + # "pandoc": ('.rst', '.md', '.txt'), +}""", + 'NAVIGATION_LINKS': """{ + DEFAULT_LANG: ( + ("/archive.html", "Archives"), + ("/categories/index.html", "Tags"), + ("/rss.xml", "RSS feed"), + ), }""", 'REDIRECTIONS': [], } +# Generate a list of supported languages here. +# Ugly code follows. +_suplang = {} +_sllength = 0 + +for k, v in LEGAL_VALUES['TRANSLATIONS'].items(): + if not isinstance(k, tuple): + main = k + _suplang[main] = v + else: + main = k[0] + k = k[1:] + bad = [] + good = [] + for i in k: + if i.startswith('!'): + bad.append(i[1:]) + else: + good.append(i) + different = '' + if good or bad: + different += ' [' + if good: + different += 'ALTERNATIVELY ' + ', '.join(good) + if bad: + if good: + different += '; ' + different += 'NOT ' + ', '.join(bad) + if good or bad: + different += ']' + _suplang[main] = v + different + + if len(main) > _sllength: + _sllength = len(main) + +_sllength = str(_sllength) +suplang = (u'# {0:<' + _sllength + u'} {1}\n').format('en', 'English') +del _suplang['en'] +for k, v in sorted(_suplang.items()): + suplang += (u'# {0:<' + _sllength + u'} {1}\n').format(k, v) + +SAMPLE_CONF['_SUPPORTED_LANGUAGES'] = suplang.strip() + +# Generate a list of supported comment systems here. + +SAMPLE_CONF['_SUPPORTED_COMMENT_SYSTEMS'] = '\n'.join(textwrap.wrap( + u', '.join(LEGAL_VALUES['COMMENT_SYSTEM']), + initial_indent=u'# ', subsequent_indent=u'# ', width=79)) + + +def format_default_translations_config(additional_languages): + """Return the string to configure the TRANSLATIONS config variable to + make each additional language visible on the generated site.""" + if not additional_languages: + return SAMPLE_CONF["TRANSLATIONS"] + lang_paths = [' DEFAULT_LANG: "",'] + for lang in sorted(additional_languages): + lang_paths.append(' "{0}": "./{0}",'.format(lang)) + return "{{\n{0}\n}}".format("\n".join(lang_paths)) + + +def format_navigation_links(additional_languages, default_lang, messages): + """Return the string to configure NAVIGATION_LINKS.""" + f = u"""\ + {0}: ( + ("{1}/archive.html", "{2[Archive]}"), + ("{1}/categories/index.html", "{2[Tags]}"), + ("{1}/rss.xml", "{2[RSS feed]}"), + ),""" + + pairs = [] + + def get_msg(lang): + """Generate a smaller messages dict with fallback.""" + fmsg = {} + for i in (u'Archive', u'Tags', u'RSS feed'): + if messages[lang][i]: + fmsg[i] = messages[lang][i] + else: + fmsg[i] = i + return fmsg + + # handle the default language + 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))) + + return u'{{\n{0}\n}}'.format('\n\n'.join(pairs)) + # In order to ensure proper escaping, all variables but the three # pre-formatted ones are handled by json.dumps(). @@ -87,7 +197,10 @@ 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'))) + if k not in ('POSTS', 'PAGES', 'COMPILERS', 'TRANSLATIONS', 'NAVIGATION_LINKS', '_SUPPORTED_LANGUAGES', '_SUPPORTED_COMMENT_SYSTEMS', 'INDEX_READ_MORE_LINK', 'RSS_READ_MORE_LINK'))) + # 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("'", "\\'") + "'" return p @@ -97,13 +210,22 @@ class CommandInit(Command): name = "init" - doc_usage = "[--demo] folder" + doc_usage = "[--demo] [--quiet] folder" needs_config = False doc_purpose = "create a Nikola site in the specified folder" cmd_options = [ { + 'name': 'quiet', + 'long': 'quiet', + 'short': 'q', + 'default': False, + 'type': bool, + 'help': "Do not ask questions about config.", + }, + { 'name': 'demo', 'long': 'demo', + 'short': 'd', 'default': False, 'type': bool, 'help': "Create a site filled with example data.", @@ -112,15 +234,12 @@ class CommandInit(Command): @classmethod def copy_sample_site(cls, target): - lib_path = cls.get_path_to_nikola_modules() - src = os.path.join(lib_path, 'data', 'samplesite') + src = resource_filename('nikola', os.path.join('data', 'samplesite')) shutil.copytree(src, target) - fix_git_symlinked(src, target) @classmethod def create_configuration(cls, target): - lib_path = cls.get_path_to_nikola_modules() - template_path = os.path.join(lib_path, 'conf.py.in') + template_path = resource_filename('nikola', 'conf.py.in') conf_template = Template(filename=template_path) conf_path = os.path.join(target, 'conf.py') with codecs.open(conf_path, 'w+', 'utf8') as fd: @@ -132,16 +251,167 @@ class CommandInit(Command): makedirs(os.path.join(target, folder)) @staticmethod - def get_path_to_nikola_modules(): - return os.path.dirname(nikola.__file__) + def ask_questions(target): + """Ask some questions about Nikola.""" + 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.") + print("Please list all the desired languages, comma-separated, using ISO 639-1 codes. The first language will be used as the default.") + print("Type '?' (a question mark, sans quotes) to list available languages.") + answer = ask('Language(s) to use', 'en') + while answer.strip() == '?': + print('\n# Available languages:') + try: + print(SAMPLE_CONF['_SUPPORTED_LANGUAGES'] + '\n') + except UnicodeEncodeError: + # avoid Unicode characters in supported language names + print(unidecode.unidecode(SAMPLE_CONF['_SUPPORTED_LANGUAGES']) + '\n') + answer = ask('Language(s) to use', 'en') + + langs = [i.strip().lower().replace('-', '_') for i in answer.split(',')] + for partial, full in LEGAL_VALUES['_TRANSLATIONS_WITH_COUNTRY_SPECIFIERS'].items(): + if partial in langs: + langs[langs.index(partial)] = full + print("NOTICE: Assuming '{0}' instead of '{1}'.".format(full, partial)) + + default = langs.pop(0) + SAMPLE_CONF['DEFAULT_LANG'] = default + # format_default_translations_config() is intelligent enough to + # return the current value if there are no additional languages. + SAMPLE_CONF['TRANSLATIONS'] = format_default_translations_config(langs) + + # Get messages for navigation_links. In order to do this, we need + # to generate a throwaway TRANSLATIONS dict. + tr = {default: ''} + for l in langs: + tr[l] = './' + l + # Assuming that base contains all the locales, and that base does + # not inherit from anywhere. + try: + messages = load_messages(['base'], tr, default) + SAMPLE_CONF['NAVIGATION_LINKS'] = format_navigation_links(langs, default, messages) + except nikola.utils.LanguageNotFoundError as e: + print(" ERROR: the language '{0}' is not supported.".format(e.lang)) + print(" Are you sure you spelled the name correctly? Names are case-sensitive and need to be reproduced as-is (complete with the country specifier, if any).") + print("\nType '?' (a question mark, sans quotes) to list available languages.") + 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("You can find your time zone here:") + print("http://en.wikipedia.org/wiki/List_of_tz_database_time_zones") + print("") + answered = False + while not answered: + try: + lz = get_localzone() + except: + lz = None + answer = ask('Time zone', lz if lz else "UTC") + tz = dateutil.tz.gettz(answer) + 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.") + + SAMPLE_CONF['TIMEZONE'] = answer + + def chandler(default, toconf): + print("You can configure comments now. Type '?' (a question mark, sans quotes) to list available comment systems. If you do not want any comments, just leave the field blank.") + answer = ask('Comment system', '') + while answer.strip() == '?': + print('\n# Available comment systems:') + print(SAMPLE_CONF['_SUPPORTED_COMMENT_SYSTEMS']) + print('') + answer = ask('Comment system', '') + + while answer and answer not in LEGAL_VALUES['COMMENT_SYSTEM']: + if answer != '?': + print(' ERROR: Nikola does not know this comment system.') + print('\n# Available comment systems:') + print(SAMPLE_CONF['_SUPPORTED_COMMENT_SYSTEMS']) + print('') + answer = ask('Comment system', '') + + SAMPLE_CONF['COMMENT_SYSTEM'] = answer + SAMPLE_CONF['COMMENT_SYSTEM_ID'] = '' + + if answer: + print("You need to provide the site identifier for your comment system. Consult the Nikola manual for details on what the value should be. (you can leave it empty and come back later)") + answer = ask('Comment system site identifier', '') + SAMPLE_CONF['COMMENT_SYSTEM_ID'] = answer + + STORAGE = {'target': target} + + questions = [ + ('Questions about the site', None, None, None), + # query, default, toconf, destination + ('Destination', None, False, '!target'), + ('Site title', 'My Nikola Site', True, 'BLOG_TITLE'), + ('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'), + ('Questions about languages and locales', None, None, None), + (lhandler, None, True, True), + (tzhandler, None, True, True), + ('Questions about comments', None, None, None), + (chandler, None, True, True), + ] + + print("Creating Nikola Site") + print("====================\n") + print("This is Nikola v{0}. We will now ask you a few easy questions about your new site.".format(nikola.__version__)) + print("If you do not want to answer and want to go with the defaults instead, simply restart with the `-q` parameter.") + + for query, default, toconf, destination in questions: + if target and destination == '!target': + # Skip the destination question if we know it already + pass + else: + if default is toconf is destination is None: + print('--- {0} ---'.format(query)) + elif destination is True: + query(default, toconf) + else: + answer = ask(query, default) + if toconf: + SAMPLE_CONF[destination] = answer + if destination == '!target': + while not answer: + print(' ERROR: you need to specify a target directory.\n') + answer = ask(query, default) + 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("Have fun!") + return STORAGE def _execute(self, options={}, args=None): """Create a new site.""" - if not args: - print("Usage: nikola init folder [options]") + try: + target = args[0] + except IndexError: + target = None + if not options.get('quiet'): + st = self.ask_questions(target=target) + try: + if not target: + target = st['target'] + except KeyError: + pass + + if not target: + print("Usage: nikola init [--demo] [--quiet] folder") + print(""" +Options: + -q, --quiet Do not ask questions about config. + -d, --demo Create a site filled with example data.""") return False - target = args[0] - if not options or not options.get('demo'): + if not options.get('demo'): self.create_empty_site(target) LOGGER.info('Created empty site at {0}.'.format(target)) else: diff --git a/nikola/plugins/command/install_plugin.plugin b/nikola/plugins/command/install_plugin.plugin deleted file mode 100644 index 3dbabd8..0000000 --- a/nikola/plugins/command/install_plugin.plugin +++ /dev/null @@ -1,10 +0,0 @@ -[Core] -Name = install_plugin -Module = install_plugin - -[Documentation] -Author = Roberto Alsina and Chris Warrick -Version = 0.1 -Website = http://getnikola.com -Description = Install a plugin into the current site. - diff --git a/nikola/plugins/command/install_plugin.py b/nikola/plugins/command/install_plugin.py deleted file mode 100644 index 34223c0..0000000 --- a/nikola/plugins/command/install_plugin.py +++ /dev/null @@ -1,188 +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 -import codecs -import os -import json -import shutil -import subprocess -from io import BytesIO - -import pygments -from pygments.lexers import PythonLexer -from pygments.formatters import TerminalFormatter - -try: - import requests -except ImportError: - requests = None # NOQA - -from nikola.plugin_categories import Command -from nikola import utils - -LOGGER = utils.get_logger('install_plugin', utils.STDERR_HANDLER) - - -# Stolen from textwrap in Python 3.3.2. -def indent(text, prefix, predicate=None): # NOQA - """Adds 'prefix' to the beginning of selected lines in 'text'. - - If 'predicate' is provided, 'prefix' will only be added to the lines - where 'predicate(line)' is True. If 'predicate' is not provided, - it will default to adding 'prefix' to all non-empty lines that do not - consist solely of whitespace characters. - """ - if predicate is None: - def predicate(line): - return line.strip() - - def prefixed_lines(): - for line in text.splitlines(True): - yield (prefix + line if predicate(line) else line) - return ''.join(prefixed_lines()) - - -class CommandInstallPlugin(Command): - """Install a plugin.""" - - name = "install_plugin" - doc_usage = "[[-u] plugin_name] | [[-u] -l]" - doc_purpose = "install plugin into current site" - output_dir = 'plugins' - cmd_options = [ - { - 'name': 'list', - 'short': 'l', - 'long': 'list', - 'type': bool, - 'default': False, - 'help': 'Show list of available plugins.' - }, - { - 'name': 'url', - 'short': 'u', - 'long': 'url', - 'type': str, - 'help': "URL for the plugin repository (default: " - "http://plugins.getnikola.com/v6/plugins.json)", - 'default': 'http://plugins.getnikola.com/v6/plugins.json' - }, - ] - - def _execute(self, options, args): - """Install plugin into current site.""" - if requests is None: - utils.req_missing(['requests'], 'install plugins') - - listing = options['list'] - url = options['url'] - if args: - name = args[0] - else: - name = None - - if name is None and not listing: - LOGGER.error("This command needs either a plugin name or the -l option.") - return False - data = requests.get(url).text - data = json.loads(data) - if listing: - print("Plugins:") - print("--------") - for plugin in sorted(data.keys()): - print(plugin) - return True - else: - self.do_install(name, data) - - def do_install(self, name, data): - if name in data: - utils.makedirs(self.output_dir) - LOGGER.info('Downloading: ' + data[name]) - zip_file = BytesIO() - zip_file.write(requests.get(data[name]).content) - LOGGER.info('Extracting: {0} into plugins'.format(name)) - utils.extract_all(zip_file, 'plugins') - dest_path = os.path.join('plugins', name) - else: - try: - plugin_path = utils.get_plugin_path(name) - except: - LOGGER.error("Can't find plugin " + name) - return False - - utils.makedirs(self.output_dir) - dest_path = os.path.join(self.output_dir, name) - if os.path.exists(dest_path): - LOGGER.error("{0} is already installed".format(name)) - return False - - LOGGER.info('Copying {0} into plugins'.format(plugin_path)) - shutil.copytree(plugin_path, dest_path) - - reqpath = os.path.join(dest_path, 'requirements.txt') - if os.path.exists(reqpath): - LOGGER.notice('This plugin has Python dependencies.') - LOGGER.info('Installing dependencies with pip...') - try: - subprocess.check_call(('pip', 'install', '-r', reqpath)) - except subprocess.CalledProcessError: - LOGGER.error('Could not install the dependencies.') - print('Contents of the requirements.txt file:\n') - with codecs.open(reqpath, 'rb', 'utf-8') as fh: - print(indent(fh.read(), 4 * ' ')) - print('You have to install those yourself or through a ' - 'package manager.') - else: - LOGGER.info('Dependency installation succeeded.') - reqnpypath = os.path.join(dest_path, 'requirements-nonpy.txt') - if os.path.exists(reqnpypath): - LOGGER.notice('This plugin has third-party ' - 'dependencies you need to install ' - 'manually.') - print('Contents of the requirements-nonpy.txt file:\n') - with codecs.open(reqnpypath, 'rb', 'utf-8') as fh: - for l in fh.readlines(): - i, j = l.split('::') - print(indent(i.strip(), 4 * ' ')) - print(indent(j.strip(), 8 * ' ')) - print() - - print('You have to install those yourself or through a package ' - 'manager.') - confpypath = os.path.join(dest_path, 'conf.py.sample') - if os.path.exists(confpypath): - LOGGER.notice('This plugin has a sample config file. Integrate it with yours in order to make this plugin work!') - print('Contents of the conf.py.sample file:\n') - with codecs.open(confpypath, 'rb', 'utf-8') as fh: - if self.site.colorful: - print(indent(pygments.highlight( - fh.read(), PythonLexer(), TerminalFormatter()), - 4 * ' ')) - else: - print(indent(fh.read(), 4 * ' ')) - return True diff --git a/nikola/plugins/command/install_theme.py b/nikola/plugins/command/install_theme.py index 47c73b4..859bd56 100644 --- a/nikola/plugins/command/install_theme.py +++ b/nikola/plugins/command/install_theme.py @@ -87,8 +87,8 @@ class CommandInstallTheme(Command): 'long': 'url', 'type': str, 'help': "URL for the theme repository (default: " - "http://themes.getnikola.com/v6/themes.json)", - 'default': 'http://themes.getnikola.com/v6/themes.json' + "http://themes.getnikola.com/v7/themes.json)", + 'default': 'http://themes.getnikola.com/v7/themes.json' }, ] diff --git a/nikola/plugins/command/mincss.plugin b/nikola/plugins/command/mincss.plugin deleted file mode 100644 index d394d06..0000000 --- a/nikola/plugins/command/mincss.plugin +++ /dev/null @@ -1,10 +0,0 @@ -[Core] -Name = mincss -Module = mincss - -[Documentation] -Author = Roberto Alsina -Version = 0.1 -Website = http://getnikola.com -Description = Apply mincss to the generated site - diff --git a/nikola/plugins/command/mincss.py b/nikola/plugins/command/mincss.py deleted file mode 100644 index 0193458..0000000 --- a/nikola/plugins/command/mincss.py +++ /dev/null @@ -1,75 +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 sys - -try: - from mincss.processor import Processor -except ImportError: - Processor = None - -from nikola.plugin_categories import Command -from nikola.utils import req_missing, get_logger, STDERR_HANDLER - - -class CommandMincss(Command): - """Check the generated site.""" - name = "mincss" - - doc_usage = "" - doc_purpose = "apply mincss to the generated site" - - logger = get_logger('mincss', STDERR_HANDLER) - - def _execute(self, options, args): - """Apply mincss the generated site.""" - output_folder = self.site.config['OUTPUT_FOLDER'] - if Processor is None: - req_missing(['mincss'], 'use the "mincss" command') - return - - p = Processor(preserve_remote_urls=False) - urls = [] - css_files = {} - for root, dirs, files in os.walk(output_folder): - for f in files: - url = os.path.join(root, f) - if url.endswith('.css'): - fname = os.path.basename(url) - if fname in css_files: - self.logger.error("You have two CSS files with the same name and that confuses me.") - sys.exit(1) - css_files[fname] = url - if not f.endswith('.html'): - continue - urls.append(url) - p.process(*urls) - for inline in p.links: - fname = os.path.basename(inline.href) - with open(css_files[fname], 'wb+') as outf: - outf.write(inline.after) diff --git a/nikola/plugins/command/new_page.py b/nikola/plugins/command/new_page.py index 39c0c1d..f07ba39 100644 --- a/nikola/plugins/command/new_page.py +++ b/nikola/plugins/command/new_page.py @@ -59,6 +59,13 @@ class CommandNewPage(Command): 'help': 'Create the page with separate metadata (two file format)' }, { + 'name': 'edit', + 'short': 'e', + 'type': bool, + 'default': False, + 'help': 'Open the page (and meta file, if any) in $EDITOR after creation.' + }, + { 'name': 'content_format', 'short': 'f', 'long': 'format', diff --git a/nikola/plugins/command/new_post.py b/nikola/plugins/command/new_post.py index cd37a75..42f77cc 100644 --- a/nikola/plugins/command/new_post.py +++ b/nikola/plugins/command/new_post.py @@ -29,8 +29,10 @@ import codecs import datetime import os import sys +import subprocess from blinker import signal +import dateutil.tz from nikola.plugin_categories import Command from nikola import utils @@ -82,7 +84,7 @@ def get_default_compiler(is_post, compilers, post_pages): return 'rest' -def get_date(schedule=False, rule=None, last_date=None, force_today=False): +def get_date(schedule=False, rule=None, last_date=None, tz=None, iso8601=False): """Returns a date stamp, given a recurrence rule. schedule - bool: @@ -94,33 +96,45 @@ def get_date(schedule=False, rule=None, last_date=None, force_today=False): last_date - datetime: timestamp of the last post - force_today - bool: - tries to schedule a post to today, if possible, even if the scheduled - time has already passed in the day. + tz - tzinfo: + the timezone used for getting the current time. + + iso8601 - bool: + whether to force ISO 8601 dates (instead of locale-specific ones) + """ - date = now = datetime.datetime.now() + if tz is None: + tz = dateutil.tz.tzlocal() + date = now = datetime.datetime.now(tz) if schedule: try: from dateutil import rrule except ImportError: LOGGER.error('To use the --schedule switch of new_post, ' 'you have to install the "dateutil" package.') - rrule = None + rrule = None # NOQA if schedule and rrule and rule: - if last_date and last_date.tzinfo: - # strip tzinfo for comparisons - last_date = last_date.replace(tzinfo=None) try: rule_ = rrule.rrulestr(rule, dtstart=last_date) except Exception: LOGGER.error('Unable to parse rule string, using current time.') else: - # Try to post today, instead of tomorrow, if no other post today. - if force_today: - now = now.replace(hour=0, minute=0, second=0, microsecond=0) date = rule_.after(max(now, last_date or now), last_date is None) - return date.strftime('%Y/%m/%d %H:%M:%S') + + offset = tz.utcoffset(now) + offset_sec = (offset.days * 24 * 3600 + offset.seconds) + offset_hrs = offset_sec // 3600 + offset_min = offset_sec % 3600 + if iso8601: + tz_str = '{0:+03d}:{1:02d}'.format(offset_hrs, offset_min // 60) + else: + if offset: + tz_str = ' UTC{0:+03d}:{1:02d}'.format(offset_hrs, offset_min // 60) + else: + tz_str = ' UTC' + + return date.strftime('%Y-%m-%d %H:%M:%S') + tz_str class CommandNewPost(Command): @@ -168,6 +182,13 @@ class CommandNewPost(Command): 'help': 'Create the post with separate metadata (two file format)' }, { + 'name': 'edit', + 'short': 'e', + 'type': bool, + 'default': False, + 'help': 'Open the post (and meta file, if any) in $EDITOR after creation.' + }, + { 'name': 'content_format', 'short': 'f', 'long': 'format', @@ -242,31 +263,44 @@ class CommandNewPost(Command): print("Creating New {0}".format(content_type.title())) print("-----------------\n") - if title is None: - print("Enter title: ", end='') - # WHY, PYTHON3???? WHY? - sys.stdout.flush() - title = sys.stdin.readline() - else: + if title is not None: print("Title:", title) + else: + while not title: + title = utils.ask('Title') + if isinstance(title, utils.bytes_str): - title = title.decode(sys.stdin.encoding) + try: + title = title.decode(sys.stdin.encoding) + except AttributeError: # for tests + title = title.decode('utf-8') + title = title.strip() if not path: slug = utils.slugify(title) else: if isinstance(path, utils.bytes_str): - path = path.decode(sys.stdin.encoding) + try: + path = path.decode(sys.stdin.encoding) + except AttributeError: # for tests + path = path.decode('utf-8') slug = utils.slugify(os.path.splitext(os.path.basename(path))[0]) # Calculate the date to use for the content schedule = options['schedule'] or self.site.config['SCHEDULE_ALL'] rule = self.site.config['SCHEDULE_RULE'] - force_today = self.site.config['SCHEDULE_FORCE_TODAY'] self.site.scan_posts() timeline = self.site.timeline last_date = None if not timeline else timeline[0].date - date = get_date(schedule, rule, last_date, force_today) - data = [title, slug, date, tags] + date = get_date(schedule, rule, last_date, self.site.tzinfo, self.site.config['FORCE_ISO8601']) + data = { + 'title': title, + 'slug': slug, + 'date': date, + 'tags': tags, + 'link': '', + 'description': '', + 'type': 'text', + } output_path = os.path.dirname(entry[0]) meta_path = os.path.join(output_path, slug + ".meta") pattern = os.path.basename(entry[0]) @@ -284,19 +318,34 @@ class CommandNewPost(Command): d_name = os.path.dirname(txt_path) utils.makedirs(d_name) metadata = self.site.config['ADDITIONAL_METADATA'] + + # 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') compiler_plugin.create_post( - txt_path, onefile, title=title, + txt_path, content=content, onefile=onefile, title=title, slug=slug, date=date, tags=tags, is_page=is_page, **metadata) event = dict(path=txt_path) if not onefile: # write metadata file with codecs.open(meta_path, "wb+", "utf8") as fd: - fd.write('\n'.join(data)) - with codecs.open(txt_path, "wb+", "utf8") as fd: - fd.write("Write your {0} here.".format(content_type)) + fd.write(utils.write_metadata(data)) LOGGER.info("Your {0}'s metadata is at: {1}".format(content_type, meta_path)) event['meta_path'] = meta_path LOGGER.info("Your {0}'s text is at: {1}".format(content_type, txt_path)) signal('new_' + content_type).send(self, **event) + + if options['edit']: + editor = os.getenv('EDITOR') + to_run = [editor, txt_path] + if not onefile: + to_run.append(meta_path) + if editor: + subprocess.call(to_run) + else: + LOGGER.error('$EDITOR not set, cannot edit the post. Please do it manually.') diff --git a/nikola/plugins/command/planetoid.plugin b/nikola/plugins/command/planetoid.plugin deleted file mode 100644 index e767f31..0000000 --- a/nikola/plugins/command/planetoid.plugin +++ /dev/null @@ -1,9 +0,0 @@ -[Core] -Name = planetoid -Module = planetoid - -[Documentation] -Author = Roberto Alsina -Version = 0.1 -Website = http://getnikola.com -Description = Maintain a planet-like site diff --git a/nikola/plugins/command/planetoid/__init__.py b/nikola/plugins/command/planetoid/__init__.py deleted file mode 100644 index fe1a59b..0000000 --- a/nikola/plugins/command/planetoid/__init__.py +++ /dev/null @@ -1,289 +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 codecs -import datetime -import hashlib -from optparse import OptionParser -import os -import sys - -from doit.tools import timeout -from nikola.plugin_categories import Command, Task -from nikola.utils import config_changed, req_missing, get_logger, STDERR_HANDLER - -LOGGER = get_logger('planetoid', STDERR_HANDLER) - -try: - import feedparser -except ImportError: - feedparser = None # NOQA - -try: - import peewee -except ImportError: - peewee = None - - -if peewee is not None: - class Feed(peewee.Model): - name = peewee.CharField() - url = peewee.CharField(max_length=200) - last_status = peewee.CharField(null=True) - etag = peewee.CharField(max_length=200) - last_modified = peewee.DateTimeField() - - class Entry(peewee.Model): - date = peewee.DateTimeField() - feed = peewee.ForeignKeyField(Feed) - content = peewee.TextField(max_length=20000) - link = peewee.CharField(max_length=200) - title = peewee.CharField(max_length=200) - guid = peewee.CharField(max_length=200) - - -class Planetoid(Command, Task): - """Maintain a planet-like thing.""" - name = "planetoid" - - def init_db(self): - # setup database - Feed.create_table(fail_silently=True) - Entry.create_table(fail_silently=True) - - def gen_tasks(self): - if peewee is None or sys.version_info[0] == 3: - if sys.version_info[0] == 3: - message = 'Peewee, a requirement of the "planetoid" command, is currently incompatible with Python 3.' - else: - req_missing('peewee', 'use the "planetoid" command') - message = '' - yield { - 'basename': self.name, - 'name': '', - 'verbosity': 2, - 'actions': ['echo "%s"' % message] - } - else: - self.init_db() - self.load_feeds() - for task in self.task_update_feeds(): - yield task - for task in self.task_generate_posts(): - yield task - yield { - 'basename': self.name, - 'name': '', - 'actions': [], - 'file_dep': ['feeds'], - 'task_dep': [ - self.name + "_fetch_feed", - self.name + "_generate_posts", - ] - } - - def run(self, *args): - parser = OptionParser(usage="nikola %s [options]" % self.name) - (options, args) = parser.parse_args(list(args)) - - def load_feeds(self): - "Read the feeds file, add it to the database." - feeds = [] - feed = name = None - for line in codecs.open('feeds', 'r', 'utf-8'): - line = line.strip() - if line.startswith("#"): - continue - elif line.startswith('http'): - feed = line - elif line: - name = line - if feed and name: - feeds.append([feed, name]) - feed = name = None - - def add_feed(name, url): - f = Feed.create( - name=name, - url=url, - etag='foo', - last_modified=datetime.datetime(1970, 1, 1), - ) - f.save() - - def update_feed_url(feed, url): - feed.url = url - feed.save() - - for feed, name in feeds: - f = Feed.select().where(Feed.name == name) - if not list(f): - add_feed(name, feed) - elif list(f)[0].url != feed: - update_feed_url(list(f)[0], feed) - - def task_update_feeds(self): - """Download feed contents, add entries to the database.""" - def update_feed(feed): - modified = feed.last_modified.timetuple() - etag = feed.etag - try: - parsed = feedparser.parse( - feed.url, - etag=etag, - modified=modified - ) - feed.last_status = str(parsed.status) - except: # Probably a timeout - # TODO: log failure - return - if parsed.feed.get('title'): - LOGGER.info(parsed.feed.title) - else: - LOGGER.info(feed.url) - feed.etag = parsed.get('etag', 'foo') - modified = tuple(parsed.get('date_parsed', (1970, 1, 1)))[:6] - LOGGER.info("==========>", modified) - modified = datetime.datetime(*modified) - feed.last_modified = modified - feed.save() - # No point in adding items from missinfg feeds - if parsed.status > 400: - # TODO log failure - return - for entry_data in parsed.entries: - LOGGER.info("=========================================") - date = entry_data.get('published_parsed', None) - if date is None: - date = entry_data.get('updated_parsed', None) - if date is None: - LOGGER.error("Can't parse date from:\n", entry_data) - return False - LOGGER.info("DATE:===>", date) - date = datetime.datetime(*(date[:6])) - title = "%s: %s" % (feed.name, entry_data.get('title', 'Sin título')) - content = entry_data.get('content', None) - if content: - content = content[0].value - if not content: - content = entry_data.get('description', None) - if not content: - content = entry_data.get('summary', 'Sin contenido') - guid = str(entry_data.get('guid', entry_data.link)) - link = entry_data.link - LOGGER.info(repr([date, title])) - e = list(Entry.select().where(Entry.guid == guid)) - LOGGER.info( - repr(dict( - date=date, - title=title, - content=content, - guid=guid, - feed=feed, - link=link, - )) - ) - if not e: - entry = Entry.create( - date=date, - title=title, - content=content, - guid=guid, - feed=feed, - link=link, - ) - else: - entry = e[0] - entry.date = date - entry.title = title - entry.content = content - entry.link = link - entry.save() - flag = False - for feed in Feed.select(): - flag = True - task = { - 'basename': self.name + "_fetch_feed", - 'name': str(feed.url), - 'actions': [(update_feed, (feed, ))], - 'uptodate': [timeout(datetime.timedelta(minutes= - self.site.config.get('PLANETOID_REFRESH', 60)))], - } - yield task - if not flag: - yield { - 'basename': self.name + "_fetch_feed", - 'name': '', - 'actions': [], - } - - def task_generate_posts(self): - """Generate post files for the blog entries.""" - def gen_id(entry): - h = hashlib.md5() - h.update(entry.feed.name.encode('utf8')) - h.update(entry.guid) - return h.hexdigest() - - def generate_post(entry): - unique_id = gen_id(entry) - meta_path = os.path.join('posts', unique_id + '.meta') - post_path = os.path.join('posts', unique_id + '.txt') - with codecs.open(meta_path, 'wb+', 'utf8') as fd: - fd.write('%s\n' % entry.title.replace('\n', ' ')) - fd.write('%s\n' % unique_id) - fd.write('%s\n' % entry.date.strftime('%Y/%m/%d %H:%M')) - fd.write('\n') - fd.write('%s\n' % entry.link) - with codecs.open(post_path, 'wb+', 'utf8') as fd: - fd.write('.. raw:: html\n\n') - content = entry.content - if not content: - content = 'Sin contenido' - for line in content.splitlines(): - fd.write(' %s\n' % line) - - if not os.path.isdir('posts'): - os.mkdir('posts') - flag = False - for entry in Entry.select().order_by(Entry.date.desc()): - flag = True - entry_id = gen_id(entry) - yield { - 'basename': self.name + "_generate_posts", - 'targets': [os.path.join('posts', entry_id + '.meta'), os.path.join('posts', entry_id + '.txt')], - 'name': entry_id, - 'actions': [(generate_post, (entry,))], - 'uptodate': [config_changed({1: entry})], - 'task_dep': [self.name + "_fetch_feed"], - } - if not flag: - yield { - 'basename': self.name + "_generate_posts", - 'name': '', - 'actions': [], - } diff --git a/nikola/plugins/command/plugin.plugin b/nikola/plugins/command/plugin.plugin new file mode 100644 index 0000000..d2bca92 --- /dev/null +++ b/nikola/plugins/command/plugin.plugin @@ -0,0 +1,10 @@ +[Core] +Name = plugin +Module = plugin + +[Documentation] +Author = Roberto Alsina and Chris Warrick +Version = 0.2 +Website = http://getnikola.com +Description = Manage Nikola plugins + diff --git a/nikola/plugins/command/plugin.py b/nikola/plugins/command/plugin.py new file mode 100644 index 0000000..df0e7a4 --- /dev/null +++ b/nikola/plugins/command/plugin.py @@ -0,0 +1,319 @@ +# -*- 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 +import codecs +from io import BytesIO +import os +import shutil +import subprocess +import sys + +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.""" + + json = None + name = "plugin" + doc_usage = "[[-u][--user] --install name] | [[-u] [-l |--upgrade|--list-installed] | [--uninstall name]]" + doc_purpose = "manage plugins" + output_dir = None + needs_config = False + cmd_options = [ + { + 'name': 'install', + 'short': 'i', + 'long': 'install', + 'type': str, + 'default': '', + 'help': 'Install a plugin.', + }, + { + 'name': 'uninstall', + 'long': 'uninstall', + 'short': 'r', + 'type': str, + 'default': '', + 'help': 'Uninstall a plugin.' + }, + { + 'name': 'list', + 'short': 'l', + 'long': 'list', + 'type': bool, + 'default': False, + 'help': 'Show list of available plugins.' + }, + { + 'name': 'url', + 'short': 'u', + 'long': 'url', + 'type': str, + 'help': "URL for the plugin repository (default: " + "http://plugins.getnikola.com/v7/plugins.json)", + 'default': 'http://plugins.getnikola.com/v7/plugins.json' + }, + { + 'name': 'user', + 'long': 'user', + 'type': bool, + 'help': "Install user-wide, available for all sites.", + 'default': False + }, + { + 'name': 'upgrade', + 'long': 'upgrade', + 'type': bool, + 'help': "Upgrade all installed plugins.", + 'default': False + }, + { + 'name': 'list_installed', + 'long': 'list-installed', + 'type': bool, + 'help': "List the installed plugins with their location.", + 'default': False + }, + ] + + def _execute(self, options, args): + """Install plugin into current site.""" + url = options['url'] + user_mode = options['user'] + + # See the "mode" we need to operate in + install = options.get('install') + uninstall = options.get('uninstall') + upgrade = options.get('upgrade') + list_available = options.get('list') + list_installed = options.get('list_installed') + command_count = [bool(x) for x in ( + install, + uninstall, + upgrade, + list_available, + list_installed)].count(True) + if command_count > 1 or command_count == 0: + print(self.help()) + return + + if not self.site.configured and not user_mode and install: + LOGGER.notice('No site found, assuming --user') + user_mode = True + + if user_mode: + self.output_dir = os.path.expanduser('~/.nikola/plugins') + else: + self.output_dir = 'plugins' + + if list_available: + self.list_available(url) + elif list_installed: + self.list_installed() + elif upgrade: + self.do_upgrade(url) + elif uninstall: + self.do_uninstall(uninstall) + elif install: + self.do_install(url, install) + + def list_available(self, url): + data = self.get_json(url) + print("Available Plugins:") + print("------------------") + for plugin in sorted(data.keys()): + print(plugin) + return True + + def list_installed(self): + plugins = [] + for plugin in self.site.plugin_manager.getAllPlugins(): + p = plugin.path + if os.path.isdir(p): + p = p + os.sep + else: + p = p + '.py' + plugins.append([plugin.name, p]) + + plugins.sort() + for name, path in plugins: + print('{0} at {1}'.format(name, path)) + + def do_upgrade(self, url): + LOGGER.warning('This is not very smart, it just reinstalls some plugins and hopes for the best') + data = self.get_json(url) + plugins = [] + for plugin in self.site.plugin_manager.getAllPlugins(): + p = plugin.path + if os.path.isdir(p): + p = p + os.sep + else: + p = p + '.py' + if plugin.name in data: + plugins.append([plugin.name, p]) + print('Will upgrade {0} plugins: {1}'.format(len(plugins), ', '.join(n for n, _ in plugins))) + for name, path in plugins: + print('Upgrading {0}'.format(name)) + p = path + while True: + tail, head = os.path.split(path) + if head == 'plugins': + self.output_dir = path + break + elif tail == '': + LOGGER.error("Can't find the plugins folder for path: {0}".format(p)) + return False + else: + path = tail + self.do_install(url, name) + + def do_install(self, url, name): + data = self.get_json(url) + if name in data: + utils.makedirs(self.output_dir) + LOGGER.info('Downloading: ' + data[name]) + zip_file = BytesIO() + zip_file.write(requests.get(data[name]).content) + LOGGER.info('Extracting: {0} into {1}/'.format(name, self.output_dir)) + utils.extract_all(zip_file, self.output_dir) + dest_path = os.path.join(self.output_dir, name) + else: + try: + plugin_path = utils.get_plugin_path(name) + except: + LOGGER.error("Can't find plugin " + name) + return False + + utils.makedirs(self.output_dir) + dest_path = os.path.join(self.output_dir, name) + if os.path.exists(dest_path): + LOGGER.error("{0} is already installed".format(name)) + return False + + LOGGER.info('Copying {0} into plugins'.format(plugin_path)) + shutil.copytree(plugin_path, dest_path) + + reqpath = os.path.join(dest_path, 'requirements.txt') + if os.path.exists(reqpath): + LOGGER.notice('This plugin has Python dependencies.') + LOGGER.info('Installing dependencies with pip...') + try: + subprocess.check_call(('pip', 'install', '-r', reqpath)) + except subprocess.CalledProcessError: + LOGGER.error('Could not install the dependencies.') + print('Contents of the requirements.txt file:\n') + with codecs.open(reqpath, 'rb', 'utf-8') as fh: + print(indent(fh.read(), 4 * ' ')) + print('You have to install those yourself or through a ' + 'package manager.') + else: + LOGGER.info('Dependency installation succeeded.') + reqnpypath = os.path.join(dest_path, 'requirements-nonpy.txt') + if os.path.exists(reqnpypath): + LOGGER.notice('This plugin has third-party ' + 'dependencies you need to install ' + 'manually.') + print('Contents of the requirements-nonpy.txt file:\n') + with codecs.open(reqnpypath, 'rb', 'utf-8') as fh: + for l in fh.readlines(): + i, j = l.split('::') + print(indent(i.strip(), 4 * ' ')) + print(indent(j.strip(), 8 * ' ')) + print() + + print('You have to install those yourself or through a package ' + 'manager.') + confpypath = os.path.join(dest_path, 'conf.py.sample') + if os.path.exists(confpypath): + LOGGER.notice('This plugin has a sample config file. Integrate it with yours in order to make this plugin work!') + print('Contents of the conf.py.sample file:\n') + with codecs.open(confpypath, 'rb', 'utf-8') as fh: + if self.site.colorful: + print(indent(pygments.highlight( + fh.read(), PythonLexer(), TerminalFormatter()), + 4 * ' ')) + else: + print(indent(fh.read(), 4 * ' ')) + return True + + def do_uninstall(self, name): + for plugin in self.site.plugin_manager.getAllPlugins(): # FIXME: this is repeated thrice + p = plugin.path + if os.path.isdir(p): + p = p + os.sep + else: + p = os.path.dirname(p) + if name == plugin.name: # Uninstall this one + LOGGER.warning('About to uninstall plugin: {0}'.format(name)) + LOGGER.warning('This will delete {0}'.format(p)) + inpf = raw_input if sys.version_info[0] == 2 else input + sure = inpf('Are you sure? [y/n] ') + if sure.lower().startswith('y'): + LOGGER.warning('Removing {0}'.format(p)) + shutil.rmtree(p) + return True + LOGGER.error('Unknown plugin: {0}'.format(name)) + 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/serve.py b/nikola/plugins/command/serve.py index f27d1f7..623e2db 100644 --- a/nikola/plugins/command/serve.py +++ b/nikola/plugins/command/serve.py @@ -89,7 +89,11 @@ class CommandServe(Command): server_url = "http://{0}:{1}/".format(options['address'], options['port']) self.logger.info("Opening {0} in the default web browser ...".format(server_url)) webbrowser.open(server_url) - httpd.serve_forever() + try: + httpd.serve_forever() + except KeyboardInterrupt: + self.logger.info("Server is shutting down.") + exit(130) class OurHTTPRequestHandler(SimpleHTTPRequestHandler): |
