Guides answer the search box, and proofing catches what spellcheck cannot

A guide was invisible to global search. Core search has a hardcoded block per
domain rather than a hook, so a new plugin stays unreachable until someone adds
one - which is the same shape as the gap this test file already documents for
USB devices and printed parts: records with a list page, labels in the world,
and no way to find them from the search box.

Guides are searched by title, summary, tags AND body. The body matters more here
than elsewhere: people look for a procedure by something written halfway down it,
not by a title they never read. PUBLISHED ONLY - a draft is deliberately
invisible to readers, and search is exactly where that rule leaks if nobody
writes it down. The test proves it with a draft sharing a tag and a body word
with a published guide, so a missing filter would show up as a ride-along.

PROOFING catches what a spellchecker cannot, because none of these are spelling:
a smart quote in a command so it will not run, a checklist item with no text so
a step is silently missing, a heading left with nothing under it after a
reorganise, an image with no alt text, a TODO published by accident. Warnings,
never blocks - somebody publishing at 14:40 with one empty bullet should be
told, not stopped, and a procedure that exists imperfectly beats one nobody
wrote.

The smart-character issue reports once per block with a one-click fix, rather
than once per offending character: an opening and a closing quote are one
problem, and listing both makes the panel look like it is nagging.

The character table itself is written as escapes. The style gate forbids
non-ASCII in source, and a file whose job is detecting smart quotes cannot
contain any - which the gate caught, correctly.
This commit is contained in:
cproudlock
2026-08-26 11:27:10 -04:00
parent a52437b40b
commit 2c016de59e
5 changed files with 343 additions and 0 deletions

View File

