Merge remote-tracking branch 'origin/main' into wilm

# Conflicts:
#	frontend/src/views/settings/SiteSettings.vue
This commit is contained in:
benjamin.baker
2026-08-27 05:45:52 -04:00
69 changed files with 5601 additions and 327 deletions

View File

@@ -168,6 +168,7 @@ import { applicationsApi } from '@/api'
import { getContactEmailDomain } from '@/utils/siteSettings'
import { sanitizeNotesHtml } from '@/utils/sanitizeHtml'
import { fileHref } from '@/utils/basePath'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute()
@@ -202,6 +203,7 @@ onMounted(async () => {
// Load application details
const response = await applicationsApi.get(route.params.id)
app.value = response.data.data
setRecordTitle(app.value?.appname)
// Load versions
try {

View File

@@ -27,8 +27,10 @@ depends_on = None
def upgrade():
columns = {c['name'] for c in
sa.inspect(op.get_bind()).get_columns('backuprevisions')}
inspector = sa.inspect(op.get_bind())
if 'backuprevisions' not in inspector.get_table_names():
return
columns = {c['name'] for c in inspector.get_columns('backuprevisions')}
if 'lastseenat' not in columns:
op.add_column('backuprevisions',
sa.Column('lastseenat', sa.DateTime(), nullable=True))
@@ -37,7 +39,9 @@ def upgrade():
def downgrade():
columns = {c['name'] for c in
sa.inspect(op.get_bind()).get_columns('backuprevisions')}
inspector = sa.inspect(op.get_bind())
if 'backuprevisions' not in inspector.get_table_names():
return
columns = {c['name'] for c in inspector.get_columns('backuprevisions')}
if 'lastseenat' in columns:
op.drop_column('backuprevisions', 'lastseenat')

View File

@@ -410,7 +410,13 @@ class BackupsPlugin(BasePlugin):
# with_for_update serializes concurrent posts for the same chain: the
# fleet collects on one GE-Enforce cycle, so a retry could otherwise
# pass the hash check twice and insert the same revision.
sourcehostname = payload.get('sourcehostname')
# Normalize ONCE, and use the same value to query and to store.
# samesource() maps '' to IS NULL, but the insert below used to store
# the raw '' - so a client posting an empty hostname wrote rows that its
# own next post could never match. Every post then deduped against
# nothing, inserting forever: unbounded growth, and a history no
# technician can read. Whitespace does the same thing more quietly.
sourcehostname = (payload.get('sourcehostname') or '').strip() or None
latest = (db.session.query(BackupRevision)
.filter(BackupRevision.assetid == assetid,
BackupRevision.backupkind == kindkey,
@@ -476,7 +482,7 @@ class BackupsPlugin(BasePlugin):
sharepath=sharepath,
sourcefilename=sourcefilename,
bytesize=bytesize,
sourcehostname=payload.get('sourcehostname'),
sourcehostname=sourcehostname,
collectedat=collectedat or datetime.utcnow(),
lastseenat=datetime.utcnow(),
)

View File

@@ -32,6 +32,31 @@ import re
SCHEMA = 'ntlars/1'
# SECRETS ARE NOT BACKED UP.
#
# The DNC registry carries an FTP password in cleartext. Stored verbatim it
# reached three places it must not: the backuprevisions table, the revision
# panel (which renders values), and the download route (which hands back a
# ready-to-import .reg). Anyone with backups.view could read the fleet's FTP
# password off a web page.
#
# Redaction happens at PARSE, so the secret never enters the database at all.
# Redacting only on display would leave it at rest and one query away.
#
# What is deliberately NOT done: dropping the value silently. This module's
# parser refuses to discard an unparseable value precisely because a backup that
# looks complete and restores a machine with a setting missing is the worst
# outcome. A redacted value keeps its NAME and records itself in
# `redacted`, so both the panel and a restore can say plainly that this one
# field must be set by hand.
REDACTEDMARKER = '***REDACTED***'
SECRETNAMEPATTERN = re.compile(r'passw|pwd|secret|apikey|token', re.IGNORECASE)
def issecretname(name):
"""Whether a registry value name holds a credential."""
return bool(SECRETNAMEPATTERN.search(name or ''))
REGROOT = 'HKEY_LOCAL_MACHINE'
DNCPATH = r'SOFTWARE\GE Aircraft Engines\DNC'
DNCPATHWOW = r'SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC'
@@ -245,6 +270,20 @@ def parse(raw):
dialect = 'wow6432node' if sawwow else ('ntlars' if sawplain else 'unknown')
# Strip credentials before the projection is returned - i.e. before anything
# persists it. Recorded by path\name so the omission is visible rather than
# discovered during a restore.
redacted = []
for path in keys:
for name, value in keys[path].items():
if not issecretname(name):
continue
if value.get('data') in ('', None):
continue # nothing to hide
keys[path][name] = {'type': value.get('type', 'REG_SZ'),
'data': REDACTEDMARKER}
redacted.append('{}\\{}'.format(path, name) if path else name)
return {
'schema': SCHEMA,
'sourcedialect': dialect,
@@ -252,6 +291,7 @@ def parse(raw):
{'path': path, 'values': dict(sorted(keys[path].items()))}
for path in sorted(keys)
],
'redacted': sorted(redacted),
}
@@ -291,6 +331,12 @@ def render(projection, dialect='ntlars', comments=None):
dialect='ntlars' omits WOW6432Node - use with the NTLARS Load... button
dialect='wow6432node' includes it - use with `reg import` on 64-bit
Redacted credentials are rendered as a comment naming the value, NOT as the
marker string. Importing '***REDACTED***' as the FTP password would leave a
machine that looks restored and cannot connect, and the technician would be
debugging the network. A commented-out value fails loudly at the one moment
somebody is looking at the file.
"""
if dialect not in ('ntlars', 'wow6432node'):
raise ValueError('unknown dialect: {!r}'.format(dialect))
@@ -302,10 +348,28 @@ def render(projection, dialect='ntlars', comments=None):
if comments:
out.append('')
redacted = projection.get('redacted') or []
if redacted:
out.append('; ---------------------------------------------------------------')
out.append('; {} credential value(s) were NOT backed up and are NOT in this'
.format(len(redacted)))
out.append('; file. Set them by hand after importing:')
for name in redacted:
out.append('; {}'.format(name))
out.append('; ---------------------------------------------------------------')
out.append('')
for key in projection.get('keys', []):
path = key.get('path', '')
out.append('[{}]'.format(root + ('\\' + path if path else '')))
for name, entry in (key.get('values') or {}).items():
# A redacted credential is commented out, never emitted as the
# marker. Importing the marker as a password produces a machine that
# looks restored and cannot log in.
if (entry or {}).get('data') == REDACTEDMARKER:
out.append('; "{}" was not backed up - set it by hand'.format(
_escape(name)))
continue
out.append(_rendervalue(name, entry))
out.append('')

View File

@@ -6,7 +6,7 @@ export default [
path: 'reports/pc-relationships',
name: 'report-pc-relationships',
component: () => import('./views/PCRelationshipsReport.vue'),
meta: { plugin: 'computers' }
meta: { plugin: 'computers', title: 'PC Relationships Report' }
},
{
path: 'settings/pctypemapping',

View File

@@ -5,6 +5,7 @@
<div class="header-actions">
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
<router-link :to="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
<router-link to="/pcs" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
@@ -276,7 +277,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { computersApi, applicationsApi } from '@/api'
import { withBase } from '@/utils/basePath'
import { useWarrantyBadge } from '@/composables/warrantyBadge'
@@ -285,8 +286,13 @@ import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetRelationships from '@/components/AssetRelationships.vue'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { setRecordTitle } from '@/composables/pageTitle'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { isEnabled } = useIdentifierFlags()
const loading = ref(true)
@@ -302,6 +308,7 @@ onMounted(async () => {
try {
const response = await computersApi.get(route.params.id)
computer.value = response.data.data
setRecordTitle(computer.value?.assetnumber || computer.value?.name)
// Load installed applications
try {
@@ -330,6 +337,23 @@ function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
// Retire, not delete: the server deactivates the asset, so the PC
// leaves every list and report while its history and relationships stay put.
// Reversible by an admin setting isactive back to true.
async function confirmRetire() {
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
+ `History and relationships are kept, and it can be restored.`)) return
try {
await computersApi.delete(route.params.id)
toast.success(`Retired ${label}`)
router.push('/pcs')
} catch (error) {
console.error('Error retiring PC:', error)
toast.error(apiError(error, 'Failed to retire PC'))
}
}
</script>
<style scoped>

View File

@@ -153,6 +153,7 @@ import { withBase } from '@/utils/basePath'
import { employeesApi, usbApi, notificationsApi } from '@/api'
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
import { useToast } from '@/composables/toast'
import { setRecordTitle } from '@/composables/pageTitle'
const toast = useToast()
const route = useRoute()
@@ -223,6 +224,7 @@ async function loadEmployee() {
try {
const response = await employeesApi.lookup(route.params.sso)
employee.value = response.data.data
setRecordTitle(employee.value?.name || employee.value?.sso)
} catch (err) {
console.error('Error loading employee:', err)
error.value = 'Employee not found'

View File

@@ -0,0 +1,3 @@
from .plugin import GuidesPlugin
__all__ = ['GuidesPlugin']

View File

@@ -0,0 +1,3 @@
from .routes import guides_bp
__all__ = ['guides_bp']

View File

@@ -0,0 +1,573 @@
"""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 json
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, harden_upload_response,
)
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.
"""
caneditall = _has('guides.edit')
mine = _userid()
# Match against the PUBLISHED text for anything the caller is only allowed
# to read, and against the working copy only for guides they may edit.
# Searching the working copy for everyone leaks draft wording to readers:
# they get a hit on a word that is not in the page when they open it.
published = db.aliased(GuideRevision)
query = Guide.query.outerjoin(
published, Guide.publishedrevisionid == published.guiderevisionid)
search = (request.args.get('q') or '').strip()
if search:
like = '%{}%'.format(search)
def matches(title, summary, tags, blocks):
# The body too: people look for "the one about the drive letter",
# not for a title they never read.
return db.or_(title.ilike(like), summary.ilike(like),
tags.ilike(like), blocks.ilike(like))
working = matches(Guide.title, Guide.summary, Guide.tags, Guide.blocksjson)
readable = matches(published.title, published.summary,
published.tags, published.blocksjson)
if caneditall:
query = query.filter(db.or_(working, readable))
else:
query = query.filter(db.or_(
readable,
db.and_(Guide.createdby == mine, working),
))
tag = (request.args.get('tag') or '').strip()
if tag:
like = '%{}%'.format(tag)
if caneditall:
query = query.filter(db.or_(Guide.tags.ilike(like),
published.tags.ilike(like)))
else:
query = query.filter(db.or_(
published.tags.ilike(like),
db.and_(Guide.createdby == mine, Guide.tags.ilike(like)),
))
if not caneditall:
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)
# A reader's card must describe the guide they will actually open. The
# Guide row carries the working copy, so a retitled draft would list
# under its new name and open under its old one.
if not data['canedit'] and guide.publishedrevisionid:
revision = db.session.get(GuideRevision, guide.publishedrevisionid)
if revision is not None:
data['title'] = revision.title
data['summary'] = revision.summary
data['tags'] = [t.strip() for t in (revision.tags or '').split(',')
if t.strip()]
data['checklistitems'] = checklistitemcount(revision.blocks)
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.
Readers count published tags only. A chip carrying a tag that exists solely
on somebody's draft is both a leak and a dead end - filtering by it returns
nothing a reader is allowed to see.
"""
counts = {}
if _has('guides.edit'):
pairs = db.session.query(Guide.tags).filter(Guide.tags.isnot(None)).all()
else:
published = db.aliased(GuideRevision)
pairs = db.session.query(published.tags).join(
Guide, Guide.publishedrevisionid == published.guiderevisionid
).filter(published.tags.isnot(None)).all()
mine = _userid()
pairs += db.session.query(Guide.tags).filter(
Guide.createdby == mine, Guide.tags.isnot(None)).all()
for (tags,) in pairs:
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)
# History is editors' business. guides.view alone is permission to read what
# was PUBLISHED; the revision list is every draft this guide has ever been,
# including the ones deliberately not published yet.
if not _mayedit(guide):
return error_response(ErrorCodes.FORBIDDEN,
'You do not have permission to see this history',
http_code=403)
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):
"""One revision by id.
Gated on its PARENT, not on guides.view alone. Revision ids are sequential
integers, so an ungated route here is an invitation to walk 1..n and read
every unpublished draft on the site - which is the opposite of what
the GUIDES wiki page promises about drafts.
A reader may fetch the published revision (it is what the slug route would
serve anyway); everything else takes edit rights on that guide.
"""
revision = db.session.get(GuideRevision, revisionid)
if revision is None:
return error_response(ErrorCodes.NOT_FOUND, 'Revision not found',
http_code=404)
guide = db.session.get(Guide, revision.guideid)
ispublished = bool(guide is not None
and guide.publishedrevisionid == revision.guiderevisionid)
if not ispublished and not (guide is not None and _mayedit(guide)):
# 404, not 403: a draft's existence is itself the thing being withheld.
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))
# The slug is set once, at create, and does NOT follow the title.
#
# _slugify's own docstring says the slug is stored so that renaming cannot
# break a link somebody pasted into a work order months ago - and then this
# re-slugged on every save that carried a title, which the editor's 3-second
# autosave does on every keystroke. Fixing a typo in a heading silently
# moved the guide's URL and broke exactly the links the stored slug exists
# to protect. A guide with no slug (older row, or a failed create) still
# gets one here.
if not guide.slug:
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')
def _mayseeimage(image):
"""Whether the caller may fetch this image.
An image is content. One pasted into a published guide must be readable by
anyone who can read that guide; one pasted into a draft must not be, or the
draft leaks a screenshot at a time to anyone willing to count upwards
through image ids.
So: editors of the parent see it, and everyone else sees it only if the
PUBLISHED revision actually references it. An unattached image - pasted
before the guide was first saved - belongs to whoever uploaded it.
"""
if image.guideid is None:
return _has('guides.edit') or image.uploadedby == _userid()
guide = db.session.get(Guide, image.guideid)
if guide is None:
return _has('guides.edit')
if _mayedit(guide):
return True
if not guide.publishedrevisionid:
return False
revision = db.session.get(GuideRevision, guide.publishedrevisionid)
if revision is None:
return False
try:
blocks = json.loads(revision.blocksjson or '[]')
except (TypeError, ValueError):
return False
needle = '/guides/images/{}'.format(image.guideimageid)
for block in blocks:
if not isinstance(block, dict) or block.get('type') != 'image':
continue
if str(block.get('imageid') or '') == str(image.guideimageid):
return True
if needle in str(block.get('url') or ''):
return True
return False
@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)
if not _mayseeimage(image):
return error_response(ErrorCodes.NOT_FOUND, 'Image not found', http_code=404)
# Same inert-document headers every other upload route serves under: an
# uploaded file is attacker-influenced content and must not be able to run
# as a document in this origin.
return harden_upload_response(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)),
}))

