(function () { 'use strict'; var SYNC_INTERVAL = 30 * 1000; var SYNC_BATCH = 500; var LOG_LIMIT = 200; var KEYS = { books: 'inventory.books', queue: 'inventory.queue', log: 'inventory.log', syncedAt: 'inventory.syncedAt' }; var root = document.getElementById('inventory'); var messages = JSON.parse(document.getElementById('messages').textContent); var config = { booksUrl: root.dataset.booksUrl, syncUrl: root.dataset.syncUrl, swUrl: root.dataset.swUrl, scope: root.dataset.scope, bookUrl: root.dataset.bookUrl, locale: root.dataset.locale }; var el = { form: document.getElementById('scan-form'), input: document.getElementById('scan-input'), location: document.getElementById('location'), message: document.getElementById('message'), connection: document.getElementById('connection'), booksCount: document.getElementById('books-count'), booksDate: document.getElementById('books-date'), pendingCount: document.getElementById('pending-count'), syncDate: document.getElementById('sync-date'), refresh: document.getElementById('refresh'), sync: document.getElementById('sync'), log: document.getElementById('log'), logEmpty: document.getElementById('log-empty'), clearLog: document.getElementById('clear-log') }; /* ---------- storage ---------- */ function load(key, fallback) { try { var value = localStorage.getItem(key); return value === null ? fallback : JSON.parse(value); } catch (e) { return fallback; } } function save(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { showMessage('error', String(e)); } } var state = { books: load(KEYS.books, {fetchedAt: null, list: []}), queue: load(KEYS.queue, []), location: null, log: load(KEYS.log, []), syncedAt: load(KEYS.syncedAt, null), syncing: false, syncFailed: false, byId: {}, byIsbn: {}, byIssn: {} }; function indexBooks() { state.byId = {}; state.byIsbn = {}; state.byIssn = {}; state.books.list.forEach(function (book) { state.byId[book.id] = book; book.isbn.forEach(function (isbn) { (state.byIsbn[isbn] = state.byIsbn[isbn] || []).push(book); }); (book.issn || []).forEach(function (issn) { (state.byIssn[issn] = state.byIssn[issn] || []).push(book); }); }); // Scans not uploaded yet are newer than whatever the server sent. state.queue.forEach(function (scan) { if (state.byId[scan.id]) { state.byId[scan.id].placement = scan.location; } }); } /* ---------- input recognition ---------- */ /** Same rules as App\Model\Isbn: an ISBN-10 or ISBN-13 becomes an ISBN-13, anything else null. */ function toIsbn13(value) { var digits = value.replace(/[- ]/g, '').toUpperCase(); var i, sum; if (/^\d{9}[\dX]$/.test(digits)) { for (sum = 0, i = 0; i < 10; i++) { sum += (10 - i) * (digits[i] === 'X' ? 10 : +digits[i]); } if (sum % 11 !== 0) { return null; } var stem = '978' + digits.substring(0, 9); return stem + isbn13CheckDigit(stem); } if (/^97[89]\d{10}$/.test(digits) && digits[12] === isbn13CheckDigit(digits.substring(0, 12))) { return digits; } return null; } /** Same rules as LabelType::ISSN: the compact form of an ISSN-8 or an ISSN-13 barcode, or null. */ function toIssn(value) { var compact = value.replace(/[- ]/g, '').toUpperCase(); return /^(\d{7}[\dX]|\d{13})$/.test(compact) ? compact : null; } function isbn13CheckDigit(stem) { for (var sum = 0, i = 0; i < 12; i++) { sum += +stem[i] * (i % 2 === 0 ? 1 : 3); } return String((10 - sum % 10) % 10); } /** * @returns {{type: 'location', value: string}|{type: 'book', book: Object}|{type: 'error', message: string}} */ function recognize(input) { if (/^\p{L}/u.test(input) || input.indexOf('/') !== -1) { return {type: 'location', value: input}; } if (/^\d{1,8}$/.test(input)) { var book = state.byId[+input]; return book ? {type: 'book', book: book} : {type: 'error', message: format(messages.unknown_id, {'%id%': +input})}; } // A 13-digit barcode may be either number, so both are looked up. var isbn = toIsbn13(input); var issn = toIssn(input); if (isbn !== null || issn !== null) { var matches = []; (isbn === null ? [] : state.byIsbn[isbn] || []) .concat(issn === null ? [] : state.byIssn[issn] || []) .forEach(function (book) { if (matches.indexOf(book) === -1) { matches.push(book); } }); var number = isbn !== null ? 'ISBN ' + isbn : 'ISSN ' + issn; if (matches.length === 1) { return {type: 'book', book: matches[0]}; } return matches.length === 0 ? {type: 'error', message: format(messages.unknown_number, {'%number%': number})} : {type: 'error', message: format(messages.ambiguous_number, { '%number%': number, '%ids%': matches.map(function (b) { return b.id; }).join(', ') })}; } return {type: 'error', message: format(messages.unrecognized, {'%input%': input})}; } /* ---------- actions ---------- */ function handleInput(raw) { var input = raw.trim(); if (input === '') { return; } var result = recognize(input); if (result.type === 'location') { state.location = result.value; showMessage('primary', format(messages.location_set, {'%location%': result.value})); beep(true); } else if (result.type === 'error') { showMessage('error', result.message); beep(false); } else if (state.location === null) { showMessage('error', messages.no_location); beep(false); } else { assign(result.book, state.location); beep(true); } render(); } function assign(book, location) { var previous = book.placement; var scan = { key: Date.now().toString(36) + '-' + Math.random().toString(36).substring(2, 10), id: book.id, location: location, at: new Date().toISOString() }; // Only the latest pending scan of a book matters. state.queue = state.queue.filter(function (s) { return s.id !== book.id; }); state.queue.push(scan); save(KEYS.queue, state.queue); state.log.forEach(function (entry) { if (entry.id === book.id && entry.status === 'pending') { entry.status = 'replaced'; } }); state.log = state.log.filter(function (entry) { return entry.status !== 'replaced'; }); state.log.unshift({ key: scan.key, id: book.id, title: book.title, author: book.author, location: location, previous: previous, borrowed: !!book.borrowed, at: scan.at, status: 'pending' }); state.log.length = Math.min(state.log.length, LOG_LIMIT); save(KEYS.log, state.log); book.placement = location; var moved = previous && previous !== location; var text = format(messages.assigned, {'%id%': book.id, '%title%': book.title, '%location%': location}); if (moved) { text += ' ' + format(messages.moved, {'%previous%': previous}); } // The server closes the loan when the scan arrives. if (book.borrowed) { text += ' ' + messages.will_return; } showMessage(moved || book.borrowed ? 'warning' : 'success', text); } function removeScan(key) { state.queue = state.queue.filter(function (s) { return s.key !== key; }); save(KEYS.queue, state.queue); state.log = state.log.filter(function (e) { return e.key !== key; }); save(KEYS.log, state.log); // The local placement may now be wrong; the next refresh corrects it. render(); } function refreshBooks(quiet) { return fetch(config.booksUrl, {headers: {Accept: 'application/json'}, cache: 'no-store'}) .then(function (response) { if (!response.ok) { throw new Error('HTTP ' + response.status); } return response.json(); }) .then(function (data) { state.books = {fetchedAt: new Date().toISOString(), list: data.books}; save(KEYS.books, state.books); indexBooks(); if (!quiet) { showMessage('success', format(messages.books_loaded, {'%count%': data.books.length})); } render(); }) .catch(function () { if (!quiet || state.books.list.length === 0) { showMessage('error', messages.books_failed); } }); } function sync(manual) { if (state.syncing || state.queue.length === 0) { if (manual && state.queue.length === 0) { showMessage('success', format(messages.synced, {'%count%': 0})); } return Promise.resolve(); } state.syncing = true; render(); // The queue holds at most one scan per book, so it maps directly to {id: location}. var batch = state.queue.slice(0, SYNC_BATCH); var body = {}; var sentKeys = {}; batch.forEach(function (scan) { body[scan.id] = scan.location; sentKeys[scan.key] = scan.id; }); return fetch(config.syncUrl, { method: 'POST', headers: {'Content-Type': 'application/json', Accept: 'application/json'}, body: JSON.stringify(body) }) .then(function (response) { if (!response.ok) { throw new Error('HTTP ' + response.status); } return response.json(); }) .then(function (data) { var results = data.results || {}; var rejected = 0; var booksChanged = false; function resultFor(key) { // A book scanned again while the request was running keeps its newer scan. return Object.prototype.hasOwnProperty.call(sentKeys, key) ? results[sentKeys[key]] : undefined; } state.queue = state.queue.filter(function (scan) { return resultFor(scan.key) === undefined; }); save(KEYS.queue, state.queue); state.log.forEach(function (entry) { var result = resultFor(entry.key); if (result !== undefined) { entry.status = result; if (result === 'returned' && state.byId[entry.id]) { state.byId[entry.id].borrowed = false; booksChanged = true; } else if (result !== 'saved') { rejected++; } } }); save(KEYS.log, state.log); if (booksChanged) { save(KEYS.books, state.books); } state.syncedAt = new Date().toISOString(); save(KEYS.syncedAt, state.syncedAt); state.syncFailed = false; if (rejected > 0) { showMessage('error', format(messages.sync_rejected, {'%count%': rejected})); } else if (manual) { showMessage('success', format(messages.synced, {'%count%': batch.length})); } }) .catch(function () { state.syncFailed = true; if (manual) { showMessage('error', messages.sync_failed); } }) .then(function () { state.syncing = false; render(); // More than one batch was waiting. if (!state.syncFailed && state.queue.length > 0 && batch.length === SYNC_BATCH) { return sync(manual); } }); } /* ---------- rendering ---------- */ function format(template, params) { return Object.keys(params).reduce(function (text, key) { return text.split(key).join(String(params[key])); }, template); } function formatDate(iso) { if (!iso) { return messages.never; } try { return new Date(iso).toLocaleString(config.locale); } catch (e) { return new Date(iso).toLocaleString(); } } var messageTimer = null; function showMessage(type, text) { el.message.className = 'toast message toast-' + type; el.message.textContent = text; el.message.hidden = false; clearTimeout(messageTimer); if (type !== 'error') { messageTimer = setTimeout(function () { el.message.hidden = true; }, 8000); } } function render() { el.location.textContent = state.location === null ? '–' : state.location; el.booksCount.textContent = state.books.list.length; el.booksDate.textContent = '(' + formatDate(state.books.fetchedAt) + ')'; el.pendingCount.textContent = state.queue.length; el.syncDate.textContent = '(' + formatDate(state.syncedAt) + ')'; el.sync.classList.toggle('loading', state.syncing); el.sync.disabled = state.syncing; var online = navigator.onLine; el.connection.textContent = online ? messages.online : messages.offline; el.connection.className = 'label label-rounded ' + (online ? 'label-success' : 'label-warning'); el.logEmpty.hidden = state.log.length > 0; el.clearLog.hidden = state.log.length === 0; el.log.textContent = ''; state.log.forEach(function (entry) { el.log.appendChild(renderLogEntry(entry)); }); } function cell(row, content, className) { var td = document.createElement('td'); if (className) { td.className = className; } if (content instanceof Node) { td.appendChild(content); } else { td.textContent = content; } row.appendChild(td); return td; } var STATUS_CLASS = { pending: 'label-warning', saved: 'label-success', returned: 'label-success', unknown_book: 'label-error', invalid: 'label-error' }; function renderLogEntry(entry) { var row = document.createElement('tr'); var link = document.createElement('a'); link.href = config.bookUrl.replace('{id}', entry.id); link.textContent = entry.id; cell(row, link); var title = document.createElement('span'); title.textContent = entry.title + ' · ' + entry.author; cell(row, title, 'log-title'); var location = document.createElement('span'); location.textContent = entry.location; if (entry.previous && entry.previous !== entry.location) { var previous = document.createElement('del'); previous.className = 'text-gray'; previous.textContent = entry.previous; location = wrap([previous, document.createTextNode(' → '), location]); } cell(row, location); cell(row, formatDate(entry.at), 'text-gray log-date'); var status = document.createElement('span'); status.className = 'label label-rounded ' + (STATUS_CLASS[entry.status] || ''); status.textContent = messages['status_' + entry.status] || entry.status; var actions = [status]; if (entry.status === 'pending') { var remove = document.createElement('button'); remove.type = 'button'; remove.className = 'btn btn-link btn-sm'; remove.textContent = messages.remove; remove.addEventListener('click', function () { removeScan(entry.key); el.input.focus(); }); actions.push(remove); } cell(row, wrap(actions), 'text-right'); return row; } function wrap(nodes) { var span = document.createElement('span'); nodes.forEach(function (node) { span.appendChild(node); }); return span; } /* ---------- feedback sound, so the screen need not be watched while scanning ---------- */ var audio = null; function beep(ok) { try { audio = audio || new (window.AudioContext || window.webkitAudioContext)(); var oscillator = audio.createOscillator(); var gain = audio.createGain(); oscillator.frequency.value = ok ? 880 : 220; oscillator.type = ok ? 'sine' : 'square'; gain.gain.value = 0.1; oscillator.connect(gain).connect(audio.destination); oscillator.start(); oscillator.stop(audio.currentTime + (ok ? 0.08 : 0.4)); } catch (e) { // No sound available; the message is enough. } } /* ---------- wiring ---------- */ el.form.addEventListener('submit', function (event) { event.preventDefault(); handleInput(el.input.value); el.input.value = ''; el.input.focus(); }); el.refresh.addEventListener('click', function () { refreshBooks(false); el.input.focus(); }); el.sync.addEventListener('click', function () { sync(true); el.input.focus(); }); el.clearLog.addEventListener('click', function () { if (confirm(messages.confirm_clear)) { // Pending scans stay in the queue; only their history goes. state.log = state.log.filter(function (e) { return e.status === 'pending'; }); save(KEYS.log, state.log); render(); } el.input.focus(); }); window.addEventListener('online', function () { render(); sync(false); }); window.addEventListener('offline', render); window.addEventListener('storage', function () { // Another tab scanned something; take over its state. state.books = load(KEYS.books, state.books); state.queue = load(KEYS.queue, []); state.log = load(KEYS.log, []); state.syncedAt = load(KEYS.syncedAt, null); indexBooks(); render(); }); window.addEventListener('beforeunload', function () { if (state.queue.length > 0 && navigator.onLine) { sync(false); } }); setInterval(function () { if (navigator.onLine) { sync(false); } }, SYNC_INTERVAL); if ('serviceWorker' in navigator) { navigator.serviceWorker.register(config.swUrl, {scope: config.scope}).catch(function () { // Without a service worker (plain HTTP on a remote host) the page still works offline // once open, it just cannot be reopened without a connection. }); } indexBooks(); render(); if (state.books.list.length === 0) { showMessage('warning', messages.no_books); } refreshBooks(true).then(function () { return sync(false); }); })();