163 lines
6.5 KiB
Python
163 lines
6.5 KiB
Python
"""Block validation.
|
|
|
|
The editor is the only thing that writes blocks today, so this is not defending
|
|
against a hostile author - it is defending against a BAD SAVE. A block list that
|
|
is wrong in a way nobody notices at save time becomes a guide that will not
|
|
render weeks later, when the person who wrote it has moved on and the only
|
|
evidence is a blank page.
|
|
|
|
So: validate at the door, name the block that is wrong, and refuse the save.
|
|
The alternative - storing whatever arrives and coping at render time - was tried
|
|
in the backups plugin with a manifest and produced exactly the failure this
|
|
avoids: a client bug turning into a broken page that outlived it.
|
|
|
|
WHAT IS NOT VALIDATED HERE: the inline text. It is a restricted Markdown subset
|
|
rendered through a sanitiser in the browser, which is where a renderer belongs.
|
|
Storing text verbatim keeps what the author typed recoverable even if the
|
|
rendering rules change later.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
|
|
from ..models import BLOCKTYPES
|
|
|
|
# A guide is a procedure, not a document repository. This cap is generous for
|
|
# anything a person will read on a shop floor and small enough that a runaway
|
|
# paste cannot fill a MEDIUMTEXT column.
|
|
MAXBLOCKSBYTES = 2 * 1024 * 1024
|
|
MAXBLOCKS = 500
|
|
MAXTEXT = 20000
|
|
|
|
|
|
class BlockError(ValueError):
|
|
"""Raised with a message naming the offending block, for the API to relay."""
|
|
|
|
|
|
def _fail(index, message):
|
|
raise BlockError('block {}: {}'.format(index + 1, message))
|
|
|
|
|
|
def validate(blocks):
|
|
"""Check a block list, returning it unchanged, or raise BlockError.
|
|
|
|
Kept pure - no database, no request - so the rules are testable directly and
|
|
the same function can be reused by an importer later.
|
|
"""
|
|
if not isinstance(blocks, list):
|
|
raise BlockError('blocks must be a list')
|
|
if len(blocks) > MAXBLOCKS:
|
|
raise BlockError('a guide is limited to {} blocks'.format(MAXBLOCKS))
|
|
|
|
seen = set()
|
|
for index, block in enumerate(blocks):
|
|
if not isinstance(block, dict):
|
|
_fail(index, 'must be an object')
|
|
|
|
blockid = block.get('id')
|
|
if not isinstance(blockid, str) or not blockid.strip():
|
|
# Ids are what make a checklist item rememberable between visits.
|
|
# A block without one silently loses its tick the next time the
|
|
# guide is opened, which reads as "the checkboxes do not work".
|
|
_fail(index, 'needs a stable id')
|
|
if blockid in seen:
|
|
_fail(index, 'duplicate id {!r} - ticks would follow the wrong item'
|
|
.format(blockid))
|
|
seen.add(blockid)
|
|
|
|
blocktype = block.get('type')
|
|
if blocktype not in BLOCKTYPES:
|
|
_fail(index, 'unknown type {!r}. Known types: {}'.format(
|
|
blocktype, ', '.join(BLOCKTYPES)))
|
|
|
|
text = block.get('text')
|
|
if text is not None:
|
|
if not isinstance(text, str):
|
|
_fail(index, 'text must be a string')
|
|
if len(text) > MAXTEXT:
|
|
_fail(index, 'text is longer than {} characters'.format(MAXTEXT))
|
|
|
|
items = block.get('items')
|
|
if items is not None:
|
|
if not isinstance(items, list):
|
|
_fail(index, 'items must be a list')
|
|
for item in items:
|
|
# Checklist items carry their own id for the same reason blocks
|
|
# do: that id is the thing a tick is stored against.
|
|
if isinstance(item, dict):
|
|
if not isinstance(item.get('id'), str) or not item['id'].strip():
|
|
_fail(index, 'every item needs an id')
|
|
if not isinstance(item.get('text', ''), str):
|
|
_fail(index, 'item text must be a string')
|
|
elif not isinstance(item, str):
|
|
_fail(index, 'items must be strings or objects')
|
|
|
|
encoded = json.dumps(blocks, separators=(',', ':'))
|
|
if len(encoded.encode('utf-8')) > MAXBLOCKSBYTES:
|
|
raise BlockError('this guide is larger than {} MB'.format(
|
|
MAXBLOCKSBYTES // (1024 * 1024)))
|
|
return blocks
|
|
|
|
|
|
def plaintext(blocks):
|
|
"""Flatten a guide to searchable text.
|
|
|
|
Search should find a guide by something written halfway down it, not only by
|
|
its title - that is how people actually look for a procedure ("the one about
|
|
the drive letter"). Cheap because it runs on save, not on every query.
|
|
"""
|
|
parts = []
|
|
for block in blocks or []:
|
|
if not isinstance(block, dict):
|
|
continue
|
|
if isinstance(block.get('text'), str):
|
|
parts.append(block['text'])
|
|
for item in block.get('items') or []:
|
|
if isinstance(item, str):
|
|
parts.append(item)
|
|
elif isinstance(item, dict) and isinstance(item.get('text'), str):
|
|
parts.append(item['text'])
|
|
for row in block.get('rows') or []:
|
|
if isinstance(row, list):
|
|
parts.extend(cell for cell in row if isinstance(cell, str))
|
|
return '\n'.join(parts)
|
|
|
|
|
|
def checklistitemcount(blocks):
|
|
"""How many tickable things a guide has, for the card and the progress bar.
|
|
|
|
Checklist items, plus the sections a reader crosses off to keep their place:
|
|
a step, or a heading. A guide converted from an article often has fifty
|
|
headings and no steps at all, and "0 steps to tick" on a card that is
|
|
entirely tickable reads as a broken feature.
|
|
"""
|
|
total = 0
|
|
for block in blocks or []:
|
|
if not isinstance(block, dict):
|
|
continue
|
|
if block.get('type') == 'checklist':
|
|
total += len(block.get('items') or [])
|
|
elif block.get('type') in ('step', 'heading'):
|
|
total += 1
|
|
return total
|
|
|
|
|
|
def contenthash(title, summary, tags, blocks):
|
|
"""sha256 over everything a reader would notice changing.
|
|
|
|
Title, summary and tags are included, not just the blocks: renaming a guide
|
|
or retagging it is a change worth a revision, and hashing only the body
|
|
would silently drop it from the history.
|
|
|
|
Canonical by construction - the blocks are re-serialized with sorted keys
|
|
and no whitespace, so the same content saved twice hashes the same however
|
|
the editor happened to order its JSON that day.
|
|
"""
|
|
canonical = json.dumps({
|
|
'title': (title or '').strip(),
|
|
'summary': (summary or '').strip(),
|
|
'tags': sorted(t.strip().lower() for t in (tags or '').split(',') if t.strip()),
|
|
'blocks': blocks or [],
|
|
}, sort_keys=True, separators=(',', ':'))
|
|
return hashlib.sha256(canonical.encode('utf-8')).hexdigest()
|