Guides: procedures written and read inside ShopDB
This commit is contained in:
@@ -45,6 +45,7 @@ export const assetTypeCols = [
|
||||
export const searchDomains = [
|
||||
{ key: 'application', label: 'Applications' },
|
||||
{ key: 'knowledgebase', label: 'Knowledge Base' },
|
||||
{ key: 'guide', label: 'Guides' },
|
||||
{ key: 'employee', label: 'Employees' },
|
||||
{ key: 'machine', label: 'Machines' },
|
||||
{ key: 'computer', label: 'PCs' },
|
||||
|
||||
@@ -117,6 +117,7 @@ const typeLabels = {
|
||||
computer: 'PC',
|
||||
application: 'App',
|
||||
knowledgebase: 'KB',
|
||||
guide: 'Guide',
|
||||
printer: 'Printer',
|
||||
network_device: 'Network',
|
||||
measuring_tool: 'Measuring Tool',
|
||||
@@ -136,6 +137,7 @@ const filterTypeMap = {
|
||||
measuringtools: ['measuring_tool'],
|
||||
applications: ['application'],
|
||||
knowledgebase: ['knowledgebase'],
|
||||
guides: ['guide'],
|
||||
notifications: ['notification'],
|
||||
employees: ['employee'],
|
||||
usb: ['usb_device'],
|
||||
@@ -151,6 +153,7 @@ const filterList = [
|
||||
{ key: 'measuringtools', label: 'Measuring Tools' },
|
||||
{ key: 'applications', label: 'Apps' },
|
||||
{ key: 'knowledgebase', label: 'KB' },
|
||||
{ key: 'guides', label: 'Guides' },
|
||||
{ key: 'notifications', label: 'Notices' },
|
||||
{ key: 'employees', label: 'Employees' },
|
||||
{ key: 'usb', label: 'USB' },
|
||||
@@ -424,6 +427,7 @@ watch(results, () => {
|
||||
--rt-computer-bg: #e8f5e9; --rt-computer-fg: #2e7d32;
|
||||
--rt-application-bg: #fff3e0; --rt-application-fg: #e65100;
|
||||
--rt-knowledgebase-bg: #f3e5f5; --rt-knowledgebase-fg: #7b1fa2;
|
||||
--rt-guide-bg: #e8f0fe; --rt-guide-fg: #1a56b8;
|
||||
--rt-printer-bg: #fce4ec; --rt-printer-fg: #c2185b;
|
||||
--rt-network-bg: #fff8e1; --rt-network-fg: #f57f17;
|
||||
--rt-measuring-bg: #e0f7fa; --rt-measuring-fg: #00838f;
|
||||
@@ -437,6 +441,7 @@ watch(results, () => {
|
||||
.result-type.computer { background: var(--rt-computer-bg); color: var(--rt-computer-fg); }
|
||||
.result-type.application { background: var(--rt-application-bg); color: var(--rt-application-fg); }
|
||||
.result-type.knowledgebase { background: var(--rt-knowledgebase-bg); color: var(--rt-knowledgebase-fg); }
|
||||
.result-type.guide { background: var(--rt-guide-bg); color: var(--rt-guide-fg); }
|
||||
.result-type.printer { background: var(--rt-printer-bg); color: var(--rt-printer-fg); }
|
||||
.result-type.network_device { background: var(--rt-network-bg); color: var(--rt-network-fg); }
|
||||
.result-type.measuring_tool { background: var(--rt-measuring-bg); color: var(--rt-measuring-fg); }
|
||||
|
||||
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[:180] # room for a '-2' style suffix inside the 191 cap
|
||||
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.modifieddate.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)),
|
||||
})
|
||||
257
plugins/guides/frontend/components/GuideBlocks.vue
Normal file
257
plugins/guides/frontend/components/GuideBlocks.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
<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>
|
||||
<!--
|
||||
SELECTS the code; it does not write to the clipboard.
|
||||
|
||||
A page that programmatically copies a shell command is the exact
|
||||
shape of a ClickFix attack - malware that plants a command in your
|
||||
clipboard and talks you into pasting it into Run. uBlock Origin
|
||||
blocks it, and it was right to: our button wrote PowerShell to the
|
||||
clipboard on click.
|
||||
|
||||
Selecting the text and letting the person press Ctrl+C keeps the
|
||||
convenience, moves the decision back to them, and gives security
|
||||
tooling nothing to object to. It also selects the code ONLY - no
|
||||
caption, no prompt, so nobody ends up pasting "PS C:\>" into a
|
||||
terminal.
|
||||
-->
|
||||
<button type="button" class="gb-copy" @click="select(block)">
|
||||
{{ selected === block.id ? 'Selected - press Ctrl+C' : 'Select' }}
|
||||
</button>
|
||||
</div>
|
||||
<pre :ref="el => registerCode(block.id, el)"><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 selected = ref('')
|
||||
const codeNodes = {}
|
||||
function registerCode (id, el) { if (el) codeNodes[id] = el }
|
||||
|
||||
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 || '')
|
||||
}
|
||||
|
||||
function select (block) {
|
||||
const node = codeNodes[block.id]
|
||||
if (!node || !window.getSelection) return
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(node)
|
||||
const selection = window.getSelection()
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
selected.value = block.id
|
||||
setTimeout(() => { selected.value = '' }, 4000)
|
||||
}
|
||||
</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. The light-mode token
|
||||
overrides live in GuideView.vue, which can reach :root; this block is only
|
||||
about what belongs on paper. */
|
||||
.gb-copy { display: none; }
|
||||
.gb-step, .gb-callout, .gb-code, .gb-figure, .gb-check { break-inside: avoid; }
|
||||
|
||||
/* Checkboxes print EMPTY on purpose, even where they are ticked on screen:
|
||||
a printed guide is a worksheet somebody carries to the bay and ticks by
|
||||
hand, and one person's progress is not part of the procedure. */
|
||||
.gb-done { opacity: 1; text-decoration: none; }
|
||||
.gb-check input { appearance: none; -webkit-appearance: none;
|
||||
width: 0.85rem; height: 0.85rem; border: 1px solid #000;
|
||||
border-radius: 2px; background: #fff; }
|
||||
|
||||
.gb-code pre { white-space: pre-wrap; }
|
||||
.gb-stepnum { color: #000; }
|
||||
}
|
||||
</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')
|
||||
}
|
||||
145
plugins/guides/frontend/utils/proof.js
Normal file
145
plugins/guides/frontend/utils/proof.js
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Proofing: the mistakes that matter in a PROCEDURE, not a document.
|
||||
*
|
||||
* A spellchecker catches "teh". None of these are spelling, and every one has
|
||||
* been seen for real:
|
||||
*
|
||||
* - a smart quote in a command, so the command does not run
|
||||
* - a checklist item with no text, so a step is silently missing
|
||||
* - a heading with nothing under it, left when a draft was reorganised
|
||||
* - an image with no alt text, invisible to anyone who cannot see it
|
||||
* - a TODO that was never finished, published anyway
|
||||
*
|
||||
* Warnings, not blocks. A person publishing a guide at 14:40 with one empty
|
||||
* bullet should be told, not stopped - the tool does not know that the bullet is
|
||||
* deliberate, and a procedure that exists imperfectly beats one nobody wrote.
|
||||
*/
|
||||
|
||||
// Characters an editor or a word processor substitutes silently. In prose they
|
||||
// are harmless; in a command they are the difference between working and a
|
||||
// syntax error nobody can explain.
|
||||
const SMARTCHARS = [
|
||||
// Written as escapes, not literals: the repo's style gate forbids non-ASCII in
|
||||
// source, and a file whose job is detecting smart quotes cannot contain any.
|
||||
// The escapes are also unambiguous in a diff, where a curly quote and a
|
||||
// straight one look identical.
|
||||
['\u2018', "'"], ['\u2019', "'"], // single quotes
|
||||
['\u201C', '"'], ['\u201D', '"'], // double quotes
|
||||
['\u2013', '-'], ['\u2014', '-'], // en dash, em dash
|
||||
['\u00A0', ' '], // non-breaking space
|
||||
]
|
||||
|
||||
const UNFINISHED = /\b(TODO|TBD|FIXME|XXX|\?\?\?)\b/i
|
||||
|
||||
function issue (level, blockid, message, fix) {
|
||||
return { level, blockid, message, fix: fix || null }
|
||||
}
|
||||
|
||||
export function proof (guide, blocks) {
|
||||
const issues = []
|
||||
|
||||
if (!guide.title || !guide.title.trim()) {
|
||||
issues.push(issue('error', null, 'This guide has no title'))
|
||||
}
|
||||
if (!guide.summary || !guide.summary.trim()) {
|
||||
issues.push(issue('warn', null,
|
||||
'No summary. It is what people read in the list and in search results.'))
|
||||
}
|
||||
if (!guide.tags || !guide.tags.trim()) {
|
||||
issues.push(issue('warn', null, 'No tags, so this will be hard to find'))
|
||||
}
|
||||
if (!blocks.length) {
|
||||
issues.push(issue('error', null, 'This guide is empty'))
|
||||
return issues
|
||||
}
|
||||
|
||||
blocks.forEach((block, index) => {
|
||||
const where = 'block ' + (index + 1) + ' (' + block.type + ')'
|
||||
|
||||
if (block.type === 'code') {
|
||||
// The important one. An autocorrected quote in a command looks right and
|
||||
// does not run.
|
||||
//
|
||||
// Reported ONCE per block, however many offending characters it has: a
|
||||
// command with an opening and a closing smart quote is one problem with
|
||||
// one fix, and listing it twice makes the panel look like it is nagging.
|
||||
const found = SMARTCHARS.filter(([bad]) => (block.text || '').includes(bad))
|
||||
if (found.length) {
|
||||
issues.push(issue('error', block.id,
|
||||
where + ': contains ' + found.map(([, good]) => 'a curly or long ' +
|
||||
(good === "'" ? 'quote' : good === '"' ? 'double quote' :
|
||||
good === '-' ? 'dash' : 'space')).filter(
|
||||
(v, i, a) => a.indexOf(v) === i).join(' and ') +
|
||||
' where a command needs the plain character. Autocorrect does this, ' +
|
||||
'and the command will not run.',
|
||||
{ type: 'replaceAll', pairs: found }))
|
||||
}
|
||||
if (!(block.text || '').trim()) {
|
||||
issues.push(issue('error', block.id, where + ' is empty'))
|
||||
}
|
||||
} else if (['heading', 'paragraph', 'callout'].includes(block.type)) {
|
||||
if (!(block.text || '').trim() && !(block.title || '').trim()) {
|
||||
issues.push(issue('warn', block.id, where + ' is empty'))
|
||||
}
|
||||
}
|
||||
|
||||
if (block.type === 'step' && !(block.title || '').trim()
|
||||
&& !(block.text || '').trim()) {
|
||||
issues.push(issue('warn', block.id, where + ' has no title and no text'))
|
||||
}
|
||||
|
||||
if (['checklist', 'bullets', 'numbers'].includes(block.type)) {
|
||||
const empties = (block.items || []).filter(
|
||||
item => !String(typeof item === 'string' ? item : item.text || '').trim())
|
||||
if (empties.length) {
|
||||
issues.push(issue('warn', block.id,
|
||||
where + ': ' + empties.length + ' empty item' +
|
||||
(empties.length > 1 ? 's' : '') +
|
||||
'. An empty step reads as a step somebody forgot to write.'))
|
||||
}
|
||||
}
|
||||
|
||||
if (block.type === 'image' && !(block.alt || '').trim()) {
|
||||
issues.push(issue('warn', block.id,
|
||||
where + ': no alt text. Anyone who cannot see the image gets nothing.'))
|
||||
}
|
||||
|
||||
if (block.type === 'table' && !(block.rows || []).length) {
|
||||
issues.push(issue('warn', block.id, where + ' has a header but no rows'))
|
||||
}
|
||||
|
||||
const text = [block.text, block.title].filter(Boolean).join(' ')
|
||||
if (UNFINISHED.test(text)) {
|
||||
issues.push(issue('warn', block.id,
|
||||
where + ': still contains ' + text.match(UNFINISHED)[0]))
|
||||
}
|
||||
})
|
||||
|
||||
// A heading immediately followed by another heading usually means the section
|
||||
// under the first one was moved or never written.
|
||||
blocks.forEach((block, index) => {
|
||||
if (block.type !== 'heading') return
|
||||
const next = blocks[index + 1]
|
||||
if (!next || next.type === 'heading') {
|
||||
issues.push(issue('warn', block.id,
|
||||
'Heading "' + (block.text || '').slice(0, 40) + '" has nothing under it'))
|
||||
}
|
||||
})
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
/** Apply a suggested fix in place, for the ones where the answer is obvious. */
|
||||
export function applyFix (block, fix) {
|
||||
if (!fix) return false
|
||||
const before = block.text || ''
|
||||
let after = before
|
||||
if (fix.type === 'replaceAll') {
|
||||
for (const [bad, good] of fix.pairs) after = after.split(bad).join(good)
|
||||
} else if (fix.type === 'replace') {
|
||||
after = after.split(fix.from).join(fix.to)
|
||||
}
|
||||
if (after === before) return false
|
||||
block.text = after
|
||||
return true
|
||||
}
|
||||
74
plugins/guides/frontend/utils/templates.js
Normal file
74
plugins/guides/frontend/utils/templates.js
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Starting points for a new guide.
|
||||
*
|
||||
* The blank page is the real barrier to somebody writing a procedure down. A
|
||||
* skeleton with the right SHAPE - what you need first, the steps, how you know
|
||||
* it worked, what to do when it does not - gets a half-decent guide out of
|
||||
* somebody who would otherwise have written nothing.
|
||||
*
|
||||
* The shape is lifted from the KB articles the team already writes, so guides
|
||||
* written here read like the ones written before them.
|
||||
*/
|
||||
|
||||
function id (prefix, n) {
|
||||
return prefix + n
|
||||
}
|
||||
|
||||
export const templates = [
|
||||
{
|
||||
key: 'blank',
|
||||
name: 'Blank',
|
||||
description: 'Start with nothing',
|
||||
blocks: () => [],
|
||||
},
|
||||
{
|
||||
key: 'procedure',
|
||||
name: 'Procedure',
|
||||
description: 'Before you start, numbered steps, how to check it worked',
|
||||
blocks: () => [
|
||||
{ id: id('t', 1), type: 'paragraph',
|
||||
text: 'What this covers, and when somebody would need it.' },
|
||||
{ id: id('t', 2), type: 'callout', variant: 'warning', title: 'Before you start',
|
||||
text: 'Anything that has to be true first, or that will bite halfway through.' },
|
||||
{ id: id('t', 3), type: 'heading', text: 'Steps' },
|
||||
{ id: id('t', 4), type: 'step', title: 'First step', text: '' },
|
||||
{ id: id('t', 5), type: 'step', title: 'Second step', text: '' },
|
||||
{ id: id('t', 6), type: 'heading', text: 'Check it worked' },
|
||||
{ id: id('t', 7), type: 'checklist', items: [
|
||||
{ id: id('ti', 1), text: '' },
|
||||
{ id: id('ti', 2), text: '' },
|
||||
] },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'troubleshooting',
|
||||
name: 'Troubleshooting',
|
||||
description: 'Symptom, likely causes, what to check first',
|
||||
blocks: () => [
|
||||
{ id: id('t', 1), type: 'paragraph', text: 'The symptom, as somebody would describe it.' },
|
||||
{ id: id('t', 2), type: 'heading', text: 'Check these first' },
|
||||
{ id: id('t', 3), type: 'checklist', items: [
|
||||
{ id: id('ti', 1), text: '' },
|
||||
{ id: id('ti', 2), text: '' },
|
||||
] },
|
||||
{ id: id('t', 4), type: 'heading', text: 'Likely causes' },
|
||||
{ id: id('t', 5), type: 'table',
|
||||
header: ['What you see', 'What it usually means'], rows: [['', '']] },
|
||||
{ id: id('t', 6), type: 'callout', variant: 'info',
|
||||
text: 'The thing that looks like the cause and is not.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'checklist',
|
||||
name: 'Checklist',
|
||||
description: 'A list to work through, nothing else',
|
||||
blocks: () => [
|
||||
{ id: id('t', 1), type: 'paragraph', text: 'When to use this list.' },
|
||||
{ id: id('t', 2), type: 'checklist', items: [
|
||||
{ id: id('ti', 1), text: '' },
|
||||
{ id: id('ti', 2), text: '' },
|
||||
{ id: id('ti', 3), text: '' },
|
||||
] },
|
||||
],
|
||||
},
|
||||
]
|
||||
691
plugins/guides/frontend/views/GuideEditor.vue
Normal file
691
plugins/guides/frontend/views/GuideEditor.vue
Normal file
@@ -0,0 +1,691 @@
|
||||
<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">
|
||||
<span class="ge-status">{{ status }}</span>
|
||||
<button type="button" class="ge-btn" @click="showPreview = !showPreview">
|
||||
{{ showPreview ? 'Hide preview' : 'Preview' }}
|
||||
</button>
|
||||
<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">
|
||||
<input v-model="guide.title" type="text" class="ge-title"
|
||||
placeholder="Guide title">
|
||||
<input v-model="guide.summary" type="text"
|
||||
placeholder="One line: what this covers and when somebody needs it">
|
||||
<input v-model="guide.tags" type="text"
|
||||
placeholder="Tags, comma separated - e.g. partmarker, backups">
|
||||
</div>
|
||||
|
||||
<!-- The blank page is the barrier, so offer a shape rather than an empty
|
||||
canvas. Only on a guide that has nothing in it yet. -->
|
||||
<div v-if="!blocks.length" class="ge-templates">
|
||||
<span>Start from:</span>
|
||||
<button v-for="tpl in templates" :key="tpl.key" type="button"
|
||||
class="ge-tpl" @click="useTemplate(tpl)">
|
||||
<strong>{{ tpl.name }}</strong>
|
||||
<small>{{ tpl.description }}</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Proofing: warnings, never a block. Somebody publishing at 14:40 with one
|
||||
empty bullet should be told, not stopped - the tool does not know the
|
||||
bullet is deliberate, and a procedure that exists imperfectly beats one
|
||||
nobody wrote.
|
||||
-->
|
||||
<div v-if="issues.length" class="ge-proof">
|
||||
<div class="ge-proofhead">
|
||||
<strong>{{ errorCount }} to fix, {{ issues.length - errorCount }} worth a look</strong>
|
||||
<button type="button" @click="showProof = !showProof">
|
||||
{{ showProof ? 'Hide' : 'Show' }}
|
||||
</button>
|
||||
</div>
|
||||
<ul v-if="showProof">
|
||||
<li v-for="(item, i) in issues" :key="i" :class="'ge-' + item.level">
|
||||
<button v-if="item.blockid" type="button" class="ge-jump"
|
||||
@click="jumpTo(item.blockid)">go</button>
|
||||
<span>{{ item.message }}</span>
|
||||
<button v-if="item.fix" type="button" class="ge-fix"
|
||||
@click="fix(item)">fix it</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="ge-split" :class="{ 'ge-haspreview': showPreview }">
|
||||
<div class="ge-editor">
|
||||
<div v-for="(block, index) in blocks" :key="block.id"
|
||||
class="ge-block" :class="{ 'ge-focused': focused === block.id }"
|
||||
@focusin="focused = block.id">
|
||||
<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">
|
||||
|
||||
<!-- Formatting acts on the selection in this block's textarea, so
|
||||
nobody has to know the markup exists. -->
|
||||
<div v-if="hasText(block) && block.type !== 'code'" class="ge-format">
|
||||
<button type="button" title="Bold" @click="wrap(block, '**')"><b>B</b></button>
|
||||
<button type="button" title="Italic" @click="wrap(block, '*')"><i>I</i></button>
|
||||
<button type="button" title="Code" @click="wrap(block, '`')"><></button>
|
||||
<button type="button" title="Link" @click="makeLink(block)">link</button>
|
||||
</div>
|
||||
|
||||
<div class="ge-blockbtns">
|
||||
<button type="button" title="Move up" @click="move(index, -1)"
|
||||
:disabled="index === 0">^</button>
|
||||
<button type="button" title="Move down" @click="move(index, 1)"
|
||||
:disabled="index === blocks.length - 1">v</button>
|
||||
<button type="button" title="Duplicate" @click="duplicate(index)">copy</button>
|
||||
<button type="button" class="ge-del" title="Remove"
|
||||
@click="remove(index)">x</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input v-if="block.type === 'step'" v-model="block.title" class="ge-steptitle"
|
||||
placeholder="Step title">
|
||||
|
||||
<!--
|
||||
Spellcheck and autocorrect are ON for prose and OFF for code.
|
||||
|
||||
A code block that autocorrects is a guide that tells somebody to run
|
||||
a command which does not work: smart quotes replacing ", an
|
||||
autocapitalised cmdlet, a "corrected" flag. The author will not
|
||||
notice, because it still LOOKS right - and the person at the bay
|
||||
gets a syntax error with no idea why.
|
||||
-->
|
||||
<textarea v-if="hasText(block)" v-model="block.text"
|
||||
:ref="el => registerArea(block.id, el)"
|
||||
class="ge-text" :class="{ 'ge-mono': block.type === 'code' }"
|
||||
:rows="block.type === 'code' ? 6 : 3"
|
||||
:spellcheck="block.type !== 'code'"
|
||||
:autocorrect="block.type === 'code' ? 'off' : 'on'"
|
||||
:autocapitalize="block.type === 'code' ? 'off' : 'sentences'"
|
||||
:autocomplete="block.type === 'code' ? 'off' : undefined"
|
||||
: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">
|
||||
<span class="ge-bullet">{{ block.type === 'numbers' ? (i + 1) + '.' : '-' }}</span>
|
||||
<input v-model="item.text" :placeholder="'Item ' + (i + 1)"
|
||||
:ref="el => registerItem(item.id, el)"
|
||||
@keydown.enter.prevent="addItemAfter(block, i)"
|
||||
@keydown.backspace="maybeRemoveItem(block, i, $event)">
|
||||
<button type="button" @click="removeItem(block, i)">x</button>
|
||||
</div>
|
||||
<button type="button" class="ge-additem" @click="addItemAfter(block, block.items.length - 1)">
|
||||
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)"
|
||||
spellcheck="false" autocorrect="off" autocapitalize="off"
|
||||
placeholder="One row per line, cells separated by | Header | Header Cell | Cell"></textarea>
|
||||
</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
|
||||
<input type="file" accept="image/*" hidden @change="onFilePick">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="ge-hint">
|
||||
Paste or drag an image anywhere to drop it in below the block you are working on.
|
||||
Text supports **bold**, *italic*, `code` and [links](https://example.com) -
|
||||
or use the buttons.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- The same renderer readers get, so what you see here is what they see.
|
||||
A separate preview implementation would drift within a month. -->
|
||||
<div v-if="showPreview" class="ge-preview">
|
||||
<div class="ge-previewhead">Preview</div>
|
||||
<h1 class="ge-previewtitle">{{ guide.title || 'Untitled guide' }}</h1>
|
||||
<p v-if="guide.summary" class="ge-previewsummary">{{ guide.summary }}</p>
|
||||
<GuideBlocks :blocks="blocks" :interactive="false" :ticked="{}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||
import api from '@/api'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import GuideBlocks from '../components/GuideBlocks.vue'
|
||||
import { templates } from '../utils/templates.js'
|
||||
import { proof, applyFix } from '../utils/proof.js'
|
||||
|
||||
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 status = ref('')
|
||||
const showPreview = ref(true)
|
||||
const focused = ref('')
|
||||
const dirty = ref(false)
|
||||
const showProof = ref(true)
|
||||
|
||||
// Recomputed as you type. Cheap - it is a walk over a few dozen blocks - and
|
||||
// live feedback is the difference between proofing and a report nobody reads.
|
||||
const issues = computed(() => proof(guide, blocks.value))
|
||||
const errorCount = computed(() => issues.value.filter(i => i.level === 'error').length)
|
||||
|
||||
function jumpTo (blockid) {
|
||||
focused.value = blockid
|
||||
const node = textareas[blockid] || itemInputs[blockid]
|
||||
if (node?.scrollIntoView) {
|
||||
node.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
node.focus?.()
|
||||
}
|
||||
}
|
||||
|
||||
function fix (item) {
|
||||
const block = blocks.value.find(b => b.id === item.blockid)
|
||||
if (block && applyFix(block, item.fix)) dirty.value = true
|
||||
}
|
||||
|
||||
const isNew = computed(() => !guide.guideid)
|
||||
const canpublish = computed(() => auth.hasPermission?.('guides.publish') ?? false)
|
||||
|
||||
const textareas = {}
|
||||
const itemInputs = {}
|
||||
function registerArea (id, el) { if (el) textareas[id] = el }
|
||||
function registerItem (id, el) { if (el) itemInputs[id] = el }
|
||||
|
||||
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 'What somebody needs to know before carrying on'
|
||||
if (block.type === 'heading') return 'Section heading'
|
||||
return 'Text'
|
||||
}
|
||||
|
||||
function useTemplate (tpl) {
|
||||
blocks.value = tpl.blocks()
|
||||
dirty.value = true
|
||||
}
|
||||
|
||||
// New blocks land AFTER the block being worked on, not at the bottom. Appending
|
||||
// to the end means scrolling back every time, which is the difference between
|
||||
// writing a guide and fighting an editor.
|
||||
function insertIndex () {
|
||||
const index = blocks.value.findIndex(b => b.id === focused.value)
|
||||
return index === -1 ? blocks.value.length : index + 1
|
||||
}
|
||||
|
||||
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 = [] }
|
||||
const at = insertIndex()
|
||||
blocks.value.splice(at, 0, block)
|
||||
focused.value = block.id
|
||||
nextTick(() => textareas[block.id]?.focus())
|
||||
}
|
||||
|
||||
function duplicate (index) {
|
||||
const source = blocks.value[index]
|
||||
const copy = JSON.parse(JSON.stringify(source))
|
||||
copy.id = newid()
|
||||
if (copy.items) copy.items = copy.items.map(item => ({ ...item, id: newid() }))
|
||||
blocks.value.splice(index + 1, 0, copy)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Enter makes the next item and focuses it - the way every list in every other
|
||||
// tool behaves. Typing a ten-item checklist should not need the mouse.
|
||||
function addItemAfter (block, index) {
|
||||
const item = { id: newid(), text: '' }
|
||||
block.items.splice(index + 1, 0, item)
|
||||
nextTick(() => itemInputs[item.id]?.focus())
|
||||
}
|
||||
|
||||
// Backspace on an ALREADY EMPTY item removes it and moves up, which is how a
|
||||
// list stops without reaching for the mouse. Never on a non-empty one.
|
||||
function maybeRemoveItem (block, index, event) {
|
||||
if (event.target.value !== '' || block.items.length <= 1) return
|
||||
event.preventDefault()
|
||||
const previous = block.items[index - 1]
|
||||
block.items.splice(index, 1)
|
||||
if (previous) nextTick(() => itemInputs[previous.id]?.focus())
|
||||
}
|
||||
|
||||
function removeItem (block, index) {
|
||||
if (block.items.length > 1) block.items.splice(index, 1)
|
||||
else block.items[0].text = ''
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Formatting wraps the SELECTION, so the markup stays an implementation detail.
|
||||
function wrap (block, marker) {
|
||||
const area = textareas[block.id]
|
||||
if (!area) return
|
||||
const start = area.selectionStart
|
||||
const end = area.selectionEnd
|
||||
const text = block.text || ''
|
||||
const selected = text.slice(start, end) || 'text'
|
||||
block.text = text.slice(0, start) + marker + selected + marker + text.slice(end)
|
||||
nextTick(() => {
|
||||
area.focus()
|
||||
area.setSelectionRange(start + marker.length, start + marker.length + selected.length)
|
||||
})
|
||||
}
|
||||
|
||||
function makeLink (block) {
|
||||
const area = textareas[block.id]
|
||||
if (!area) return
|
||||
const start = area.selectionStart
|
||||
const end = area.selectionEnd
|
||||
const text = block.text || ''
|
||||
const selected = text.slice(start, end) || 'link text'
|
||||
const url = window.prompt('Link to:', 'https://')
|
||||
if (!url) return
|
||||
block.text = text.slice(0, start) + '[' + selected + '](' + url + ')' + text.slice(end)
|
||||
nextTick(() => area.focus())
|
||||
}
|
||||
|
||||
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 = ''
|
||||
status.value = 'Uploading image...'
|
||||
try {
|
||||
const image = await uploadImage(file)
|
||||
const block = { id: newid(), type: 'image', url: image.url, caption: '', alt: '' }
|
||||
blocks.value.splice(insertIndex(), 0, block)
|
||||
focused.value = block.id
|
||||
status.value = ''
|
||||
} catch (err) {
|
||||
status.value = ''
|
||||
error.value = apiError(err, 'Could not upload that image')
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!guide.title.trim()) {
|
||||
if (!quiet) error.value = 'A guide needs a title before it can be saved'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
error.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 })
|
||||
router.replace({ name: 'guide-edit', params: { guideid: created.guideid } })
|
||||
} else {
|
||||
const response = await api.put('/guides/' + guide.guideid, payload())
|
||||
const saved = response.data?.data || response.data
|
||||
guide.slug = saved.slug
|
||||
}
|
||||
dirty.value = false
|
||||
status.value = 'Saved ' + new Date().toLocaleTimeString()
|
||||
await loadRevisions()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Could not save this guide')
|
||||
status.value = 'Not saved'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Autosave, debounced. The server dedups on content, so an autosave that
|
||||
// changed nothing writes no version - which is what makes this safe to run on a
|
||||
// timer rather than a deliberate act.
|
||||
let autosaveTimer = null
|
||||
watch([blocks, () => guide.title, () => guide.summary, () => guide.tags],
|
||||
() => {
|
||||
dirty.value = true
|
||||
status.value = 'Unsaved changes'
|
||||
if (isNew.value) return // a new guide is saved deliberately, once
|
||||
clearTimeout(autosaveTimer)
|
||||
autosaveTimer = setTimeout(() => save(true), 3000)
|
||||
},
|
||||
{ deep: true })
|
||||
|
||||
onBeforeUnmount(() => clearTimeout(autosaveTimer))
|
||||
|
||||
// Leaving with unsaved work should require saying so out loud.
|
||||
onBeforeRouteLeave(() => {
|
||||
if (!dirty.value) return true
|
||||
return window.confirm('This guide has unsaved changes. Leave anyway?')
|
||||
})
|
||||
|
||||
async function publish () {
|
||||
if (errorCount.value) {
|
||||
const ok = window.confirm(
|
||||
errorCount.value + ' problem(s) would go out with this guide, including '
|
||||
+ 'anything flagged in a command. Publish anyway?')
|
||||
if (!ok) return
|
||||
}
|
||||
await save(true)
|
||||
try {
|
||||
const response = await api.post('/guides/' + guide.guideid + '/publish')
|
||||
guide.ispublished = true
|
||||
message.value = response.data?.message || 'Published'
|
||||
setTimeout(() => { message.value = '' }, 3000)
|
||||
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'
|
||||
setTimeout(() => { message.value = '' }, 3000)
|
||||
await loadRevisions()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Could not withdraw this guide')
|
||||
}
|
||||
}
|
||||
|
||||
async function restore (rev) {
|
||||
if (!window.confirm('Bring version ' + rev.revision + ' back as a new draft version?')) return
|
||||
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(', ') })
|
||||
dirty.value = false
|
||||
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 nextTick()
|
||||
dirty.value = false
|
||||
status.value = ''
|
||||
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; align-items: center; }
|
||||
.ge-status { font-size: 0.75rem; color: var(--text-light); margin-right: 0.3rem; }
|
||||
.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.4rem; margin: 0.8rem 0 1rem; max-width: 45rem; }
|
||||
.ge-fields input {
|
||||
padding: 0.4rem 0.55rem; border: 1px solid var(--border); border-radius: 4px;
|
||||
background: var(--surface, transparent); color: var(--text);
|
||||
}
|
||||
.ge-title { font-size: 1.1rem; font-weight: 600; }
|
||||
|
||||
.ge-templates { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;
|
||||
margin-bottom: 1rem; }
|
||||
.ge-templates > span { font-size: 0.8rem; color: var(--text-light); }
|
||||
.ge-tpl {
|
||||
display: grid; text-align: left; gap: 0.1rem; padding: 0.45rem 0.7rem;
|
||||
border: 1px solid var(--border); border-radius: 5px; background: transparent;
|
||||
color: var(--text); cursor: pointer; max-width: 15rem;
|
||||
}
|
||||
.ge-tpl:hover { border-color: var(--primary); }
|
||||
.ge-tpl small { color: var(--text-light); font-size: 0.72rem; }
|
||||
|
||||
.ge-split { display: grid; gap: 1.2rem; }
|
||||
.ge-haspreview { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||
@media (max-width: 70rem) { .ge-haspreview { grid-template-columns: 1fr; } }
|
||||
|
||||
.ge-block {
|
||||
border: 1px solid var(--border); border-radius: 5px; padding: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.ge-focused { border-color: var(--primary); }
|
||||
.ge-blockbar { display: flex; gap: 0.35rem; align-items: center; margin-bottom: 0.35rem;
|
||||
flex-wrap: wrap; }
|
||||
.ge-type { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
color: var(--text-light); }
|
||||
.ge-mini {
|
||||
font-size: 0.72rem; padding: 0.12rem 0.3rem; border: 1px solid var(--border);
|
||||
border-radius: 3px; background: var(--surface, transparent); color: var(--text);
|
||||
max-width: 8rem;
|
||||
}
|
||||
.ge-format { display: flex; gap: 0.15rem; }
|
||||
.ge-format button, .ge-blockbtns button {
|
||||
font-size: 0.7rem; padding: 0.1rem 0.35rem; border: 1px solid var(--border);
|
||||
background: transparent; color: var(--text-light); border-radius: 3px; cursor: pointer;
|
||||
min-width: 1.5rem;
|
||||
}
|
||||
.ge-format button:hover, .ge-blockbtns button:hover { color: var(--primary); border-color: var(--primary); }
|
||||
.ge-blockbtns { margin-left: auto; display: flex; gap: 0.15rem; }
|
||||
.ge-del:hover { border-color: var(--danger, #c0392b) !important; color: var(--danger, #c0392b) !important; }
|
||||
|
||||
.ge-text, .ge-steptitle, .ge-tableedit textarea {
|
||||
width: 100%; padding: 0.4rem 0.5rem; border: 1px solid var(--border);
|
||||
border-radius: 4px; background: var(--surface, transparent); color: var(--text);
|
||||
font-family: inherit; font-size: 0.88rem; resize: vertical;
|
||||
}
|
||||
.ge-mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.82rem; }
|
||||
.ge-steptitle { margin-bottom: 0.35rem; }
|
||||
|
||||
.ge-items { display: grid; gap: 0.25rem; }
|
||||
.ge-item { display: flex; gap: 0.3rem; align-items: center; }
|
||||
.ge-bullet { font-size: 0.8rem; color: var(--text-light); width: 1.2rem; }
|
||||
.ge-item input {
|
||||
flex: 1; padding: 0.3rem 0.45rem; border: 1px solid var(--border);
|
||||
border-radius: 4px; background: var(--surface, transparent); color: var(--text);
|
||||
}
|
||||
.ge-item button, .ge-additem, .ge-add button, .ge-upload {
|
||||
font-size: 0.72rem; padding: 0.15rem 0.45rem; border: 1px solid var(--border);
|
||||
background: transparent; color: var(--text-light); border-radius: 3px;
|
||||
cursor: pointer; justify-self: start;
|
||||
}
|
||||
.ge-image img { max-width: 18rem; display: block; margin-bottom: 0.35rem;
|
||||
border: 1px solid var(--border); border-radius: 4px; }
|
||||
.ge-image input { width: 100%; margin-bottom: 0.25rem; padding: 0.3rem 0.45rem;
|
||||
border: 1px solid var(--border); border-radius: 4px;
|
||||
background: var(--surface, transparent); color: var(--text); }
|
||||
|
||||
.ge-add { display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; margin: 0.8rem 0 0.4rem; }
|
||||
.ge-add > span { font-size: 0.75rem; color: var(--text-light); }
|
||||
.ge-hint { font-size: 0.75rem; color: var(--text-light); }
|
||||
|
||||
.ge-preview {
|
||||
border-left: 1px solid var(--border); padding-left: 1.2rem;
|
||||
position: sticky; top: 1rem; align-self: start; max-height: calc(100vh - 3rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ge-previewhead {
|
||||
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: var(--text-light); margin-bottom: 0.5rem;
|
||||
}
|
||||
.ge-previewtitle { margin: 0 0 0.2rem; font-size: 1.4rem; }
|
||||
.ge-previewsummary { color: var(--text-light); margin: 0 0 1rem; }
|
||||
|
||||
.ge-proof {
|
||||
border: 1px solid var(--border); border-left: 3px solid var(--warning, #d9822b);
|
||||
border-radius: 4px; padding: 0.5rem 0.7rem; margin-bottom: 1rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.ge-proofhead { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.ge-proofhead button {
|
||||
margin-left: auto; font-size: 0.72rem; background: none; border: none;
|
||||
color: var(--text-light); cursor: pointer; text-decoration: underline;
|
||||
}
|
||||
.ge-proof ul { margin: 0.4rem 0 0; padding: 0; list-style: none; }
|
||||
.ge-proof li { display: flex; gap: 0.4rem; align-items: baseline; padding: 0.15rem 0; }
|
||||
.ge-error span { color: var(--danger, #c0392b); }
|
||||
.ge-warn span { color: var(--text-light); }
|
||||
.ge-jump, .ge-fix {
|
||||
font-size: 0.68rem; padding: 0.05rem 0.35rem; border: 1px solid var(--border);
|
||||
background: transparent; color: var(--text-light); border-radius: 3px; cursor: pointer;
|
||||
}
|
||||
.ge-fix { margin-left: auto; border-color: var(--primary); color: var(--primary); }
|
||||
|
||||
.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>
|
||||
271
plugins/guides/frontend/views/GuideView.vue
Normal file
271
plugins/guides/frontend/views/GuideView.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<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>
|
||||
|
||||
<style>
|
||||
/*
|
||||
* PRINT IS ALWAYS LIGHT, whatever the screen theme is.
|
||||
*
|
||||
* Printing a dark-mode page lays down a full page of toner for the background
|
||||
* and then knocks the text out of it in white. On a shopfloor printer that is
|
||||
* a cartridge for a handful of guides, and the result is harder to read than
|
||||
* the plain version.
|
||||
*
|
||||
* So the theme tokens are redefined for print only. Every guide element already
|
||||
* renders through these variables rather than literal colours, which is what
|
||||
* makes this a dozen lines instead of a rewrite - and the same discipline that
|
||||
* made dark mode work at all.
|
||||
*
|
||||
* Deliberately NOT scoped: a scoped block cannot reach :root, and these have to
|
||||
* override tokens set on the document element.
|
||||
*/
|
||||
@media print {
|
||||
:root,
|
||||
:root[data-theme="dark"],
|
||||
:root[data-theme="light"] {
|
||||
--text: #000;
|
||||
--text-light: #333;
|
||||
--border: #999;
|
||||
--surface: #fff;
|
||||
--surface-2: #fff;
|
||||
--primary: #000;
|
||||
--info: #666;
|
||||
--warning: #666;
|
||||
--success: #666;
|
||||
}
|
||||
|
||||
html, body, .app-layout, .main-content {
|
||||
background: #fff !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
/* Backgrounds are dropped rather than printed grey: a callout is identified
|
||||
by its rule and its heading, which cost nothing to print. */
|
||||
.gb-callout, .gb-code, .gb-codehead, .gb-table th {
|
||||
background: #fff !important;
|
||||
}
|
||||
.gb-callout { border-left: 3px solid #000 !important; }
|
||||
.gb-code, .gb-table th, .gb-table td { border-color: #999 !important; }
|
||||
|
||||
/* Links: the text is what matters on paper, and a blue underline on a mono
|
||||
printer is just grey. */
|
||||
a { color: #000 !important; text-decoration: underline; }
|
||||
}
|
||||
</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>
|
||||
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"}
|
||||
114
plugins/guides/migrations/versions/0001_guides_baseline.py
Normal file
114
plugins/guides/migrations/versions/0001_guides_baseline.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""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),
|
||||
# 191 because this is UNIQUE-indexed and MySQL caps an index key at
|
||||
# 767 bytes; utf8mb4 is 4 bytes per character.
|
||||
sa.Column('slug', sa.String(length=191), 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('publishedat', sa.DateTime()),
|
||||
# BaseModel contributes these to every model in the app; a plugin
|
||||
# migration has to spell them out or the table is missing columns
|
||||
# the ORM selects on every query.
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), nullable=False),
|
||||
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()),
|
||||
# BaseModel contributes these to every model in the app; a plugin
|
||||
# migration has to spell them out or the table is missing columns
|
||||
# the ORM selects on every query.
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), 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()),
|
||||
# BaseModel contributes these to every model in the app; a plugin
|
||||
# migration has to spell them out or the table is missing columns
|
||||
# the ORM selects on every query.
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), 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)
|
||||
@@ -1,3 +1,3 @@
|
||||
from .guide import Guide, GuideImage, BLOCKTYPES
|
||||
from .guide import Guide, GuideRevision, GuideImage, BLOCKTYPES
|
||||
|
||||
__all__ = ['Guide', 'GuideImage', 'BLOCKTYPES']
|
||||
__all__ = ['Guide', 'GuideRevision', 'GuideImage', 'BLOCKTYPES']
|
||||
|
||||
@@ -74,7 +74,12 @@ class Guide(BaseModel):
|
||||
# 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)
|
||||
# 191, not 300: this column is UNIQUE-indexed, and MySQL caps an index key
|
||||
# at 767 bytes. utf8mb4 stores up to 4 bytes per character, so 191 * 4 =
|
||||
# 764 is the longest indexable string - 300 produces
|
||||
# "Specified key was too long" the moment the migration runs on a real
|
||||
# database, having passed every SQLite test.
|
||||
slug = db.Column(db.String(191), nullable=False, unique=True, index=True)
|
||||
|
||||
# One line, shown in the list and in search results.
|
||||
summary = db.Column(db.String(1000))
|
||||
@@ -93,15 +98,39 @@ class Guide(BaseModel):
|
||||
# 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'))
|
||||
|
||||
# createddate / modifieddate / isactive come from BaseModel - no second set
|
||||
# of timestamps on top of them, or the next person has to work out which
|
||||
# pair is authoritative. publishedat is ours because "when did readers start
|
||||
# seeing this" is a different fact from "when was the row last touched".
|
||||
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)
|
||||
|
||||
# Bumped on every save. Cheap, and it gives a printed copy something
|
||||
# honest to put in its footer: "version 7, printed 26 Aug 2026".
|
||||
# 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
|
||||
@@ -138,8 +167,10 @@ class Guide(BaseModel):
|
||||
'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,
|
||||
# API names stay createdat/updatedat: the column names are a
|
||||
# storage detail, and renaming a payload field breaks callers.
|
||||
'createdat': self.createddate.isoformat() + 'Z' if self.createddate else None,
|
||||
'updatedat': self.modifieddate.isoformat() + 'Z' if self.modifieddate else None,
|
||||
'publishedat': (self.publishedat.isoformat() + 'Z'
|
||||
if self.publishedat else None),
|
||||
}
|
||||
@@ -148,6 +179,76 @@ class Guide(BaseModel):
|
||||
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'))
|
||||
|
||||
@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.createddate.isoformat() + 'Z' if self.createddate 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.
|
||||
|
||||
@@ -174,7 +275,6 @@ class GuideImage(BaseModel):
|
||||
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 {
|
||||
@@ -184,5 +284,5 @@ class GuideImage(BaseModel):
|
||||
'mimetype': self.mimetype,
|
||||
'bytesize': self.bytesize,
|
||||
'url': '/api/guides/images/{}'.format(self.guideimageid),
|
||||
'createdat': self.createdat.isoformat() + 'Z' if self.createdat else None,
|
||||
'createdat': self.createddate.isoformat() + 'Z' if self.createddate else None,
|
||||
}
|
||||
|
||||
92
plugins/guides/plugin.py
Normal file
92
plugins/guides/plugin.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""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]:
|
||||
# Position 8, beside Slides (7), rather than down in the Information
|
||||
# section with the reference lists. A guide is something you go and DO
|
||||
# something with, like a slide deck you put on a screen - not a
|
||||
# catalogue you look things up in.
|
||||
return [
|
||||
{
|
||||
'name': 'Guides',
|
||||
'icon': 'book-open',
|
||||
'route': '/guides',
|
||||
'position': 8,
|
||||
},
|
||||
]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
logger.info('Guides plugin initialized (v%s)', self.meta.version)
|
||||
133
plugins/guides/services/authoring.py
Normal file
133
plugins/guides/services/authoring.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""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
|
||||
|
||||
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)
|
||||
@@ -17,6 +17,7 @@ Storing text verbatim keeps what the author typed recoverable even if the
|
||||
rendering rules change later.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from ..models import BLOCKTYPES
|
||||
@@ -129,3 +130,23 @@ def checklistitemcount(blocks):
|
||||
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()
|
||||
|
||||
@@ -255,6 +255,55 @@ def _search_applications(query, search_term):
|
||||
return results
|
||||
|
||||
|
||||
def _search_guides(query, search_term):
|
||||
"""Search guides by title, summary, tags and BODY.
|
||||
|
||||
The body matters more here than in most domains. People look for a procedure
|
||||
by something written halfway down it - "the one about the drive letter" -
|
||||
not by a title they never read. The body is a JSON blob of blocks, so this
|
||||
is a LIKE against it: crude, but it finds the guide, and a guide corpus is
|
||||
tens of documents rather than millions of rows.
|
||||
|
||||
PUBLISHED ONLY. A draft is deliberately invisible to readers, and search is
|
||||
the one place that rule would otherwise leak - somebody would find a
|
||||
half-written procedure through the search box and follow it.
|
||||
"""
|
||||
results = []
|
||||
try:
|
||||
_require_enabled('guides')
|
||||
from plugins.guides.models import Guide
|
||||
|
||||
rows = Guide.query.filter(
|
||||
Guide.isactive == True,
|
||||
Guide.publishedrevisionid.isnot(None),
|
||||
_word_match(query, Guide.title, Guide.summary, Guide.tags,
|
||||
Guide.blocksjson)
|
||||
).order_by(Guide.modifieddate.desc()).limit(20).all()
|
||||
|
||||
for guide in rows:
|
||||
# A title match outranks a body match: someone searching "marker"
|
||||
# wants the guide called that, not every guide mentioning one.
|
||||
relevance = 10
|
||||
if query.lower() in (guide.title or '').lower():
|
||||
relevance += 20
|
||||
if guide.tags and query.lower() in guide.tags.lower():
|
||||
relevance += 15
|
||||
|
||||
results.append({
|
||||
'type': 'guide',
|
||||
'id': guide.guideid,
|
||||
'title': guide.title,
|
||||
'subtitle': guide.summary,
|
||||
'url': f"/guides/{guide.slug}",
|
||||
'relevance': relevance
|
||||
})
|
||||
except ImportError:
|
||||
pass # guides plugin absent or disabled
|
||||
except Exception as e:
|
||||
logger.error(f"Guides search failed: {e}")
|
||||
return results
|
||||
|
||||
|
||||
def _search_knowledgebase(query, search_term):
|
||||
"""Search Knowledge Base by description and keywords.
|
||||
|
||||
@@ -1148,6 +1197,7 @@ def global_search():
|
||||
# Run all search domains
|
||||
results.extend(_search_applications(query, search_term))
|
||||
results.extend(_search_knowledgebase(query, search_term))
|
||||
results.extend(_search_guides(query, search_term))
|
||||
results.extend(_search_employees(query, search_term))
|
||||
results.extend(_search_assets(query, search_term))
|
||||
results.extend(_search_measuringtools(query, search_term))
|
||||
|
||||
@@ -101,6 +101,7 @@ IDENTIFIER_ASSETTYPES = ['machine', 'computer', 'printer', 'network_device',
|
||||
SEARCH_DOMAINS = {
|
||||
'application': 'Applications',
|
||||
'knowledgebase': 'Knowledge Base',
|
||||
'guide': 'Guides',
|
||||
'employee': 'Employees',
|
||||
'machine': 'Machines',
|
||||
'computer': 'PCs',
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -266,3 +266,75 @@ def test_an_ip_search_links_to_the_matched_subnet(client, subnetrow):
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['id'] == subnetrow
|
||||
assert rows[0]['url'] == f'/networks/{subnetrow}'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Guides. A guide is not an asset either, so the generic asset search cannot see
|
||||
# one - and the draft rule makes the omission worse than invisibility: a
|
||||
# half-written procedure reachable through the search box is one somebody
|
||||
# follows.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def guides(db):
|
||||
"""One published guide and one draft, with distinctive text.
|
||||
|
||||
The published one carries its distinguishing words in the BODY, not the
|
||||
title, because that is how people look for a procedure - by something
|
||||
written halfway down it.
|
||||
"""
|
||||
from plugins.guides.models import Guide
|
||||
from plugins.guides.services import authoring
|
||||
|
||||
published = Guide(title='Zzmarker controller swap', slug='zzmarker-swap',
|
||||
status='draft')
|
||||
draft = Guide(title='Zzdraft unfinished procedure', slug='zzdraft-unfinished',
|
||||
status='draft')
|
||||
db.session.add_all([published, draft])
|
||||
db.session.flush()
|
||||
|
||||
authoring.save(published, title=published.title,
|
||||
summary='Restoring after a swap', tags='zztelesis, zzbackups',
|
||||
blocks=[{'id': 'g1', 'type': 'paragraph',
|
||||
'text': 'Check the zzmappeddrive before starting.'}])
|
||||
authoring.publish(published)
|
||||
authoring.save(draft, title=draft.title, summary='', tags='zztelesis',
|
||||
blocks=[{'id': 'g2', 'type': 'paragraph',
|
||||
'text': 'Half written zzmappeddrive notes.'}])
|
||||
db.session.commit()
|
||||
return {'published': published.guideid, 'draft': draft.guideid}
|
||||
|
||||
|
||||
def test_a_published_guide_is_found_by_title(client, guides):
|
||||
rows = [r for r in search(client, 'Zzmarker') if r['type'] == 'guide']
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['id'] == guides['published']
|
||||
assert rows[0]['url'] == '/guides/zzmarker-swap'
|
||||
|
||||
|
||||
def test_a_guide_is_found_by_tag(client, guides):
|
||||
"""Tags are how a technician narrows to a family of procedures."""
|
||||
assert 'Zzmarker controller swap' in titles(client, 'zzbackups', 'guide')
|
||||
|
||||
|
||||
def test_a_guide_is_found_by_words_in_its_body(client, guides):
|
||||
"""People search for what they remember reading, not for the title.
|
||||
|
||||
The body is stored as a JSON blob of blocks, so this is the check that the
|
||||
blob is actually reachable rather than merely present.
|
||||
"""
|
||||
assert 'Zzmarker controller swap' in titles(client, 'zzmappeddrive', 'guide')
|
||||
|
||||
|
||||
def test_a_draft_guide_is_never_returned(client, guides):
|
||||
"""The draft rule has to hold on EVERY path that reaches the row.
|
||||
|
||||
A draft is deliberately invisible to readers, and search is where that rule
|
||||
would otherwise leak: somebody finds a half-written procedure through the
|
||||
search box and follows it. The draft here shares a tag and a body word with
|
||||
the published guide, so it would ride along on both queries if the filter
|
||||
were missing.
|
||||
"""
|
||||
assert 'Zzdraft unfinished procedure' not in titles(client, 'zzmappeddrive', 'guide')
|
||||
assert 'Zzdraft unfinished procedure' not in titles(client, 'zztelesis', 'guide')
|
||||
assert titles(client, 'Zzdraft', 'guide') == []
|
||||
|
||||
@@ -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