View File

@@ -0,0 +1,286 @@
<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>
<!--
The image is fetched by script, not by the browser.
An <img src> is a plain browser request: it carries no Authorization
header, and this app keeps its JWT in one. So a direct src against the
authenticated image route is a 401 and a broken-image icon for every
reader - which is exactly how this shipped. Fetching through the api
client sends the token, and it resolves against the client's baseURL, so
it also survives a subpath mount that a stored '/api/...' string does
not.
A failure says so in words. A guide whose screenshot is missing must not
look like a guide that never had one.
-->
<figure v-else-if="block.type === 'image'" class="gb-figure">
<img v-if="imageState(block).src" :src="imageState(block).src"
:alt="block.alt || ''">
<p v-else-if="imageState(block).failed" class="gb-imagefailed">
Image unavailable{{ block.alt ? ': ' + block.alt : '' }}
</p>
<p v-else class="gb-imageloading">Loading image...</p>
<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, toRef } from 'vue'
import { renderInline } from '../utils/inline.js'
import { useGuideImages } from '../utils/images.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 }
// Images are fetched by script, not by <img src>; see utils/images.js.
const { state: imageState } = useGuideImages(toRef(props, 'blocks'))
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-imageloading, .gb-imagefailed {
margin: 0; padding: 0.75rem 1rem; border: 1px dashed var(--border);
border-radius: 4px; font-size: 0.9rem;
}
.gb-imageloading { color: var(--textmuted); }
.gb-imagefailed { color: var(--danger, #b3261e); }
.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>

View 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 = []

View File

@@ -0,0 +1,77 @@
/**
* Resolving guide images.
*
* A guide image lives behind an authenticated route, and an <img src> is a
* plain browser request that carries no Authorization header - this app keeps
* its JWT in one. Pointing src at the image route therefore yields 401 and a
* broken-image icon for every reader, which is how guides first shipped.
*
* So the bytes are fetched through the api client, which attaches the token and
* resolves against its own baseURL - the latter also fixing a stored
* '/api/guides/images/N' string under a subpath mount, where the API is not at
* /api at all.
*
* Used by the renderer AND the editor: an author needs to see the screenshot
* they just pasted as much as a reader does.
*/
import { reactive, watch, onBeforeUnmount } from 'vue'
import api from '@/api'
/** The image id a block refers to, whichever way it was written.
*
* Blocks store imageid (written since this was fixed) or a url string (written
* before it). The url is never used as a src, only read for its id.
*/
export function imageIdOf (block) {
if (!block) return ''
if (block.imageid) return String(block.imageid)
const match = /\/guides\/images\/(\d+)/.exec(block.url || '')
return match ? match[1] : ''
}
/**
* Keeps object URLs for the image blocks in `blocksRef`, fetching each id once
* however many blocks use it, and revoking them as blocks go away.
*
* Returns `state(block)` -> { src, failed }.
*/
export function useGuideImages (blocksRef) {
const images = reactive({})
async function load (id) {
if (images[id]) return
images[id] = { src: '', failed: false }
try {
const response = await api.get(`/guides/images/${id}`, { responseType: 'blob' })
images[id].src = URL.createObjectURL(response.data)
} catch (err) {
images[id].failed = true
}
}
function release (id) {
if (images[id] && images[id].src) URL.revokeObjectURL(images[id].src)
delete images[id]
}
function state (block) {
const id = imageIdOf(block)
if (!id) return { src: '', failed: true }
return images[id] || { src: '', failed: false }
}
// Editing replaces the block list on every keystroke, so resolve only ids
// that are new and keep the ones already held. Without that, every keystroke
// would refetch every image in the guide.
watch(blocksRef, blocks => {
const wanted = new Set(
(blocks || []).filter(b => b && b.type === 'image')
.map(imageIdOf).filter(Boolean))
wanted.forEach(load)
Object.keys(images).forEach(id => { if (!wanted.has(id)) release(id) })
}, { immediate: true, deep: true })
onBeforeUnmount(() => { Object.keys(images).forEach(release) })
return { state }
}

View File

@@ -0,0 +1,106 @@
/**
* 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
// The text was HTML-escaped before any pattern ran, so a captured URL arrives
// carrying entities: a real query string like ?a=1&b=2 reaches here as
// ?a=1&amp;b=2. Escaping that again for the attribute produced &amp;amp; and a
// link that opened the wrong record - the ServiceNow and SharePoint links that
// carry several parameters were all broken this way. Decode first, then let the
// single escape on output put back exactly one level.
function decodeEntities (text) {
return String(text)
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&') // last, so &amp;lt; does not become <
}
// 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
// In-app link. NOT '//host', which looks like a path and is read by the
// browser as protocol-relative - an off-site link wearing a local costume.
if (trimmed.startsWith('/') && !trimmed.startsWith('//')) return trimmed
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.
// Finished links are stashed like code spans, so the emphasis passes below
// cannot reach inside an href. Without that, a URL containing *characters*
// has <em> injected into the middle of the attribute and the link breaks.
const links = []
html = html.replace(/\[([^\]]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/g, (match, label, url) => {
const href = safeHref(decodeEntities(url))
if (!href) return label
const external = /^https?:/i.test(href)
links.push('<a href="' + escapeHtml(href) + '"' +
(external ? ' target="_blank" rel="noopener noreferrer"' : '') +
'>' + label + '</a>')
return '\u0000LINK' + (links.length - 1) + '\u0000'
})
// **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(/\u0000LINK(\d+)\u0000/g, (match, index) => links[Number(index)])
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')
}

View File

@@ -0,0 +1,50 @@
/**
* Inline rendering: the security model and the link bugs it hid.
*
* Escape-then-format is the whole defence, and it has one awkward consequence
* that produced real broken links: by the time a URL is captured it is already
* escaped, so it has to be decoded before it can be escaped again exactly once.
*/
import { describe, it, expect } from 'vitest'
import { renderInline } from './inline.js'
describe('renderInline', () => {
it('escapes markup an author pasted', () => {
expect(renderInline('<img src=x onerror=alert(1)>'))
.toBe('&lt;img src=x onerror=alert(1)&gt;')
})
it('keeps a multi-parameter URL intact', () => {
// Double-escaped, this became &amp;amp; and the link opened the wrong
// record. Every ServiceNow and SharePoint link carries several parameters.
const html = renderInline('[ticket](https://x.example/nav?a=1&b=2&c=3)')
expect(html).toContain('href="https://x.example/nav?a=1&amp;b=2&amp;c=3"')
expect(html).not.toContain('&amp;amp;')
})
it('does not let emphasis reach inside an href', () => {
const html = renderInline('[t](https://x.example/a*b*c)')
expect(html).toContain('href="https://x.example/a*b*c"')
expect(html).not.toContain('<em>')
})
it('refuses a protocol-relative URL dressed as an in-app path', () => {
// '//evil.example' starts with '/' but the browser reads it as off-site.
expect(renderInline('[evil](//evil.example/x)')).toBe('evil')
})
it('refuses javascript: and keeps the label', () => {
expect(renderInline('[bad](javascript:alert(1))')).toBe('bad')
})
it('allows an in-app link', () => {
expect(renderInline('[map](/map)')).toBe('<a href="/map">map</a>')
})
it('still renders bold, italic and code', () => {
expect(renderInline('**b**')).toBe('<strong>b</strong>')
expect(renderInline('*i*')).toBe('<em>i</em>')
expect(renderInline('`C:\\**temp**`'))
.toBe('<code>C:\\**temp**</code>')
})
})

View 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
}

View 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: '' },
] },
],
},
]

