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
|