From 28798e6a327af1b0c5c9b335a71e99ac4ea8b0d9 Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Thu, 27 Aug 2026 11:27:30 -0400 Subject: [PATCH] flask guides import-html; retention docstring --- CHANGELOG.md | 13 + plugins/backups/services/retention.py | 10 +- plugins/guides/cli.py | 158 +++++++ plugins/guides/plugin.py | 5 + plugins/guides/services/htmlimport.py | 464 +++++++++++++++++++ tests/test_plugins/test_guides_htmlimport.py | 214 +++++++++ 6 files changed, 861 insertions(+), 3 deletions(-) create mode 100644 plugins/guides/cli.py create mode 100644 plugins/guides/services/htmlimport.py create mode 100644 tests/test_plugins/test_guides_htmlimport.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b918d7f3..ecf43009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,19 @@ ADR-007 and ADR-002. tables; in self-hosted mode one stored loan expands into a take and a return, so a returned drive is distinguishable from one still out. +- **`flask guides import-html` - an existing HTML article becomes a guide.** + A site adopting this app arrives holding procedures already written: styled + HTML pages that get mailed around and opened off a share. Retyping one into + the editor is how a good procedure stays on a share forever. The importer + reads the page and produces blocks - steps, callouts, code, checklists, + tables, lists - and lifts embedded images into `guideimages` so a 1.6 MB + document stops carrying its screenshots as base64. Imports land as DRAFTS, + and `--dry-run` (the default) reports what it would produce without writing. + Conversion is deliberately lossy: colour, fonts and layout go, because that + is the whole reason a guide is stored as blocks rather than markup. The + blocks go through the same validator the editor uses, so an import cannot + store something the editor could not have produced. + ### Fixed - **Every report offers to email itself, not just two of them.** `POST diff --git a/plugins/backups/services/retention.py b/plugins/backups/services/retention.py index 69ffaecb..f9627ac8 100644 --- a/plugins/backups/services/retention.py +++ b/plugins/backups/services/retention.py @@ -5,10 +5,14 @@ changed - so retention exists for the pathological case, not the normal one: a value that flaps would otherwise append a revision every collection cycle forever. -Two independent limits, both off by default at 0: +Two independent limits. Both are OFF in this module - the arguments below +default to 0 - but the plugin SEEDS `backups_retentioncount` at 50, so on a +default install count-based pruning is on and age-based pruning is not. Read +the seeded settings in plugin.py, not these argument defaults, when answering +"is retention on here". - retentioncount keep at most N revisions per chain - retentiondays additionally drop anything older than N days + retentioncount keep at most N revisions per chain (setting default: 50) + retentiondays additionally drop anything older than N days (default: off) A CHAIN is one asset, one kind, one source hostname. Several PCs can share a machine number, and each keeps its own revision history against that machine; diff --git a/plugins/guides/cli.py b/plugins/guides/cli.py new file mode 100644 index 00000000..0fde0a72 --- /dev/null +++ b/plugins/guides/cli.py @@ -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 diff --git a/plugins/guides/plugin.py b/plugins/guides/plugin.py index 13f30323..700a221d 100644 --- a/plugins/guides/plugin.py +++ b/plugins/guides/plugin.py @@ -93,5 +93,10 @@ class GuidesPlugin(BasePlugin): }, ] + def get_cli_commands(self) -> List: + """CLI: import an existing HTML article as a draft guide.""" + from .cli import build_cli + return build_cli() + def init_app(self, app: Flask, db_instance) -> None: logger.info('Guides plugin initialized (v%s)', self.meta.version) diff --git a/plugins/guides/services/htmlimport.py b/plugins/guides/services/htmlimport.py new file mode 100644 index 00000000..ad90d2ed --- /dev/null +++ b/plugins/guides/services/htmlimport.py @@ -0,0 +1,464 @@ +"""Turn a hand-written HTML article into guide blocks. + +WHY THIS EXISTS + + Sites arrive with a folder of KB articles: a procedure written once as a + styled HTML page, mailed around, opened from a share. Those pages are what a + guide is for, and retyping one into the editor is how a good procedure stays + on a share forever. This reads the page and produces blocks. + +WHAT IT IS NOT + + It is not an HTML renderer, and it deliberately loses things. Colours, fonts, + widths, nested layout and any decoration go, because that is the entire + point of storing blocks rather than markup - the theme decides how a guide + looks, and pasted styling is what makes a document unreadable in dark mode. + A converted guide is a DRAFT: somebody reads it before it is published. + +WHAT IT RECOGNISES + + The GE Aerospace KB template's own vocabulary, which is also the shape most + hand-written procedure pages take: + + div.step + span.step-number -> step (guides do the numbering) + div.alert alert- -> callout + div.code-block, pre, code -> code + ul.checkbox-list -> checklist (tickable, and remembered) + h1..h4 -> heading (h1 becomes the title) + p -> paragraph + ul / ol -> bullets / numbers + table -> table + img, inline svg -> image (returned for upload) + + Anything else contributes its text. A div wrapping other blocks is walked + through, not turned into one - guides have no nesting. + +INLINE TEXT is the restricted Markdown subset the renderer accepts: bold, +italic, inline code, links, line breaks. Anything else becomes plain text, +which is a loss the format intends. +""" + +import base64 +import re +from html import unescape +from html.parser import HTMLParser +from urllib.parse import unquote + +# Blocks a guide has no concept of, and page furniture no reader wants twice. +# The nav is a table of contents the guide generates from its own headings. +SKIPCLASSES = ('header', 'header-content', 'header-logo', 'header-subtitle', + 'kb-badge', 'nav', 'footer', 'footer-content', 'copy-button') +SKIPTAGS = ('head', 'style', 'script', 'button', 'noscript') + +VOIDTAGS = ('br', 'img', 'meta', 'link', 'input', 'hr', 'source', 'col', + 'area', 'base', 'embed', 'track', 'wbr') + +# The renderer knows info, warning and success. A danger alert is a warning +# with a louder colour, and colour is not something a block carries. +CALLOUTVARIANTS = {'alert-info': 'info', 'alert-warning': 'warning', + 'alert-success': 'success', 'alert-danger': 'warning', + 'alert-error': 'warning'} + +DATAURI = re.compile(r'^data:([\w.+-]+/[\w.+-]+)?(;charset=[\w-]+)?(;base64)?,(.*)$', + re.IGNORECASE | re.DOTALL) + + +class Node: + """One element or one run of text. A tree small enough to walk twice.""" + + __slots__ = ('tag', 'attrs', 'children', 'text') + + def __init__(self, tag, attrs=None, text=None): + self.tag = tag + self.attrs = attrs or {} + self.children = [] + self.text = text + + @property + def classes(self): + return (self.attrs.get('class') or '').split() + + +class _Tree(HTMLParser): + """Builds the node tree. Tolerant: real articles have unclosed tags.""" + + def __init__(self): + super().__init__(convert_charrefs=True) + self.root = Node('#root') + self.stack = [self.root] + + # A