@@ -117,6 +117,7 @@ const typeLabels = {
computer: 'PC', computer: 'PC',
application: 'App', application: 'App',
knowledgebase: 'KB', knowledgebase: 'KB',
guide: 'Guide',
printer: 'Printer', printer: 'Printer',
network_device: 'Network', network_device: 'Network',
measuring_tool: 'Measuring Tool', measuring_tool: 'Measuring Tool',
@@ -136,6 +137,7 @@ const filterTypeMap = {
measuringtools: ['measuring_tool'], measuringtools: ['measuring_tool'],
applications: ['application'], applications: ['application'],
knowledgebase: ['knowledgebase'], knowledgebase: ['knowledgebase'],
guides: ['guide'],
notifications: ['notification'], notifications: ['notification'],
employees: ['employee'], employees: ['employee'],
usb: ['usb_device'], usb: ['usb_device'],
@@ -151,6 +153,7 @@ const filterList = [
{ key: 'measuringtools', label: 'Measuring Tools' }, { key: 'measuringtools', label: 'Measuring Tools' },
{ key: 'applications', label: 'Apps' }, { key: 'applications', label: 'Apps' },
{ key: 'knowledgebase', label: 'KB' }, { key: 'knowledgebase', label: 'KB' },
{ key: 'guides', label: 'Guides' },
{ key: 'notifications', label: 'Notices' }, { key: 'notifications', label: 'Notices' },
{ key: 'employees', label: 'Employees' }, { key: 'employees', label: 'Employees' },
{ key: 'usb', label: 'USB' }, { key: 'usb', label: 'USB' },
@@ -424,6 +427,7 @@ watch(results, () => {
--rt-computer-bg: #e8f5e9; --rt-computer-fg: #2e7d32; --rt-computer-bg: #e8f5e9; --rt-computer-fg: #2e7d32;
--rt-application-bg: #fff3e0; --rt-application-fg: #e65100; --rt-application-bg: #fff3e0; --rt-application-fg: #e65100;
--rt-knowledgebase-bg: #f3e5f5; --rt-knowledgebase-fg: #7b1fa2; --rt-knowledgebase-bg: #f3e5f5; --rt-knowledgebase-fg: #7b1fa2;
--rt-guide-bg: #e8f0fe; --rt-guide-fg: #1a56b8;
--rt-printer-bg: #fce4ec; --rt-printer-fg: #c2185b; --rt-printer-bg: #fce4ec; --rt-printer-fg: #c2185b;
--rt-network-bg: #fff8e1; --rt-network-fg: #f57f17; --rt-network-bg: #fff8e1; --rt-network-fg: #f57f17;
--rt-measuring-bg: #e0f7fa; --rt-measuring-fg: #00838f; --rt-measuring-bg: #e0f7fa; --rt-measuring-fg: #00838f;
@@ -437,6 +441,7 @@ watch(results, () => {
.result-type.computer { background: var(--rt-computer-bg); color: var(--rt-computer-fg); } .result-type.computer { background: var(--rt-computer-bg); color: var(--rt-computer-fg); }
.result-type.application { background: var(--rt-application-bg); color: var(--rt-application-fg); } .result-type.application { background: var(--rt-application-bg); color: var(--rt-application-fg); }
.result-type.knowledgebase { background: var(--rt-knowledgebase-bg); color: var(--rt-knowledgebase-fg); } .result-type.knowledgebase { background: var(--rt-knowledgebase-bg); color: var(--rt-knowledgebase-fg); }
.result-type.guide { background: var(--rt-guide-bg); color: var(--rt-guide-fg); }
.result-type.printer { background: var(--rt-printer-bg); color: var(--rt-printer-fg); } .result-type.printer { background: var(--rt-printer-bg); color: var(--rt-printer-fg); }
.result-type.network_device { background: var(--rt-network-bg); color: var(--rt-network-fg); } .result-type.network_device { background: var(--rt-network-bg); color: var(--rt-network-fg); }
.result-type.measuring_tool { background: var(--rt-measuring-bg); color: var(--rt-measuring-fg); } .result-type.measuring_tool { background: var(--rt-measuring-bg); color: var(--rt-measuring-fg); }

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

@@ -44,6 +44,30 @@
</button> </button>
</div> </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-split" :class="{ 'ge-haspreview': showPreview }">
<div class="ge-editor"> <div class="ge-editor">
<div v-for="(block, index) in blocks" :key="block.id" <div v-for="(block, index) in blocks" :key="block.id"
@@ -178,6 +202,7 @@ import { apiError } from '@/utils/apiError'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import GuideBlocks from '../components/GuideBlocks.vue' import GuideBlocks from '../components/GuideBlocks.vue'
import { templates } from '../utils/templates.js' import { templates } from '../utils/templates.js'
import { proof, applyFix } from '../utils/proof.js'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -197,6 +222,26 @@ const status = ref('')
const showPreview = ref(true) const showPreview = ref(true)
const focused = ref('') const focused = ref('')
const dirty = ref(false) 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 isNew = computed(() => !guide.guideid)
const canpublish = computed(() => auth.hasPermission?.('guides.publish') ?? false) const canpublish = computed(() => auth.hasPermission?.('guides.publish') ?? false)
@@ -425,6 +470,12 @@ onBeforeRouteLeave(() => {
}) })
async function publish () { async function publish () {
if (errorCount.value) {
const ok = window.confirm(
errorCount.value + ' problem(s) would go out with this guide, including '
+ 'anything flagged in a command. Publish anyway?')
if (!ok) return
}
await save(true) await save(true)
try { try {
const response = await api.post('/guides/' + guide.guideid + '/publish') const response = await api.post('/guides/' + guide.guideid + '/publish')
@@ -608,6 +659,26 @@ onMounted(async () => {
.ge-previewtitle { margin: 0 0 0.2rem; font-size: 1.4rem; } .ge-previewtitle { margin: 0 0 0.2rem; font-size: 1.4rem; }
.ge-previewsummary { color: var(--text-light); margin: 0 0 1rem; } .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-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-rev { display: flex; gap: 0.6rem; align-items: center; font-size: 0.8rem; padding: 0.25rem 0; }
.ge-revnum { font-weight: 600; } .ge-revnum { font-weight: 600; }

View File

@@ -255,6 +255,55 @@ def _search_applications(query, search_term):
return results return results
def _search_guides(query, search_term):
"""Search guides by title, summary, tags and BODY.
The body matters more here than in most domains. People look for a procedure
by something written halfway down it - "the one about the drive letter" -
not by a title they never read. The body is a JSON blob of blocks, so this
is a LIKE against it: crude, but it finds the guide, and a guide corpus is
tens of documents rather than millions of rows.
PUBLISHED ONLY. A draft is deliberately invisible to readers, and search is
the one place that rule would otherwise leak - somebody would find a
half-written procedure through the search box and follow it.
"""
results = []
try:
_require_enabled('guides')
from plugins.guides.models import Guide
rows = Guide.query.filter(
Guide.isactive == True,
Guide.publishedrevisionid.isnot(None),
_word_match(query, Guide.title, Guide.summary, Guide.tags,
Guide.blocksjson)
).order_by(Guide.modifieddate.desc()).limit(20).all()
for guide in rows:
# A title match outranks a body match: someone searching "marker"
# wants the guide called that, not every guide mentioning one.
relevance = 10
if query.lower() in (guide.title or '').lower():
relevance += 20
if guide.tags and query.lower() in guide.tags.lower():
relevance += 15
results.append({
'type': 'guide',
'id': guide.guideid,
'title': guide.title,
'subtitle': guide.summary,
'url': f"/guides/{guide.slug}",
'relevance': relevance
})
except ImportError:
pass # guides plugin absent or disabled
except Exception as e:
logger.error(f"Guides search failed: {e}")
return results
def _search_knowledgebase(query, search_term): def _search_knowledgebase(query, search_term):
"""Search Knowledge Base by description and keywords. """Search Knowledge Base by description and keywords.
@@ -1148,6 +1197,7 @@ def global_search():
# Run all search domains # Run all search domains
results.extend(_search_applications(query, search_term)) results.extend(_search_applications(query, search_term))
results.extend(_search_knowledgebase(query, search_term)) results.extend(_search_knowledgebase(query, search_term))
results.extend(_search_guides(query, search_term))
results.extend(_search_employees(query, search_term)) results.extend(_search_employees(query, search_term))
results.extend(_search_assets(query, search_term)) results.extend(_search_assets(query, search_term))
results.extend(_search_measuringtools(query, search_term)) results.extend(_search_measuringtools(query, search_term))

View File

@@ -266,3 +266,75 @@ def test_an_ip_search_links_to_the_matched_subnet(client, subnetrow):
assert len(rows) == 1 assert len(rows) == 1
assert rows[0]['id'] == subnetrow assert rows[0]['id'] == subnetrow
assert rows[0]['url'] == f'/networks/{subnetrow}' assert rows[0]['url'] == f'/networks/{subnetrow}'
# ---------------------------------------------------------------------------
# Guides. A guide is not an asset either, so the generic asset search cannot see
# one - and the draft rule makes the omission worse than invisibility: a
# half-written procedure reachable through the search box is one somebody
# follows.
# ---------------------------------------------------------------------------
@pytest.fixture
def guides(db):
"""One published guide and one draft, with distinctive text.
The published one carries its distinguishing words in the BODY, not the
title, because that is how people look for a procedure - by something
written halfway down it.
"""
from plugins.guides.models import Guide
from plugins.guides.services import authoring
published = Guide(title='Zzmarker controller swap', slug='zzmarker-swap',
status='draft')
draft = Guide(title='Zzdraft unfinished procedure', slug='zzdraft-unfinished',
status='draft')
db.session.add_all([published, draft])
db.session.flush()
authoring.save(published, title=published.title,
summary='Restoring after a swap', tags='zztelesis, zzbackups',
blocks=[{'id': 'g1', 'type': 'paragraph',
'text': 'Check the zzmappeddrive before starting.'}])
authoring.publish(published)
authoring.save(draft, title=draft.title, summary='', tags='zztelesis',
blocks=[{'id': 'g2', 'type': 'paragraph',
'text': 'Half written zzmappeddrive notes.'}])
db.session.commit()
return {'published': published.guideid, 'draft': draft.guideid}
def test_a_published_guide_is_found_by_title(client, guides):
rows = [r for r in search(client, 'Zzmarker') if r['type'] == 'guide']
assert len(rows) == 1
assert rows[0]['id'] == guides['published']
assert rows[0]['url'] == '/guides/zzmarker-swap'
def test_a_guide_is_found_by_tag(client, guides):
"""Tags are how a technician narrows to a family of procedures."""
assert 'Zzmarker controller swap' in titles(client, 'zzbackups', 'guide')
def test_a_guide_is_found_by_words_in_its_body(client, guides):
"""People search for what they remember reading, not for the title.
The body is stored as a JSON blob of blocks, so this is the check that the
blob is actually reachable rather than merely present.
"""
assert 'Zzmarker controller swap' in titles(client, 'zzmappeddrive', 'guide')
def test_a_draft_guide_is_never_returned(client, guides):
"""The draft rule has to hold on EVERY path that reaches the row.
A draft is deliberately invisible to readers, and search is where that rule
would otherwise leak: somebody finds a half-written procedure through the
search box and follows it. The draft here shares a tag and a body word with
the published guide, so it would ride along on both queries if the filter
were missing.
"""
assert 'Zzdraft unfinished procedure' not in titles(client, 'zzmappeddrive', 'guide')
assert 'Zzdraft unfinished procedure' not in titles(client, 'zztelesis', 'guide')
assert titles(client, 'Zzdraft', 'guide') == []