View File

@@ -0,0 +1,717 @@
<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, '`')">&lt;&gt;</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">
<!-- Same authenticated fetch as the reader's view: an author must
see the screenshot they just pasted. -->
<img v-if="imageState(block).src" :src="imageState(block).src" alt="">
<p v-else-if="imageState(block).failed" class="ge-imagefailed">
Image unavailable - it may have been removed
</p>
<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 |&#10;Header | Header&#10;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'
import { useGuideImages } from '../utils/images.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)
// imageid is what the renderer resolves; url stays for older readers and
// for anything that still treats a block as a plain document.
const block = { id: newid(), type: 'image', imageid: image.guideimageid,
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 = ''
}
// Editor thumbnails resolve the same way the reader's do; see utils/images.js.
const { state: imageState } = useGuideImages(blocks)
function payload () {
return { title: guide.title, summary: guide.summary || '',
tags: guide.tags || '', blocks: blocks.value }
}
// Returns whether the save actually reached the server. publish() depends on
// that answer: a publish that runs after a failed save promotes the LAST
// revision the server accepted, which is not what is on the author's screen.
async function save (quiet) {
if (!guide.title.trim()) {
if (!quiet) error.value = 'A guide needs a title before it can be saved'
return false
}
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()
return true
} catch (err) {
error.value = apiError(err, 'Could not save this guide')
status.value = 'Not saved'
return false
} 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
}
// Publishing promotes the newest SAVED revision, so an unsaved edit is not
// in what readers get. Reporting "Published version 4" over a failed save
// tells the author their words went out when the old ones did.
if (!await save(true)) {
error.value = error.value
|| 'Could not save this guide, so it was not published'
return
}
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-imagefailed { margin: 0 0 0.35rem; padding: 0.5rem 0.7rem; font-size: 0.85rem;
color: var(--danger, #b3261e); border: 1px dashed var(--border); border-radius: 4px; }
.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>

View File

@@ -0,0 +1,273 @@
<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'
import { setRecordTitle } from '@/composables/pageTitle'
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
setRecordTitle(guide.value?.title)
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>

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

View 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()

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

View 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)

View File

@@ -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']

View File

@@ -33,8 +33,7 @@ without an author thinking about it.
import json
from datetime import datetime
from shopdb.api import db
from shopdb.core.models.base import BaseModel
from shopdb.api import db, BaseModel
def _utcnow():
@@ -74,7 +73,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 +97,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 +166,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 +178,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 +274,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 +283,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
View 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)

