flask guides import-html; retention docstring
This commit is contained in:
158
plugins/guides/cli.py
Normal file
158
plugins/guides/cli.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""CLI: bring an existing HTML article in as a guide.
|
||||
|
||||
flask guides import-html PATH [PATH ...]
|
||||
|
||||
A site that adopts this app usually arrives with a folder of procedures already
|
||||
written - styled HTML pages that get mailed around and opened off a share. This
|
||||
imports them so they live where they are used, become searchable, tickable and
|
||||
printable, and stop being a file somebody has to find.
|
||||
|
||||
Imports land as DRAFTS, always. The conversion is faithful but it is not a
|
||||
render: layout, colour and anything decorative are dropped on purpose, and a
|
||||
person should read the result before anybody is told to follow it.
|
||||
"""
|
||||
|
||||
import click
|
||||
from flask.cli import AppGroup, with_appcontext
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
from .models import Guide, GuideImage
|
||||
from .services import authoring
|
||||
from .services.blocks import BlockError
|
||||
from .services.htmlimport import parse
|
||||
|
||||
|
||||
# Mirrors what the upload route accepts. An import must not be able to store an
|
||||
# image the editor would have refused, or the guide renders here and nowhere
|
||||
# else.
|
||||
ALLOWEDIMAGETYPES = ('image/png', 'image/jpeg', 'image/gif', 'image/webp')
|
||||
MAXIMAGEBYTES = 8 * 1024 * 1024
|
||||
|
||||
|
||||
def _slugify(title):
|
||||
"""Same rule as the API's, kept here so the CLI does not import a route."""
|
||||
import re
|
||||
base = re.sub(r'[^a-z0-9]+', '-', (title or '').lower()).strip('-') or 'guide'
|
||||
base = base[:180]
|
||||
candidate = base
|
||||
suffix = 2
|
||||
while Guide.query.filter(Guide.slug == candidate).first() is not None:
|
||||
candidate = '{}-{}'.format(base, suffix)
|
||||
suffix += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def build_cli():
|
||||
guides_cli = AppGroup('guides')
|
||||
|
||||
@guides_cli.command('import-html')
|
||||
@click.argument('paths', nargs=-1, required=True,
|
||||
type=click.Path(exists=True, dir_okay=False))
|
||||
@click.option('--title', default=None,
|
||||
help='Override the title taken from the article.')
|
||||
@click.option('--tags', default=None,
|
||||
help='Comma-separated tags for every guide imported.')
|
||||
@click.option('--userid', type=int, default=None,
|
||||
help='Record this user as the author.')
|
||||
@click.option('--replace', is_flag=True,
|
||||
help='If a guide already has this slug, edit it in place '
|
||||
'(a new revision) instead of creating a second one.')
|
||||
@click.option('--apply/--dry-run', default=False,
|
||||
help='Write to the database. The default reports only.')
|
||||
@with_appcontext
|
||||
def import_html(paths, title, tags, userid, replace, apply):
|
||||
"""Convert HTML article(s) into draft guides."""
|
||||
if title and len(paths) > 1:
|
||||
raise click.UsageError('--title names one guide; pass one file.')
|
||||
|
||||
for path in paths:
|
||||
with open(path, encoding='utf-8', errors='replace') as handle:
|
||||
parsed = parse(handle.read())
|
||||
|
||||
name = title or parsed['title']
|
||||
counts = {}
|
||||
for block in parsed['blocks']:
|
||||
counts[block['type']] = counts.get(block['type'], 0) + 1
|
||||
|
||||
click.echo('{}'.format(path))
|
||||
click.echo(' title {}'.format(name))
|
||||
click.echo(' blocks {} ({})'.format(
|
||||
len(parsed['blocks']),
|
||||
', '.join('{} {}'.format(v, k) for k, v in sorted(counts.items()))))
|
||||
click.echo(' images {}'.format(len(parsed['images'])))
|
||||
for note in parsed['skipped']:
|
||||
click.echo(' skipped {}'.format(note))
|
||||
|
||||
rejected = [image for image in parsed['images']
|
||||
if image['mimetype'] not in ALLOWEDIMAGETYPES
|
||||
or len(image['data']) > MAXIMAGEBYTES]
|
||||
for image in rejected:
|
||||
click.echo(' DROPPED {} ({}, {:.1f} MB) - not an accepted image'
|
||||
.format(image['token'], image['mimetype'],
|
||||
len(image['data']) / 1048576))
|
||||
|
||||
if not apply:
|
||||
click.echo(' dry run, nothing written. Pass --apply to import.')
|
||||
continue
|
||||
|
||||
try:
|
||||
guide = _import(parsed, name, tags, userid, replace, rejected)
|
||||
except BlockError as error:
|
||||
db.session.rollback()
|
||||
click.echo(' FAILED {}'.format(error))
|
||||
continue
|
||||
|
||||
db.session.commit()
|
||||
click.echo(' imported as /guides/{} (draft, revision {})'
|
||||
.format(guide.slug, guide.revision))
|
||||
|
||||
return [guides_cli]
|
||||
|
||||
|
||||
def _import(parsed, name, tags, userid, replace, rejected):
|
||||
"""Create or update the guide and its images. Caller commits."""
|
||||
slug = None
|
||||
guide = None
|
||||
if replace:
|
||||
# Match on the slug the title WOULD produce, which is how somebody
|
||||
# re-imports a corrected article over the one they imported yesterday.
|
||||
import re
|
||||
wanted = re.sub(r'[^a-z0-9]+', '-', (name or '').lower()).strip('-')
|
||||
guide = Guide.query.filter(Guide.slug == wanted).first()
|
||||
|
||||
if guide is None:
|
||||
slug = _slugify(name)
|
||||
guide = Guide(title=name, slug=slug, status='draft', createdby=userid)
|
||||
db.session.add(guide)
|
||||
db.session.flush()
|
||||
|
||||
# Images first: the block needs the stored id, and an id only exists once
|
||||
# the row does.
|
||||
droppedtokens = {image['token'] for image in rejected}
|
||||
ids = {}
|
||||
for image in parsed['images']:
|
||||
if image['token'] in droppedtokens:
|
||||
continue
|
||||
row = GuideImage(guideid=guide.guideid, filename=image['filename'],
|
||||
mimetype=image['mimetype'],
|
||||
bytesize=len(image['data']), data=image['data'],
|
||||
uploadedby=userid)
|
||||
db.session.add(row)
|
||||
db.session.flush()
|
||||
ids[image['token']] = row.guideimageid
|
||||
|
||||
blocks = []
|
||||
for block in parsed['blocks']:
|
||||
block = dict(block)
|
||||
token = block.pop('imagetoken', None)
|
||||
if token is not None:
|
||||
if token not in ids:
|
||||
continue # its image was dropped; the caption would lie
|
||||
block['imageid'] = ids[token]
|
||||
blocks.append(block)
|
||||
|
||||
authoring.save(guide, title=name, summary=parsed['summary'] or None,
|
||||
tags=tags, blocks=blocks,
|
||||
note='Imported from HTML', userid=userid)
|
||||
return guide
|
||||
Reference in New Issue
Block a user