Guides: write a procedure in ShopDB, not beside it
The knowledgebase plugin is a link registry - a description, a URL and some keywords pointing at a document held somewhere else. That leaves the documents themselves as HTML files on a share: no search, no history, no way to know whether the copy pinned to a noticeboard is current, and no way for a technician to tick off where they are. A guide is stored as an ordered list of typed blocks rather than a blob of HTML, because three of the four things this feature exists for depend on it. A tickable step needs a stable id to be remembered; dark mode needs semantic markup rather than pasted colours; and print needs to know where a step begins so it does not split one across a page. Free HTML gives none of that and lets pasted styling fight the theme. Inline text is a restricted Markdown subset - bold, italic, inline code, links, line breaks - escaped BEFORE formatting, so nothing an author pasted can reach the DOM as markup. No fonts, sizes or colours: emphasis is semantic and the theme decides how it looks. That is what makes a guide written next year match one written today, and dark mode work without an author thinking about it. DRAFTS AND VERSIONS. The guide row is the working copy; every save that changes something writes an immutable revision; and a separate pointer says which revision READERS get. Without that pointer, editing a live procedure changes it under a technician who is halfway through following it. Publishing promotes an existing revision rather than snapshotting, so a publish with unsaved edits cannot ship a version nobody reviewed. Restoring writes a new revision with old content, so the record of what people were told in between survives. Saves dedup on a content hash covering title, summary, tags and blocks, so an autosave that changed nothing writes no version - otherwise the history fills with identical entries and hides the three edits that mattered. Progress lives in the browser, not the database: shopfloor PCs share a login, so per-user ticks would show one technician's progress to the next person on that bay, which reads as "already done". Keyed by guide and version, because step 4 of version 3 is not step 4 of version 2. Five permissions, and the split between edit and publish is the point: a technician can write a procedure up while somebody else decides it is fit for the floor. create-without-edit works too - an author may edit their own guide until it is published, after which other people are following it.
This commit is contained in:
3
plugins/guides/__init__.py
Normal file
3
plugins/guides/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .plugin import GuidesPlugin
|
||||
|
||||
__all__ = ['GuidesPlugin']
|
||||
3
plugins/guides/api/__init__.py
Normal file
3
plugins/guides/api/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .routes import guides_bp
|
||||
|
||||
__all__ = ['guides_bp']
|
||||
442
plugins/guides/api/routes.py
Normal file
442
plugins/guides/api/routes.py
Normal file
@@ -0,0 +1,442 @@
|
||||
"""Guides API.
|
||||
|
||||
TWO WAYS TO READ A GUIDE, on purpose:
|
||||
|
||||
GET /api/guides/<slug> what was PUBLISHED
|
||||
GET /api/guides/<id>/working the working copy, for editors
|
||||
|
||||
A reader following a procedure must not have it change under them because
|
||||
somebody upstairs is editing. The published pointer is what makes that true, and
|
||||
serving the working copy from the reader's route would quietly undo it.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
from flask import Blueprint, request, Response
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity, current_user
|
||||
|
||||
from shopdb.api import (
|
||||
db,
|
||||
success_response, error_response, ErrorCodes,
|
||||
require_permission,
|
||||
)
|
||||
|
||||
from ..models import Guide, GuideRevision, GuideImage
|
||||
from ..services import authoring
|
||||
from ..services.blocks import BlockError, checklistitemcount
|
||||
|
||||
guides_bp = Blueprint('guides', __name__)
|
||||
|
||||
|
||||
# Screenshots of a settings dialog, not photographs. Generous for the former and
|
||||
# small enough that a pasted phone picture is refused rather than silently
|
||||
# making a guide take ten seconds to open on a shopfloor PC.
|
||||
MAXIMAGEBYTES = 8 * 1024 * 1024
|
||||
ALLOWEDIMAGETYPES = {
|
||||
'image/png': '.png',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/gif': '.gif',
|
||||
'image/webp': '.webp',
|
||||
}
|
||||
|
||||
|
||||
def _userid():
|
||||
if current_user is not None:
|
||||
return current_user.userid
|
||||
try:
|
||||
return int(get_jwt_identity())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _has(permission):
|
||||
"""Whether the caller holds a permission, without raising.
|
||||
|
||||
require_permission decorates a route; this is for the in-route decisions -
|
||||
may this person see drafts, may they edit this particular guide. Same check
|
||||
the decorator makes, including the admin bypass that lives inside
|
||||
haspermission.
|
||||
"""
|
||||
return bool(current_user is not None
|
||||
and current_user.haspermission(permission))
|
||||
|
||||
|
||||
def _mayedit(guide):
|
||||
"""Edit rights for one guide.
|
||||
|
||||
guides.edit covers everything. Otherwise an author may edit their OWN guide
|
||||
while it is unpublished - which is what makes drafting useful without
|
||||
handing everyone rights over published procedures. Once it is published it
|
||||
takes guides.edit, because by then other people are following it.
|
||||
"""
|
||||
if _has('guides.edit'):
|
||||
return True
|
||||
if not _has('guides.create'):
|
||||
return False
|
||||
return guide.createdby == _userid() and not guide.publishedrevisionid
|
||||
|
||||
|
||||
def _slugify(title, guideid=None):
|
||||
"""A readable, stable, unique slug.
|
||||
|
||||
Stored rather than derived on read, so renaming a guide cannot break a link
|
||||
somebody pasted into a work order months ago.
|
||||
"""
|
||||
base = re.sub(r'[^a-z0-9]+', '-', (title or '').lower()).strip('-') or 'guide'
|
||||
base = base[:200]
|
||||
candidate = base
|
||||
suffix = 2
|
||||
while True:
|
||||
clash = Guide.query.filter(Guide.slug == candidate)
|
||||
if guideid:
|
||||
clash = clash.filter(Guide.guideid != guideid)
|
||||
if not clash.first():
|
||||
return candidate
|
||||
candidate = '{}-{}'.format(base, suffix)
|
||||
suffix += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- reading ---
|
||||
|
||||
@guides_bp.route('', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def list_guides():
|
||||
"""Published guides, plus the caller's own drafts if they can author.
|
||||
|
||||
A half-written procedure on a shop floor is worse than no procedure, so
|
||||
drafts are invisible to readers - but an author must be able to find their
|
||||
own, or drafting is a trap door.
|
||||
"""
|
||||
query = Guide.query
|
||||
|
||||
search = (request.args.get('q') or '').strip()
|
||||
if search:
|
||||
like = '%{}%'.format(search)
|
||||
query = query.filter(db.or_(
|
||||
Guide.title.ilike(like),
|
||||
Guide.summary.ilike(like),
|
||||
Guide.tags.ilike(like),
|
||||
# The body too: people look for "the one about the drive letter",
|
||||
# not for a title they never read.
|
||||
Guide.blocksjson.ilike(like),
|
||||
))
|
||||
|
||||
tag = (request.args.get('tag') or '').strip()
|
||||
if tag:
|
||||
query = query.filter(Guide.tags.ilike('%{}%'.format(tag)))
|
||||
|
||||
caneditall = _has('guides.edit')
|
||||
if not caneditall:
|
||||
mine = _userid()
|
||||
query = query.filter(db.or_(
|
||||
Guide.publishedrevisionid.isnot(None),
|
||||
Guide.createdby == mine,
|
||||
))
|
||||
|
||||
rows = query.order_by(Guide.updatedat.desc()).all()
|
||||
|
||||
out = []
|
||||
for guide in rows:
|
||||
data = guide.to_dict()
|
||||
data['ispublished'] = bool(guide.publishedrevisionid)
|
||||
data['checklistitems'] = checklistitemcount(guide.blocks)
|
||||
data['canedit'] = _mayedit(guide)
|
||||
out.append(data)
|
||||
return success_response(out)
|
||||
|
||||
|
||||
@guides_bp.route('/tags', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def list_tags():
|
||||
"""Every tag in use, with a count, for the filter chips."""
|
||||
counts = {}
|
||||
for (tags,) in db.session.query(Guide.tags).filter(Guide.tags.isnot(None)).all():
|
||||
for tag in [t.strip() for t in tags.split(',') if t.strip()]:
|
||||
counts[tag] = counts.get(tag, 0) + 1
|
||||
return success_response([
|
||||
{'tag': tag, 'count': count}
|
||||
for tag, count in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0].lower()))
|
||||
])
|
||||
|
||||
|
||||
@guides_bp.route('/<slug>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def read_guide(slug):
|
||||
"""What a reader gets: the published revision.
|
||||
|
||||
An unpublished guide is 404 to anyone who cannot edit it - not 403, which
|
||||
would confirm it exists.
|
||||
"""
|
||||
guide = Guide.query.filter_by(slug=slug).first()
|
||||
if guide is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
|
||||
|
||||
revision = authoring.readable(guide)
|
||||
if revision is None:
|
||||
if not _mayedit(guide):
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found',
|
||||
http_code=404)
|
||||
# An editor previewing their own unpublished draft.
|
||||
data = guide.to_dict(includeblocks=True)
|
||||
data['ispublished'] = False
|
||||
data['isdraftpreview'] = True
|
||||
data['canedit'] = True
|
||||
return success_response(data)
|
||||
|
||||
data = guide.to_dict()
|
||||
data.update({
|
||||
'title': revision.title,
|
||||
'summary': revision.summary,
|
||||
'tags': [t.strip() for t in (revision.tags or '').split(',') if t.strip()],
|
||||
'blocks': revision.blocks,
|
||||
'revision': revision.revision,
|
||||
'ispublished': True,
|
||||
'isdraftpreview': False,
|
||||
'canedit': _mayedit(guide),
|
||||
# So an editor can see there is newer unpublished work without leaving
|
||||
# the page.
|
||||
'hasunpublishedchanges': guide.revision != revision.revision,
|
||||
})
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>/working', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def read_working(guideid):
|
||||
"""The working copy, for the editor."""
|
||||
guide = db.session.get(Guide, guideid)
|
||||
if guide is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
|
||||
if not _mayedit(guide):
|
||||
return error_response(ErrorCodes.FORBIDDEN,
|
||||
'You do not have permission to edit this guide',
|
||||
http_code=403)
|
||||
data = guide.to_dict(includeblocks=True)
|
||||
data['ispublished'] = bool(guide.publishedrevisionid)
|
||||
data['canedit'] = True
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>/revisions', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def list_revisions(guideid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
if guide is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
|
||||
rows = (GuideRevision.query.filter_by(guideid=guideid)
|
||||
.order_by(GuideRevision.revision.desc()).all())
|
||||
published = guide.publishedrevisionid
|
||||
return success_response([
|
||||
dict(row.to_dict(), ispublished=(row.guiderevisionid == published))
|
||||
for row in rows
|
||||
])
|
||||
|
||||
|
||||
@guides_bp.route('/revisions/<int:revisionid>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def read_revision(revisionid):
|
||||
revision = db.session.get(GuideRevision, revisionid)
|
||||
if revision is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Revision not found',
|
||||
http_code=404)
|
||||
return success_response(revision.to_dict(includeblocks=True))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- writing ---
|
||||
|
||||
@guides_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.create')
|
||||
def create_guide():
|
||||
data = request.get_json(silent=True) or {}
|
||||
title = (data.get('title') or '').strip()
|
||||
if not title:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'A guide needs a title')
|
||||
|
||||
guide = Guide(title=title, slug=_slugify(title), status='draft',
|
||||
createdby=_userid(), updatedby=_userid())
|
||||
db.session.add(guide)
|
||||
db.session.flush()
|
||||
|
||||
try:
|
||||
authoring.save(guide,
|
||||
title=title,
|
||||
summary=data.get('summary', ''),
|
||||
tags=data.get('tags', ''),
|
||||
blocks=data.get('blocks', []),
|
||||
note=data.get('note'),
|
||||
userid=_userid())
|
||||
except BlockError as exc:
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
|
||||
|
||||
db.session.commit()
|
||||
return success_response(guide.to_dict(includeblocks=True),
|
||||
message='Guide created')
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def update_guide(guideid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
if guide is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
|
||||
if not _mayedit(guide):
|
||||
return error_response(ErrorCodes.FORBIDDEN,
|
||||
'You do not have permission to edit this guide',
|
||||
http_code=403)
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
if 'title' in data and not (data.get('title') or '').strip():
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'A guide needs a title')
|
||||
|
||||
try:
|
||||
guide, revision = authoring.save(
|
||||
guide,
|
||||
title=data.get('title'),
|
||||
summary=data.get('summary'),
|
||||
tags=data.get('tags'),
|
||||
blocks=data.get('blocks'),
|
||||
note=data.get('note'),
|
||||
userid=_userid())
|
||||
except BlockError as exc:
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
|
||||
|
||||
if 'title' in data:
|
||||
guide.slug = _slugify(guide.title, guide.guideid)
|
||||
|
||||
db.session.commit()
|
||||
result = guide.to_dict(includeblocks=True)
|
||||
# 'unchanged' is a real outcome, not a failure: an autosave where the author
|
||||
# changed nothing must not manufacture a version.
|
||||
result['saved'] = revision is not None
|
||||
return success_response(result,
|
||||
message='Saved' if revision else 'No changes to save')
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>/publish', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.publish')
|
||||
def publish_guide(guideid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
if guide is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
|
||||
try:
|
||||
revision = authoring.publish(guide, userid=_userid())
|
||||
except ValueError as exc:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
|
||||
db.session.commit()
|
||||
return success_response(guide.to_dict(),
|
||||
message='Published version {}'.format(revision.revision))
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>/unpublish', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.publish')
|
||||
def unpublish_guide(guideid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
if guide is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
|
||||
authoring.unpublish(guide, userid=_userid())
|
||||
db.session.commit()
|
||||
return success_response(guide.to_dict(), message='Withdrawn from readers')
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>/restore/<int:revisionid>', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def restore_revision(guideid, revisionid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
revision = db.session.get(GuideRevision, revisionid)
|
||||
if guide is None or revision is None or revision.guideid != guideid:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Revision not found',
|
||||
http_code=404)
|
||||
if not _mayedit(guide):
|
||||
return error_response(ErrorCodes.FORBIDDEN,
|
||||
'You do not have permission to edit this guide',
|
||||
http_code=403)
|
||||
|
||||
authoring.restore(guide, revision, userid=_userid())
|
||||
db.session.commit()
|
||||
return success_response(guide.to_dict(includeblocks=True),
|
||||
message='Restored version {} as a new draft version'
|
||||
.format(revision.revision))
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.delete')
|
||||
def delete_guide(guideid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
if guide is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
|
||||
db.session.delete(guide)
|
||||
db.session.commit()
|
||||
return success_response({'guideid': guideid}, message='Guide deleted')
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- images ---
|
||||
|
||||
@guides_bp.route('/images', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.create')
|
||||
def upload_image():
|
||||
"""Take a pasted or dropped image.
|
||||
|
||||
Unattached at first: an image is pasted into a NEW guide before that guide
|
||||
has an id. The editor attaches it on save; anything never attached is
|
||||
orphaned and prunable, which is a tidier problem than refusing to accept a
|
||||
paste until the author has saved.
|
||||
"""
|
||||
upload = request.files.get('file')
|
||||
if upload is None:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
|
||||
|
||||
mimetype = (upload.mimetype or '').lower()
|
||||
if mimetype not in ALLOWEDIMAGETYPES:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Unsupported image type {!r}. Allowed: {}'.format(
|
||||
mimetype, ', '.join(sorted(ALLOWEDIMAGETYPES))))
|
||||
|
||||
data = upload.read()
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'That file is empty')
|
||||
if len(data) > MAXIMAGEBYTES:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'That image is {:.1f} MB; the limit is {} MB'.format(
|
||||
len(data) / 1048576, MAXIMAGEBYTES // 1048576))
|
||||
|
||||
guideid = request.form.get('guideid', type=int)
|
||||
image = GuideImage(guideid=guideid, filename=upload.filename,
|
||||
mimetype=mimetype, bytesize=len(data), data=data,
|
||||
uploadedby=_userid())
|
||||
db.session.add(image)
|
||||
db.session.commit()
|
||||
return success_response(image.to_dict(), message='Image uploaded')
|
||||
|
||||
|
||||
@guides_bp.route('/images/<int:imageid>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def serve_image(imageid):
|
||||
image = db.session.get(GuideImage, imageid)
|
||||
if image is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Image not found', http_code=404)
|
||||
return Response(image.data, mimetype=image.mimetype, headers={
|
||||
# Immutable: an image is never edited in place, only replaced by a new
|
||||
# upload with a new id, so it can be cached hard.
|
||||
'Cache-Control': 'private, max-age=31536000, immutable',
|
||||
'Content-Disposition': 'inline; filename="{}"'.format(
|
||||
image.filename or 'image{}'.format(imageid)),
|
||||
})
|
||||
240
plugins/guides/frontend/components/GuideBlocks.vue
Normal file
240
plugins/guides/frontend/components/GuideBlocks.vue
Normal file
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<div class="gb-blocks" :class="{ 'gb-interactive': interactive }">
|
||||
<template v-for="(block, index) in blocks" :key="block.id || index">
|
||||
|
||||
<h2 v-if="block.type === 'heading'" :id="anchorFor(block)" class="gb-heading"
|
||||
v-html="inline(block.text)"></h2>
|
||||
|
||||
<p v-else-if="block.type === 'paragraph'" class="gb-para"
|
||||
v-html="inline(block.text)"></p>
|
||||
|
||||
<div v-else-if="block.type === 'step'" class="gb-step">
|
||||
<span class="gb-stepnum">Step {{ stepNumber(index) }}</span>
|
||||
<h3 v-if="block.title" v-html="inline(block.title)"></h3>
|
||||
<div v-if="block.text" v-html="inline(block.text)"></div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
The reason this feature exists. Interactive ONLY when viewing: in the
|
||||
editor a tick would be an edit, and an author clicking around their own
|
||||
draft would quietly change what the next reader sees as "done".
|
||||
-->
|
||||
<ul v-else-if="block.type === 'checklist'" class="gb-checklist">
|
||||
<li v-for="item in itemsOf(block)" :key="item.id" class="gb-check">
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
:checked="isTicked(item.id)"
|
||||
:disabled="!interactive"
|
||||
@change="toggle(item.id, $event.target.checked)">
|
||||
<span :class="{ 'gb-done': interactive && isTicked(item.id) }"
|
||||
v-html="inline(item.text)"></span>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul v-else-if="block.type === 'bullets'" class="gb-list">
|
||||
<li v-for="item in itemsOf(block)" :key="item.id" v-html="inline(item.text)"></li>
|
||||
</ul>
|
||||
|
||||
<ol v-else-if="block.type === 'numbers'" class="gb-list">
|
||||
<li v-for="item in itemsOf(block)" :key="item.id" v-html="inline(item.text)"></li>
|
||||
</ol>
|
||||
|
||||
<div v-else-if="block.type === 'callout'"
|
||||
class="gb-callout" :class="'gb-' + (block.variant || 'info')">
|
||||
<strong v-if="block.title">{{ block.title }}</strong>
|
||||
<div v-html="inline(block.text)"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="block.type === 'code'" class="gb-code">
|
||||
<div class="gb-codehead">
|
||||
<span class="gb-lang">{{ block.language || 'text' }}</span>
|
||||
<span v-if="block.caption" class="gb-caption">{{ block.caption }}</span>
|
||||
<!-- Copies the code ONLY: no caption, no prompt. A prompt glued to a
|
||||
copied command is how somebody ends up pasting "PS C:\>" into a
|
||||
terminal. -->
|
||||
<button type="button" class="gb-copy" @click="copy(block)">
|
||||
{{ copied === block.id ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<pre><code>{{ block.text }}</code></pre>
|
||||
</div>
|
||||
|
||||
<figure v-else-if="block.type === 'image'" class="gb-figure">
|
||||
<img :src="block.url" :alt="block.alt || ''" loading="lazy">
|
||||
<figcaption v-if="block.caption">{{ block.caption }}</figcaption>
|
||||
</figure>
|
||||
|
||||
<div v-else-if="block.type === 'table'" class="gb-tablewrap">
|
||||
<table class="gb-table">
|
||||
<thead v-if="block.header && block.header.length">
|
||||
<tr><th v-for="(cell, c) in block.header" :key="c" v-html="inline(cell)"></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, r) in (block.rows || [])" :key="r">
|
||||
<td v-for="(cell, c) in row" :key="c" v-html="inline(cell)"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- An unknown type renders as nothing rather than as an error. A guide
|
||||
written against a newer version must stay readable on an older one. -->
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { renderInline } from '../utils/inline.js'
|
||||
|
||||
const props = defineProps({
|
||||
blocks: { type: Array, default: () => [] },
|
||||
// Live checkboxes belong to the viewer only.
|
||||
interactive: { type: Boolean, default: false },
|
||||
ticked: { type: Object, default: () => ({}) },
|
||||
})
|
||||
const emit = defineEmits(['toggle'])
|
||||
|
||||
const copied = ref('')
|
||||
|
||||
function inline (text) {
|
||||
return renderInline(text)
|
||||
}
|
||||
|
||||
// Items may be plain strings (bullets) or objects with ids (checklists). One
|
||||
// shape here so the template does not branch on it.
|
||||
function itemsOf (block) {
|
||||
return (block.items || []).map((item, index) =>
|
||||
typeof item === 'string'
|
||||
? { id: (block.id || 'b') + '-' + index, text: item }
|
||||
: item)
|
||||
}
|
||||
|
||||
function isTicked (itemid) {
|
||||
return Boolean(props.ticked[itemid])
|
||||
}
|
||||
|
||||
function toggle (itemid, checked) {
|
||||
if (!props.interactive) return
|
||||
emit('toggle', itemid, checked)
|
||||
}
|
||||
|
||||
// Steps number themselves by position among steps, so inserting a paragraph
|
||||
// between two of them does not renumber anything by hand.
|
||||
const stepIndex = computed(() => {
|
||||
const map = {}
|
||||
let n = 0
|
||||
props.blocks.forEach((block, index) => {
|
||||
if (block.type === 'step') { n += 1; map[index] = n }
|
||||
})
|
||||
return map
|
||||
})
|
||||
function stepNumber (index) {
|
||||
return stepIndex.value[index] || 1
|
||||
}
|
||||
|
||||
function anchorFor (block) {
|
||||
return 'g-' + (block.id || '')
|
||||
}
|
||||
|
||||
async function copy (block) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(block.text || '')
|
||||
copied.value = block.id
|
||||
setTimeout(() => { copied.value = '' }, 2000)
|
||||
} catch (err) {
|
||||
// Clipboard needs a secure context. Select the text instead of failing
|
||||
// silently, so the tech can copy it by hand.
|
||||
const node = document.querySelector('.gb-code pre')
|
||||
if (node && window.getSelection) {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(node)
|
||||
const selection = window.getSelection()
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Every colour is a theme token. Nothing here is a literal, which is what makes
|
||||
dark mode work with no dark-mode code. */
|
||||
.gb-blocks { line-height: 1.65; }
|
||||
.gb-heading { margin: 1.6rem 0 0.6rem; font-size: 1.35rem; color: var(--text); }
|
||||
.gb-para { margin: 0 0 0.9rem; color: var(--text); }
|
||||
|
||||
.gb-step {
|
||||
border-left: 3px solid var(--primary);
|
||||
padding: 0.6rem 0 0.6rem 1rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.gb-stepnum {
|
||||
display: inline-block; font-size: 0.72rem; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--primary); font-weight: 600;
|
||||
}
|
||||
.gb-step h3 { margin: 0.2rem 0 0.4rem; font-size: 1.05rem; }
|
||||
|
||||
.gb-checklist { list-style: none; padding: 0; margin: 0 0 1rem; }
|
||||
.gb-check { margin: 0.35rem 0; }
|
||||
.gb-check label { display: flex; gap: 0.6rem; align-items: flex-start; cursor: pointer; }
|
||||
.gb-interactive .gb-check label:hover { color: var(--primary); }
|
||||
.gb-check input { margin-top: 0.28rem; flex: none; width: 1rem; height: 1rem; }
|
||||
.gb-done { opacity: 0.55; text-decoration: line-through; }
|
||||
|
||||
.gb-list { margin: 0 0 1rem 1.2rem; }
|
||||
.gb-list li { margin: 0.25rem 0; }
|
||||
|
||||
.gb-callout {
|
||||
border-left: 4px solid var(--info, var(--primary));
|
||||
background: var(--surface-2, rgba(127, 127, 127, 0.08));
|
||||
padding: 0.75rem 1rem; margin: 0 0 1rem; border-radius: 4px;
|
||||
}
|
||||
.gb-callout strong { display: block; margin-bottom: 0.25rem; }
|
||||
.gb-warning { border-left-color: var(--warning, #d9822b); }
|
||||
.gb-success { border-left-color: var(--success, #2e9e5b); }
|
||||
|
||||
.gb-code { margin: 0 0 1rem; border: 1px solid var(--border); border-radius: 5px; overflow: hidden; }
|
||||
.gb-codehead {
|
||||
display: flex; align-items: center; gap: 0.6rem;
|
||||
padding: 0.3rem 0.6rem; background: var(--surface-2, rgba(127,127,127,0.08));
|
||||
border-bottom: 1px solid var(--border); font-size: 0.75rem;
|
||||
}
|
||||
.gb-lang { text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-light); }
|
||||
.gb-caption { color: var(--text-light); }
|
||||
.gb-copy {
|
||||
margin-left: auto; font-size: 0.72rem; padding: 0.15rem 0.5rem;
|
||||
border: 1px solid var(--border); background: transparent;
|
||||
color: var(--text-light); border-radius: 3px; cursor: pointer;
|
||||
}
|
||||
.gb-copy:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.gb-code pre {
|
||||
margin: 0; padding: 0.7rem 0.8rem; overflow-x: auto;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.84rem;
|
||||
/* WRAP rather than clip: these guides are full of long UNC paths, and a
|
||||
line that scrolls out of sight is a command somebody copies half of. */
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
|
||||
.gb-figure { margin: 0 0 1.2rem; }
|
||||
.gb-figure img { max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: 4px; }
|
||||
.gb-figure figcaption { font-size: 0.8rem; color: var(--text-light); margin-top: 0.35rem; }
|
||||
|
||||
.gb-tablewrap { overflow-x: auto; margin: 0 0 1.2rem; }
|
||||
.gb-table { border-collapse: collapse; width: 100%; }
|
||||
.gb-table th, .gb-table td {
|
||||
border: 1px solid var(--border); padding: 0.45rem 0.6rem; text-align: left;
|
||||
vertical-align: top; font-size: 0.9rem;
|
||||
}
|
||||
.gb-table th { background: var(--surface-2, rgba(127,127,127,0.08)); }
|
||||
|
||||
@media print {
|
||||
/* Paper has no hover, no clipboard and no theme. */
|
||||
.gb-copy { display: none; }
|
||||
.gb-step, .gb-callout, .gb-code, .gb-figure { break-inside: avoid; }
|
||||
.gb-check input { /* keep the box, printed empty, so it can be ticked by hand */ }
|
||||
.gb-done { opacity: 1; text-decoration: none; }
|
||||
.gb-code pre { white-space: pre-wrap; }
|
||||
}
|
||||
</style>
|
||||
42
plugins/guides/frontend/routes.js
Normal file
42
plugins/guides/frontend/routes.js
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Guides plugin routes.
|
||||
*
|
||||
* Three views, and the split between the last two is deliberate: reading a
|
||||
* guide and editing one are different jobs with different rights, and the
|
||||
* checkboxes are live in exactly one of them.
|
||||
*
|
||||
* meta.plugin = 'guides' so the ADR-009 router guard redirects when the backend
|
||||
* plugin is disabled. Every route requires auth because every guides API route
|
||||
* is behind JWT plus a guides.* permission.
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'guides',
|
||||
name: 'guides',
|
||||
component: () => import('./views/GuidesList.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'guides' }
|
||||
},
|
||||
{
|
||||
path: 'guides/new',
|
||||
name: 'guide-new',
|
||||
component: () => import('./views/GuideEditor.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'guides' }
|
||||
},
|
||||
{
|
||||
path: 'guides/:slug',
|
||||
name: 'guide-view',
|
||||
component: () => import('./views/GuideView.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'guides' }
|
||||
},
|
||||
{
|
||||
path: 'guides/:guideid/edit',
|
||||
name: 'guide-edit',
|
||||
component: () => import('./views/GuideEditor.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'guides' }
|
||||
}
|
||||
]
|
||||
|
||||
// No full-screen routes: every view renders inside AppLayout. Declared so
|
||||
// routes.gen.js can read `.toplevel` off the module without Rollup warning
|
||||
// about a missing export.
|
||||
export const toplevel = []
|
||||
82
plugins/guides/frontend/utils/inline.js
Normal file
82
plugins/guides/frontend/utils/inline.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Inline text rendering for guide blocks.
|
||||
*
|
||||
* A guide stores its text as a RESTRICTED Markdown subset - bold, italic,
|
||||
* inline code, links, line breaks - and nothing else. This turns that into
|
||||
* HTML.
|
||||
*
|
||||
* ESCAPE FIRST, THEN FORMAT. The order is the whole security model: every
|
||||
* angle bracket and ampersand becomes an entity before any pattern is applied,
|
||||
* so no markup an author pasted can survive into the DOM. There is no path here
|
||||
* that emits a tag the code below did not write itself.
|
||||
*
|
||||
* The restriction is not only about safety. Pasted rich text carries fonts and
|
||||
* hardcoded colours, which then fight the theme - black text on a dark
|
||||
* background, and nobody can say why. Emphasis is semantic; the stylesheet
|
||||
* decides how it looks. That is what makes dark mode work without an author
|
||||
* thinking about it, and why a guide written next year still matches one
|
||||
* written today.
|
||||
*/
|
||||
|
||||
function escapeHtml (text) {
|
||||
return String(text == null ? '' : text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
// Only http(s) and mailto. A javascript: or data: URL in a link is the one way
|
||||
// an author could otherwise smuggle script past the escaping above.
|
||||
function safeHref (url) {
|
||||
const trimmed = String(url || '').trim()
|
||||
if (/^(https?:|mailto:)/i.test(trimmed)) return trimmed
|
||||
if (trimmed.startsWith('/')) return trimmed // in-app link
|
||||
return null
|
||||
}
|
||||
|
||||
export function renderInline (text) {
|
||||
let html = escapeHtml(text)
|
||||
|
||||
// Inline code first, so formatting characters inside `code` stay literal -
|
||||
// a path like C:\**temp** should not turn half of itself bold.
|
||||
const codeSpans = []
|
||||
html = html.replace(/`([^`]+)`/g, (match, code) => {
|
||||
codeSpans.push(code)
|
||||
return '\u0000CODE' + (codeSpans.length - 1) + '\u0000'
|
||||
})
|
||||
|
||||
// [label](url). The URL pattern allows ONE level of balanced parentheses so
|
||||
// real links survive - Wikipedia-style /wiki/Foo_(bar) is the common case,
|
||||
// and a naive [^)]+ silently truncates it into a broken link.
|
||||
html = html.replace(/\[([^\]]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/g, (match, label, url) => {
|
||||
const href = safeHref(url)
|
||||
if (!href) return label
|
||||
const external = /^https?:/i.test(href)
|
||||
return '<a href="' + escapeHtml(href) + '"' +
|
||||
(external ? ' target="_blank" rel="noopener noreferrer"' : '') +
|
||||
'>' + label + '</a>'
|
||||
})
|
||||
|
||||
// **bold** then *italic*, longest marker first so **x** is not read as
|
||||
// *(*x*)*.
|
||||
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
html = html.replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, '$1<em>$2</em>')
|
||||
|
||||
html = html.replace(/\n/g, '<br>')
|
||||
|
||||
html = html.replace(/\u0000CODE(\d+)\u0000/g, (match, index) =>
|
||||
'<code>' + codeSpans[Number(index)] + '</code>')
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
/** Plain text, for titles and anywhere markup would be noise. */
|
||||
export function stripInline (text) {
|
||||
return String(text == null ? '' : text)
|
||||
.replace(/`([^`]+)`/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)\s]+\)/g, '$1')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
||||
.replace(/\*([^*]+)\*/g, '$1')
|
||||
}
|
||||
427
plugins/guides/frontend/views/GuideEditor.vue
Normal file
427
plugins/guides/frontend/views/GuideEditor.vue
Normal file
@@ -0,0 +1,427 @@
|
||||
<template>
|
||||
<div class="ge" @paste="onPaste" @dragover.prevent @drop.prevent="onDrop">
|
||||
<div class="page-header">
|
||||
<h2>{{ isNew ? 'New guide' : 'Edit guide' }}</h2>
|
||||
<div class="ge-actions">
|
||||
<button type="button" class="ge-btn" @click="save()" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
<button v-if="!isNew && canpublish" type="button" class="ge-btn ge-primary"
|
||||
@click="publish" :disabled="saving">
|
||||
{{ guide.ispublished ? 'Publish changes' : 'Publish' }}
|
||||
</button>
|
||||
<button v-if="!isNew && canpublish && guide.ispublished" type="button"
|
||||
class="ge-btn" @click="unpublish">Withdraw</button>
|
||||
<router-link v-if="!isNew && guide.slug" class="ge-btn"
|
||||
:to="{ name: 'guide-view', params: { slug: guide.slug } }">
|
||||
View
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="message" class="settings-success">{{ message }}</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div class="ge-fields">
|
||||
<label class="ge-field">
|
||||
<span>Title</span>
|
||||
<input v-model="guide.title" type="text" placeholder="What is this guide for?">
|
||||
</label>
|
||||
<label class="ge-field">
|
||||
<span>Summary</span>
|
||||
<input v-model="guide.summary" type="text"
|
||||
placeholder="One line, shown in the list and in search results">
|
||||
</label>
|
||||
<label class="ge-field">
|
||||
<span>Tags</span>
|
||||
<input v-model="guide.tags" type="text"
|
||||
placeholder="Comma separated, e.g. pxe, share, backups">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="ge-blocks">
|
||||
<div v-for="(block, index) in blocks" :key="block.id" class="ge-block">
|
||||
<div class="ge-blockbar">
|
||||
<span class="ge-type">{{ block.type }}</span>
|
||||
<select v-if="block.type === 'callout'" v-model="block.variant" class="ge-mini">
|
||||
<option value="info">info</option>
|
||||
<option value="warning">warning</option>
|
||||
<option value="success">success</option>
|
||||
</select>
|
||||
<input v-if="block.type === 'code'" v-model="block.language"
|
||||
class="ge-mini" placeholder="language">
|
||||
<input v-if="block.type === 'code'" v-model="block.caption"
|
||||
class="ge-mini" placeholder="caption (optional)">
|
||||
<div class="ge-blockbtns">
|
||||
<button type="button" @click="move(index, -1)" :disabled="index === 0">Up</button>
|
||||
<button type="button" @click="move(index, 1)"
|
||||
:disabled="index === blocks.length - 1">Down</button>
|
||||
<button type="button" class="ge-del" @click="remove(index)">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input v-if="block.type === 'step'" v-model="block.title" class="ge-steptitle"
|
||||
placeholder="Step title">
|
||||
|
||||
<textarea v-if="hasText(block)" v-model="block.text" class="ge-text"
|
||||
:rows="block.type === 'code' ? 6 : 3"
|
||||
:placeholder="placeholderFor(block)"></textarea>
|
||||
|
||||
<div v-if="hasItems(block)" class="ge-items">
|
||||
<div v-for="(item, i) in block.items" :key="item.id" class="ge-item">
|
||||
<input v-model="item.text" :placeholder="'Item ' + (i + 1)">
|
||||
<button type="button" @click="removeItem(block, i)">x</button>
|
||||
</div>
|
||||
<button type="button" class="ge-additem" @click="addItem(block)">Add item</button>
|
||||
</div>
|
||||
|
||||
<div v-if="block.type === 'image'" class="ge-image">
|
||||
<img v-if="block.url" :src="block.url" alt="">
|
||||
<input v-model="block.caption" placeholder="Caption (optional)">
|
||||
<input v-model="block.alt" placeholder="Alt text, for anyone who cannot see it">
|
||||
</div>
|
||||
|
||||
<div v-if="block.type === 'table'" class="ge-tableedit">
|
||||
<textarea v-model="block.raw" rows="4" @input="parseTable(block)"
|
||||
placeholder="One row per line, cells separated by | Header | Header Cell | Cell"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ge-add">
|
||||
<span>Add:</span>
|
||||
<button v-for="type in blocktypes" :key="type" type="button"
|
||||
@click="add(type)">{{ type }}</button>
|
||||
<label class="ge-upload">
|
||||
image file
|
||||
<input type="file" accept="image/*" hidden @change="onFilePick">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="ge-hint">
|
||||
Paste or drag an image straight onto this page to add it. Text supports
|
||||
**bold**, *italic*, `code` and [links](https://example.com).
|
||||
</p>
|
||||
|
||||
<div v-if="!isNew && revisions.length" class="ge-revisions">
|
||||
<h3>Version history</h3>
|
||||
<div v-for="rev in revisions" :key="rev.guiderevisionid" class="ge-rev">
|
||||
<span class="ge-revnum">v{{ rev.revision }}</span>
|
||||
<span class="ge-revwhen">{{ formatDate(rev.createdat) }}</span>
|
||||
<span v-if="rev.ispublished" class="ge-revpub">published</span>
|
||||
<span class="ge-revnote">{{ rev.note }}</span>
|
||||
<button type="button" @click="restore(rev)">Restore</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import api from '@/api'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const blocktypes = ['heading', 'paragraph', 'step', 'checklist', 'bullets',
|
||||
'numbers', 'callout', 'code', 'table']
|
||||
|
||||
const guide = reactive({ guideid: null, title: '', summary: '', tags: '',
|
||||
slug: '', ispublished: false })
|
||||
const blocks = ref([])
|
||||
const revisions = ref([])
|
||||
const saving = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
|
||||
const isNew = computed(() => !guide.guideid)
|
||||
const canpublish = computed(() => auth.hasPermission?.('guides.publish') ?? false)
|
||||
|
||||
// Stable ids are what make a tick rememberable between visits, so every block
|
||||
// and every item gets one at creation and keeps it for life.
|
||||
function newid () {
|
||||
if (window.crypto?.randomUUID) return window.crypto.randomUUID().slice(0, 8)
|
||||
return Math.random().toString(36).slice(2, 10)
|
||||
}
|
||||
|
||||
function hasText (block) {
|
||||
return ['heading', 'paragraph', 'step', 'callout', 'code'].includes(block.type)
|
||||
}
|
||||
function hasItems (block) {
|
||||
return ['checklist', 'bullets', 'numbers'].includes(block.type)
|
||||
}
|
||||
function placeholderFor (block) {
|
||||
if (block.type === 'code') return 'The command, exactly as it should be run'
|
||||
if (block.type === 'callout') return 'The thing somebody needs to know before they carry on'
|
||||
return 'Text'
|
||||
}
|
||||
|
||||
function add (type) {
|
||||
const block = { id: newid(), type }
|
||||
if (hasItems(block)) block.items = [{ id: newid(), text: '' }]
|
||||
if (type === 'callout') block.variant = 'info'
|
||||
if (type === 'table') { block.raw = ''; block.header = []; block.rows = [] }
|
||||
blocks.value.push(block)
|
||||
}
|
||||
|
||||
function remove (index) { blocks.value.splice(index, 1) }
|
||||
|
||||
function move (index, delta) {
|
||||
const target = index + delta
|
||||
if (target < 0 || target >= blocks.value.length) return
|
||||
const [block] = blocks.value.splice(index, 1)
|
||||
blocks.value.splice(target, 0, block)
|
||||
}
|
||||
|
||||
function addItem (block) { block.items.push({ id: newid(), text: '' }) }
|
||||
function removeItem (block, index) { block.items.splice(index, 1) }
|
||||
|
||||
// Tables are edited as pipe-separated lines: quick to type, quick to paste out
|
||||
// of a spreadsheet, and no grid widget to maintain.
|
||||
function parseTable (block) {
|
||||
const lines = (block.raw || '').split('\n').filter(l => l.trim())
|
||||
const rows = lines.map(line => line.split('|').map(cell => cell.trim()))
|
||||
block.header = rows.length ? rows[0] : []
|
||||
block.rows = rows.slice(1)
|
||||
}
|
||||
|
||||
async function uploadImage (file) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
if (guide.guideid) form.append('guideid', guide.guideid)
|
||||
const response = await api.post('/guides/images', form,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
return response.data?.data || response.data
|
||||
}
|
||||
|
||||
async function insertImage (file) {
|
||||
error.value = ''
|
||||
try {
|
||||
const image = await uploadImage(file)
|
||||
blocks.value.push({ id: newid(), type: 'image', url: image.url,
|
||||
caption: '', alt: '' })
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Could not upload that image')
|
||||
}
|
||||
}
|
||||
|
||||
// Paste and drop are the two ways people actually add a screenshot. Both end up
|
||||
// in the same place, and neither requires finding a file on disk first.
|
||||
function onPaste (event) {
|
||||
const items = Array.from(event.clipboardData?.items || [])
|
||||
const imageItem = items.find(item => item.type.startsWith('image/'))
|
||||
if (!imageItem) return
|
||||
event.preventDefault()
|
||||
const file = imageItem.getAsFile()
|
||||
if (file) insertImage(file)
|
||||
}
|
||||
|
||||
function onDrop (event) {
|
||||
const file = Array.from(event.dataTransfer?.files || [])
|
||||
.find(f => f.type.startsWith('image/'))
|
||||
if (file) insertImage(file)
|
||||
}
|
||||
|
||||
function onFilePick (event) {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) insertImage(file)
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
function payload () {
|
||||
return {
|
||||
title: guide.title,
|
||||
summary: guide.summary || '',
|
||||
tags: guide.tags || '',
|
||||
blocks: blocks.value,
|
||||
}
|
||||
}
|
||||
|
||||
async function save (quiet) {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
message.value = ''
|
||||
try {
|
||||
if (isNew.value) {
|
||||
const response = await api.post('/guides', payload())
|
||||
const created = response.data?.data || response.data
|
||||
Object.assign(guide, { guideid: created.guideid, slug: created.slug,
|
||||
ispublished: false })
|
||||
router.replace({ name: 'guide-edit', params: { guideid: created.guideid } })
|
||||
message.value = 'Guide created'
|
||||
} else {
|
||||
const response = await api.put('/guides/' + guide.guideid, payload())
|
||||
const saved = response.data?.data || response.data
|
||||
guide.slug = saved.slug
|
||||
// 'No changes to save' is a real outcome, not a failure: an unchanged
|
||||
// save must not manufacture a version.
|
||||
message.value = response.data?.message || 'Saved'
|
||||
}
|
||||
await loadRevisions()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Could not save this guide')
|
||||
} finally {
|
||||
saving.value = false
|
||||
if (!quiet) setTimeout(() => { message.value = '' }, 2500)
|
||||
}
|
||||
}
|
||||
|
||||
async function publish () {
|
||||
await save(true)
|
||||
try {
|
||||
const response = await api.post('/guides/' + guide.guideid + '/publish')
|
||||
guide.ispublished = true
|
||||
message.value = response.data?.message || 'Published'
|
||||
await loadRevisions()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Could not publish this guide')
|
||||
}
|
||||
}
|
||||
|
||||
async function unpublish () {
|
||||
try {
|
||||
await api.post('/guides/' + guide.guideid + '/unpublish')
|
||||
guide.ispublished = false
|
||||
message.value = 'Withdrawn from readers'
|
||||
await loadRevisions()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Could not withdraw this guide')
|
||||
}
|
||||
}
|
||||
|
||||
async function restore (rev) {
|
||||
try {
|
||||
const response = await api.post(
|
||||
'/guides/' + guide.guideid + '/restore/' + rev.guiderevisionid)
|
||||
const restored = response.data?.data || response.data
|
||||
blocks.value = restored.blocks || []
|
||||
Object.assign(guide, { title: restored.title, summary: restored.summary || '',
|
||||
tags: (restored.tags || []).join(', ') })
|
||||
message.value = response.data?.message || 'Restored'
|
||||
await loadRevisions()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Could not restore that version')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRevisions () {
|
||||
if (!guide.guideid) return
|
||||
try {
|
||||
const response = await api.get('/guides/' + guide.guideid + '/revisions')
|
||||
revisions.value = response.data?.data || response.data || []
|
||||
} catch (err) {
|
||||
revisions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate (value) {
|
||||
return value ? new Date(value).toLocaleString() : ''
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const guideid = route.params.guideid
|
||||
if (!guideid) return
|
||||
try {
|
||||
const response = await api.get('/guides/' + guideid + '/working')
|
||||
const data = response.data?.data || response.data
|
||||
Object.assign(guide, {
|
||||
guideid: data.guideid, title: data.title, summary: data.summary || '',
|
||||
tags: (data.tags || []).join(', '), slug: data.slug,
|
||||
ispublished: data.ispublished,
|
||||
})
|
||||
blocks.value = (data.blocks || []).map(block => {
|
||||
if (block.type === 'table' && !block.raw) {
|
||||
const rows = [block.header || [], ...(block.rows || [])]
|
||||
block.raw = rows.filter(r => r.length).map(r => r.join(' | ')).join('\n')
|
||||
}
|
||||
return block
|
||||
})
|
||||
await loadRevisions()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Could not load this guide')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.ge-actions { display: flex; gap: 0.4rem; }
|
||||
.ge-btn {
|
||||
font-size: 0.8rem; padding: 0.3rem 0.7rem; border: 1px solid var(--primary);
|
||||
color: var(--primary); background: transparent; border-radius: 4px;
|
||||
cursor: pointer; text-decoration: none;
|
||||
}
|
||||
.ge-primary { background: var(--primary); color: #fff; }
|
||||
.ge-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.ge-fields { display: grid; gap: 0.6rem; margin: 0.8rem 0 1.2rem; max-width: 45rem; }
|
||||
.ge-field { display: grid; gap: 0.2rem; }
|
||||
.ge-field span { font-size: 0.78rem; color: var(--text-light); }
|
||||
.ge-field input {
|
||||
padding: 0.4rem 0.55rem; border: 1px solid var(--border); border-radius: 4px;
|
||||
background: var(--surface, transparent); color: var(--text);
|
||||
}
|
||||
|
||||
.ge-block {
|
||||
border: 1px solid var(--border); border-radius: 5px; padding: 0.6rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.ge-blockbar { display: flex; gap: 0.4rem; align-items: center; margin-bottom: 0.4rem; }
|
||||
.ge-type {
|
||||
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
color: var(--text-light);
|
||||
}
|
||||
.ge-mini {
|
||||
font-size: 0.75rem; padding: 0.15rem 0.35rem; border: 1px solid var(--border);
|
||||
border-radius: 3px; background: var(--surface, transparent); color: var(--text);
|
||||
max-width: 10rem;
|
||||
}
|
||||
.ge-blockbtns { margin-left: auto; display: flex; gap: 0.25rem; }
|
||||
.ge-blockbtns button {
|
||||
font-size: 0.72rem; padding: 0.12rem 0.4rem; border: 1px solid var(--border);
|
||||
background: transparent; color: var(--text-light); border-radius: 3px; cursor: pointer;
|
||||
}
|
||||
.ge-del:hover { border-color: var(--danger, #c0392b); color: var(--danger, #c0392b); }
|
||||
|
||||
.ge-text, .ge-steptitle, .ge-tableedit textarea {
|
||||
width: 100%; padding: 0.45rem 0.55rem; border: 1px solid var(--border);
|
||||
border-radius: 4px; background: var(--surface, transparent); color: var(--text);
|
||||
font-family: inherit; font-size: 0.9rem;
|
||||
}
|
||||
.ge-steptitle { margin-bottom: 0.4rem; }
|
||||
.ge-items { display: grid; gap: 0.3rem; }
|
||||
.ge-item { display: flex; gap: 0.3rem; }
|
||||
.ge-item input {
|
||||
flex: 1; padding: 0.35rem 0.5rem; border: 1px solid var(--border);
|
||||
border-radius: 4px; background: var(--surface, transparent); color: var(--text);
|
||||
}
|
||||
.ge-additem, .ge-add button, .ge-upload {
|
||||
font-size: 0.75rem; padding: 0.2rem 0.55rem; border: 1px solid var(--border);
|
||||
background: transparent; color: var(--text-light); border-radius: 3px;
|
||||
cursor: pointer; justify-self: start;
|
||||
}
|
||||
.ge-image img { max-width: 20rem; display: block; margin-bottom: 0.4rem;
|
||||
border: 1px solid var(--border); border-radius: 4px; }
|
||||
.ge-image input { width: 100%; margin-bottom: 0.3rem; padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--border); border-radius: 4px;
|
||||
background: var(--surface, transparent); color: var(--text); }
|
||||
|
||||
.ge-add { display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center; margin: 1rem 0 0.4rem; }
|
||||
.ge-add > span { font-size: 0.78rem; color: var(--text-light); }
|
||||
.ge-hint { font-size: 0.78rem; color: var(--text-light); }
|
||||
|
||||
.ge-revisions { margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 0.8rem; }
|
||||
.ge-rev { display: flex; gap: 0.6rem; align-items: center; font-size: 0.8rem;
|
||||
padding: 0.25rem 0; }
|
||||
.ge-revnum { font-weight: 600; }
|
||||
.ge-revwhen, .ge-revnote { color: var(--text-light); }
|
||||
.ge-revpub { font-size: 0.68rem; padding: 0.05rem 0.35rem; border-radius: 3px;
|
||||
border: 1px solid var(--primary); color: var(--primary); }
|
||||
.ge-rev button { margin-left: auto; font-size: 0.72rem; padding: 0.12rem 0.45rem;
|
||||
border: 1px solid var(--border); background: transparent;
|
||||
color: var(--text-light); border-radius: 3px; cursor: pointer; }
|
||||
</style>
|
||||
220
plugins/guides/frontend/views/GuideView.vue
Normal file
220
plugins/guides/frontend/views/GuideView.vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div class="gv">
|
||||
<div v-if="loading" class="gv-loading">Loading...</div>
|
||||
<div v-else-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<template v-else-if="guide">
|
||||
<div class="gv-head">
|
||||
<div class="gv-headmain">
|
||||
<h1>{{ guide.title }}</h1>
|
||||
<p v-if="guide.summary" class="gv-summary">{{ guide.summary }}</p>
|
||||
<div class="gv-meta">
|
||||
<span v-if="guide.isdraftpreview" class="gv-badge gv-draft">Draft preview</span>
|
||||
<span v-else class="gv-badge">Version {{ guide.revision }}</span>
|
||||
<span v-for="tag in guide.tags" :key="tag" class="gv-tag"
|
||||
@click="$router.push({ name: 'guides', query: { tag } })">{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gv-actions">
|
||||
<button type="button" class="gv-btn" @click="print">Print</button>
|
||||
<router-link v-if="guide.canedit" class="gv-btn"
|
||||
:to="{ name: 'guide-edit', params: { guideid: guide.guideid } }">
|
||||
Edit
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- An editor needs to know there is newer work than what readers see,
|
||||
without leaving the page to find out. -->
|
||||
<div v-if="guide.hasunpublishedchanges && guide.canedit" class="gv-notice">
|
||||
There are unpublished changes. Readers are seeing version {{ guide.revision }}.
|
||||
</div>
|
||||
|
||||
<div v-if="totalChecks" class="gv-progress">
|
||||
<div class="gv-progressbar">
|
||||
<div class="gv-progressfill" :style="{ width: progressPercent + '%' }"></div>
|
||||
</div>
|
||||
<span class="gv-progresstext">{{ tickedCount }} of {{ totalChecks }} done</span>
|
||||
<button v-if="tickedCount" type="button" class="gv-reset" @click="resetProgress">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<GuideBlocks :blocks="guide.blocks" :interactive="true"
|
||||
:ticked="ticked" @toggle="toggle" />
|
||||
|
||||
<div class="gv-foot">
|
||||
<span>Version {{ guide.revision }}</span>
|
||||
<span v-if="guide.publishedat">Published {{ formatDate(guide.publishedat) }}</span>
|
||||
<span class="gv-printedon">Printed {{ today }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import api from '@/api'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import GuideBlocks from '../components/GuideBlocks.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const guide = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const ticked = ref({})
|
||||
|
||||
const today = new Date().toLocaleDateString()
|
||||
|
||||
/**
|
||||
* Progress lives in the BROWSER, not the database.
|
||||
*
|
||||
* Shopfloor PCs share a login. Storing progress per user would mean one
|
||||
* technician's ticks appearing for the next person on that bay, which is worse
|
||||
* than useless on a procedure - it reads as "already done". Per browser, each
|
||||
* station keeps its own place, and the Reset button is right there.
|
||||
*
|
||||
* Keyed by guide id and version: a new version invalidates old ticks, because
|
||||
* step 4 of version 3 is not step 4 of version 2.
|
||||
*/
|
||||
function storageKey () {
|
||||
return guide.value
|
||||
? 'guides.progress.' + guide.value.guideid + '.v' + guide.value.revision
|
||||
: null
|
||||
}
|
||||
|
||||
function loadProgress () {
|
||||
const key = storageKey()
|
||||
if (!key) return
|
||||
try {
|
||||
ticked.value = JSON.parse(window.localStorage.getItem(key) || '{}')
|
||||
} catch (err) {
|
||||
ticked.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
function saveProgress () {
|
||||
const key = storageKey()
|
||||
if (!key) return
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(ticked.value))
|
||||
} catch (err) {
|
||||
// Private window, or storage disabled. The checkboxes still work for this
|
||||
// visit; they just will not be remembered. Not worth an error message.
|
||||
}
|
||||
}
|
||||
|
||||
function toggle (itemid, checked) {
|
||||
if (checked) ticked.value = { ...ticked.value, [itemid]: true }
|
||||
else {
|
||||
const next = { ...ticked.value }
|
||||
delete next[itemid]
|
||||
ticked.value = next
|
||||
}
|
||||
saveProgress()
|
||||
}
|
||||
|
||||
function resetProgress () {
|
||||
ticked.value = {}
|
||||
saveProgress()
|
||||
}
|
||||
|
||||
const totalChecks = computed(() => {
|
||||
let total = 0
|
||||
for (const block of guide.value?.blocks || []) {
|
||||
if (block.type === 'checklist') total += (block.items || []).length
|
||||
}
|
||||
return total
|
||||
})
|
||||
const tickedCount = computed(() => Object.keys(ticked.value).length)
|
||||
const progressPercent = computed(() =>
|
||||
totalChecks.value ? Math.round((tickedCount.value / totalChecks.value) * 100) : 0)
|
||||
|
||||
function formatDate (value) {
|
||||
return value ? new Date(value).toLocaleDateString() : ''
|
||||
}
|
||||
|
||||
function print () {
|
||||
window.print()
|
||||
}
|
||||
|
||||
async function load () {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await api.get('/guides/' + route.params.slug)
|
||||
guide.value = response.data?.data || response.data
|
||||
loadProgress()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Could not load this guide')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch(() => route.params.slug, load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gv { max-width: 60rem; }
|
||||
.gv-head { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 0.5rem; }
|
||||
.gv-headmain { flex: 1; }
|
||||
.gv-head h1 { margin: 0 0 0.3rem; }
|
||||
.gv-summary { color: var(--text-light); margin: 0 0 0.5rem; }
|
||||
.gv-meta { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; }
|
||||
.gv-badge {
|
||||
font-size: 0.72rem; padding: 0.1rem 0.45rem; border-radius: 3px;
|
||||
border: 1px solid var(--border); color: var(--text-light);
|
||||
}
|
||||
.gv-draft { border-color: var(--warning, #d9822b); color: var(--warning, #d9822b); }
|
||||
.gv-tag {
|
||||
font-size: 0.72rem; padding: 0.1rem 0.5rem; border-radius: 999px;
|
||||
background: var(--surface-2, rgba(127,127,127,0.1)); cursor: pointer;
|
||||
}
|
||||
.gv-tag:hover { color: var(--primary); }
|
||||
.gv-actions { display: flex; gap: 0.4rem; }
|
||||
.gv-btn {
|
||||
font-size: 0.8rem; padding: 0.3rem 0.7rem; border: 1px solid var(--primary);
|
||||
color: var(--primary); background: transparent; border-radius: 4px;
|
||||
cursor: pointer; text-decoration: none;
|
||||
}
|
||||
.gv-btn:hover { background: var(--primary); color: #fff; }
|
||||
|
||||
.gv-notice {
|
||||
border-left: 4px solid var(--warning, #d9822b);
|
||||
background: var(--surface-2, rgba(127,127,127,0.08));
|
||||
padding: 0.5rem 0.8rem; margin: 0.5rem 0 1rem; font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.gv-progress { display: flex; align-items: center; gap: 0.7rem; margin: 1rem 0 1.4rem; }
|
||||
.gv-progressbar {
|
||||
flex: 1; height: 6px; border-radius: 3px; overflow: hidden;
|
||||
background: var(--surface-2, rgba(127,127,127,0.15));
|
||||
}
|
||||
.gv-progressfill { height: 100%; background: var(--primary); transition: width 0.2s; }
|
||||
.gv-progresstext { font-size: 0.78rem; color: var(--text-light); white-space: nowrap; }
|
||||
.gv-reset {
|
||||
font-size: 0.72rem; background: none; border: none; color: var(--text-light);
|
||||
cursor: pointer; text-decoration: underline;
|
||||
}
|
||||
|
||||
.gv-foot {
|
||||
margin-top: 2rem; padding-top: 0.6rem; border-top: 1px solid var(--border);
|
||||
display: flex; gap: 1rem; font-size: 0.75rem; color: var(--text-light);
|
||||
}
|
||||
.gv-printedon { display: none; }
|
||||
|
||||
@media print {
|
||||
/* A printed guide is a worksheet: no controls, everything visible, and the
|
||||
footer says which version it was so a stale printout on a noticeboard can
|
||||
be spotted. */
|
||||
.gv-actions, .gv-progress, .gv-notice, .gv-reset { display: none !important; }
|
||||
.gv-printedon { display: inline; }
|
||||
.gv { max-width: none; }
|
||||
.gv-head h1 { font-size: 1.6rem; }
|
||||
.gv-foot { border-top: 1px solid #999; }
|
||||
}
|
||||
</style>
|
||||
163
plugins/guides/frontend/views/GuidesList.vue
Normal file
163
plugins/guides/frontend/views/GuidesList.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="gl">
|
||||
<div class="page-header">
|
||||
<h2>Guides</h2>
|
||||
<router-link v-if="cancreate" class="gl-new" :to="{ name: 'guide-new' }">
|
||||
New guide
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="gl-filters">
|
||||
<input v-model="search" type="search" class="gl-search"
|
||||
placeholder="Search titles, summaries, tags and content..."
|
||||
@input="debouncedLoad">
|
||||
<div v-if="tags.length" class="gl-tags">
|
||||
<button type="button" class="gl-tag" :class="{ 'gl-tagon': !activeTag }"
|
||||
@click="setTag('')">All</button>
|
||||
<button v-for="tag in tags" :key="tag.tag" type="button"
|
||||
class="gl-tag" :class="{ 'gl-tagon': activeTag === tag.tag }"
|
||||
@click="setTag(tag.tag)">
|
||||
{{ tag.tag }} <span class="gl-count">{{ tag.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="gl-loading">Loading...</div>
|
||||
<div v-else-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-else-if="!guides.length" class="gl-empty">
|
||||
<p v-if="search || activeTag">Nothing matches that.</p>
|
||||
<p v-else>No guides yet.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="gl-grid">
|
||||
<router-link v-for="guide in guides" :key="guide.guideid" class="gl-card"
|
||||
:to="{ name: 'guide-view', params: { slug: guide.slug } }">
|
||||
<div class="gl-cardhead">
|
||||
<h3>{{ guide.title }}</h3>
|
||||
<!-- Drafts are visible here only to people who can edit them, so the
|
||||
badge is a working state rather than a warning. -->
|
||||
<span v-if="!guide.ispublished" class="gl-draft">Draft</span>
|
||||
</div>
|
||||
<p v-if="guide.summary" class="gl-summary">{{ guide.summary }}</p>
|
||||
<div class="gl-cardfoot">
|
||||
<span v-for="tag in guide.tags" :key="tag" class="gl-minitag">{{ tag }}</span>
|
||||
<span v-if="guide.checklistitems" class="gl-checks">
|
||||
{{ guide.checklistitems }} steps to tick
|
||||
</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import api from '@/api'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const guides = ref([])
|
||||
const tags = ref([])
|
||||
const search = ref('')
|
||||
const activeTag = ref('')
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
// computed, not a one-off read: permissions may not be loaded yet at setup.
|
||||
const cancreate = computed(() => auth.hasPermission?.('guides.create') ?? false)
|
||||
|
||||
let timer = null
|
||||
function debouncedLoad () {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(load, 250)
|
||||
}
|
||||
|
||||
function setTag (tag) {
|
||||
activeTag.value = tag
|
||||
router.replace({ query: tag ? { tag } : {} })
|
||||
load()
|
||||
}
|
||||
|
||||
async function load () {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const params = {}
|
||||
if (search.value.trim()) params.q = search.value.trim()
|
||||
if (activeTag.value) params.tag = activeTag.value
|
||||
const response = await api.get('/guides', { params })
|
||||
guides.value = response.data?.data || response.data || []
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Could not load guides')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTags () {
|
||||
try {
|
||||
const response = await api.get('/guides/tags')
|
||||
tags.value = response.data?.data || response.data || []
|
||||
} catch (err) {
|
||||
tags.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
activeTag.value = route.query.tag || ''
|
||||
load()
|
||||
loadTags()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.gl-new {
|
||||
font-size: 0.82rem; padding: 0.35rem 0.8rem; border-radius: 4px;
|
||||
background: var(--primary); color: #fff; text-decoration: none;
|
||||
}
|
||||
.gl-filters { margin: 0.8rem 0 1.2rem; }
|
||||
.gl-search {
|
||||
width: 100%; max-width: 34rem; padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--border); border-radius: 4px;
|
||||
background: var(--surface, transparent); color: var(--text);
|
||||
}
|
||||
.gl-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.6rem; }
|
||||
.gl-tag {
|
||||
font-size: 0.75rem; padding: 0.2rem 0.6rem; border-radius: 999px;
|
||||
border: 1px solid var(--border); background: transparent;
|
||||
color: var(--text-light); cursor: pointer;
|
||||
}
|
||||
.gl-tagon { border-color: var(--primary); color: var(--primary); }
|
||||
.gl-count { opacity: 0.6; }
|
||||
|
||||
.gl-grid {
|
||||
display: grid; gap: 0.8rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(19rem, 1fr));
|
||||
}
|
||||
.gl-card {
|
||||
display: block; padding: 0.9rem 1rem; border: 1px solid var(--border);
|
||||
border-radius: 6px; text-decoration: none; color: inherit;
|
||||
background: var(--surface, transparent);
|
||||
}
|
||||
.gl-card:hover { border-color: var(--primary); }
|
||||
.gl-cardhead { display: flex; align-items: baseline; gap: 0.5rem; }
|
||||
.gl-cardhead h3 { margin: 0; font-size: 1rem; flex: 1; }
|
||||
.gl-draft {
|
||||
font-size: 0.68rem; padding: 0.05rem 0.4rem; border-radius: 3px;
|
||||
border: 1px solid var(--warning, #d9822b); color: var(--warning, #d9822b);
|
||||
}
|
||||
.gl-summary { margin: 0.4rem 0 0.6rem; font-size: 0.85rem; color: var(--text-light); }
|
||||
.gl-cardfoot { display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center; }
|
||||
.gl-minitag {
|
||||
font-size: 0.68rem; padding: 0.05rem 0.4rem; border-radius: 999px;
|
||||
background: var(--surface-2, rgba(127,127,127,0.1)); color: var(--text-light);
|
||||
}
|
||||
.gl-checks { font-size: 0.7rem; color: var(--text-light); margin-left: auto; }
|
||||
.gl-empty, .gl-loading { color: var(--text-light); padding: 1.5rem 0; }
|
||||
</style>
|
||||
13
plugins/guides/manifest.json
Normal file
13
plugins/guides/manifest.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "guides",
|
||||
"display_name": "Guides",
|
||||
"version": "1.0.0",
|
||||
"description": "Step-by-step guides written and read inside ShopDB, with images, working checklists and printable output",
|
||||
"author": "ShopDB Team",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.1.0,<1.0.0",
|
||||
"api_prefix": "/api/guides",
|
||||
"provides": {
|
||||
"features": ["guides"]
|
||||
}
|
||||
}
|
||||
16
plugins/guides/migrations/env.py
Normal file
16
plugins/guides/migrations/env.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Alembic environment for the guides plugin migration chain.
|
||||
|
||||
Delegates to the shared runner in shopdb.plugins.alembic_template, which filters
|
||||
the metadata to this plugin's tables and drives Alembic against the per-plugin
|
||||
version table alembic_version_guides. See ADR-008 for the ownership model.
|
||||
|
||||
Like the backups plugin and unlike the cutover ones, guides is NEW: its 0001
|
||||
baseline really CREATES its tables, because the core chain never built them.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['PLUGIN_NAME'] = 'guides'
|
||||
|
||||
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
|
||||
|
||||
run_migrations()
|
||||
24
plugins/guides/migrations/script.py.mako
Normal file
24
plugins/guides/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
98
plugins/guides/migrations/versions/0001_guides_baseline.py
Normal file
98
plugins/guides/migrations/versions/0001_guides_baseline.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""guides plugin baseline: guides, guiderevisions, guideimages.
|
||||
|
||||
A new plugin, so this builds its tables rather than stamping an anchor over
|
||||
core-owned ones (contrast the cutover plugins, whose 0001 is a no-op).
|
||||
|
||||
Idempotent: each table is created only if it is not already there, so a database
|
||||
where an earlier run half-succeeded can be upgraded again without hand-editing
|
||||
alembic_version_guides.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = 'guides0001baseline'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _tables():
|
||||
return set(inspect(op.get_bind()).get_table_names())
|
||||
|
||||
|
||||
def upgrade():
|
||||
existing = _tables()
|
||||
|
||||
if 'guides' not in existing:
|
||||
op.create_table(
|
||||
'guides',
|
||||
sa.Column('guideid', sa.Integer(), nullable=False),
|
||||
sa.Column('title', sa.String(length=300), nullable=False),
|
||||
sa.Column('slug', sa.String(length=300), nullable=False),
|
||||
sa.Column('summary', sa.String(length=1000)),
|
||||
sa.Column('tags', sa.String(length=500)),
|
||||
sa.Column('status', sa.String(length=20), nullable=False,
|
||||
server_default='draft'),
|
||||
sa.Column('blocksjson', sa.dialects.mysql.MEDIUMTEXT()
|
||||
if op.get_bind().dialect.name == 'mysql' else sa.Text(),
|
||||
nullable=False),
|
||||
sa.Column('publishedrevisionid', sa.Integer()),
|
||||
sa.Column('createdby', sa.Integer()),
|
||||
sa.Column('updatedby', sa.Integer()),
|
||||
sa.Column('createdat', sa.DateTime(), nullable=False),
|
||||
sa.Column('updatedat', sa.DateTime(), nullable=False),
|
||||
sa.Column('publishedat', sa.DateTime()),
|
||||
sa.Column('revision', sa.Integer(), nullable=False, server_default='1'),
|
||||
sa.PrimaryKeyConstraint('guideid'),
|
||||
sa.UniqueConstraint('slug', name='uq_guides_slug'),
|
||||
)
|
||||
op.create_index('idx_guides_status', 'guides', ['status'])
|
||||
|
||||
if 'guiderevisions' not in existing:
|
||||
op.create_table(
|
||||
'guiderevisions',
|
||||
sa.Column('guiderevisionid', sa.Integer(), nullable=False),
|
||||
sa.Column('guideid', sa.Integer(), nullable=False),
|
||||
sa.Column('revision', sa.Integer(), nullable=False),
|
||||
sa.Column('title', sa.String(length=300), nullable=False),
|
||||
sa.Column('summary', sa.String(length=1000)),
|
||||
sa.Column('tags', sa.String(length=500)),
|
||||
sa.Column('blocksjson', sa.dialects.mysql.MEDIUMTEXT()
|
||||
if op.get_bind().dialect.name == 'mysql' else sa.Text(),
|
||||
nullable=False),
|
||||
sa.Column('contenthash', sa.String(length=64), nullable=False),
|
||||
sa.Column('note', sa.String(length=500)),
|
||||
sa.Column('createdby', sa.Integer()),
|
||||
sa.Column('createdat', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('guiderevisionid'),
|
||||
sa.ForeignKeyConstraint(['guideid'], ['guides.guideid'],
|
||||
ondelete='CASCADE'),
|
||||
)
|
||||
op.create_index('idx_guiderevisions_guide', 'guiderevisions', ['guideid'])
|
||||
op.create_index('idx_guiderevisions_hash', 'guiderevisions', ['contenthash'])
|
||||
|
||||
if 'guideimages' not in existing:
|
||||
op.create_table(
|
||||
'guideimages',
|
||||
sa.Column('guideimageid', sa.Integer(), nullable=False),
|
||||
sa.Column('guideid', sa.Integer()),
|
||||
sa.Column('filename', sa.String(length=255)),
|
||||
sa.Column('mimetype', sa.String(length=100), nullable=False),
|
||||
sa.Column('bytesize', sa.Integer(), nullable=False),
|
||||
sa.Column('data', sa.dialects.mysql.MEDIUMBLOB()
|
||||
if op.get_bind().dialect.name == 'mysql' else sa.LargeBinary(),
|
||||
nullable=False),
|
||||
sa.Column('uploadedby', sa.Integer()),
|
||||
sa.Column('createdat', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('guideimageid'),
|
||||
sa.ForeignKeyConstraint(['guideid'], ['guides.guideid'],
|
||||
ondelete='CASCADE'),
|
||||
)
|
||||
op.create_index('idx_guideimages_guide', 'guideimages', ['guideid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
for table in ('guideimages', 'guiderevisions', 'guides'):
|
||||
op.drop_table(table)
|
||||
3
plugins/guides/models/__init__.py
Normal file
3
plugins/guides/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .guide import Guide, GuideRevision, GuideImage, BLOCKTYPES
|
||||
|
||||
__all__ = ['Guide', 'GuideRevision', 'GuideImage', 'BLOCKTYPES']
|
||||
282
plugins/guides/models/guide.py
Normal file
282
plugins/guides/models/guide.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""Guides: step-by-step procedures written and read inside ShopDB.
|
||||
|
||||
WHY THIS IS NOT THE KNOWLEDGE BASE PLUGIN
|
||||
|
||||
The knowledgebase plugin is a LINK REGISTRY - a short description, a URL and
|
||||
some keywords, pointing at a document that lives somewhere else. That is a
|
||||
different job. This holds the document itself.
|
||||
|
||||
WHY BLOCKS RATHER THAN A BLOB OF HTML
|
||||
|
||||
A guide is stored as an ordered list of typed blocks, each with a stable id,
|
||||
serialized as JSON. Free-form HTML would be easier to write and would defeat
|
||||
three of the four things this feature exists for:
|
||||
|
||||
checklists a tickable step has to be identifiable to be remembered, and
|
||||
an <input> buried in pasted markup has no identity
|
||||
dark mode pasted content carries hardcoded colours, which then fight the
|
||||
theme - black text on a dark background, and nobody can say
|
||||
why
|
||||
printing print has to know where a step begins and ends to avoid
|
||||
splitting one across a page
|
||||
|
||||
Blocks also mean a new block type later is additive: old guides keep
|
||||
rendering, because the renderer skips what it does not know.
|
||||
|
||||
INLINE TEXT IS A RESTRICTED MARKDOWN SUBSET - bold, italic, inline code, links,
|
||||
line breaks, and nothing else. No fonts, sizes or colours: emphasis is semantic
|
||||
and the theme decides how it looks. That single rule is what keeps a guide
|
||||
written next year looking like one written today, and what makes dark mode work
|
||||
without an author thinking about it.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from shopdb.api import db
|
||||
from shopdb.core.models.base import BaseModel
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.utcnow()
|
||||
|
||||
|
||||
# Block types the renderer knows. Stored as data, not as markup, so the same
|
||||
# guide can render to screen, to dark mode and to paper differently.
|
||||
BLOCKTYPES = (
|
||||
'heading',
|
||||
'paragraph',
|
||||
'step', # a numbered step, with its own body
|
||||
'checklist', # tickable items - the reason progress is a feature
|
||||
'bullets',
|
||||
'numbers',
|
||||
'callout', # info / warning / success, per the KB house style
|
||||
'code',
|
||||
'image',
|
||||
'table',
|
||||
)
|
||||
|
||||
|
||||
class Guide(BaseModel):
|
||||
"""One guide. Blocks live in a JSON column rather than a child table.
|
||||
|
||||
A guide is edited and saved WHOLE - nobody reorders one paragraph in
|
||||
isolation - so a child table would buy ordering machinery, a second
|
||||
migration and N+1 reads, in exchange for nothing this feature needs. The
|
||||
same reasoning the geenforce manifests and backup payloads already follow.
|
||||
"""
|
||||
|
||||
__tablename__ = 'guides'
|
||||
|
||||
guideid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Human title, and the slug the URL uses. The slug is stored rather than
|
||||
# derived on read so an edited title cannot silently break a link somebody
|
||||
# pasted into a work order.
|
||||
title = db.Column(db.String(300), nullable=False)
|
||||
slug = db.Column(db.String(300), nullable=False, unique=True, index=True)
|
||||
|
||||
# One line, shown in the list and in search results.
|
||||
summary = db.Column(db.String(1000))
|
||||
|
||||
# Comma-separated, matching how knowledgebase.keywords already works, so one
|
||||
# search can cover both and there is one tag vocabulary rather than two.
|
||||
tags = db.Column(db.String(500))
|
||||
|
||||
# 'draft' or 'published'. A draft is visible to people who can edit, and
|
||||
# invisible to everyone else: a half-written procedure on a shop floor is
|
||||
# worse than no procedure.
|
||||
status = db.Column(db.String(20), nullable=False, default='draft', index=True)
|
||||
|
||||
# MEDIUMTEXT: serialized blocks. Text rather than a native JSON column so the
|
||||
# bytes come back exactly as written and a diff between versions is stable -
|
||||
# the same reason the backups plugin stores its projection as text.
|
||||
blocksjson = db.Column(db.Text(length=16777215), nullable=False, default='[]')
|
||||
|
||||
# What READERS see. Points at an immutable snapshot in guiderevisions, not
|
||||
# at the working copy above.
|
||||
#
|
||||
# This is the whole point of separating the two: without it, editing a live
|
||||
# procedure changes it under a technician who is halfway through following
|
||||
# it. With it, they keep reading the published version until somebody
|
||||
# deliberately publishes a new one. NULL means never published - a draft
|
||||
# nobody outside the authors can see.
|
||||
#
|
||||
# use_alter, because this and guiderevisions.guideid form a cycle: a guide
|
||||
# points at its published revision, and every revision points back at its
|
||||
# guide. Without it SQLAlchemy cannot order CREATE/DROP and warns that it
|
||||
# cannot sort the tables. The database-level constraint is deliberately not
|
||||
# created by the migration either - the cycle buys nothing there, and the
|
||||
# pointer is maintained in one place (services/authoring.py).
|
||||
publishedrevisionid = db.Column(
|
||||
db.Integer, db.ForeignKey('guiderevisions.guiderevisionid',
|
||||
ondelete='SET NULL',
|
||||
use_alter=True,
|
||||
name='fk_guides_publishedrevision'))
|
||||
|
||||
createdby = db.Column(db.Integer, db.ForeignKey('users.userid'))
|
||||
updatedby = db.Column(db.Integer, db.ForeignKey('users.userid'))
|
||||
createdat = db.Column(db.DateTime, nullable=False, default=_utcnow)
|
||||
updatedat = db.Column(db.DateTime, nullable=False, default=_utcnow,
|
||||
onupdate=_utcnow)
|
||||
publishedat = db.Column(db.DateTime)
|
||||
|
||||
# The working copy's version number. Bumped only when a save actually
|
||||
# changes something, so it counts real edits rather than keystrokes, and a
|
||||
# printed copy has something honest for its footer: "version 7 (published),
|
||||
# printed 26 Aug 2026".
|
||||
revision = db.Column(db.Integer, nullable=False, default=1)
|
||||
|
||||
@property
|
||||
def blocks(self):
|
||||
"""The decoded block list, or [] on a corrupt row.
|
||||
|
||||
Deliberately forgiving, unlike the backups payload getter: a guide that
|
||||
will not render is a page a person cannot read, and an empty guide with
|
||||
its title and tags intact is a better failure than an error page. The
|
||||
corruption is still visible - the editor shows no blocks.
|
||||
"""
|
||||
if not self.blocksjson:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(self.blocksjson)
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
@blocks.setter
|
||||
def blocks(self, value):
|
||||
self.blocksjson = json.dumps(value or [], separators=(',', ':'))
|
||||
|
||||
def taglist(self):
|
||||
"""Tags as a clean list, empties dropped."""
|
||||
return [t.strip() for t in (self.tags or '').split(',') if t.strip()]
|
||||
|
||||
def to_dict(self, includeblocks=False):
|
||||
data = {
|
||||
'guideid': self.guideid,
|
||||
'title': self.title,
|
||||
'slug': self.slug,
|
||||
'summary': self.summary,
|
||||
'tags': self.taglist(),
|
||||
'status': self.status,
|
||||
'revision': self.revision,
|
||||
'createdat': self.createdat.isoformat() + 'Z' if self.createdat else None,
|
||||
'updatedat': self.updatedat.isoformat() + 'Z' if self.updatedat else None,
|
||||
'publishedat': (self.publishedat.isoformat() + 'Z'
|
||||
if self.publishedat else None),
|
||||
}
|
||||
if includeblocks:
|
||||
data['blocks'] = self.blocks
|
||||
return data
|
||||
|
||||
|
||||
class GuideRevision(BaseModel):
|
||||
"""An immutable snapshot of a guide, written when a save changes something.
|
||||
|
||||
Full snapshots rather than diffs. A guide is small, and a snapshot answers
|
||||
the question people actually ask - "what did this say in March, when the
|
||||
job last ran" - without replaying a chain of deltas to find out. The same
|
||||
reasoning the part-marker backups follow, for the same reason: the version
|
||||
you need is the one nobody thought to keep.
|
||||
|
||||
DEDUP: a save whose content hashes identically to the latest revision writes
|
||||
nothing. Without that, autosave produces a hundred identical versions a day
|
||||
and the history becomes noise that hides the three edits that mattered.
|
||||
|
||||
Never updated after insert. A revision that can be edited is not a record of
|
||||
anything.
|
||||
"""
|
||||
|
||||
__tablename__ = 'guiderevisions'
|
||||
|
||||
guiderevisionid = db.Column(db.Integer, primary_key=True)
|
||||
guideid = db.Column(db.Integer,
|
||||
db.ForeignKey('guides.guideid', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
|
||||
# Monotonic per guide, and shown to people: "version 7".
|
||||
revision = db.Column(db.Integer, nullable=False)
|
||||
|
||||
# The whole guide as it stood, not just the blocks: a title or tag change is
|
||||
# a change worth being able to see and roll back.
|
||||
title = db.Column(db.String(300), nullable=False)
|
||||
summary = db.Column(db.String(1000))
|
||||
tags = db.Column(db.String(500))
|
||||
blocksjson = db.Column(db.Text(length=16777215), nullable=False)
|
||||
|
||||
# sha256 of the canonical content, and the dedup key.
|
||||
contenthash = db.Column(db.String(64), nullable=False, index=True)
|
||||
|
||||
# Optional "what changed and why", the way a commit message earns its keep.
|
||||
note = db.Column(db.String(500))
|
||||
|
||||
createdby = db.Column(db.Integer, db.ForeignKey('users.userid'))
|
||||
createdat = db.Column(db.DateTime, nullable=False, default=_utcnow)
|
||||
|
||||
@property
|
||||
def blocks(self):
|
||||
if not self.blocksjson:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(self.blocksjson)
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
def to_dict(self, includeblocks=False):
|
||||
data = {
|
||||
'guiderevisionid': self.guiderevisionid,
|
||||
'guideid': self.guideid,
|
||||
'revision': self.revision,
|
||||
'title': self.title,
|
||||
'summary': self.summary,
|
||||
'tags': [t.strip() for t in (self.tags or '').split(',') if t.strip()],
|
||||
'note': self.note,
|
||||
'createdby': self.createdby,
|
||||
'createdat': self.createdat.isoformat() + 'Z' if self.createdat else None,
|
||||
'shorthash': (self.contenthash or '')[:12],
|
||||
}
|
||||
if includeblocks:
|
||||
data['blocks'] = self.blocks
|
||||
return data
|
||||
|
||||
|
||||
class GuideImage(BaseModel):
|
||||
"""An image pasted or dropped into a guide.
|
||||
|
||||
Bytes live in the database, not on disk. Guides are small, the images are
|
||||
screenshots rather than photographs, and a row travels with a database
|
||||
backup - whereas a file under instance/ has been lost to a container rebuild
|
||||
on this very deployment before. If a site ever puts thousands of these in,
|
||||
the storage decision is one column and one route to revisit.
|
||||
"""
|
||||
|
||||
__tablename__ = 'guideimages'
|
||||
|
||||
guideimageid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Nullable: an image is uploaded while a NEW guide is still unsaved, so it
|
||||
# exists before the guide it belongs to does. The editor attaches it on
|
||||
# save; anything still unattached is orphaned and prunable.
|
||||
guideid = db.Column(db.Integer, db.ForeignKey('guides.guideid',
|
||||
ondelete='CASCADE'), index=True)
|
||||
|
||||
filename = db.Column(db.String(255))
|
||||
mimetype = db.Column(db.String(100), nullable=False)
|
||||
bytesize = db.Column(db.Integer, nullable=False)
|
||||
data = db.Column(db.LargeBinary(length=(2 ** 24) - 1), nullable=False)
|
||||
|
||||
uploadedby = db.Column(db.Integer, db.ForeignKey('users.userid'))
|
||||
createdat = db.Column(db.DateTime, nullable=False, default=_utcnow)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'guideimageid': self.guideimageid,
|
||||
'guideid': self.guideid,
|
||||
'filename': self.filename,
|
||||
'mimetype': self.mimetype,
|
||||
'bytesize': self.bytesize,
|
||||
'url': '/api/guides/images/{}'.format(self.guideimageid),
|
||||
'createdat': self.createdat.isoformat() + 'Z' if self.createdat else None,
|
||||
}
|
||||
89
plugins/guides/plugin.py
Normal file
89
plugins/guides/plugin.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Guides plugin.
|
||||
|
||||
Step-by-step procedures written and read inside ShopDB: images, working
|
||||
checklists, dark mode and printable output, with drafts and revision history.
|
||||
|
||||
Distinct from the knowledgebase plugin, which is a LINK REGISTRY - a
|
||||
description, a URL and keywords pointing at a document held somewhere else.
|
||||
This holds the document. They share a tag vocabulary on purpose, so one search
|
||||
can cover both.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
|
||||
from .models import Guide, GuideRevision, GuideImage
|
||||
from .api import guides_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GuidesPlugin(BasePlugin):
|
||||
|
||||
def __init__(self):
|
||||
self._manifest = self._load_manifest()
|
||||
|
||||
def _load_manifest(self) -> Dict:
|
||||
manifest_path = Path(__file__).parent / 'manifest.json'
|
||||
if manifest_path.exists():
|
||||
with open(manifest_path, 'r') as fh:
|
||||
return json.load(fh)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
return PluginMeta(
|
||||
name=self._manifest.get('name', 'guides'),
|
||||
version=self._manifest.get('version', '1.0.0'),
|
||||
description=self._manifest.get('description', 'Step-by-step guides'),
|
||||
author=self._manifest.get('author', 'ShopDB Team'),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=0.1.0,<1.0.0'),
|
||||
api_prefix=self._manifest.get('api_prefix', '/api/guides'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
return guides_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
return [Guide, GuideRevision, GuideImage]
|
||||
|
||||
def get_permissions(self) -> List:
|
||||
"""Five permissions, and the split between the last two is the point.
|
||||
|
||||
publish is separate from edit so a technician can write up a procedure
|
||||
while somebody else decides it is fit for the floor. Without that split,
|
||||
anyone who can fix a typo can also put an unreviewed procedure in front
|
||||
of an operator.
|
||||
|
||||
create without edit is a working combination on purpose: an author may
|
||||
edit their OWN guide while it is unpublished, and needs guides.edit once
|
||||
other people are following it (see _mayedit in the API).
|
||||
"""
|
||||
return [
|
||||
('guides.view', 'View guides', 'guides'),
|
||||
('guides.create', 'Create guides and edit your own drafts', 'guides'),
|
||||
('guides.edit', 'Edit any guide, including published ones', 'guides'),
|
||||
('guides.publish', 'Publish or withdraw a guide', 'guides'),
|
||||
('guides.delete', 'Delete guides', 'guides'),
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
return [
|
||||
{
|
||||
'name': 'Guides',
|
||||
'icon': 'book-open',
|
||||
'route': '/guides',
|
||||
'position': 36,
|
||||
'section': 'information',
|
||||
},
|
||||
]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
logger.info('Guides plugin initialized (v%s)', self.meta.version)
|
||||
0
plugins/guides/services/__init__.py
Normal file
0
plugins/guides/services/__init__.py
Normal file
134
plugins/guides/services/authoring.py
Normal file
134
plugins/guides/services/authoring.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Saving, revisioning and publishing a guide.
|
||||
|
||||
Kept out of the API layer so the rules are testable without a request, and so
|
||||
there is exactly ONE path that writes a guide. A second write path is how a
|
||||
system ends up with revisions that skipped the dedup, or a published pointer
|
||||
that points at nothing.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
from ..models import Guide, GuideRevision
|
||||
from .blocks import validate, contenthash
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.utcnow()
|
||||
|
||||
|
||||
def save(guide, title=None, summary=None, tags=None, blocks=None, note=None,
|
||||
userid=None):
|
||||
"""Apply an edit to the working copy, writing a revision if it changed.
|
||||
|
||||
Returns (guide, revision_or_None). None means the save was a no-op: the
|
||||
content hashed identically to the last revision, so nothing was recorded.
|
||||
That is the normal outcome of an autosave where the author changed nothing,
|
||||
and it is what keeps the history readable.
|
||||
"""
|
||||
if title is not None:
|
||||
guide.title = title.strip()
|
||||
if summary is not None:
|
||||
guide.summary = summary.strip() or None
|
||||
if tags is not None:
|
||||
# Normalised once, here: trimmed, empties dropped, order preserved.
|
||||
# Tags are compared case-insensitively when hashing, but stored as the
|
||||
# author typed them - "PXE" should not become "pxe" on screen.
|
||||
guide.tags = ', '.join(t.strip() for t in tags.split(',') if t.strip()) or None
|
||||
if blocks is not None:
|
||||
guide.blocks = validate(blocks)
|
||||
|
||||
incoming = contenthash(guide.title, guide.summary, guide.tags, guide.blocks)
|
||||
|
||||
latest = (GuideRevision.query
|
||||
.filter_by(guideid=guide.guideid)
|
||||
.order_by(GuideRevision.revision.desc())
|
||||
.first())
|
||||
if latest is not None and latest.contenthash == incoming:
|
||||
# Nothing a reader would notice has changed. Touch nothing else: an
|
||||
# updatedat that moves on every autosave makes "last edited" a lie.
|
||||
return guide, None
|
||||
|
||||
guide.revision = (latest.revision + 1) if latest else 1
|
||||
guide.updatedby = userid
|
||||
guide.updatedat = _utcnow()
|
||||
|
||||
revision = GuideRevision(
|
||||
guideid=guide.guideid,
|
||||
revision=guide.revision,
|
||||
title=guide.title,
|
||||
summary=guide.summary,
|
||||
tags=guide.tags,
|
||||
blocksjson=guide.blocksjson,
|
||||
contenthash=incoming,
|
||||
note=(note or '').strip() or None,
|
||||
createdby=userid,
|
||||
)
|
||||
db.session.add(revision)
|
||||
db.session.flush()
|
||||
return guide, revision
|
||||
|
||||
|
||||
def publish(guide, userid=None):
|
||||
"""Point readers at the current working copy.
|
||||
|
||||
Publishing does NOT snapshot: it promotes the latest existing revision. If
|
||||
it snapshotted, a publish with unsaved edits would create a version nobody
|
||||
reviewed - which is exactly the accident this workflow exists to prevent.
|
||||
"""
|
||||
latest = (GuideRevision.query
|
||||
.filter_by(guideid=guide.guideid)
|
||||
.order_by(GuideRevision.revision.desc())
|
||||
.first())
|
||||
if latest is None:
|
||||
raise ValueError('nothing to publish: this guide has no saved content')
|
||||
|
||||
guide.publishedrevisionid = latest.guiderevisionid
|
||||
guide.status = 'published'
|
||||
guide.publishedat = _utcnow()
|
||||
guide.updatedby = userid
|
||||
db.session.flush()
|
||||
return latest
|
||||
|
||||
|
||||
def unpublish(guide, userid=None):
|
||||
"""Withdraw a guide from readers without deleting anything.
|
||||
|
||||
For a procedure that turns out to be wrong. The revisions stay, so it can be
|
||||
corrected and published again, and so there is still a record of what people
|
||||
were being told in the meantime.
|
||||
"""
|
||||
guide.publishedrevisionid = None
|
||||
guide.status = 'draft'
|
||||
guide.updatedby = userid
|
||||
db.session.flush()
|
||||
return guide
|
||||
|
||||
|
||||
def readable(guide):
|
||||
"""The revision a READER should be served, or None.
|
||||
|
||||
Never the working copy: a published guide shows what was published, even
|
||||
while somebody is editing the next version. Editors get the working copy
|
||||
through the editor, which is a different route on purpose.
|
||||
"""
|
||||
if not guide.publishedrevisionid:
|
||||
return None
|
||||
return db.session.get(GuideRevision, guide.publishedrevisionid)
|
||||
|
||||
|
||||
def restore(guide, revision, userid=None):
|
||||
"""Copy an old revision back onto the working copy.
|
||||
|
||||
Deliberately NOT a rollback of history: restoring writes a NEW revision with
|
||||
the old content. The record of what was published between then and now stays
|
||||
intact, which is the difference between a history and a rewrite.
|
||||
"""
|
||||
return save(guide,
|
||||
title=revision.title,
|
||||
summary=revision.summary or '',
|
||||
tags=revision.tags or '',
|
||||
blocks=revision.blocks,
|
||||
note='Restored from version {}'.format(revision.revision),
|
||||
userid=userid)
|
||||
152
plugins/guides/services/blocks.py
Normal file
152
plugins/guides/services/blocks.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""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 items a guide has, for the list and the progress bar."""
|
||||
total = 0
|
||||
for block in blocks or []:
|
||||
if isinstance(block, dict) and block.get('type') == 'checklist':
|
||||
total += len(block.get('items') or [])
|
||||
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()
|
||||
@@ -58,6 +58,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
||||
'manifestblobs',
|
||||
'manifestenforcementreports', 'manifestenforcementresults',
|
||||
'pctypealiases'),
|
||||
'guides': ('guides', 'guiderevisions', 'guideimages'),
|
||||
'knowledgebase': ('knowledgebase',),
|
||||
'machines': ('machinetypes', 'machines'),
|
||||
'measuringtools': ('measuringtooltypes', 'measuringtools'),
|
||||
|
||||
@@ -50,6 +50,9 @@ EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline'
|
||||
# 0002 adds lastseenat - when a config was last CONFIRMED unchanged, which dedup
|
||||
# otherwise throws away.
|
||||
EXPECTED_HEAD_REVISION['backups'] = 'backups0003clearlastseen'
|
||||
# guides is a NEW plugin, so its 0001 builds tables rather than stamping an
|
||||
# anchor over core-owned ones.
|
||||
EXPECTED_HEAD_REVISION['guides'] = 'guides0001baseline'
|
||||
# geenforce adds the content-addressed blob store (manifestblobs) on top of its
|
||||
# baseline.
|
||||
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0004minlib'
|
||||
|
||||
334
tests/test_plugins/test_guides.py
Normal file
334
tests/test_plugins/test_guides.py
Normal file
@@ -0,0 +1,334 @@
|
||||
"""Guides: block validation, revisioning, and the draft/publish split.
|
||||
|
||||
The behaviour worth protecting hardest is the last one. A technician following a
|
||||
procedure on a shop floor must not have it change under them because somebody is
|
||||
editing it upstairs, and the only thing standing between them and that is the
|
||||
published pointer.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb import create_app
|
||||
from shopdb.extensions import db as _db
|
||||
from shopdb.plugins import plugin_manager
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Block validation - pure, no app context needed. These rules exist to stop a
|
||||
# BAD SAVE becoming a guide that will not render weeks later, when the author
|
||||
# has moved on and the only evidence is a blank page.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _block(blockid='b1', blocktype='paragraph', **extra):
|
||||
return dict({'id': blockid, 'type': blocktype}, **extra)
|
||||
|
||||
|
||||
def test_a_well_formed_block_list_passes():
|
||||
from plugins.guides.services.blocks import validate
|
||||
blocks = [
|
||||
_block('b1', 'heading', text='Before you start'),
|
||||
_block('b2', 'paragraph', text='Check the **cable** first.'),
|
||||
_block('b3', 'checklist', items=[{'id': 'i1', 'text': 'Power on'}]),
|
||||
]
|
||||
assert validate(blocks) == blocks
|
||||
|
||||
|
||||
def test_a_block_without_an_id_is_refused():
|
||||
"""Ids are what make a tick rememberable.
|
||||
|
||||
Without one the checkbox silently loses its state on the next visit, which
|
||||
reads to a user as "the checkboxes do not work" - a bug report with no
|
||||
stack trace and no obvious cause.
|
||||
"""
|
||||
from plugins.guides.services.blocks import validate, BlockError
|
||||
with pytest.raises(BlockError) as exc:
|
||||
validate([{'type': 'paragraph', 'text': 'no id'}])
|
||||
assert 'stable id' in str(exc.value)
|
||||
|
||||
|
||||
def test_duplicate_ids_are_refused():
|
||||
from plugins.guides.services.blocks import validate, BlockError
|
||||
with pytest.raises(BlockError) as exc:
|
||||
validate([_block('same'), _block('same')])
|
||||
assert 'duplicate' in str(exc.value)
|
||||
|
||||
|
||||
def test_an_unknown_block_type_is_refused_and_lists_the_known_ones():
|
||||
from plugins.guides.services.blocks import validate, BlockError
|
||||
with pytest.raises(BlockError) as exc:
|
||||
validate([_block('b1', 'carousel')])
|
||||
message = str(exc.value)
|
||||
assert 'carousel' in message and 'checklist' in message
|
||||
|
||||
|
||||
def test_checklist_items_need_their_own_ids():
|
||||
"""Same reason as blocks: the item id is what a tick is stored against."""
|
||||
from plugins.guides.services.blocks import validate, BlockError
|
||||
with pytest.raises(BlockError) as exc:
|
||||
validate([_block('b1', 'checklist', items=[{'text': 'no id'}])])
|
||||
assert 'item' in str(exc.value)
|
||||
|
||||
|
||||
def test_the_error_names_which_block_is_wrong():
|
||||
"""An author fixing a rejected save should not have to count blocks."""
|
||||
from plugins.guides.services.blocks import validate, BlockError
|
||||
with pytest.raises(BlockError) as exc:
|
||||
validate([_block('b1'), _block('b2'), {'type': 'paragraph'}])
|
||||
assert str(exc.value).startswith('block 3:')
|
||||
|
||||
|
||||
def test_content_hash_covers_title_and_tags_not_just_blocks():
|
||||
"""Renaming or retagging a guide is a change a reader notices.
|
||||
|
||||
Hashing only the body would drop those edits out of the history entirely.
|
||||
"""
|
||||
from plugins.guides.services.blocks import contenthash
|
||||
blocks = [_block('b1', 'paragraph', text='same body')]
|
||||
base = contenthash('Title', 'Summary', 'pxe, share', blocks)
|
||||
assert contenthash('Different', 'Summary', 'pxe, share', blocks) != base
|
||||
assert contenthash('Title', 'Summary', 'pxe', blocks) != base
|
||||
assert contenthash('Title', 'Different', 'pxe, share', blocks) != base
|
||||
|
||||
|
||||
def test_content_hash_ignores_tag_order_case_and_spacing():
|
||||
"""Otherwise reordering tags in the editor would manufacture a version."""
|
||||
from plugins.guides.services.blocks import contenthash
|
||||
blocks = [_block()]
|
||||
assert (contenthash('T', '', 'PXE, Share', blocks)
|
||||
== contenthash('T', '', 'share ,pxe', blocks))
|
||||
|
||||
|
||||
def test_checklist_item_count_counts_only_checklists():
|
||||
from plugins.guides.services.blocks import checklistitemcount
|
||||
blocks = [
|
||||
_block('b1', 'checklist', items=[{'id': 'i1'}, {'id': 'i2'}]),
|
||||
_block('b2', 'bullets', items=['not tickable']),
|
||||
_block('b3', 'checklist', items=[{'id': 'i3'}]),
|
||||
]
|
||||
assert checklistitemcount(blocks) == 3
|
||||
|
||||
|
||||
def test_plaintext_flattens_a_guide_for_search():
|
||||
"""People search for something written halfway down, not for the title."""
|
||||
from plugins.guides.services.blocks import plaintext
|
||||
blocks = [
|
||||
_block('b1', 'heading', text='Mapped drive'),
|
||||
_block('b2', 'checklist', items=[{'id': 'i1', 'text': 'Run the sync'}]),
|
||||
_block('b3', 'table', rows=[['left', 'right']]),
|
||||
]
|
||||
flat = plaintext(blocks)
|
||||
assert 'Mapped drive' in flat and 'Run the sync' in flat and 'right' in flat
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authoring: revisions, dedup and publishing. Needs an app context.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def gd_app():
|
||||
saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry,
|
||||
plugin_manager.loader, plugin_manager.migration_manager,
|
||||
plugin_manager._registered_prefixes)
|
||||
application = create_app('testing')
|
||||
|
||||
from plugins.guides.plugin import GuidesPlugin
|
||||
plugin = GuidesPlugin()
|
||||
pm = application.extensions['plugin_manager']
|
||||
if plugin.meta.api_prefix not in pm._registered_prefixes:
|
||||
pm._register_plugin_components(plugin)
|
||||
|
||||
with application.app_context():
|
||||
_db.create_all()
|
||||
yield application
|
||||
_db.session.remove()
|
||||
_db.drop_all()
|
||||
|
||||
(plugin_manager._app, plugin_manager._db, plugin_manager.registry,
|
||||
plugin_manager.loader, plugin_manager.migration_manager,
|
||||
plugin_manager._registered_prefixes) = saved
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gd_clean(gd_app):
|
||||
from plugins.guides.models import Guide, GuideRevision
|
||||
with gd_app.app_context():
|
||||
_db.session.query(GuideRevision).delete()
|
||||
_db.session.query(Guide).delete()
|
||||
_db.session.commit()
|
||||
yield
|
||||
|
||||
|
||||
def _newguide(title='Replacing a marker controller'):
|
||||
from plugins.guides.models import Guide
|
||||
guide = Guide(title=title, slug=title.lower().replace(' ', '-'),
|
||||
status='draft')
|
||||
_db.session.add(guide)
|
||||
_db.session.flush()
|
||||
return guide
|
||||
|
||||
|
||||
def test_the_first_save_creates_version_one(gd_app, gd_clean):
|
||||
from plugins.guides.services import authoring
|
||||
with gd_app.app_context():
|
||||
guide = _newguide()
|
||||
guide, revision = authoring.save(
|
||||
guide, title='Replacing a marker controller',
|
||||
blocks=[_block('b1', 'paragraph', text='Power it off first.')])
|
||||
_db.session.commit()
|
||||
assert revision is not None
|
||||
assert revision.revision == 1
|
||||
assert guide.revision == 1
|
||||
|
||||
|
||||
def test_saving_identical_content_writes_no_revision(gd_app, gd_clean):
|
||||
"""Autosave would otherwise produce a hundred versions a day.
|
||||
|
||||
A history full of identical entries hides the three edits that mattered,
|
||||
which is the same failure the backups plugin dedups away.
|
||||
"""
|
||||
from plugins.guides.models import GuideRevision
|
||||
from plugins.guides.services import authoring
|
||||
with gd_app.app_context():
|
||||
guide = _newguide()
|
||||
blocks = [_block('b1', 'paragraph', text='Unchanged.')]
|
||||
authoring.save(guide, title='T', blocks=blocks)
|
||||
_db.session.commit()
|
||||
|
||||
guide, second = authoring.save(guide, title='T', blocks=blocks)
|
||||
_db.session.commit()
|
||||
|
||||
assert second is None, 'an unchanged save must not manufacture a version'
|
||||
assert GuideRevision.query.filter_by(guideid=guide.guideid).count() == 1
|
||||
assert guide.revision == 1
|
||||
|
||||
|
||||
def test_a_real_edit_creates_the_next_version(gd_app, gd_clean):
|
||||
from plugins.guides.models import GuideRevision
|
||||
from plugins.guides.services import authoring
|
||||
with gd_app.app_context():
|
||||
guide = _newguide()
|
||||
authoring.save(guide, title='T',
|
||||
blocks=[_block('b1', 'paragraph', text='One.')])
|
||||
_db.session.commit()
|
||||
guide, second = authoring.save(
|
||||
guide, blocks=[_block('b1', 'paragraph', text='Two.')])
|
||||
_db.session.commit()
|
||||
|
||||
assert second.revision == 2
|
||||
assert GuideRevision.query.filter_by(guideid=guide.guideid).count() == 2
|
||||
|
||||
|
||||
def test_readers_see_nothing_until_it_is_published(gd_app, gd_clean):
|
||||
"""A half-written procedure on a shop floor is worse than no procedure."""
|
||||
from plugins.guides.services import authoring
|
||||
with gd_app.app_context():
|
||||
guide = _newguide()
|
||||
authoring.save(guide, title='T', blocks=[_block()])
|
||||
_db.session.commit()
|
||||
assert authoring.readable(guide) is None
|
||||
|
||||
|
||||
def test_editing_a_published_guide_does_not_change_what_readers_see(gd_app, gd_clean):
|
||||
"""THE point of the draft/publish split.
|
||||
|
||||
A technician halfway through a procedure keeps reading the version that was
|
||||
published, even while the next one is being written.
|
||||
"""
|
||||
from plugins.guides.services import authoring
|
||||
with gd_app.app_context():
|
||||
guide = _newguide()
|
||||
authoring.save(guide, title='T',
|
||||
blocks=[_block('b1', 'paragraph', text='Published text.')])
|
||||
authoring.publish(guide)
|
||||
_db.session.commit()
|
||||
|
||||
authoring.save(guide,
|
||||
blocks=[_block('b1', 'paragraph', text='Work in progress.')])
|
||||
_db.session.commit()
|
||||
|
||||
served = authoring.readable(guide)
|
||||
assert served.blocks[0]['text'] == 'Published text.'
|
||||
assert guide.revision == 2, 'the working copy moved on'
|
||||
assert served.revision == 1, 'what readers get did not'
|
||||
|
||||
|
||||
def test_publishing_promotes_the_latest_saved_revision(gd_app, gd_clean):
|
||||
from plugins.guides.services import authoring
|
||||
with gd_app.app_context():
|
||||
guide = _newguide()
|
||||
authoring.save(guide, title='T', blocks=[_block('b1', 'paragraph', text='v1')])
|
||||
authoring.save(guide, blocks=[_block('b1', 'paragraph', text='v2')])
|
||||
authoring.publish(guide)
|
||||
_db.session.commit()
|
||||
assert authoring.readable(guide).blocks[0]['text'] == 'v2'
|
||||
assert guide.status == 'published'
|
||||
|
||||
|
||||
def test_publishing_an_empty_guide_is_refused(gd_app, gd_clean):
|
||||
from plugins.guides.services import authoring
|
||||
with gd_app.app_context():
|
||||
guide = _newguide()
|
||||
_db.session.commit()
|
||||
with pytest.raises(ValueError):
|
||||
authoring.publish(guide)
|
||||
|
||||
|
||||
def test_unpublishing_withdraws_without_destroying_the_history(gd_app, gd_clean):
|
||||
"""For a procedure that turns out to be wrong.
|
||||
|
||||
The versions stay, so it can be corrected and published again, and so there
|
||||
is still a record of what people were being told in the meantime.
|
||||
"""
|
||||
from plugins.guides.models import GuideRevision
|
||||
from plugins.guides.services import authoring
|
||||
with gd_app.app_context():
|
||||
guide = _newguide()
|
||||
authoring.save(guide, title='T', blocks=[_block()])
|
||||
authoring.publish(guide)
|
||||
_db.session.commit()
|
||||
|
||||
authoring.unpublish(guide)
|
||||
_db.session.commit()
|
||||
|
||||
assert authoring.readable(guide) is None
|
||||
assert guide.status == 'draft'
|
||||
assert GuideRevision.query.filter_by(guideid=guide.guideid).count() == 1
|
||||
|
||||
|
||||
def test_restoring_writes_a_new_version_rather_than_rewriting_history(gd_app, gd_clean):
|
||||
"""A history you can edit is not a record of anything.
|
||||
|
||||
Restoring brings old content forward as a NEW version, so what was
|
||||
published in between stays visible.
|
||||
"""
|
||||
from plugins.guides.models import GuideRevision
|
||||
from plugins.guides.services import authoring
|
||||
with gd_app.app_context():
|
||||
guide = _newguide()
|
||||
_, first = authoring.save(guide, title='T',
|
||||
blocks=[_block('b1', 'paragraph', text='original')])
|
||||
authoring.save(guide, blocks=[_block('b1', 'paragraph', text='mistake')])
|
||||
_db.session.commit()
|
||||
|
||||
authoring.restore(guide, first)
|
||||
_db.session.commit()
|
||||
|
||||
rows = (GuideRevision.query.filter_by(guideid=guide.guideid)
|
||||
.order_by(GuideRevision.revision).all())
|
||||
assert [r.revision for r in rows] == [1, 2, 3]
|
||||
assert rows[2].blocks[0]['text'] == 'original'
|
||||
assert 'Restored from version 1' in (rows[2].note or '')
|
||||
assert rows[1].blocks[0]['text'] == 'mistake', 'the mistake stays on record'
|
||||
|
||||
|
||||
def test_a_corrupt_blocks_column_renders_as_empty_rather_than_erroring(gd_app, gd_clean):
|
||||
"""A guide that will not render is a page a person cannot read.
|
||||
|
||||
An empty guide with its title and tags intact is a better failure than an
|
||||
error page, and the corruption is still visible - the editor shows nothing.
|
||||
"""
|
||||
with gd_app.app_context():
|
||||
guide = _newguide()
|
||||
guide.blocksjson = 'not json at all'
|
||||
_db.session.commit()
|
||||
assert guide.blocks == []
|
||||
Reference in New Issue
Block a user