View 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)

View File

@@ -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()

View File

@@ -1,212 +1,214 @@
<template>
<div class="detail-page">
<div class="page-header">
<h2>Knowledge Base Article</h2>
<div class="header-actions">
<router-link :to="`/knowledgebase/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<router-link to="/knowledgebase" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="article">
<div class="card article-card">
<table class="info-table">
<tbody>
<tr>
<th>Description:</th>
<td>{{ article.shortdescription }}</td>
</tr>
<tr v-if="article.application">
<th>Topic:</th>
<td>
<router-link :to="`/applications/${article.application.appid}`">
{{ article.application.appname }}
</router-link>
</td>
</tr>
<tr v-if="article.linkurl">
<th>URL:</th>
<td>
<a href="#" @click.prevent="openArticle">
{{ article.linkurl }}
</a>
</td>
</tr>
<tr v-if="article.keywords">
<th>Keywords:</th>
<td>{{ article.keywords }}</td>
</tr>
<tr>
<th>Clicks:</th>
<td>{{ article.clicks }}</td>
</tr>
<tr>
<th>Last Updated:</th>
<td>{{ formatDate(article.lastupdated) }}</td>
</tr>
</tbody>
</table>
<hr />
<div class="actions-center">
<a
v-if="article.linkurl"
href="#"
class="btn btn-primary btn-lg"
@click.prevent="openArticle"
>
<span class="btn-icon">&#x2197;</span> Open Article
</a>
<div v-else class="alert alert-warning">
This article does not have a URL link defined. Please edit the article to add one.
</div>
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-secondary btn-lg">
<span class="btn-icon">&#x270E;</span> Edit
</router-link>
</div>
</div>
</template>
<div v-else class="card">
<p style="text-align: center; color: var(--text-light);">Article not found</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { knowledgebaseApi } from '@/api'
const route = useRoute()
const loading = ref(true)
const article = ref(null)
onMounted(async () => {
try {
const response = await knowledgebaseApi.get(route.params.id)
article.value = response.data.data
} catch (error) {
console.error('Error loading article:', error)
} finally {
loading.value = false
}
})
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
async function openArticle() {
try {
await knowledgebaseApi.trackClick(article.value.linkid)
article.value.clicks = (article.value.clicks || 0) + 1
// Open the URL after tracking
if (article.value.linkurl) {
window.open(article.value.linkurl, '_blank')
}
} catch (error) {
console.error('Error tracking click:', error)
// Still open even if tracking fails
if (article.value.linkurl) {
window.open(article.value.linkurl, '_blank')
}
}
}
</script>
<style scoped>
.detail-page {
max-width: 800px;
margin: 0 auto;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.article-card {
padding: 1.5rem;
}
.info-table {
width: 100%;
border-collapse: collapse;
}
.info-table th,
.info-table td {
padding: 0.75rem;
text-align: left;
border: none;
}
.info-table th {
width: 150px;
font-weight: 600;
color: var(--text-light, #666);
vertical-align: top;
}
.info-table td a {
color: var(--primary, #1976d2);
text-decoration: none;
word-break: break-all;
overflow-wrap: break-word;
}
.info-table td a:hover {
text-decoration: underline;
}
.info-table td {
word-break: break-word;
overflow-wrap: break-word;
max-width: 500px;
}
hr {
margin: 1.5rem 0;
border: none;
border-top: 1px solid var(--border-color, #e5e5e5);
}
.actions-center {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1rem;
}
.btn-lg {
padding: 0.75rem 1.5rem;
font-size: 1rem;
}
.btn-icon {
margin-right: 0.5rem;
}
.alert {
padding: 1rem;
border-radius: 4px;
}
.alert-warning {
background: #fff3cd;
color: #856404;
}
@media (prefers-color-scheme: dark) {
.alert-warning {
background: #3d3200;
color: #ffc107;
}
}
</style>
<template>
<div class="detail-page">
<div class="page-header">
<h2>Knowledge Base Article</h2>
<div class="header-actions">
<router-link :to="`/knowledgebase/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<router-link to="/knowledgebase" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="article">
<div class="card article-card">
<table class="info-table">
<tbody>
<tr>
<th>Description:</th>
<td>{{ article.shortdescription }}</td>
</tr>
<tr v-if="article.application">
<th>Topic:</th>
<td>
<router-link :to="`/applications/${article.application.appid}`">
{{ article.application.appname }}
</router-link>
</td>
</tr>
<tr v-if="article.linkurl">
<th>URL:</th>
<td>
<a href="#" @click.prevent="openArticle">
{{ article.linkurl }}
</a>
</td>
</tr>
<tr v-if="article.keywords">
<th>Keywords:</th>
<td>{{ article.keywords }}</td>
</tr>
<tr>
<th>Clicks:</th>
<td>{{ article.clicks }}</td>
</tr>
<tr>
<th>Last Updated:</th>
<td>{{ formatDate(article.lastupdated) }}</td>
</tr>
</tbody>
</table>
<hr />
<div class="actions-center">
<a
v-if="article.linkurl"
href="#"
class="btn btn-primary btn-lg"
@click.prevent="openArticle"
>
<span class="btn-icon">&#x2197;</span> Open Article
</a>
<div v-else class="alert alert-warning">
This article does not have a URL link defined. Please edit the article to add one.
</div>
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-secondary btn-lg">
<span class="btn-icon">&#x270E;</span> Edit
</router-link>
</div>
</div>
</template>
<div v-else class="card">
<p style="text-align: center; color: var(--text-light);">Article not found</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { knowledgebaseApi } from '@/api'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute()
const loading = ref(true)
const article = ref(null)
onMounted(async () => {
try {
const response = await knowledgebaseApi.get(route.params.id)
article.value = response.data.data
setRecordTitle(article.value?.shortdescription)
} catch (error) {
console.error('Error loading article:', error)
} finally {
loading.value = false
}
})
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
async function openArticle() {
try {
await knowledgebaseApi.trackClick(article.value.linkid)
article.value.clicks = (article.value.clicks || 0) + 1
// Open the URL after tracking
if (article.value.linkurl) {
window.open(article.value.linkurl, '_blank')
}
} catch (error) {
console.error('Error tracking click:', error)
// Still open even if tracking fails
if (article.value.linkurl) {
window.open(article.value.linkurl, '_blank')
}
}
}
</script>
<style scoped>
.detail-page {
max-width: 800px;
margin: 0 auto;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.article-card {
padding: 1.5rem;
}
.info-table {
width: 100%;
border-collapse: collapse;
}
.info-table th,
.info-table td {
padding: 0.75rem;
text-align: left;
border: none;
}
.info-table th {
width: 150px;
font-weight: 600;
color: var(--text-light, #666);
vertical-align: top;
}
.info-table td a {
color: var(--primary, #1976d2);
text-decoration: none;
word-break: break-all;
overflow-wrap: break-word;
}
.info-table td a:hover {
text-decoration: underline;
}
.info-table td {
word-break: break-word;
overflow-wrap: break-word;
max-width: 500px;
}
hr {
margin: 1.5rem 0;
border: none;
border-top: 1px solid var(--border-color, #e5e5e5);
}
.actions-center {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1rem;
}
.btn-lg {
padding: 0.75rem 1.5rem;
font-size: 1rem;
}
.btn-icon {
margin-right: 0.5rem;
}
.alert {
padding: 1rem;
border-radius: 4px;
}
.alert-warning {
background: #fff3cd;
color: #856404;
}
@media (prefers-color-scheme: dark) {
.alert-warning {
background: #3d3200;
color: #ffc107;
}
}
</style>

View File

@@ -12,6 +12,7 @@
<router-link :to="`/machines/${machine?.machine?.machineid}/edit`" class="btn btn-primary" v-if="machine">
Edit
</router-link>
<button @click="confirmRetire" class="btn btn-danger" v-if="machine">Retire</button>
<router-link to="/machines" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
@@ -248,7 +249,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { machinesApi } from '@/api'
import { withBase } from '@/utils/basePath'
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
@@ -257,8 +258,13 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetRelationships from '@/components/AssetRelationships.vue'
import { useWarrantyBadge } from '@/composables/warrantyBadge'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { setRecordTitle } from '@/composables/pageTitle'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { isEnabled } = useIdentifierFlags()
const loading = ref(true)
@@ -271,6 +277,7 @@ onMounted(async () => {
try {
const response = await machinesApi.get(route.params.id)
machine.value = response.data.data
setRecordTitle(machine.value?.assetnumber || machine.value?.name)
} catch (error) {
console.error('Error loading machine:', error)
} finally {
@@ -291,6 +298,23 @@ function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
// Retire, not delete: the server deactivates the asset, so the machine
// leaves every list and report while its history and relationships stay put.
// Reversible by an admin setting isactive back to true.
async function confirmRetire() {
const label = machine.value?.assetnumber || machine.value?.name || 'this machine'
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
+ `History and relationships are kept, and it can be restored.`)) return
try {
await machinesApi.delete(machine.value?.machine?.machineid)
toast.success(`Retired ${label}`)
router.push('/machines')
} catch (error) {
console.error('Error retiring machine:', error)
toast.error(apiError(error, 'Failed to retire machine'))
}
}
</script>
<style scoped>

View File

@@ -43,7 +43,7 @@ export default [
path: 'reports/calibration',
name: 'calibration-report',
component: () => import('./views/CalibrationReport.vue'),
meta: { plugin: 'measuringtools' }
meta: { plugin: 'measuringtools', title: 'Calibration Report' }
},
{
path: 'settings/measuringtooltypes',

View File

@@ -9,6 +9,7 @@
<router-link :to="`/measuringtools/${tool?.measuringtool?.measuringtoolid}/edit`" class="btn btn-primary" v-if="tool">
Edit
</router-link>
<button @click="confirmRetire" class="btn btn-danger" v-if="tool">Retire</button>
<router-link to="/measuringtools" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
@@ -165,15 +166,20 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { colorStyle } from '@/utils/colorStyle'
import { measuringtoolsApi } from '@/api'
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetRelationships from '@/components/AssetRelationships.vue'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { setRecordTitle } from '@/composables/pageTitle'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { isEnabled } = useIdentifierFlags()
@@ -193,12 +199,30 @@ onMounted(async () => {
? await measuringtoolsApi.getByAsset(route.params.assetid)
: await measuringtoolsApi.get(route.params.id)
tool.value = response.data.data
setRecordTitle(tool.value?.assetnumber || tool.value?.name)
} catch (err) {
console.error('Error loading measuring tool:', err)
} finally {
loading.value = false
}
})
// Retire, not delete: the server deactivates the asset, so the measuring tool
// leaves every list and report while its history and relationships stay put.
// Reversible by an admin setting isactive back to true.
async function confirmRetire() {
const label = tool.value?.assetnumber || tool.value?.name || 'this measuring tool'
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
+ `History and relationships are kept, and it can be restored.`)) return
try {
await measuringtoolsApi.remove(tool.value?.measuringtool?.measuringtoolid)
toast.success(`Retired ${label}`)
router.push('/measuringtools')
} catch (error) {
console.error('Error retiring measuring tool:', error)
toast.error(apiError(error, 'Failed to retire measuring tool'))
}
}
</script>
<style scoped>

View File

@@ -228,6 +228,7 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import { useWarrantyBadge } from '@/composables/warrantyBadge'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { useToast } from '@/composables/toast'
import { setRecordTitle } from '@/composables/pageTitle'
const toast = useToast()
const { isEnabled } = useIdentifierFlags()
@@ -250,6 +251,7 @@ async function loadDevice() {
try {
const response = await networkApi.get(deviceId)
device.value = response.data.data
setRecordTitle(device.value?.assetnumber || device.value?.name)
} catch (error) {
console.error('Error loading device:', error)
device.value = null

View File

@@ -59,6 +59,7 @@
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { networkApi } from '@/api'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute()
const subnet = ref(null)
@@ -79,6 +80,7 @@ async function load() {
try {
const response = await networkApi.subnets.get(route.params.id)
subnet.value = response.data?.data || response.data
setRecordTitle(subnet.value?.name || subnet.value?.cidr)
} finally {
loading.value = false
}

View File

@@ -187,6 +187,7 @@ import { useRoute } from 'vue-router'
import { printedpartsApi } from '@/api'
import { withBase } from '@/utils/basePath'
import Modal from '@/components/Modal.vue'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute()
const item = ref(null)
@@ -196,6 +197,7 @@ onMounted(async () => {
try {
const response = await printedpartsApi.get(route.params.id)
item.value = response.data.data
setRecordTitle(item.value?.itemname || item.value?.itemcode)
await loadFiles()
} catch (loadError) {
console.error('Error loading printed item:', loadError)

View File

@@ -3,6 +3,15 @@
The gage lab issues WJRP asset numbers for printed parts; the internal
itemcode stays auto-generated, and this optional unique tag carries the
lab's number. The kiosk resolves scans/typed digits against both.
Guarded/idempotent: skips when the table is absent (plugin disabled) or the
column already exists (e.g. a test DB built by db.create_all() from the model).
Unguarded, this failed `flask plugin upgrade-all` on any create-all-built
database and took 0004 down with it, since a chain stops at its first error.
The index is matched by COLUMN SET, not by name: create_all names indexes by
SQLAlchemy's convention and this migration by the repo's, so a name-keyed guard
would add a second index over the same column rather than skip.
"""
from alembic import op
import sqlalchemy as sa
@@ -15,13 +24,31 @@ branch_labels = None
depends_on = None
def _indexed(inspector):
return any(tuple(ix.get('column_names') or ()) == ('gagelabtag',)
for ix in inspector.get_indexes('printeditems'))
def upgrade():
op.add_column('printeditems',
sa.Column('gagelabtag', sa.String(length=50), nullable=True))
op.create_index('ix_printeditems_gagelabtag', 'printeditems',
['gagelabtag'], unique=True)
inspector = sa.inspect(op.get_bind())
if 'printeditems' not in inspector.get_table_names():
return
columns = {column['name'] for column in inspector.get_columns('printeditems')}
if 'gagelabtag' not in columns:
op.add_column('printeditems',
sa.Column('gagelabtag', sa.String(length=50), nullable=True))
inspector = sa.inspect(op.get_bind())
if not _indexed(inspector):
op.create_index('ix_printeditems_gagelabtag', 'printeditems',
['gagelabtag'], unique=True)
def downgrade():
op.drop_index('ix_printeditems_gagelabtag', table_name='printeditems')
op.drop_column('printeditems', 'gagelabtag')
inspector = sa.inspect(op.get_bind())
if 'printeditems' not in inspector.get_table_names():
return
if _indexed(inspector):
op.drop_index('ix_printeditems_gagelabtag', table_name='printeditems')
columns = {column['name'] for column in inspector.get_columns('printeditems')}
if 'gagelabtag' in columns:
op.drop_column('printeditems', 'gagelabtag')

View File

@@ -6,13 +6,13 @@ export default [
path: 'reports/toner',
name: 'toner-report',
component: () => import('./views/TonerReport.vue'),
meta: { plugin: 'printers' }
meta: { plugin: 'printers', title: 'Toner Report' }
},
{
path: 'reports/toner-forecast',
name: 'toner-forecast',
component: () => import('./views/TonerForecast.vue'),
meta: { plugin: 'printers' }
meta: { plugin: 'printers', title: 'Toner Forecast' }
},
{
path: 'settings/printertypes',

View File

@@ -10,6 +10,7 @@
Print Label
</router-link>
<router-link :to="`/printers/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
<router-link to="/printers" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
@@ -296,7 +297,7 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { printersApi } from '@/api'
import { withBase } from '@/utils/basePath'
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
@@ -305,8 +306,13 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetRelationships from '@/components/AssetRelationships.vue'
import { useWarrantyBadge } from '@/composables/warrantyBadge'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { setRecordTitle } from '@/composables/pageTitle'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { isEnabled } = useIdentifierFlags()
const loading = ref(true)
@@ -344,6 +350,9 @@ onMounted(async () => {
])
printer.value = printerRes.data.data
// the page's own heading logic, which already discards the literal
// name 'NONE' that a discovered queue can carry
setRecordTitle(displayTitle.value)
// supplies endpoint returns {ipaddress, pingstatus, supplies:[...]}
supplies.value = suppliesRes.data.data?.supplies || []
// drivers are attached to the printer detail, matched by model
@@ -382,6 +391,23 @@ function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
// Retire, not delete: the server deactivates the asset, so the printer
// leaves every list and report while its history and relationships stay put.
// Reversible by an admin setting isactive back to true.
async function confirmRetire() {
const label = displayTitle.value || 'this printer'
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
+ `History and relationships are kept, and it can be restored.`)) return
try {
await printersApi.remove(route.params.id)
toast.success(`Retired ${label}`)
router.push('/printers')
} catch (error) {
console.error('Error retiring printer:', error)
toast.error(apiError(error, 'Failed to retire printer'))
}
}
</script>
<style scoped>

View File

@@ -160,6 +160,7 @@ import { usbApi } from '@/api'
import Modal from '@/components/Modal.vue'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
import { setRecordTitle } from '@/composables/pageTitle'
const toast = useToast()
const route = useRoute()
@@ -181,6 +182,7 @@ async function loadDevice() {
try {
const response = await usbApi.get(route.params.id)
device.value = response.data.data
setRecordTitle(device.value?.label || device.value?.serialnumber)
} catch (error) {
console.error('Error loading USB device:', error)
} finally {

View File

@@ -18,7 +18,7 @@ export default [
path: 'reports/warranty',
name: 'warranty-report',
component: () => import('./views/WarrantyReport.vue'),
meta: { plugin: 'warranty' }
meta: { plugin: 'warranty', title: 'Warranty Report' }
}
]