Search spelling suggestions across every list

This commit is contained in:
2026-08-21 11:15:06 -04:00
parent fdf3c51cb0
commit 04107ff8ab
18 changed files with 1005 additions and 82 deletions

View File

@@ -31,10 +31,37 @@ ADR-007 and ADR-002.
- **Contents can be positioned inside the label without moving the label.**
Align and Vertical place the code and caption block anywhere on the stock,
and Content X/Y nudge it from there. The existing X/Y nudge kept its
behaviour and is now labelled Media X/Y: it moves the whole printed area, for
behavior and is now labeled Media X/Y: it moves the whole printed area, for
a printer whose origin is off, which is a different problem from composing a
label.
- **A picture can go on the label.** Pick an image once and it prints on every
label - above the code, beside it, or behind everything as a watermark - on
the front, the back, or both. The file never leaves the browser, so an
air-gapped site can use it, and it is deliberately not saved between visits:
a data URL runs to megabytes and would take every sizing value with it when
the storage quota gave out.
- **The back can carry its own code, in its own symbology.** "Back carries" now
defaults to a code rather than text, takes per-row content from the CSV
`back` column, and has its own QR/CODE128 choice - a QR on the front that
opens the record, a CODE128 on the back that the bench scanner already reads.
A back set to carry a code can no longer be laid out as text-only: that
combination printed a blank card that looked like the code had failed, and it
is corrected on load for anyone who already saved it.
### Fixed
- **The generator prints from inside the app shell.** It was a full-screen
route with no sidebar and a narrow column of controls on the left. It is now
an ordinary page - navigation, full width - and `style.css` hides the shell at
print time, so the stock still gets nothing but labels. Verified against a
real print PDF rather than a preview.
- A test print paired each page with a code by loop position while the codes
are keyed to position in the whole run, so a two-sided test card could carry
the wrong code. The page index now travels with the page.
- The back label's dotted preview outline outspecified the print reset and drew
a dashed rule down the edge of every second card.
### Changed
- CSV parsing and page building moved out of the generator component into

View File