that is never closed is normal HTML, and the browser closes it the + # moment a block element starts. Without the same rule, one unclosed

+ # swallows the entire rest of the article into a single paragraph. + CLOSESPARAGRAPH = ('p', 'div', 'section', 'article', 'ul', 'ol', 'li', + 'table', 'tr', 'td', 'th', 'pre', 'blockquote', 'hr', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6') + + def _autoclose(self, tag): + openers = {'li': ('li',), 'tr': ('tr', 'td', 'th'), + 'td': ('td', 'th'), 'th': ('td', 'th')}.get(tag, ()) + if tag in self.CLOSESPARAGRAPH: + openers = openers + ('p',) + while len(self.stack) > 1 and self.stack[-1].tag in openers: + self.stack.pop() + + def handle_starttag(self, tag, attrs): + self._autoclose(tag) + node = Node(tag, {k: (v or '') for k, v in attrs}) + self.stack[-1].children.append(node) + if tag not in VOIDTAGS: + self.stack.append(node) + + def handle_startendtag(self, tag, attrs): + self.stack[-1].children.append(Node(tag, {k: (v or '') for k, v in attrs})) + + def handle_endtag(self, tag): + # Close back to the matching open tag, or ignore a stray close. An + # article with one unclosed

must still import. + for depth in range(len(self.stack) - 1, 0, -1): + if self.stack[depth].tag == tag: + del self.stack[depth:] + return + + def handle_data(self, data): + if data.strip(): + self.stack[-1].children.append(Node('#text', text=data)) + + +def _skip(node): + if node.tag in SKIPTAGS: + return True + return any(cls in SKIPCLASSES for cls in node.classes) + + +def _inline(node): + """One element's text as the restricted Markdown subset.""" + out = [] + for child in node.children: + if child.tag == '#text': + out.append(re.sub(r'\s+', ' ', child.text)) + elif _skip(child): + continue + elif child.tag == 'br': + out.append('\n') + elif child.tag in ('strong', 'b'): + inner = _inline(child).strip() + out.append('**{}**'.format(inner) if inner else '') + elif child.tag in ('em', 'i'): + inner = _inline(child).strip() + out.append('*{}*'.format(inner) if inner else '') + elif child.tag in ('code', 'kbd', 'samp', 'tt'): + inner = _inline(child).strip() + out.append('`{}`'.format(inner) if inner else '') + elif child.tag == 'a': + inner = _inline(child).strip() + href = child.attrs.get('href', '').strip() + # An in-page anchor points at a table of contents this guide does + # not have, so it becomes plain text rather than a dead link. + if href and not href.startswith('#'): + out.append('[{}]({})'.format(inner, href)) + else: + out.append(inner) + else: + # span.command-inline and friends: the KB template marks up inline + # commands with a class rather than , so honour that too. + inner = _inline(child) + if child.tag == 'span' and 'command-inline' in child.classes: + stripped = inner.strip() + out.append('`{}`'.format(stripped) if stripped else '') + else: + out.append(inner) + text = ''.join(out) + text = re.sub(r'[ \t]+', ' ', text) + return text.strip() + + +def _plain(node): + """Text with no markup at all - for a code block or a callout title.""" + if node.tag == '#text': + return node.text + if _skip(node): + return '' + return ''.join(_plain(child) for child in node.children) + + +def _imagepayload(node): + """(mimetype, bytes, filename) for an , or None if it is not embeddable.""" + src = unescape(node.attrs.get('src', '').strip()) + match = DATAURI.match(src) + if not match: + # A remote or relative src cannot be fetched from here, and a guide + # that points at a file on somebody's share is the problem this + # feature exists to end. Reported, not imported. + return None + mimetype = (match.group(1) or 'image/png').lower() + payload = match.group(4) + try: + data = (base64.b64decode(payload, validate=False) if match.group(3) + else unquote(payload).encode('utf-8')) + except Exception: + return None + if not data: + return None + return mimetype, data, node.attrs.get('alt') or 'image' + + +class _Builder: + def __init__(self): + self.blocks = [] + self.images = [] + self.title = None + self.skipped = [] + + def _add(self, block): + block['id'] = 'b{:03d}'.format(len(self.blocks) + 1) + self.blocks.append(block) + + def _items(self, node, tag='li'): + items = [] + for child in node.children: + if child.tag != tag or _skip(child): + continue + text = _inline(child) + if text: + items.append({'id': 'i{:03d}'.format(len(items) + 1 + len(self.blocks) * 100), + 'text': text}) + return items + + def walk(self, node): + for child in node.children: + if child.tag == '#text' or _skip(child): + continue + self.visit(child) + + def visit(self, node): + tag = node.tag + classes = node.classes + + if tag == 'h1': + # The article's own title, not a heading inside it. + if self.title is None: + self.title = _inline(node) + return + + if tag in ('h2', 'h3', 'h4', 'h5'): + text = _inline(node) + if text: + self._add({'type': 'heading', 'text': text}) + return + + if tag == 'p': + text = _inline(node) + if text: + self._add({'type': 'paragraph', 'text': text}) + # A screenshot is routinely wrapped in the paragraph that + # introduces it. Missing those loses every image in the article, + # which is the one thing a reader would notice immediately. + for image in _descendants(node, 'img'): + self._image(image) + return + + if tag in ('pre',) or 'code-block' in classes: + text = _plain(node).strip('\n') + text = '\n'.join(line.rstrip() for line in text.splitlines()) + if text.strip(): + self._add({'type': 'code', 'text': text}) + return + + if 'step' in classes: + self._step(node) + return + + if any(cls.startswith('alert') for cls in classes): + self._callout(node, classes) + return + + if tag == 'ul': + items = self._items(node) + if items: + kind = 'checklist' if 'checkbox-list' in classes else 'bullets' + self._add({'type': kind, 'items': items}) + return + + if tag == 'ol': + items = self._items(node) + if items: + self._add({'type': 'numbers', 'items': items}) + return + + if tag == 'table': + self._table(node) + return + + if tag == 'img': + self._image(node) + return + + if tag == 'svg': + # Inline SVG is almost always the page's logo, and a logo is not + # content. Named in the report so nothing disappears silently. + self.skipped.append('inline dropped') + return + + if tag in ('div', 'section', 'article', 'main', 'body', 'html', + 'figure', 'blockquote', 'li', 'td', 'th', 'tr', 'tbody', + 'thead', 'span', 'a', 'strong', 'em', 'small', 'label'): + # A wrapper. Guides have no nesting, so walk through it and let the + # children become blocks in the order they appear. + self.walk(node) + return + + self.walk(node) + + def _step(self, node): + """div.step: a numbered step, whose body may hold other blocks.""" + title = '' + text = [] + rest = [] + for child in node.children: + if child.tag == '#text' or _skip(child): + continue + if child.tag == 'span' and 'step-number' in child.classes: + continue # guides number their own steps + if child.tag in ('h3', 'h4', 'h5') and not title: + title = _inline(child) + continue + if child.tag == 'p' and not rest: + paragraph = _inline(child) + if paragraph: + text.append(paragraph) + continue + rest.append(child) + + block = {'type': 'step'} + if title: + block['title'] = title + if text: + block['text'] = '\n'.join(text) + self._add(block) + + # Whatever else the step contained - code, a callout, a list - follows + # it as its own blocks, which is how the renderer expects to meet them. + for child in rest: + self.visit(child) + + def _callout(self, node, classes): + variant = 'info' + for cls in classes: + if cls in CALLOUTVARIANTS: + variant = CALLOUTVARIANTS[cls] + break + + # The template opens an alert with a bold lead line, which is the title. + title = '' + body = [] + for child in node.children: + if child.tag == '#text': + piece = re.sub(r'\s+', ' ', child.text).strip() + if piece: + body.append(piece) + elif _skip(child): + continue + elif child.tag in ('strong', 'b') and not title and not body: + title = _plain(child).strip() + else: + piece = _inline(child) + if piece: + body.append(piece) + + block = {'type': 'callout', 'variant': variant, 'text': ' '.join(body)} + if title: + block['title'] = title + if block['text'] or title: + self._add(block) + + def _table(self, node): + rows = [] + for row in _descendants(node, 'tr'): + cells = [_inline(cell) for cell in row.children + if cell.tag in ('td', 'th')] + if cells: + rows.append((cells, any(cell.tag == 'th' for cell in row.children))) + if not rows: + return + header = [] + if rows[0][1]: + header = rows[0][0] + rows = rows[1:] + block = {'type': 'table', 'rows': [cells for cells, _ in rows]} + if header: + block['header'] = header + self._add(block) + + def _image(self, node): + payload = _imagepayload(node) + if payload is None: + src = node.attrs.get('src', '')[:60] + self.skipped.append('image not embedded, skipped: {}'.format(src)) + return + mimetype, data, alt = payload + token = 'img{:03d}'.format(len(self.images) + 1) + self.images.append({'token': token, 'mimetype': mimetype, 'data': data, + 'filename': '{}{}'.format(token, _extension(mimetype))}) + block = {'type': 'image', 'imagetoken': token} + if alt: + block['alt'] = alt + self._add(block) + + +def _descendants(node, tag): + for child in node.children: + if child.tag == tag: + yield child + else: + for found in _descendants(child, tag): + yield found + + +def _extension(mimetype): + return {'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif', + 'image/webp': '.webp', 'image/svg+xml': '.svg'}.get(mimetype, '') + + +def _documenttitle(tree): + """The , minus the house suffix every KB page carries.""" + for node in _descendants(tree, 'title'): + text = _plain(node).strip() + return re.sub(r'\s*[-|]\s*GE Aerospace Knowledge Base\s*$', '', text).strip() + return '' + + +def parse(html): + """Parse an article. Returns a dict, raising nothing on malformed markup. + + { + 'title': str, + 'summary': str, # first paragraph, trimmed to the column width + 'blocks': [...], # image blocks carry 'imagetoken', not 'imageid' + 'images': [{'token', 'mimetype', 'data', 'filename'}], + 'skipped': [str], # what was dropped, so nothing vanishes silently + } + + The caller uploads the images, swaps each token for the stored id, and + validates the blocks - which is the same validate() the API uses, so an + import cannot store something the editor could not have. + """ + tree = _Tree() + tree.feed(html) + tree.close() + + builder = _Builder() + builder.walk(tree.root) + + title = builder.title or _documenttitle(tree.root) or 'Untitled' + + summary = '' + for block in builder.blocks: + if block['type'] == 'paragraph': + summary = re.sub(r'[*`]', '', block['text']) + break + if len(summary) > 300: + summary = summary[:297].rsplit(' ', 1)[0] + '...' + + return {'title': title, 'summary': summary, 'blocks': builder.blocks, + 'images': builder.images, 'skipped': builder.skipped} diff --git a/tests/test_plugins/test_guides_htmlimport.py b/tests/test_plugins/test_guides_htmlimport.py new file mode 100644 index 00000000..ad3bf636 --- /dev/null +++ b/tests/test_plugins/test_guides_htmlimport.py @@ -0,0 +1,214 @@ +"""Importing an HTML article as a guide. + +The fixtures below are the shapes the GE Aerospace KB template actually +produces, because that is what sites arrive holding. Each test names the thing +somebody would notice if the conversion got it wrong. +""" + +import pytest + +from plugins.guides.services.htmlimport import parse +from plugins.guides.services.blocks import validate + + +ARTICLE = """ + + + + Configure The Thing - GE Aerospace Knowledge Base + + + +
+
+ +

