(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);
|
});
|
})();
|