@@ -1763,3 +1763,31 @@ td.actions .btn + .btn {
independently. */
.clickable-row { cursor: pointer; }
.clickable-row:hover td { background: var(--bg); }
/* --- Printing from inside the app shell -------------------------------------
*
* Core's label pages are full-screen routes precisely so the shell cannot reach
* the stock. A tool someone works in for ten minutes wants the navigation
* though, so the shell is hidden here instead: the sidebar goes, the content
* column gives back the margin it was holding for it, and the notification
* banner - which is chrome, not content - goes with them.
*
* Anything the page itself must keep off the paper marks itself .no-print. */
@media print {
.sidebar,
.notification-banner,
.no-print {
display: none !important;
}
.app-layout {
display: block !important;
min-height: 0 !important;
}
.main-content {
margin-left: 0 !important;
padding: 0 !important;
overflow: visible !important;
}
}

View File

@@ -0,0 +1,42 @@
<template>
<!-- Renders nothing at all when there is no suggestion, so a list can drop
this in beside its empty state without an extra v-if of its own. -->
<p v-if="suggestion" class="search-suggestion">
Did you mean
<button type="button" class="linklike" @click="$emit('pick', suggestion)">
{{ suggestion }}
</button>?
</p>
</template>
<script setup>
defineProps({
suggestion: { type: String, default: null },
})
defineEmits(['pick'])
</script>
<style scoped>
.search-suggestion {
margin: 0.5rem 0 0;
color: var(--text-light, #666);
font-size: 0.9rem;
}
/* A button, not an anchor: it re-runs the search in place rather than
navigating, and a link that does not link is a lie to anyone using a
keyboard or a screen reader. */
.linklike {
background: none;
border: none;
padding: 0;
font: inherit;
color: var(--primary, #0066cc);
cursor: pointer;
text-decoration: underline;
}
.linklike:hover {
text-decoration: none;
}
</style>

View File

@@ -1,5 +1,6 @@
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSearchSuggestion } from './searchSuggestion'
// Keep a list page's current page and search term in the URL query so the
// browser Back button restores them. Without this a list keeps page in local
@@ -16,6 +17,18 @@ import { useRoute, useRouter } from 'vue-router'
// deep link) so the list reloads at the restored page.
// extraKeys optional extra query keys to persist (e.g. ['typeid']); each
// gets a ref exposed under the returned `extras` object.
//
// SPELLING SUGGESTIONS come along for free, because this composable already
// owns the search term. A list reports how many rows it got and gets back a
// suggestion to offer when that number is zero:
//
// const { search, suggestion, reportCount } = useListQuery({ onChange: load })
// // at the end of load(): reportCount(rows.value.length)
// // in the empty state: <SearchSuggestion :suggestion="suggestion"
// // @pick="applySuggestion" />
//
// A list that never calls reportCount simply never offers one, which is what
// every list did before.
export function useListQuery(options = {}) {
const route = useRoute()
const router = useRouter()
@@ -98,5 +111,17 @@ export function useListQuery(options = {}) {
if (changed) onChange()
})
return { page, search, extras, setPage, setSearch, setExtra }
const { suggestion, checkSuggestion, clearSuggestion } = useSearchSuggestion()
// Called by the list after a fetch. Zero rows for a non-empty search is the
// only moment a suggestion is worth asking for.
function reportCount(count) {
if (count > 0 || !search.value) clearSuggestion()
else checkSuggestion(search.value)
}
return {
page, search, extras, setPage, setSearch, setExtra,
suggestion, reportCount,
}
}

View File

@@ -0,0 +1,56 @@
import { ref } from 'vue'
import api from '../api'
/**
* "Did you mean" for a search that found nothing.
*
* ONE composable for every list in the product. Thirty routes take a search
* parameter across a dozen files, so carrying a suggestion in each of their
* responses would be a large change now and a thing every future plugin has to
* remember. Instead a page that renders no rows asks for a spelling, and a page
* that has not adopted this shows nothing - exactly what it showed before.
*
* Usage in a list view:
*
* const { suggestion, checkSuggestion, clearSuggestion } = useSearchSuggestion()
* // after a fetch:
* items.value.length ? clearSuggestion() : checkSuggestion(searchTerm.value)
*
* and in the template, beside the empty state:
*
* <SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
*/
export function useSearchSuggestion() {
const suggestion = ref(null)
// Guards a slow answer arriving after a newer search has already been typed,
// which would offer a spelling for a query nobody is looking at any more.
let latest = 0
function clearSuggestion() {
suggestion.value = null
latest += 1
}
async function checkSuggestion(query) {
const term = (query || '').trim()
if (term.length < 2) {
clearSuggestion()
return
}
const ticket = ++latest
try {
const response = await api.get('/search/suggest', { params: { q: term } })
if (ticket !== latest) return
const offered = response.data?.data?.suggestion || null
// Never offer back what was typed: a suggestion identical to the query
// reads as a broken feature.
suggestion.value = offered && offered !== term.toLowerCase() ? offered : null
} catch (err) {
// A search that found nothing is already the answer. Failing to improve
// on it is not worth an error in front of anyone.
if (ticket === latest) suggestion.value = null
}
}
return { suggestion, checkSuggestion, clearSuggestion }
}

View File

@@ -42,6 +42,7 @@
<div v-else-if="results.length === 0" class="no-results">
No results found for "{{ query }}"
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</div>
<div v-else class="results-list">
@@ -94,12 +95,14 @@
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { searchApi, knowledgebaseApi } from '../api'
import SearchSuggestion from '../components/SearchSuggestion.vue'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const results = ref([])
const suggestion = ref(null)
const query = ref('')
const searchInput = ref('')
const activeFilter = ref('all')
@@ -175,6 +178,7 @@ const filteredResults = computed(() => {
async function search(q) {
if (!q || q.length < 2) {
results.value = []
suggestion.value = null
return
}
@@ -184,6 +188,10 @@ async function search(q) {
try {
const response = await searchApi.search(q)
const data = response.data.data
// The global endpoint carries the suggestion with the results, so no second
// call is needed here. A list page that has no such key uses the
// useSearchSuggestion composable against /search/suggest instead.
suggestion.value = data?.suggestion || null
// Handle ServiceNOW redirect
if (data?.redirect?.type === 'servicenow') {
@@ -212,6 +220,14 @@ async function search(q) {
}
}
// Take the offered spelling: put it in the box and search it, so the person
// sees what was searched rather than results for a word they did not type.
function applySuggestion(word) {
searchInput.value = word
suggestion.value = null
performSearch()
}
function performSearch() {
const term = searchInput.value.trim()
if (!term) return

View File

@@ -55,6 +55,7 @@
<tr v-if="vendors.length === 0">
<td colspan="5" style="text-align: center; color: var(--text-light);">
No vendors found
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</td>
</tr>
</tbody>
@@ -189,11 +190,21 @@ import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
import { useListQuery } from '@/composables/listQuery'
import SearchSuggestion from '@/components/SearchSuggestion.vue'
const toast = useToast()
const vendors = ref([])
const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadVendors })
const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadVendors })
// Take the offered spelling. setSearch only syncs the URL and the watcher
// that reloads is suppressed because search.value already holds the new
// term, so load directly.
function applySuggestion(word) {
search.value = word
setSearch(word)
loadVendors()
}
const totalPages = ref(1)
const perPage = ref(20)
@@ -232,6 +243,7 @@ async function loadVendors() {
const response = await vendorsApi.list(params)
vendors.value = response.data.data || []
reportCount(vendors.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading vendors:', err)

View File

@@ -66,6 +66,7 @@
<tr v-if="computers.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);">
No computers found
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</td>
</tr>
</tbody>
@@ -91,10 +92,20 @@ import { computersApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { colorStyle } from '@/utils/colorStyle'
import { useListQuery } from '@/composables/listQuery'
import SearchSuggestion from '@/components/SearchSuggestion.vue'
const computers = ref([])
const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadComputers })
const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadComputers })
// Take the offered spelling. setSearch only syncs the URL and the watcher
// that reloads is suppressed because search.value already holds the new
// term, so load directly.
function applySuggestion(word) {
search.value = word
setSearch(word)
loadComputers()
}
const totalPages = ref(1)
const perPage = ref(20)
@@ -115,6 +126,7 @@ async function loadComputers() {
const response = await computersApi.list(params)
computers.value = response.data.data || []
reportCount(computers.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading computers:', error)

View File

@@ -88,6 +88,7 @@
<tr v-if="articles.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No articles found
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</td>
</tr>
</tbody>
@@ -112,12 +113,22 @@ import { ref, onMounted } from 'vue'
import { knowledgebaseApi, applicationsApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
import SearchSuggestion from '@/components/SearchSuggestion.vue'
const loading = ref(true)
const articles = ref([])
const topics = ref([])
const stats = ref(null)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadArticles })
const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadArticles })
// Take the offered spelling. setSearch only syncs the URL and the watcher
// that reloads is suppressed because search.value already holds the new
// term, so load directly.
function applySuggestion(word) {
search.value = word
setSearch(word)
loadArticles()
}
const perPage = ref(20)
const totalPages = ref(1)
const topicFilter = ref('')
@@ -161,6 +172,7 @@ async function loadArticles() {
const response = await knowledgebaseApi.list(params)
articles.value = response.data.data || []
reportCount(articles.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading articles:', error)

View File

@@ -79,6 +79,7 @@
<tr v-if="machines.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);">
No machines found
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</td>
</tr>
</tbody>
@@ -105,10 +106,22 @@ import { colorStyle } from "@/utils/colorStyle"
import { machinesApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
import SearchSuggestion from '@/components/SearchSuggestion.vue'
const machines = ref([])
const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadMachines })
const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadMachines })
// Take the offered spelling: put it in the box and search it, so what was
// searched is what the person sees.
function applySuggestion(word) {
search.value = word
setSearch(word)
// setSearch only syncs the URL, and the watcher that reloads is suppressed
// because search.value already holds the new term - the same trap the global
// search page documents. Load directly.
loadMachines()
}
const totalPages = ref(1)
const perPage = ref(20)
@@ -129,6 +142,7 @@ async function loadMachines() {
const response = await machinesApi.list(params)
machines.value = response.data.data || []
reportCount(machines.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading machines:', error)

View File

@@ -73,6 +73,7 @@
<tr v-if="tools.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);">
No measuring tools found
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</td>
</tr>
</tbody>
@@ -97,11 +98,21 @@ import { colorStyle } from '@/utils/colorStyle'
import { measuringtoolsApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
import SearchSuggestion from '@/components/SearchSuggestion.vue'
const tools = ref([])
const types = ref([])
const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadTools })
const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadTools })
// Take the offered spelling. setSearch only syncs the URL and the watcher
// that reloads is suppressed because search.value already holds the new
// term, so load directly.
function applySuggestion(word) {
search.value = word
setSearch(word)
loadTools()
}
const typeid = ref('')
const calibrationstatus = ref('')
const totalPages = ref(1)
@@ -133,6 +144,7 @@ async function loadTools() {
const response = await measuringtoolsApi.list(params)
tools.value = response.data.data || []
reportCount(tools.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading measuring tools:', err)

View File

@@ -97,6 +97,7 @@
<tr v-if="devices.length === 0">
<td colspan="9" style="text-align: center; color: var(--text-light);">
No network devices found
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</td>
</tr>
</tbody>
@@ -122,13 +123,23 @@ import { colorStyle } from "@/utils/colorStyle"
import { networkApi, vendorsApi, locationsApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
import SearchSuggestion from '@/components/SearchSuggestion.vue'
const devices = ref([])
const deviceTypes = ref([])
const vendors = ref([])
const locations = ref([])
const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadDevices })
const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadDevices })
// Take the offered spelling. setSearch only syncs the URL and the watcher
// that reloads is suppressed because search.value already holds the new
// term, so load directly.
function applySuggestion(word) {
search.value = word
setSearch(word)
loadDevices()
}
const selectedType = ref(null)
const vendorFilter = ref('')
const locationFilter = ref('')
@@ -220,6 +231,7 @@ async function loadDevices() {
const response = await networkApi.list(params)
devices.value = response.data.data || []
reportCount(devices.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading network devices:', error)

View File

@@ -68,6 +68,7 @@
<tr v-if="printers.length === 0">
<td colspan="7" style="text-align: center; color: var(--text-light);">
No printers found
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</td>
</tr>
</tbody>
@@ -92,12 +93,24 @@ import { ref, onMounted } from 'vue'
import { printersApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
import SearchSuggestion from '@/components/SearchSuggestion.vue'
const printers = ref([])
const printerTypes = ref([])
const typeFilter = ref('')
const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadPrinters })
const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadPrinters })
// Take the offered spelling: put it in the box and search it, so what was
// searched is what the person sees.
function applySuggestion(word) {
search.value = word
setSearch(word)
// setSearch only syncs the URL, and the watcher that reloads is suppressed
// because search.value already holds the new term - the same trap the global
// search page documents. Load directly.
loadPrinters()
}
const totalPages = ref(1)
const perPage = ref(20)
@@ -125,6 +138,7 @@ async function loadPrinters() {
const response = await printersApi.list(params)
printers.value = response.data.data || []
reportCount(printers.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading printers:', error)

View File

@@ -3,9 +3,12 @@
*
* `default` = AppLayout child routes; `toplevel` = full-screen routes.
*
* The index sits inside the app shell. Each tool that PRINTS is toplevel, the
* way every other label page in this repo is: printing from inside AppLayout
* would put the sidebar and header on the label stock.
* The generator sits INSIDE the app shell, unlike the label pages in core.
* Those are toplevel because a print from inside AppLayout would put the
* sidebar on the label stock - which is true unless the print stylesheet says
* otherwise, and style.css now does: the shell is hidden at print time so only
* the labels reach the page. A tool someone spends ten minutes in wants the
* navigation; a one-shot print page does not.
*/
export default [
{
@@ -14,11 +17,8 @@ export default [
component: () => import('./views/ToolsIndex.vue'),
meta: { requiresAuth: true, plugin: 'tools' }
},
]
export const toplevel = [
{
path: '/tools/codes',
path: 'tools/codes',
name: 'tools-codes',
component: () => import('./views/CodeGenerator.vue'),
meta: { requiresAuth: true, plugin: 'tools' }

View File

@@ -1,12 +1,12 @@
<template>
<div>
<div class="code-generator">
<div class="no-print">
<div class="controls">
<div class="controls-head">
<h3>Barcode / QR Generator</h3>
<router-link to="/tools" class="btn btn-secondary">Back to Tech Tools</router-link>
</div>
<div class="page-header">
<h1>Barcode / QR Generator</h1>
<router-link to="/tools" class="btn btn-secondary">Back to Tech Tools</router-link>
</div>
<div class="controls card">
<!-- What to encode -->
<div class="control-row">
<label>
@@ -66,9 +66,9 @@
<p class="control-note" v-if="csvError">{{ csvError }}</p>
<p class="control-hint">
Columns: <code>content</code> (required), <code>label</code>, <code>copies</code><span
v-if="backenabled && backsource === 'column'">, <code>back</code>, <code>backlabel</code></span>.
v-if="backCarriesOwnContent">, <code>back</code>, <code>backlabel</code></span>.
A header row is optional; without one the order is content, label, copies<span
v-if="backenabled && backsource === 'column'">, back, backlabel</span>.
v-if="backCarriesOwnContent">, back, backlabel</span>.
</p>
</div>
@@ -135,7 +135,7 @@
Align
<select v-model="align">
<option value="flex-start">Left</option>
<option value="center">Centre</option>
<option value="center">Center</option>
<option value="flex-end">Right</option>
</select>
</label>
@@ -159,6 +159,47 @@
is for a printer whose origin is off, not for composing a label.
</p>
<!-- Picture -->
<h4 class="section-heading">Picture</h4>
<div class="control-row">
<label>
Image file
<input type="file" accept="image/*" @change="onPictureFile" />
</label>
<button v-if="pictureUrl" class="btn btn-secondary" @click="clearPicture">Remove picture</button>
<template v-if="pictureUrl">
<label>Size (in)<input v-model.number="picturesize" type="number" step="0.01" min="0.05" /></label>
<label>
Where
<select v-model="pictureposition">
<option value="above">Above the code</option>
<option value="beside">Beside the code</option>
<option value="behind">Behind everything (watermark)</option>
</select>
</label>
<label>
Which side
<select v-model="pictureside">
<option value="front">Front only</option>
<option value="back">Back only</option>
<option value="both">Both sides</option>
</select>
</label>
<label v-if="pictureposition === 'behind'">
Opacity
<input v-model.number="pictureopacity" type="number" step="0.05" min="0.05" max="1" />
</label>
</template>
</div>
<p class="control-hint">
The picture never leaves the browser, so this works on an air-gapped
site. It is also NOT remembered between visits, unlike the sizing -
an image is far too big to keep in the settings the page saves.
A watermark behind a code costs the scanner contrast: keep the opacity
low and test a scan before printing a run.
</p>
<!-- Back side -->
<h4 class="section-heading">Back side</h4>
<div class="control-row">
@@ -170,19 +211,27 @@
<label>
Back carries
<select v-model="backsource">
<option value="text">The same fixed text</option>
<option value="code">The same code again</option>
<option value="column">Its own content (per row)</option>
<option value="column">Its own code (per row)</option>
<option value="text">Fixed text, no code</option>
<option value="blank">Nothing - blank back</option>
</select>
</label>
<label v-if="backCarriesCode">
Back code type
<select v-model="backcodetype">
<option value="same">Same as the front</option>
<option value="qr">QR</option>
<option value="barcode">CODE128</option>
</select>
</label>
<label>
Back layout
<select v-model="backlayout">
<option value="side">Code left, label right</option>
<option value="stack">Code above label</option>
<option value="codeonly">Code only</option>
<option value="textonly">Text only</option>
<option value="textonly" :disabled="backCarriesCode">Text only, no code</option>
</select>
</label>
<label>
@@ -196,15 +245,16 @@
</div>
<div v-if="backenabled" class="control-row">
<label class="grow" v-if="backsource === 'text' || backsource === 'column'">
<label class="grow" v-if="source === 'single' && backCarriesOwnContent">
Back content (encoded on the back)
<textarea v-model="singleBack" rows="2"
placeholder="A second code for the reverse - text or URL"></textarea>
</label>
<label class="grow" v-if="backsource === 'text' || backCarriesOwnContent">
Back text
<textarea v-model="backtext" rows="2"
placeholder="Property of GE Aerospace - return to IT"></textarea>
</label>
<label class="grow" v-if="source === 'single' && backsource === 'column'">
Back content (encoded on the back)
<input v-model="singleBack" type="text" placeholder="Text or URL for the reverse" />
</label>
</div>
<p v-if="backenabled" class="control-hint">
@@ -241,20 +291,27 @@
<!-- Preview doubles as the print surface: what is on screen is what prints. -->
<div class="sheet">
<div
v-for="(page, index) in visiblePages"
:key="index"
v-for="item in visible"
:key="item.index"
class="label"
:class="['layout-' + layoutFor(page), 'type-' + codetype, 'side-' + page.side]"
:class="labelClasses(item.page)"
>
<img v-if="showPicture(item.page) && pictureposition === 'behind'"
:src="pictureUrl" class="picture-behind" alt="" />
<div class="content">
<div v-if="layoutFor(page) !== 'textonly'" class="codebox">
<img v-if="images[index]" :src="images[index]" class="code-img" alt="" />
<div v-if="showPicture(item.page) && pictureposition !== 'behind'" class="picturebox">
<img :src="pictureUrl" class="picture-img" alt="" />
</div>
<div v-if="layoutFor(page) !== 'codeonly' && page.label" class="label-text">
{{ page.label }}
<div class="codegroup">
<div v-if="layoutFor(item.page) !== 'textonly'" class="codebox">
<img v-if="images[item.index]" :src="images[item.index]" class="code-img" alt="" />
</div>
<div v-if="layoutFor(item.page) !== 'codeonly' && item.page.label" class="label-text">
{{ item.page.label }}
</div>
</div>
</div>
<span v-if="backenabled" class="side-flag">{{ page.side }}</span>
<span v-if="backenabled" class="side-flag">{{ item.page.side }}</span>
</div>
</div>
</div>
@@ -298,10 +355,20 @@ const nudgex = ref(0)
const nudgey = ref(0)
const dpi = ref(203)
// The image itself is deliberately NOT a saved setting: a data URL runs to
// megabytes and would blow the localStorage quota, taking every sizing value
// with it. Only how it is placed persists.
const pictureUrl = ref('')
const picturesize = ref(0.5)
const pictureposition = ref('above')
const pictureside = ref('front')
const pictureopacity = ref(0.15)
const backenabled = ref(false)
const backsource = ref('text')
const backsource = ref('code')
const backcodetype = ref('same')
const backtext = ref('')
const backlayout = ref('textonly')
const backlayout = ref('stack')
const backorder = ref('interleave')
const images = ref([])
@@ -312,13 +379,61 @@ const SETTING_KEYS = [
'codetype', 'errorcorrection', 'preset', 'labelwidth', 'labelheight', 'padding',
'quiet', 'gap', 'codesize', 'barheight', 'labelfont', 'layout', 'align', 'valign',
'contentx', 'contenty', 'nudgex', 'nudgey', 'dpi',
'backenabled', 'backsource', 'backtext', 'backlayout', 'backorder',
'picturesize', 'pictureposition', 'pictureside', 'pictureopacity',
'backenabled', 'backsource', 'backcodetype', 'backtext', 'backlayout', 'backorder',
]
const settingRefs = {
codetype, errorcorrection, preset, labelwidth, labelheight, padding,
quiet, gap, codesize, barheight, labelfont, layout, align, valign,
contentx, contenty, nudgex, nudgey, dpi,
backenabled, backsource, backtext, backlayout, backorder,
picturesize, pictureposition, pictureside, pictureopacity,
backenabled, backsource, backcodetype, backtext, backlayout, backorder,
}
// --- back side --------------------------------------------------------------
const backCarriesCode = computed(() =>
backsource.value === 'code' || backsource.value === 'column')
const backCarriesOwnContent = computed(() =>
backenabled.value && backsource.value === 'column')
// A back that carries a code and a back laid out as text-only cancel each other
// out, and the result is a blank card that looks like the code failed to
// render. Keep the two honest in both directions, including for settings
// restored from a previous visit.
function reconcileBackLayout() {
if (backCarriesCode.value && backlayout.value === 'textonly') backlayout.value = 'stack'
if (!backCarriesCode.value && backlayout.value !== 'textonly') backlayout.value = 'textonly'
}
watch(backsource, reconcileBackLayout)
/** QR or CODE128 for this page. The back may deliberately differ from the
* front: a QR that opens a record, a CODE128 the bench scanner already
* understands. */
function codeTypeFor(page) {
if (page.side !== 'back') return codetype.value
return backcodetype.value === 'same' ? codetype.value : backcodetype.value
}
function layoutFor(page) {
return page.side === 'back' ? backlayout.value : layout.value
}
function showPicture(page) {
if (!pictureUrl.value) return false
if (pictureside.value === 'both') return true
return pictureside.value === page.side
}
function labelClasses(page) {
return [
'layout-' + layoutFor(page),
'type-' + codeTypeFor(page),
'side-' + page.side,
showPicture(page) && pictureposition.value !== 'behind' ? 'pic-' + pictureposition.value : '',
]
}
// --- rows -------------------------------------------------------------------
@@ -366,19 +481,26 @@ const pages = computed(() => built.value.pages)
const cardCount = computed(() => built.value.fronts.length)
const truncated = computed(() => built.value.dropped)
// Printing one test label prints the first card only - both of its sides when
// there is a back, because a back that lands upside down is the whole reason
// to print one first.
const visiblePages = computed(() => {
if (!testMode.value) return pages.value
// What the preview renders, each item carrying its index into `images`. The
// index has to travel with the page: a test print shows two of the pages and
// the rendered codes are keyed by position in the full run, so pairing them by
// loop position printed the wrong code onto the test card.
const visible = computed(() => {
const all = pages.value.map((page, index) => ({ page, index }))
if (!testMode.value) return all
const { fronts, backs } = built.value
return [fronts[0], backenabled.value ? backs[0] : null].filter(Boolean)
if (!fronts.length) return []
const items = [{ page: fronts[0], index: 0 }]
if (backenabled.value && backs.length) {
// Where the first back sits depends on the order the run is emitted in.
items.push({
page: backs[0],
index: backorder.value === 'grouped' ? fronts.length : 1,
})
}
return items
})
function layoutFor(page) {
return page.side === 'back' ? backlayout.value : layout.value
}
// --- fit advice -------------------------------------------------------------
// Modules across the QR at the current content and error correction, quiet
@@ -416,10 +538,10 @@ const fitAdvice = computed(() => {
//
// margin 0 on the QR: the quiet zone is blank label supplied by --tool-quiet,
// so none of the code box is spent on white we cannot then adjust.
async function renderOne(row) {
const text = row.content
async function renderOne(page) {
const text = page.content
if (!text) return ''
if (codetype.value === 'qr') {
if (codeTypeFor(page) === 'qr') {
return qrSvgDataUri(text, { errorCorrection: errorcorrection.value })
}
return barcodeSvgDataUri(text)
@@ -453,6 +575,7 @@ const VAR_NAMES = [
'--tool-label-w', '--tool-label-h', '--tool-pad', '--tool-code', '--tool-barh',
'--tool-quiet', '--tool-gap', '--tool-font', '--tool-align', '--tool-valign',
'--tool-content-x', '--tool-content-y', '--tool-nudge-x', '--tool-nudge-y',
'--tool-pic', '--tool-pic-opacity',
]
function applyVars() {
@@ -478,6 +601,8 @@ function applyVars() {
root.style.setProperty('--tool-content-y', contenty.value + 'in')
root.style.setProperty('--tool-nudge-x', nudgex.value + 'in')
root.style.setProperty('--tool-nudge-y', nudgey.value + 'in')
root.style.setProperty('--tool-pic', picturesize.value + 'in')
root.style.setProperty('--tool-pic-opacity', String(pictureopacity.value))
}
function clearVars() {
@@ -510,6 +635,21 @@ function resetSettings() {
nudgey.value = 0
}
// --- picture ----------------------------------------------------------------
function onPictureFile(event) {
const file = event.target.files && event.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => { pictureUrl.value = String(reader.result || '') }
reader.onerror = () => { pictureUrl.value = '' }
reader.readAsDataURL(file)
}
function clearPicture() {
pictureUrl.value = ''
}
// --- csv helpers ------------------------------------------------------------
function onCsvFile(event) {
@@ -524,8 +664,7 @@ function onCsvFile(event) {
function downloadTemplate() {
// The template carries the back columns only when they would be read, so a
// one-sided run is not handed a CSV with two columns it must ignore.
const perRowBack = backenabled.value && backsource.value === 'column'
const csv = perRowBack
const csv = backCarriesOwnContent.value
? [
'content,label,copies,back,backlabel',
'https://example.com/asset/1,Bay 12 press,1,https://example.com/asset/1/history,Service history',
@@ -571,6 +710,7 @@ onMounted(() => {
if (saved[key] !== undefined) settingRefs[key].value = saved[key]
}
} catch { /* ignore a corrupt entry */ }
reconcileBackLayout()
applyVars()
renderAll()
})
@@ -582,7 +722,7 @@ watch(SETTING_KEYS.map(key => settingRefs[key]), () => {
saveSettings()
})
watch([pages, codetype, errorcorrection], renderAll)
watch([pages, codetype, backcodetype, errorcorrection], renderAll)
</script>
<style scoped>
@@ -590,7 +730,7 @@ watch([pages, codetype, errorcorrection], renderAll)
to Margins = None and Scale = 100%. */
@page { size: var(--tool-label-w) var(--tool-label-h); margin: 0; }
.no-print { padding: 20px; }
.code-generator { width: 100%; }
.controls {
background: var(--bg-card);
@@ -598,19 +738,8 @@ watch([pages, codetype, errorcorrection], renderAll)
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
max-width: 66rem;
}
.controls-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.controls-head h3 { margin: 0; }
.section-heading {
margin: 1.25rem 0 0.5rem;
font-size: 0.9rem;
@@ -675,7 +804,7 @@ watch([pages, codetype, errorcorrection], renderAll)
.fit-advice { flex: 1 1 22rem; align-self: center; }
.sheet {
padding: 20px;
padding: 20px 0;
display: flex;
flex-wrap: wrap;
gap: 10px;
@@ -701,21 +830,64 @@ watch([pages, codetype, errorcorrection], renderAll)
}
/* Everything printed sits in here, so it can be aligned and nudged as one
block without the label box following it off the stock. */
block without the label box following it off the stock. No max-width or
max-height: overflow must CLIP against the label, the way it did before this
wrapper existed. Constrain it and the flex children shrink instead, and the
one that gives way is the barcode - it is the only box allowed to flex. */
.content {
display: flex;
align-items: center;
gap: var(--tool-gap);
max-width: 100%;
max-height: 100%;
position: relative;
z-index: 1;
transform: translate(var(--tool-content-x, 0), var(--tool-content-y, 0));
}
.label.layout-stack .content { flex-direction: column; justify-content: center; }
/* The code and its caption, which is the group a picture sits above or beside. */
.codegroup {
display: flex;
align-items: center;
gap: var(--tool-gap);
}
.label.layout-stack .codegroup { flex-direction: column; justify-content: center; }
.label.pic-above .content { flex-direction: column; }
.label.pic-beside .content { flex-direction: row; }
/* A CODE128 wants the width, so its block takes the label rather than sitting
in the middle of it. */
.label.type-barcode .content { width: 100%; }
.label.type-barcode .content,
.label.type-barcode .codegroup { width: 100%; }
.picturebox {
flex: 0 0 auto;
width: var(--tool-pic);
height: var(--tool-pic);
display: flex;
align-items: center;
justify-content: center;
}
.picture-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
/* A watermark sits under the code, not in it, and only as dark as the setting
says - a scanner reads contrast, and this is the control that costs it. */
.picture-behind {
position: absolute;
top: 50%;
left: 50%;
width: var(--tool-pic);
height: var(--tool-pic);
transform: translate(-50%, -50%);
object-fit: contain;
opacity: var(--tool-pic-opacity, 0.15);
z-index: 0;
}
.codebox {
flex: 0 0 auto;
@@ -764,15 +936,18 @@ watch([pages, codetype, errorcorrection], renderAll)
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-light);
z-index: 2;
}
.label.side-back { outline-style: dotted; }
@media print {
.no-print { display: none !important; }
.side-flag { display: none !important; }
.sheet { padding: 0; display: block; gap: 0; }
.label {
/* The back's dotted variant outspecifies a bare .label reset, so it printed
a dashed rule down the edge of every second card. */
.label,
.label.side-back {
outline: none;
break-after: page;
page-break-after: always;

View File

@@ -9,13 +9,14 @@ from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required
from sqlalchemy.orm import joinedload
from shopdb.extensions import db
from shopdb.extensions import db, cache
from shopdb.core.models import (
Application, Setting,
Asset, AssetType, Communication, Vendor, Model,
CustomField, CustomFieldValue
)
from shopdb.core.api.settings import get_cached_settings
from shopdb.core.services.spellfix import Vocabulary
from shopdb.utils.responses import success_response
logger = logging.getLogger(__name__)
@@ -1003,6 +1004,94 @@ def _check_smart_redirect(query, classification):
return None
# How long a harvested vocabulary is reused. Long enough that a burst of
# searching does not rebuild it, short enough that something entered this
# morning is correctable before lunch.
_VOCABULARY_TTL = 600
# Per-column ceiling on harvested terms. A correction is only offered when a
# search found NOTHING, so the vocabulary exists to catch a typo against the
# names people actually use - not to mirror the database. Without a bound this
# would load every asset number on a site into memory to answer one query.
_VOCABULARY_LIMIT = 5000
def _search_vocabulary():
"""Words worth correcting a search against, harvested from the data.
From the DATA and not a dictionary, because no dictionary holds Genspect,
Telesis, Keyence or wax-trace - and because a vendor added this morning is
then correctable this morning.
Cached: this runs only on a search that found nothing, but a person who
mistypes once usually mistypes twice.
"""
cached = cache.get('search_vocabulary')
if cached is not None:
return cached
vocabulary = Vocabulary()
columns = (
(Asset, (Asset.assetnumber, Asset.name)),
(Vendor, (Vendor.vendor,)),
(Model, (Model.modelnumber,)),
(Application, (Application.appname,)),
(AssetType, (AssetType.assettype,)),
)
for model, fields in columns:
for field in fields:
try:
rows = (db.session.query(field)
.filter(field.isnot(None))
.distinct()
.limit(_VOCABULARY_LIMIT)
.all())
except Exception:
# A plugin table missing on a lean install must not break
# search. No vocabulary is worse than the alternative, not
# fatal.
logger.debug('vocabulary: skipped %s', field, exc_info=True)
continue
for (value,) in rows:
vocabulary.add(value)
cache.set('search_vocabulary', vocabulary, timeout=_VOCABULARY_TTL)
return vocabulary
def _suggestion_for(query):
"""A better spelling of `query`, or None. Never raises into a search."""
try:
return _search_vocabulary().suggest_query(query)
except Exception:
logger.warning('vocabulary lookup failed', exc_info=True)
return None
@search_bp.route('/suggest', methods=['GET'])
@jwt_required(optional=True)
def search_suggest():
"""A better spelling for a query that found nothing, or null.
ITS OWN ENDPOINT, deliberately, rather than a `suggestion` key added to
every list response. Thirty routes take a search parameter across a dozen
files, so folding it into each one is a large change today and a thing every
future plugin has to remember. A page that finds no rows calls this instead;
a page that has not adopted it simply shows no suggestion, which is what it
showed before.
Returns null rather than a poor guess. The caller offers "did you mean" and
a person decides - nothing searches on their behalf.
"""
query = request.args.get('q', '').strip()
if not query or len(query) < 2 or len(query) > 200:
return success_response({'query': query, 'suggestion': None})
return success_response({
'query': query,
'suggestion': _suggestion_for(query),
})
@search_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def global_search():
@@ -1099,6 +1188,21 @@ def global_search():
total_all = len(unique_results)
# A search that found NOTHING is the only evidence of what people cannot
# find, and it is not otherwise recorded anywhere. Logged at INFO with a
# stable prefix so a week of it can be grepped into a list, which is what
# should decide whether spelling correction is worth wiring in - the recent
# multi-word and cross-field matching changes may already have fixed a good
# share of what used to fail. Guessing at that would be building for a
# problem nobody has measured.
suggestion = None
if not unique_results:
logger.info('search-no-results query=%r', query)
# Offered, never applied. The response carries the better spelling and
# the caller decides: silently searching for something else is how
# somebody orders the wrong cartridge.
suggestion = _suggestion_for(query)
# Limit total results
unique_results = unique_results[:50]
@@ -1110,6 +1214,8 @@ def global_search():
'total_all': total_all,
'counts': type_counts,
}
if suggestion:
response_data['suggestion'] = suggestion
redirect = _check_smart_redirect(query, classification)
if redirect:

View File

@@ -0,0 +1,169 @@
"""Suggest a spelling for a search word that found nothing.
WHY NOT SOUNDEX. MySQL has it; SQLite does not, and the suite runs on SQLite
while production runs MySQL - so a SOUNDEX() in a query is either an error in
every test or a production path no test ever executes. It is also wrong for this
data: soundex is English-name phonetics, four characters wide, and it DISCARDS
DIGITS, so CSF16 and CSF17 hash identically. Half of what people search here is
an identifier.
So this is character-based, runs in Python, and behaves the same on both
dialects.
THE VOCABULARY IS THE DATA. No dictionary knows Genspect, Telesis, Keyence or
wax-trace. The terms come from the columns being searched, which also means the
corpus is never stale: a vendor added this morning is correctable this morning.
IT SUGGESTS, IT DOES NOT REWRITE. The caller offers "did you mean" on a search
that found nothing. Silently searching for something else is how somebody orders
the wrong cartridge.
"""
from collections import defaultdict
# Longest edit distance allowed, by length of the word being corrected. A short
# word has few ways to be wrong, so one edit is already a different word: 'bay'
# to 'bar' is not a typo anybody wants guessed for them.
_DISTANCE_BY_LENGTH = ((4, 1), (8, 2))
_MAX_DISTANCE = 3
# Below this a word is too short to correct at all.
MIN_LENGTH = 3
# Candidates sharing no trigram with the query are not scored. Cuts the work
# from the whole vocabulary to a handful without changing the answer, because a
# word within the distances above always shares at least one trigram.
_TRIGRAM = 3
def _digits(word):
return ''.join(c for c in word if c.isdigit())
def max_distance(word):
for length, allowed in _DISTANCE_BY_LENGTH:
if len(word) <= length:
return allowed
return _MAX_DISTANCE
def damerau_levenshtein(first, second, cutoff=None):
"""Edit distance counting a transposition as ONE edit, not two.
Transposition is the commonest typing error - 'Keyecne' for 'Keyence' - and
plain Levenshtein charges two for it, which pushes real typos past the
threshold on short words.
`cutoff` abandons a comparison that cannot come in under it. Most candidates
are hopeless and this is the difference between scoring a vocabulary and
scanning one.
"""
if first == second:
return 0
if abs(len(first) - len(second)) > (cutoff if cutoff is not None else 99):
return (cutoff or 99) + 1
previous = list(range(len(second) + 1))
beforelast = None
for i, a in enumerate(first, 1):
current = [i] + [0] * len(second)
for j, b in enumerate(second, 1):
cost = 0 if a == b else 1
current[j] = min(previous[j] + 1, # deletion
current[j - 1] + 1, # insertion
previous[j - 1] + cost) # substitution
if (beforelast is not None and i > 1 and j > 1
and a == second[j - 2] and first[i - 2] == b):
current[j] = min(current[j], beforelast[j - 2] + cost)
if cutoff is not None and min(current) > cutoff:
return cutoff + 1
beforelast, previous = previous, current
return previous[-1]
def _trigrams(word):
padded = ' %s ' % word
return {padded[i:i + _TRIGRAM] for i in range(len(padded) - _TRIGRAM + 1)}
class Vocabulary:
"""Words harvested from searchable data, indexed for near-match lookup."""
def __init__(self, terms=()):
self.words = set()
self._bytrigram = defaultdict(set)
for term in terms:
self.add(term)
def add(self, term):
for word in str(term or '').split():
word = word.strip().lower()
if len(word) < MIN_LENGTH:
continue
if word in self.words:
continue
self.words.add(word)
for gram in _trigrams(word):
self._bytrigram[gram].add(word)
def candidates(self, word):
found = set()
for gram in _trigrams(word):
found |= self._bytrigram.get(gram, set())
return found
def suggest(self, word):
"""The best correction for `word`, or None to leave it alone.
Returns None rather than a poor guess: a wrong suggestion costs more
than no suggestion, because somebody acts on it.
"""
word = (word or '').strip().lower()
if len(word) < MIN_LENGTH or word in self.words:
return None
limit = max_distance(word)
best, bestdistance = None, limit + 1
for candidate in self.candidates(word):
if not _acceptable(word, candidate):
continue
distance = damerau_levenshtein(word, candidate, cutoff=limit)
if distance < bestdistance:
best, bestdistance = candidate, distance
elif distance == bestdistance and best is not None:
# A tie means two equally good answers and no way to choose.
# Offering either one implies a confidence that is not there.
best = None
return best if bestdistance <= limit else None
def suggest_query(self, query):
"""Correct a whole query, or None if nothing in it can be improved."""
words = [w for w in (query or '').split() if w]
if not words:
return None
fixed, changed = [], False
for word in words:
suggestion = self.suggest(word)
if suggestion and suggestion != word.lower():
fixed.append(suggestion)
changed = True
else:
fixed.append(word)
return ' '.join(fixed) if changed else None
def _acceptable(word, candidate):
"""Guards that keep a correction from destroying an identifier.
DIGITS MUST MATCH EXACTLY. CSF16 to CSF17 is one edit and a catastrophe: it
is a different bay, a different machine, a different cartridge. Any term
carrying digits is treated as an identifier, and an identifier is either
right or not correctable.
THE FIRST CHARACTER MUST MATCH. Typing errors land in the middle and at the
end far more often than on the first key, so requiring it costs almost no
recall and removes most of the nonsense.
"""
if _digits(word) != _digits(candidate):
return False
return bool(word) and bool(candidate) and word[0] == candidate[0]

View File

@@ -0,0 +1,191 @@
"""Correcting a misspelled search word without wrecking an identifier.
Pure arithmetic over a word list, so it is tested that way. The cases that
matter most are the ones where it must REFUSE: a wrong suggestion is acted on,
and here that means the wrong bay or the wrong cartridge.
"""
from shopdb.core.services.spellfix import (
Vocabulary, damerau_levenshtein, max_distance,
)
SHOPFLOOR = [
'Keyence vision system', 'Genspect', 'Telesis part marker',
'wax-trace', 'Zebra ZT411', 'calibration', 'Haas Automation',
'CSF16', 'CSF17', 'MT-1042', 'FC733SY3', 'printer', 'grinder',
]
def vocab():
return Vocabulary(SHOPFLOOR)
# ------------------------------------------------------------------ distance
def test_a_transposition_costs_one_edit_not_two():
"""The commonest typing error. Plain Levenshtein charges two, which pushes
a real typo past the threshold on a short word."""
assert damerau_levenshtein('keyecne', 'keyence') == 1
def test_distance_allowed_scales_with_length():
assert max_distance('bay') == 1
assert max_distance('grinder') == 2
assert max_distance('calibration') == 3
# ------------------------------------------------------------------ suggests
def test_it_corrects_vocabulary_no_dictionary_would_hold():
assert vocab().suggest('keyecne') == 'keyence'
assert vocab().suggest('genspct') == 'genspect'
assert vocab().suggest('telisis') == 'telesis'
def test_a_word_that_is_already_right_is_left_alone():
assert vocab().suggest('keyence') is None
assert vocab().suggest('printer') is None
def test_a_word_nothing_resembles_gets_no_guess():
"""A wrong suggestion costs more than no suggestion."""
assert vocab().suggest('helicopter') is None
# ------------------------------------------------------- identifier guards
def test_an_asset_number_is_never_corrected_to_a_different_one():
"""THE case this exists for. CSF16 to CSF17 is one edit and a different bay.
Both are in the vocabulary, so without the digit guard the near miss is
exactly the kind of thing edit distance loves to 'fix'.
"""
assert vocab().suggest('csf18') is None
assert vocab().suggest('mt-1043') is None
def test_a_typo_in_the_letters_of_an_identifier_still_corrects():
"""The guard is on the DIGITS, not on identifiers as a whole: the digits
have to match, and here they do."""
assert vocab().suggest('cfs16') == 'csf16'
def test_the_first_character_must_match():
"""Typos land in the middle and at the end. Requiring the first key costs
little recall and removes most of the nonsense."""
assert vocab().suggest('teyence') is None
def test_a_tie_produces_no_suggestion():
"""Two equally good answers means there is no answer. CSF16 and CSF17 are
both one edit from CSF1x, and offering either implies a confidence that is
not there - but the digit guard rejects both first, so build a case where
only the tie is in play."""
tied = Vocabulary(['barn', 'bard'])
assert tied.suggest('bare') is None
# ---------------------------------------------------------------- queries
def test_a_multi_word_query_corrects_only_what_is_wrong():
assert vocab().suggest_query('keyecne calibration') == 'keyence calibration'
def test_a_query_that_is_entirely_fine_returns_none():
assert vocab().suggest_query('keyence calibration') is None
def test_short_words_are_left_alone():
"""Below three characters there is not enough word to correct."""
assert vocab().suggest('cs') is None
assert vocab().suggest_query('cs') is None
def test_the_vocabulary_comes_from_the_data():
"""A vendor added this morning is correctable this morning."""
fresh = vocab()
assert fresh.suggest('renishw') is None
fresh.add('Renishaw')
assert fresh.suggest('renishw') == 'renishaw'
# ------------------------------------------------------- wired into search
def test_a_search_that_finds_nothing_offers_a_spelling(client, db, auth_headers):
"""End to end: the vocabulary is harvested from real rows, and a misspelled
query that returns no results comes back with a better spelling."""
from shopdb.core.models import Vendor
from shopdb.extensions import cache
db.session.add(Vendor(vendor='Keyence'))
db.session.commit()
cache.delete('search_vocabulary')
resp = client.get('/api/search?q=keyecne', headers=auth_headers)
assert resp.status_code == 200
data = resp.get_json()['data']
assert data['results'] == []
assert data.get('suggestion') == 'keyence'
def test_a_search_that_finds_something_offers_nothing(client, db, auth_headers):
from shopdb.core.models import Vendor
from shopdb.extensions import cache
db.session.add(Vendor(vendor='Keyence'))
db.session.commit()
cache.delete('search_vocabulary')
resp = client.get('/api/search?q=keyence', headers=auth_headers)
assert 'suggestion' not in resp.get_json()['data']
def test_a_mistyped_asset_number_is_not_corrected_to_a_real_one(
client, db, auth_headers):
"""The guard, through the endpoint. CSF17 exists; CSF18 does not; and the
answer to "CSF18" must be no results and NO suggestion, not a different
bay."""
from shopdb.core.models import Asset, AssetType
from shopdb.extensions import cache
assettype = AssetType.query.first() or AssetType(assettype='machine')
if not assettype.assettypeid:
db.session.add(assettype)
db.session.flush()
db.session.add(Asset(assetnumber='CSF17', assettypeid=assettype.assettypeid))
db.session.commit()
cache.delete('search_vocabulary')
resp = client.get('/api/search?q=CSF18', headers=auth_headers)
data = resp.get_json()['data']
assert data['results'] == []
assert 'suggestion' not in data
def test_the_suggest_endpoint_serves_any_page(client, db, auth_headers):
"""The shared endpoint every list uses, rather than a suggestion key added
to thirty search routes."""
from shopdb.core.models import Vendor
from shopdb.extensions import cache
db.session.add(Vendor(vendor='Keyence'))
db.session.commit()
cache.delete('search_vocabulary')
resp = client.get('/api/search/suggest?q=keyecne', headers=auth_headers)
assert resp.status_code == 200
assert resp.get_json()['data']['suggestion'] == 'keyence'
def test_the_suggest_endpoint_says_null_rather_than_guessing(client, db,
auth_headers):
from shopdb.extensions import cache
cache.delete('search_vocabulary')
resp = client.get('/api/search/suggest?q=helicopter', headers=auth_headers)
assert resp.get_json()['data']['suggestion'] is None
def test_the_suggest_endpoint_ignores_a_too_short_query(client, auth_headers):
resp = client.get('/api/search/suggest?q=c', headers=auth_headers)
assert resp.status_code == 200
assert resp.get_json()['data']['suggestion'] is None