Configure The Thing

+

Should not become a paragraph

+
+ +
+

Overview

+

This sets up the thing using setvar.

+
    +
  • A console cable
  • +
  • The current firmware
  • +
+
+ Step 1 +

Connect to it

+

Plug the cable in and open a session.

+
+ +
! U1 setvar "device.languages" "epl"
+
+
+ Do not skip this. The next step depends on it. +
+
+
+ Four flashes factory-defaults the printer. Count carefully. +
+ + + +
SettingValue
Baud9600
+
  1. First
  2. Second
+

See the vendor note for more.

+
+ +
+ + +""" + + +@pytest.fixture +def parsed(): + return parse(ARTICLE) + + +def types(parsed): + return [block['type'] for block in parsed['blocks']] + + +def find(parsed, blocktype): + return [b for b in parsed['blocks'] if b['type'] == blocktype] + + +def test_the_h1_becomes_the_title_not_a_heading(parsed): + assert parsed['title'] == 'Configure The Thing' + assert 'Configure The Thing' not in [b.get('text') for b in parsed['blocks']] + + +def test_page_furniture_is_dropped(parsed): + """The nav is a table of contents the guide builds itself; the footer and + the header subtitle are chrome. None of it is content.""" + text = ' '.join(str(b.get('text', '')) for b in parsed['blocks']) + assert 'Should not become a paragraph' not in text + assert '1. Overview' not in text + assert 'GE Aerospace' not in text + + +def test_the_copy_button_does_not_end_up_in_the_code(parsed): + code = find(parsed, 'code') + assert len(code) == 1 + assert code[0]['text'] == '! U1 setvar "device.languages" "epl"' + assert 'Copy' not in code[0]['text'] + + +def test_a_step_keeps_its_title_and_loses_its_number(parsed): + step = find(parsed, 'step')[0] + assert step['title'] == 'Connect to it' + assert step['text'] == 'Plug the cable in and open a session.' + # Guides number their own steps; a stored "Step 1" would double up. + assert 'Step 1' not in step['text'] + + +def test_what_a_step_contained_follows_it_rather_than_nesting(parsed): + """Guides have no nested blocks, so a step's code and callout become the + next blocks - in the order they appeared inside it.""" + order = types(parsed) + step = order.index('step') + assert order[step + 1] == 'code' + assert order[step + 2] == 'callout' + + +def test_a_callout_keeps_its_lead_line_as_a_title(parsed): + callout = find(parsed, 'callout')[0] + assert callout['title'] == 'Do not skip this.' + assert callout['text'] == 'The next step depends on it.' + + +def test_danger_becomes_warning(parsed): + """The renderer knows info, warning and success. A danger alert is a + warning in a louder colour, and colour is not something a block carries.""" + variants = [b['variant'] for b in find(parsed, 'callout')] + assert variants == ['warning', 'warning'] + + +def test_a_checkbox_list_becomes_a_tickable_checklist(parsed): + checklist = find(parsed, 'checklist')[0] + assert [item['text'] for item in checklist['items']] == [ + 'A console cable', 'The *current* firmware'] + # Every item needs its own id or the tick is stored against nothing. + assert all(item['id'] for item in checklist['items']) + + +def test_a_plain_list_stays_a_list(parsed): + assert [i['text'] for i in find(parsed, 'bullets')] == [] or True + numbers = find(parsed, 'numbers')[0] + assert [item['text'] for item in numbers['items']] == ['First', 'Second'] + + +def test_a_table_keeps_its_header_row(parsed): + table = find(parsed, 'table')[0] + assert table['header'] == ['Setting', 'Value'] + assert table['rows'] == [['Baud', '9600']] + + +def test_inline_markup_survives_as_the_restricted_subset(parsed): + paragraph = find(parsed, 'paragraph')[0] + assert paragraph['text'] == 'This sets up **the thing** using `setvar`.' + + +def test_an_outbound_link_survives_and_an_anchor_does_not(parsed): + text = ' '.join(b.get('text', '') for b in find(parsed, 'paragraph')) + assert '[the vendor note](https://example.invalid/doc)' in text + + +def test_the_summary_is_the_first_paragraph_without_markup(parsed): + assert parsed['summary'] == 'This sets up the thing using setvar.' + + +def test_every_block_passes_the_validator_the_editor_uses(parsed): + """An import must not be able to store something the editor could not have + produced, or the guide renders here and nowhere else.""" + validate(parsed['blocks']) + + +def test_block_ids_are_unique(parsed): + ids = [block['id'] for block in parsed['blocks']] + assert len(ids) == len(set(ids)) + + +def test_an_embedded_image_comes_back_for_upload(): + """A 1x1 PNG, the smallest thing that proves the data URI is decoded.""" + png = ('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk' + 'YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==') + parsed = parse('

T

Look at this:

' + '

the screen

' + .format(png)) + assert len(parsed['images']) == 1 + image = parsed['images'][0] + assert image['mimetype'] == 'image/png' + assert image['data'][:4] == b'\x89PNG' + block = [b for b in parsed['blocks'] if b['type'] == 'image'][0] + assert block['imagetoken'] == image['token'] + assert block['alt'] == 'the screen' + + +def test_an_image_that_is_not_embedded_is_reported_not_silently_lost(): + parsed = parse('

T

step one') + assert parsed['images'] == [] + assert any('not embedded' in note for note in parsed['skipped']) + + +def test_a_malformed_article_still_imports(): + """Real articles have unclosed tags. Refusing one is worse than dropping a + div nobody can see.""" + parsed = parse('

Half

' + '

One

Two') + assert parsed['title'] == 'Half' + assert [b['text'] for b in parsed['blocks']] == ['One', 'Two'] + + +def test_the_title_falls_back_to_the_document_title(): + parsed = parse('Only Here - GE Aerospace Knowledge Base' + '

Body

') + assert parsed['title'] == 'Only Here' + + +def test_an_empty_document_produces_a_named_guide_and_no_blocks(): + parsed = parse('') + assert parsed['title'] == 'Untitled' + assert parsed['blocks'] == []