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.**
|
- **Contents can be positioned inside the label without moving the label.**
|
||||||
Align and Vertical place the code and caption block anywhere on the stock,
|
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
|
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
|
a printer whose origin is off, which is a different problem from composing a
|
||||||
label.
|
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
|
### Changed
|
||||||
|
|
||||||
- CSV parsing and page building moved out of the generator component into
|
- CSV parsing and page building moved out of the generator component into
|
||||||
|
|||||||
@@ -1763,3 +1763,31 @@ td.actions .btn + .btn {
|
|||||||
independently. */
|
independently. */
|
||||||
.clickable-row { cursor: pointer; }
|
.clickable-row { cursor: pointer; }
|
||||||
.clickable-row:hover td { background: var(--bg); }
|
.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 { ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
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
|
// 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
|
// 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.
|
// deep link) so the list reloads at the restored page.
|
||||||
// extraKeys optional extra query keys to persist (e.g. ['typeid']); each
|
// extraKeys optional extra query keys to persist (e.g. ['typeid']); each
|
||||||
// gets a ref exposed under the returned `extras` object.
|
// 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 = {}) {
|
export function useListQuery(options = {}) {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -98,5 +111,17 @@ export function useListQuery(options = {}) {
|
|||||||
if (changed) onChange()
|
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">
|
<div v-else-if="results.length === 0" class="no-results">
|
||||||
No results found for "{{ query }}"
|
No results found for "{{ query }}"
|
||||||
|
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="results-list">
|
<div v-else class="results-list">
|
||||||
@@ -94,12 +95,14 @@
|
|||||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { searchApi, knowledgebaseApi } from '../api'
|
import { searchApi, knowledgebaseApi } from '../api'
|
||||||
|
import SearchSuggestion from '../components/SearchSuggestion.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const results = ref([])
|
const results = ref([])
|
||||||
|
const suggestion = ref(null)
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const searchInput = ref('')
|
const searchInput = ref('')
|
||||||
const activeFilter = ref('all')
|
const activeFilter = ref('all')
|
||||||
@@ -175,6 +178,7 @@ const filteredResults = computed(() => {
|
|||||||
async function search(q) {
|
async function search(q) {
|
||||||
if (!q || q.length < 2) {
|
if (!q || q.length < 2) {
|
||||||
results.value = []
|
results.value = []
|
||||||
|
suggestion.value = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +188,10 @@ async function search(q) {
|
|||||||
try {
|
try {
|
||||||
const response = await searchApi.search(q)
|
const response = await searchApi.search(q)
|
||||||
const data = response.data.data
|
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
|
// Handle ServiceNOW redirect
|
||||||
if (data?.redirect?.type === 'servicenow') {
|
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() {
|
function performSearch() {
|
||||||
const term = searchInput.value.trim()
|
const term = searchInput.value.trim()
|
||||||
if (!term) return
|
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">
|
<tr v-if="vendors.length === 0">
|
||||||
<td colspan="5" style="text-align: center; color: var(--text-light);">
|
<td colspan="5" style="text-align: center; color: var(--text-light);">
|
||||||
No vendors found
|
No vendors found
|
||||||
|
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -189,11 +190,21 @@ import PaginationBar from '../../components/PaginationBar.vue'
|
|||||||
import { useToast } from '../../composables/toast'
|
import { useToast } from '../../composables/toast'
|
||||||
import { apiError } from '../../utils/apiError'
|
import { apiError } from '../../utils/apiError'
|
||||||
import { useListQuery } from '@/composables/listQuery'
|
import { useListQuery } from '@/composables/listQuery'
|
||||||
|
import SearchSuggestion from '@/components/SearchSuggestion.vue'
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const vendors = ref([])
|
const vendors = ref([])
|
||||||
const loading = ref(true)
|
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 totalPages = ref(1)
|
||||||
const perPage = ref(20)
|
const perPage = ref(20)
|
||||||
|
|
||||||
@@ -232,6 +243,7 @@ async function loadVendors() {
|
|||||||
|
|
||||||
const response = await vendorsApi.list(params)
|
const response = await vendorsApi.list(params)
|
||||||
vendors.value = response.data.data || []
|
vendors.value = response.data.data || []
|
||||||
|
reportCount(vendors.value.length)
|
||||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading vendors:', err)
|
console.error('Error loading vendors:', err)
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
<tr v-if="computers.length === 0">
|
<tr v-if="computers.length === 0">
|
||||||
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
||||||
No computers found
|
No computers found
|
||||||
|
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -91,10 +92,20 @@ import { computersApi } from '@/api'
|
|||||||
import PaginationBar from '@/components/PaginationBar.vue'
|
import PaginationBar from '@/components/PaginationBar.vue'
|
||||||
import { colorStyle } from '@/utils/colorStyle'
|
import { colorStyle } from '@/utils/colorStyle'
|
||||||
import { useListQuery } from '@/composables/listQuery'
|
import { useListQuery } from '@/composables/listQuery'
|
||||||
|
import SearchSuggestion from '@/components/SearchSuggestion.vue'
|
||||||
|
|
||||||
const computers = ref([])
|
const computers = ref([])
|
||||||
const loading = ref(true)
|
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 totalPages = ref(1)
|
||||||
const perPage = ref(20)
|
const perPage = ref(20)
|
||||||
|
|
||||||
@@ -115,6 +126,7 @@ async function loadComputers() {
|
|||||||
|
|
||||||
const response = await computersApi.list(params)
|
const response = await computersApi.list(params)
|
||||||
computers.value = response.data.data || []
|
computers.value = response.data.data || []
|
||||||
|
reportCount(computers.value.length)
|
||||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading computers:', error)
|
console.error('Error loading computers:', error)
|
||||||
|
|||||||
@@ -88,6 +88,7 @@
|
|||||||
<tr v-if="articles.length === 0">
|
<tr v-if="articles.length === 0">
|
||||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||||
No articles found
|
No articles found
|
||||||
|
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -112,12 +113,22 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { knowledgebaseApi, applicationsApi } from '@/api'
|
import { knowledgebaseApi, applicationsApi } from '@/api'
|
||||||
import PaginationBar from '@/components/PaginationBar.vue'
|
import PaginationBar from '@/components/PaginationBar.vue'
|
||||||
import { useListQuery } from '@/composables/listQuery'
|
import { useListQuery } from '@/composables/listQuery'
|
||||||
|
import SearchSuggestion from '@/components/SearchSuggestion.vue'
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const articles = ref([])
|
const articles = ref([])
|
||||||
const topics = ref([])
|
const topics = ref([])
|
||||||
const stats = ref(null)
|
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 perPage = ref(20)
|
||||||
const totalPages = ref(1)
|
const totalPages = ref(1)
|
||||||
const topicFilter = ref('')
|
const topicFilter = ref('')
|
||||||
@@ -161,6 +172,7 @@ async function loadArticles() {
|
|||||||
|
|
||||||
const response = await knowledgebaseApi.list(params)
|
const response = await knowledgebaseApi.list(params)
|
||||||
articles.value = response.data.data || []
|
articles.value = response.data.data || []
|
||||||
|
reportCount(articles.value.length)
|
||||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading articles:', error)
|
console.error('Error loading articles:', error)
|
||||||
|
|||||||
@@ -79,6 +79,7 @@
|
|||||||
<tr v-if="machines.length === 0">
|
<tr v-if="machines.length === 0">
|
||||||
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
||||||
No machines found
|
No machines found
|
||||||
|
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -105,10 +106,22 @@ import { colorStyle } from "@/utils/colorStyle"
|
|||||||
import { machinesApi } from '@/api'
|
import { machinesApi } from '@/api'
|
||||||
import PaginationBar from '@/components/PaginationBar.vue'
|
import PaginationBar from '@/components/PaginationBar.vue'
|
||||||
import { useListQuery } from '@/composables/listQuery'
|
import { useListQuery } from '@/composables/listQuery'
|
||||||
|
import SearchSuggestion from '@/components/SearchSuggestion.vue'
|
||||||
|
|
||||||
const machines = ref([])
|
const machines = ref([])
|
||||||
const loading = ref(true)
|
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 totalPages = ref(1)
|
||||||
const perPage = ref(20)
|
const perPage = ref(20)
|
||||||
|
|
||||||
@@ -129,6 +142,7 @@ async function loadMachines() {
|
|||||||
|
|
||||||
const response = await machinesApi.list(params)
|
const response = await machinesApi.list(params)
|
||||||
machines.value = response.data.data || []
|
machines.value = response.data.data || []
|
||||||
|
reportCount(machines.value.length)
|
||||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading machines:', error)
|
console.error('Error loading machines:', error)
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
<tr v-if="tools.length === 0">
|
<tr v-if="tools.length === 0">
|
||||||
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
||||||
No measuring tools found
|
No measuring tools found
|
||||||
|
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -97,11 +98,21 @@ import { colorStyle } from '@/utils/colorStyle'
|
|||||||
import { measuringtoolsApi } from '@/api'
|
import { measuringtoolsApi } from '@/api'
|
||||||
import PaginationBar from '@/components/PaginationBar.vue'
|
import PaginationBar from '@/components/PaginationBar.vue'
|
||||||
import { useListQuery } from '@/composables/listQuery'
|
import { useListQuery } from '@/composables/listQuery'
|
||||||
|
import SearchSuggestion from '@/components/SearchSuggestion.vue'
|
||||||
|
|
||||||
const tools = ref([])
|
const tools = ref([])
|
||||||
const types = ref([])
|
const types = ref([])
|
||||||
const loading = ref(true)
|
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 typeid = ref('')
|
||||||
const calibrationstatus = ref('')
|
const calibrationstatus = ref('')
|
||||||
const totalPages = ref(1)
|
const totalPages = ref(1)
|
||||||
@@ -133,6 +144,7 @@ async function loadTools() {
|
|||||||
|
|
||||||
const response = await measuringtoolsApi.list(params)
|
const response = await measuringtoolsApi.list(params)
|
||||||
tools.value = response.data.data || []
|
tools.value = response.data.data || []
|
||||||
|
reportCount(tools.value.length)
|
||||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading measuring tools:', err)
|
console.error('Error loading measuring tools:', err)
|
||||||
|
|||||||
@@ -97,6 +97,7 @@
|
|||||||
<tr v-if="devices.length === 0">
|
<tr v-if="devices.length === 0">
|
||||||
<td colspan="9" style="text-align: center; color: var(--text-light);">
|
<td colspan="9" style="text-align: center; color: var(--text-light);">
|
||||||
No network devices found
|
No network devices found
|
||||||
|
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -122,13 +123,23 @@ import { colorStyle } from "@/utils/colorStyle"
|
|||||||
import { networkApi, vendorsApi, locationsApi } from '@/api'
|
import { networkApi, vendorsApi, locationsApi } from '@/api'
|
||||||
import PaginationBar from '@/components/PaginationBar.vue'
|
import PaginationBar from '@/components/PaginationBar.vue'
|
||||||
import { useListQuery } from '@/composables/listQuery'
|
import { useListQuery } from '@/composables/listQuery'
|
||||||
|
import SearchSuggestion from '@/components/SearchSuggestion.vue'
|
||||||
|
|
||||||
const devices = ref([])
|
const devices = ref([])
|
||||||
const deviceTypes = ref([])
|
const deviceTypes = ref([])
|
||||||
const vendors = ref([])
|
const vendors = ref([])
|
||||||
const locations = ref([])
|
const locations = ref([])
|
||||||
const loading = ref(true)
|
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 selectedType = ref(null)
|
||||||
const vendorFilter = ref('')
|
const vendorFilter = ref('')
|
||||||
const locationFilter = ref('')
|
const locationFilter = ref('')
|
||||||
@@ -220,6 +231,7 @@ async function loadDevices() {
|
|||||||
|
|
||||||
const response = await networkApi.list(params)
|
const response = await networkApi.list(params)
|
||||||
devices.value = response.data.data || []
|
devices.value = response.data.data || []
|
||||||
|
reportCount(devices.value.length)
|
||||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading network devices:', error)
|
console.error('Error loading network devices:', error)
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
<tr v-if="printers.length === 0">
|
<tr v-if="printers.length === 0">
|
||||||
<td colspan="7" style="text-align: center; color: var(--text-light);">
|
<td colspan="7" style="text-align: center; color: var(--text-light);">
|
||||||
No printers found
|
No printers found
|
||||||
|
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -92,12 +93,24 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { printersApi } from '@/api'
|
import { printersApi } from '@/api'
|
||||||
import PaginationBar from '@/components/PaginationBar.vue'
|
import PaginationBar from '@/components/PaginationBar.vue'
|
||||||
import { useListQuery } from '@/composables/listQuery'
|
import { useListQuery } from '@/composables/listQuery'
|
||||||
|
import SearchSuggestion from '@/components/SearchSuggestion.vue'
|
||||||
|
|
||||||
const printers = ref([])
|
const printers = ref([])
|
||||||
const printerTypes = ref([])
|
const printerTypes = ref([])
|
||||||
const typeFilter = ref('')
|
const typeFilter = ref('')
|
||||||
const loading = ref(true)
|
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 totalPages = ref(1)
|
||||||
const perPage = ref(20)
|
const perPage = ref(20)
|
||||||
|
|
||||||
@@ -125,6 +138,7 @@ async function loadPrinters() {
|
|||||||
|
|
||||||
const response = await printersApi.list(params)
|
const response = await printersApi.list(params)
|
||||||
printers.value = response.data.data || []
|
printers.value = response.data.data || []
|
||||||
|
reportCount(printers.value.length)
|
||||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading printers:', error)
|
console.error('Error loading printers:', error)
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
*
|
*
|
||||||
* `default` = AppLayout child routes; `toplevel` = full-screen routes.
|
* `default` = AppLayout child routes; `toplevel` = full-screen routes.
|
||||||
*
|
*
|
||||||
* The index sits inside the app shell. Each tool that PRINTS is toplevel, the
|
* The generator sits INSIDE the app shell, unlike the label pages in core.
|
||||||
* way every other label page in this repo is: printing from inside AppLayout
|
* Those are toplevel because a print from inside AppLayout would put the
|
||||||
* would put the sidebar and header on the label stock.
|
* 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 [
|
export default [
|
||||||
{
|
{
|
||||||
@@ -14,11 +17,8 @@ export default [
|
|||||||
component: () => import('./views/ToolsIndex.vue'),
|
component: () => import('./views/ToolsIndex.vue'),
|
||||||
meta: { requiresAuth: true, plugin: 'tools' }
|
meta: { requiresAuth: true, plugin: 'tools' }
|
||||||
},
|
},
|
||||||
]
|
|
||||||
|
|
||||||
export const toplevel = [
|
|
||||||
{
|
{
|
||||||
path: '/tools/codes',
|
path: 'tools/codes',
|
||||||
name: 'tools-codes',
|
name: 'tools-codes',
|
||||||
component: () => import('./views/CodeGenerator.vue'),
|
component: () => import('./views/CodeGenerator.vue'),
|
||||||
meta: { requiresAuth: true, plugin: 'tools' }
|
meta: { requiresAuth: true, plugin: 'tools' }
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="code-generator">
|
||||||
<div class="no-print">
|
<div class="no-print">
|
||||||
<div class="controls">
|
<div class="page-header">
|
||||||
<div class="controls-head">
|
<h1>Barcode / QR Generator</h1>
|
||||||
<h3>Barcode / QR Generator</h3>
|
<router-link to="/tools" class="btn btn-secondary">Back to Tech Tools</router-link>
|
||||||
<router-link to="/tools" class="btn btn-secondary">Back to Tech Tools</router-link>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div class="controls card">
|
||||||
<!-- What to encode -->
|
<!-- What to encode -->
|
||||||
<div class="control-row">
|
<div class="control-row">
|
||||||
<label>
|
<label>
|
||||||
@@ -66,9 +66,9 @@
|
|||||||
<p class="control-note" v-if="csvError">{{ csvError }}</p>
|
<p class="control-note" v-if="csvError">{{ csvError }}</p>
|
||||||
<p class="control-hint">
|
<p class="control-hint">
|
||||||
Columns: <code>content</code> (required), <code>label</code>, <code>copies</code><span
|
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
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
Align
|
Align
|
||||||
<select v-model="align">
|
<select v-model="align">
|
||||||
<option value="flex-start">Left</option>
|
<option value="flex-start">Left</option>
|
||||||
<option value="center">Centre</option>
|
<option value="center">Center</option>
|
||||||
<option value="flex-end">Right</option>
|
<option value="flex-end">Right</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -159,6 +159,47 @@
|
|||||||
is for a printer whose origin is off, not for composing a label.
|
is for a printer whose origin is off, not for composing a label.
|
||||||
</p>
|
</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 -->
|
<!-- Back side -->
|
||||||
<h4 class="section-heading">Back side</h4>
|
<h4 class="section-heading">Back side</h4>
|
||||||
<div class="control-row">
|
<div class="control-row">
|
||||||
@@ -170,19 +211,27 @@
|
|||||||
<label>
|
<label>
|
||||||
Back carries
|
Back carries
|
||||||
<select v-model="backsource">
|
<select v-model="backsource">
|
||||||
<option value="text">The same fixed text</option>
|
|
||||||
<option value="code">The same code again</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>
|
<option value="blank">Nothing - blank back</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
<label>
|
||||||
Back layout
|
Back layout
|
||||||
<select v-model="backlayout">
|
<select v-model="backlayout">
|
||||||
<option value="side">Code left, label right</option>
|
<option value="side">Code left, label right</option>
|
||||||
<option value="stack">Code above label</option>
|
<option value="stack">Code above label</option>
|
||||||
<option value="codeonly">Code only</option>
|
<option value="codeonly">Code only</option>
|
||||||
<option value="textonly">Text only</option>
|
<option value="textonly" :disabled="backCarriesCode">Text only, no code</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -196,15 +245,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="backenabled" class="control-row">
|
<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
|
Back text
|
||||||
<textarea v-model="backtext" rows="2"
|
<textarea v-model="backtext" rows="2"
|
||||||
placeholder="Property of GE Aerospace - return to IT"></textarea>
|
placeholder="Property of GE Aerospace - return to IT"></textarea>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
|
|
||||||
<p v-if="backenabled" class="control-hint">
|
<p v-if="backenabled" class="control-hint">
|
||||||
@@ -241,20 +291,27 @@
|
|||||||
<!-- Preview doubles as the print surface: what is on screen is what prints. -->
|
<!-- Preview doubles as the print surface: what is on screen is what prints. -->
|
||||||
<div class="sheet">
|
<div class="sheet">
|
||||||
<div
|
<div
|
||||||
v-for="(page, index) in visiblePages"
|
v-for="item in visible"
|
||||||
:key="index"
|
:key="item.index"
|
||||||
class="label"
|
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 class="content">
|
||||||
<div v-if="layoutFor(page) !== 'textonly'" class="codebox">
|
<div v-if="showPicture(item.page) && pictureposition !== 'behind'" class="picturebox">
|
||||||
<img v-if="images[index]" :src="images[index]" class="code-img" alt="" />
|
<img :src="pictureUrl" class="picture-img" alt="" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="layoutFor(page) !== 'codeonly' && page.label" class="label-text">
|
<div class="codegroup">
|
||||||
{{ page.label }}
|
<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>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -298,10 +355,20 @@ const nudgex = ref(0)
|
|||||||
const nudgey = ref(0)
|
const nudgey = ref(0)
|
||||||
const dpi = ref(203)
|
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 backenabled = ref(false)
|
||||||
const backsource = ref('text')
|
const backsource = ref('code')
|
||||||
|
const backcodetype = ref('same')
|
||||||
const backtext = ref('')
|
const backtext = ref('')
|
||||||
const backlayout = ref('textonly')
|
const backlayout = ref('stack')
|
||||||
const backorder = ref('interleave')
|
const backorder = ref('interleave')
|
||||||
|
|
||||||
const images = ref([])
|
const images = ref([])
|
||||||
@@ -312,13 +379,61 @@ const SETTING_KEYS = [
|
|||||||
'codetype', 'errorcorrection', 'preset', 'labelwidth', 'labelheight', 'padding',
|
'codetype', 'errorcorrection', 'preset', 'labelwidth', 'labelheight', 'padding',
|
||||||
'quiet', 'gap', 'codesize', 'barheight', 'labelfont', 'layout', 'align', 'valign',
|
'quiet', 'gap', 'codesize', 'barheight', 'labelfont', 'layout', 'align', 'valign',
|
||||||
'contentx', 'contenty', 'nudgex', 'nudgey', 'dpi',
|
'contentx', 'contenty', 'nudgex', 'nudgey', 'dpi',
|
||||||
'backenabled', 'backsource', 'backtext', 'backlayout', 'backorder',
|
'picturesize', 'pictureposition', 'pictureside', 'pictureopacity',
|
||||||
|
'backenabled', 'backsource', 'backcodetype', 'backtext', 'backlayout', 'backorder',
|
||||||
]
|
]
|
||||||
const settingRefs = {
|
const settingRefs = {
|
||||||
codetype, errorcorrection, preset, labelwidth, labelheight, padding,
|
codetype, errorcorrection, preset, labelwidth, labelheight, padding,
|
||||||
quiet, gap, codesize, barheight, labelfont, layout, align, valign,
|
quiet, gap, codesize, barheight, labelfont, layout, align, valign,
|
||||||
contentx, contenty, nudgex, nudgey, dpi,
|
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 -------------------------------------------------------------------
|
// --- rows -------------------------------------------------------------------
|
||||||
@@ -366,19 +481,26 @@ const pages = computed(() => built.value.pages)
|
|||||||
const cardCount = computed(() => built.value.fronts.length)
|
const cardCount = computed(() => built.value.fronts.length)
|
||||||
const truncated = computed(() => built.value.dropped)
|
const truncated = computed(() => built.value.dropped)
|
||||||
|
|
||||||
// Printing one test label prints the first card only - both of its sides when
|
// What the preview renders, each item carrying its index into `images`. The
|
||||||
// there is a back, because a back that lands upside down is the whole reason
|
// index has to travel with the page: a test print shows two of the pages and
|
||||||
// to print one first.
|
// the rendered codes are keyed by position in the full run, so pairing them by
|
||||||
const visiblePages = computed(() => {
|
// loop position printed the wrong code onto the test card.
|
||||||
if (!testMode.value) return pages.value
|
const visible = computed(() => {
|
||||||
|
const all = pages.value.map((page, index) => ({ page, index }))
|
||||||
|
if (!testMode.value) return all
|
||||||
const { fronts, backs } = built.value
|
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 -------------------------------------------------------------
|
// --- fit advice -------------------------------------------------------------
|
||||||
|
|
||||||
// Modules across the QR at the current content and error correction, quiet
|
// 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,
|
// 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.
|
// so none of the code box is spent on white we cannot then adjust.
|
||||||
async function renderOne(row) {
|
async function renderOne(page) {
|
||||||
const text = row.content
|
const text = page.content
|
||||||
if (!text) return ''
|
if (!text) return ''
|
||||||
if (codetype.value === 'qr') {
|
if (codeTypeFor(page) === 'qr') {
|
||||||
return qrSvgDataUri(text, { errorCorrection: errorcorrection.value })
|
return qrSvgDataUri(text, { errorCorrection: errorcorrection.value })
|
||||||
}
|
}
|
||||||
return barcodeSvgDataUri(text)
|
return barcodeSvgDataUri(text)
|
||||||
@@ -453,6 +575,7 @@ const VAR_NAMES = [
|
|||||||
'--tool-label-w', '--tool-label-h', '--tool-pad', '--tool-code', '--tool-barh',
|
'--tool-label-w', '--tool-label-h', '--tool-pad', '--tool-code', '--tool-barh',
|
||||||
'--tool-quiet', '--tool-gap', '--tool-font', '--tool-align', '--tool-valign',
|
'--tool-quiet', '--tool-gap', '--tool-font', '--tool-align', '--tool-valign',
|
||||||
'--tool-content-x', '--tool-content-y', '--tool-nudge-x', '--tool-nudge-y',
|
'--tool-content-x', '--tool-content-y', '--tool-nudge-x', '--tool-nudge-y',
|
||||||
|
'--tool-pic', '--tool-pic-opacity',
|
||||||
]
|
]
|
||||||
|
|
||||||
function applyVars() {
|
function applyVars() {
|
||||||
@@ -478,6 +601,8 @@ function applyVars() {
|
|||||||
root.style.setProperty('--tool-content-y', contenty.value + 'in')
|
root.style.setProperty('--tool-content-y', contenty.value + 'in')
|
||||||
root.style.setProperty('--tool-nudge-x', nudgex.value + 'in')
|
root.style.setProperty('--tool-nudge-x', nudgex.value + 'in')
|
||||||
root.style.setProperty('--tool-nudge-y', nudgey.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() {
|
function clearVars() {
|
||||||
@@ -510,6 +635,21 @@ function resetSettings() {
|
|||||||
nudgey.value = 0
|
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 ------------------------------------------------------------
|
// --- csv helpers ------------------------------------------------------------
|
||||||
|
|
||||||
function onCsvFile(event) {
|
function onCsvFile(event) {
|
||||||
@@ -524,8 +664,7 @@ function onCsvFile(event) {
|
|||||||
function downloadTemplate() {
|
function downloadTemplate() {
|
||||||
// The template carries the back columns only when they would be read, so a
|
// 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.
|
// one-sided run is not handed a CSV with two columns it must ignore.
|
||||||
const perRowBack = backenabled.value && backsource.value === 'column'
|
const csv = backCarriesOwnContent.value
|
||||||
const csv = perRowBack
|
|
||||||
? [
|
? [
|
||||||
'content,label,copies,back,backlabel',
|
'content,label,copies,back,backlabel',
|
||||||
'https://example.com/asset/1,Bay 12 press,1,https://example.com/asset/1/history,Service history',
|
'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]
|
if (saved[key] !== undefined) settingRefs[key].value = saved[key]
|
||||||
}
|
}
|
||||||
} catch { /* ignore a corrupt entry */ }
|
} catch { /* ignore a corrupt entry */ }
|
||||||
|
reconcileBackLayout()
|
||||||
applyVars()
|
applyVars()
|
||||||
renderAll()
|
renderAll()
|
||||||
})
|
})
|
||||||
@@ -582,7 +722,7 @@ watch(SETTING_KEYS.map(key => settingRefs[key]), () => {
|
|||||||
saveSettings()
|
saveSettings()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch([pages, codetype, errorcorrection], renderAll)
|
watch([pages, codetype, backcodetype, errorcorrection], renderAll)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -590,7 +730,7 @@ watch([pages, codetype, errorcorrection], renderAll)
|
|||||||
to Margins = None and Scale = 100%. */
|
to Margins = None and Scale = 100%. */
|
||||||
@page { size: var(--tool-label-w) var(--tool-label-h); margin: 0; }
|
@page { size: var(--tool-label-w) var(--tool-label-h); margin: 0; }
|
||||||
|
|
||||||
.no-print { padding: 20px; }
|
.code-generator { width: 100%; }
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
@@ -598,19 +738,8 @@ watch([pages, codetype, errorcorrection], renderAll)
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 20px;
|
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 {
|
.section-heading {
|
||||||
margin: 1.25rem 0 0.5rem;
|
margin: 1.25rem 0 0.5rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@@ -675,7 +804,7 @@ watch([pages, codetype, errorcorrection], renderAll)
|
|||||||
.fit-advice { flex: 1 1 22rem; align-self: center; }
|
.fit-advice { flex: 1 1 22rem; align-self: center; }
|
||||||
|
|
||||||
.sheet {
|
.sheet {
|
||||||
padding: 20px;
|
padding: 20px 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
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
|
/* 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 {
|
.content {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--tool-gap);
|
gap: var(--tool-gap);
|
||||||
max-width: 100%;
|
position: relative;
|
||||||
max-height: 100%;
|
z-index: 1;
|
||||||
transform: translate(var(--tool-content-x, 0), var(--tool-content-y, 0));
|
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
|
/* A CODE128 wants the width, so its block takes the label rather than sitting
|
||||||
in the middle of it. */
|
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 {
|
.codebox {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
@@ -764,15 +936,18 @@ watch([pages, codetype, errorcorrection], renderAll)
|
|||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label.side-back { outline-style: dotted; }
|
.label.side-back { outline-style: dotted; }
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
.no-print { display: none !important; }
|
|
||||||
.side-flag { display: none !important; }
|
.side-flag { display: none !important; }
|
||||||
.sheet { padding: 0; display: block; gap: 0; }
|
.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;
|
outline: none;
|
||||||
break-after: page;
|
break-after: page;
|
||||||
page-break-after: always;
|
page-break-after: always;
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ from flask import Blueprint, request, current_app
|
|||||||
from flask_jwt_extended import jwt_required
|
from flask_jwt_extended import jwt_required
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from shopdb.extensions import db
|
from shopdb.extensions import db, cache
|
||||||
from shopdb.core.models import (
|
from shopdb.core.models import (
|
||||||
Application, Setting,
|
Application, Setting,
|
||||||
Asset, AssetType, Communication, Vendor, Model,
|
Asset, AssetType, Communication, Vendor, Model,
|
||||||
CustomField, CustomFieldValue
|
CustomField, CustomFieldValue
|
||||||
)
|
)
|
||||||
from shopdb.core.api.settings import get_cached_settings
|
from shopdb.core.api.settings import get_cached_settings
|
||||||
|
from shopdb.core.services.spellfix import Vocabulary
|
||||||
from shopdb.utils.responses import success_response
|
from shopdb.utils.responses import success_response
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -1003,6 +1004,94 @@ def _check_smart_redirect(query, classification):
|
|||||||
return None
|
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'])
|
@search_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@jwt_required(optional=True)
|
||||||
def global_search():
|
def global_search():
|
||||||
@@ -1099,6 +1188,21 @@ def global_search():
|
|||||||
|
|
||||||
total_all = len(unique_results)
|
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
|
# Limit total results
|
||||||
unique_results = unique_results[:50]
|
unique_results = unique_results[:50]
|
||||||
|
|
||||||
@@ -1110,6 +1214,8 @@ def global_search():
|
|||||||
'total_all': total_all,
|
'total_all': total_all,
|
||||||
'counts': type_counts,
|
'counts': type_counts,
|
||||||
}
|
}
|
||||||
|
if suggestion:
|
||||||
|
response_data['suggestion'] = suggestion
|
||||||
|
|
||||||
redirect = _check_smart_redirect(query, classification)
|
redirect = _check_smart_redirect(query, classification)
|
||||||
if redirect:
|
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