Guides are written on one site and needed on all of them, and the sites that matter are air-gapped from wherever the writing happens. Until now a procedure was stranded in the database it was typed into: the HTML importer could rebuild one from its original article, but anything authored in the editor had nowhere to go. flask guides export writes one .guide.json per guide, with every image the blocks reference carried inside it as base64. One file on purpose - a bundle that needs its images copied alongside it is a bundle that will eventually arrive without them. flask guides import reads them back, a directory at a time, and refuses anything that is not a bundle before it writes a row rather than half-importing a stray .json sitting in the same folder. What travels is the content: title, summary, tags, blocks, images. What does not is ids, revision history, authorship and publication state, because those belong to whichever database issued them - an imported guide starts as a draft at revision 1, owned by whoever ran the import. The SLUG does travel: it is the address somebody pastes into a work order, and the same guide should answer to the same name on every site. --replace updates the guide already carrying that slug instead of creating a second one, which is how a corrected guide is re-shipped. It drops the stored images first: the incoming bundle is the whole truth for that guide, and a guide re-shipped monthly would otherwise accumulate orphans nothing points at. Bundles refuse a version they are too old to read rather than importing the parts they recognise. A newer site's guide is not a guide this one can be trusted to render. Proven by round-tripping a five-guide export back into the same site, including one carrying two PNG screenshots, and reading it in a browser.
131 lines
4.7 KiB
Python
131 lines
4.7 KiB
Python
"""A guide as one portable file.
|
|
|
|
WHY
|
|
|
|
Sites are separate installations with separate databases, and the useful
|
|
ones are air-gapped from where a guide gets written. A procedure that can
|
|
only exist on the machine it was typed on is the problem this plugin was
|
|
built to end, one layer up: instead of a document stranded on a share, a
|
|
guide stranded in one site's database.
|
|
|
|
So: export to a single JSON file, carry it however files get carried
|
|
there - a share, a robocopy, a USB drive - and import it on the other side.
|
|
|
|
WHAT TRAVELS
|
|
|
|
Title, summary, tags and the blocks, plus every image the blocks reference,
|
|
inline as base64. One file, no companion directory: half a guide arriving
|
|
is worse than none, and a bundle that needs its images to be copied
|
|
alongside it is a bundle that will eventually arrive without them.
|
|
|
|
WHAT DOES NOT TRAVEL
|
|
|
|
Ids, revision history, authorship and publication state. A guide's id and
|
|
its revision numbering belong to the database that issued them, and an
|
|
imported guide starts as a draft on the far side, at revision 1, owned by
|
|
whoever ran the import. The SLUG travels, because the slug is the name
|
|
somebody pastes into a work order and the whole point is that the same
|
|
guide is the same guide on both sites.
|
|
"""
|
|
|
|
import base64
|
|
import json
|
|
|
|
FORMAT = 'shopdb-guide'
|
|
VERSION = 1
|
|
|
|
# An image block points at a stored image by its id, and an id means nothing on
|
|
# the far side. Blocks travel referring to a token instead, and the importer
|
|
# swaps the tokens for the ids its own database issued.
|
|
IMAGETOKEN = 'imagetoken'
|
|
IMAGEID = 'imageid'
|
|
|
|
|
|
class BundleError(ValueError):
|
|
"""The file is not a guide bundle, or is one this version cannot read."""
|
|
|
|
|
|
def build(guide, images):
|
|
"""One guide plus its images as a plain dict, ready to be written as JSON.
|
|
|
|
`images` is the GuideImage rows for this guide. Only the ones its blocks
|
|
actually reference are carried - an orphaned paste from a draft is not part
|
|
of the procedure and does not need to cross the gap.
|
|
"""
|
|
byid = {image.guideimageid: image for image in images}
|
|
used = []
|
|
tokens = {}
|
|
blocks = []
|
|
for block in guide.blocks:
|
|
block = dict(block)
|
|
imageid = block.pop(IMAGEID, None)
|
|
if imageid is not None:
|
|
image = byid.get(imageid)
|
|
if image is None:
|
|
# The image is gone. Carrying a block that points at nothing
|
|
# would render as "Image unavailable" on the far side, which
|
|
# reads as an import failure rather than as missing data.
|
|
continue
|
|
if imageid not in tokens:
|
|
tokens[imageid] = 'img{:03d}'.format(len(used) + 1)
|
|
used.append(image)
|
|
block[IMAGETOKEN] = tokens[imageid]
|
|
blocks.append(block)
|
|
|
|
return {
|
|
'format': FORMAT,
|
|
'version': VERSION,
|
|
'slug': guide.slug,
|
|
'title': guide.title,
|
|
'summary': guide.summary,
|
|
'tags': guide.tags,
|
|
'blocks': blocks,
|
|
'images': [{
|
|
'token': tokens[image.guideimageid],
|
|
'filename': image.filename,
|
|
'mimetype': image.mimetype,
|
|
'data': base64.b64encode(image.data).decode('ascii'),
|
|
} for image in used],
|
|
}
|
|
|
|
|
|
def dumps(bundle):
|
|
return json.dumps(bundle, indent=2, sort_keys=False) + '\n'
|
|
|
|
|
|
def loads(text):
|
|
"""Parse a bundle, with the image data decoded back to bytes.
|
|
|
|
Refuses anything that is not this format. A JSON file that happens to be
|
|
somewhere near the others must not be half-imported before the failure
|
|
shows up.
|
|
"""
|
|
try:
|
|
bundle = json.loads(text)
|
|
except ValueError as error:
|
|
raise BundleError('not valid JSON: {}'.format(error))
|
|
|
|
if not isinstance(bundle, dict) or bundle.get('format') != FORMAT:
|
|
raise BundleError('not a {} file'.format(FORMAT))
|
|
version = bundle.get('version')
|
|
if not isinstance(version, int) or version > VERSION:
|
|
raise BundleError(
|
|
'bundle version {} is newer than this site can read (max {})'
|
|
.format(version, VERSION))
|
|
if not isinstance(bundle.get('blocks'), list):
|
|
raise BundleError('bundle has no blocks')
|
|
|
|
images = []
|
|
for image in bundle.get('images') or []:
|
|
try:
|
|
data = base64.b64decode(image['data'], validate=True)
|
|
except Exception:
|
|
raise BundleError('image {!r} is not readable'
|
|
.format(image.get('token')))
|
|
images.append({'token': image.get('token'),
|
|
'filename': image.get('filename'),
|
|
'mimetype': (image.get('mimetype') or '').lower(),
|
|
'data': data})
|
|
bundle['images'] = images
|
|
return bundle
|