Search spelling suggestions across every list
This commit is contained in:
29
CHANGELOG.md
29
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
42
frontend/src/components/SearchSuggestion.vue
Normal file
42
frontend/src/components/SearchSuggestion.vue
Normal 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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
56
frontend/src/composables/searchSuggestion.js
Normal file
56
frontend/src/composables/searchSuggestion.js
Normal 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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
14
frontend/src/views/vendors/VendorsList.vue
vendored
14
frontend/src/views/vendors/VendorsList.vue
vendored
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
169
shopdb/core/services/spellfix.py
Normal file
169
shopdb/core/services/spellfix.py
Normal 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]
|
||||
191
tests/test_core/test_spellfix.py
Normal file
191
tests/test_core/test_spellfix.py
Normal 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
|
||||
Reference in New Issue
Block a user