"""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