272 lines
10 KiB
JavaScript
272 lines
10 KiB
JavaScript
(function (root, factory) {
|
|
if (typeof module === 'object' && module.exports) module.exports = factory;
|
|
else root.createMobileQueuePriority = factory;
|
|
})(typeof self !== 'undefined' ? self : this, function createMobileQueuePriority(options = {}) {
|
|
const DEFAULT_ORDER = [
|
|
'attention', 'today', 'update', 'agenda', 'following', 'authored', 'filed', 'later', 'draft',
|
|
];
|
|
const storage = options.storage;
|
|
const getLogin = options.getLogin || (() => '');
|
|
const fetchJson = options.fetchJson;
|
|
const prefix = 'stackchain-mobile-queue-priority-v1:';
|
|
const labels = options.labels || {};
|
|
const documentRef = options.document || (typeof document !== 'undefined' ? document : null);
|
|
let memory = null;
|
|
const syncFlights = new Map();
|
|
|
|
function key() {
|
|
const login = String(getLogin() || '').trim().toLowerCase();
|
|
return login ? prefix + encodeURIComponent(login) : '';
|
|
}
|
|
|
|
function valid(order) {
|
|
return Array.isArray(order) && order.length === DEFAULT_ORDER.length &&
|
|
new Set(order).size === DEFAULT_ORDER.length &&
|
|
order.every(name => DEFAULT_ORDER.includes(name) && typeof name === 'string');
|
|
}
|
|
|
|
function fresh() {
|
|
return {revision:0, order:DEFAULT_ORDER.slice(), pending:false, status:'ready', remote:null};
|
|
}
|
|
|
|
function read() {
|
|
const accountKey = key();
|
|
if (!accountKey || !storage) return fresh();
|
|
if (memory?.key === accountKey) return memory.value;
|
|
let value = fresh();
|
|
try {
|
|
const saved = JSON.parse(storage.getItem(accountKey) || 'null');
|
|
if (valid(saved)) value = {revision:0, order:saved.slice(), pending:true, status:'pending', remote:null};
|
|
else if (saved && valid(saved.order) && Number.isInteger(saved.revision) && saved.revision >= 0) {
|
|
value = {
|
|
revision:saved.revision, order:saved.order.slice(), pending:Boolean(saved.pending),
|
|
status:saved.status === 'conflict' ? 'conflict' : (saved.pending ? 'pending' : 'ready'),
|
|
remote:saved.remote && valid(saved.remote.order) ? {
|
|
revision:Number(saved.remote.revision) || 0, order:saved.remote.order.slice(),
|
|
} : null,
|
|
};
|
|
}
|
|
} catch (_error) {}
|
|
memory = {key:accountKey, value};
|
|
return value;
|
|
}
|
|
|
|
function persist(value) {
|
|
const accountKey = key();
|
|
if (!accountKey || !storage) return false;
|
|
memory = {key:accountKey, value};
|
|
try {
|
|
storage.setItem(accountKey, JSON.stringify(value));
|
|
return true;
|
|
} catch (_error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function snapshot() {
|
|
const value = read();
|
|
return {
|
|
revision:value.revision, order:value.order.slice(), pending:value.pending,
|
|
status:value.status, remote:value.remote ? {revision:value.remote.revision, order:value.remote.order.slice()} : null,
|
|
};
|
|
}
|
|
|
|
function announce(value) {
|
|
if (options.status) {
|
|
options.status.textContent = ({pending:'Sync pending.', conflict:'Routine order changed on another device.',
|
|
syncing:'Syncing routine order…', error:'Routine order could not sync.', ready:''})[value.status] || '';
|
|
}
|
|
options.onState?.(snapshot());
|
|
}
|
|
|
|
function getOrder() {
|
|
return read().order.slice();
|
|
}
|
|
|
|
function save(order) {
|
|
if (!key() || !valid(order)) return false;
|
|
const current = read();
|
|
const value = {revision:current.revision, order:order.slice(), pending:true, status:'pending', remote:null};
|
|
if (!persist(value)) return false;
|
|
options.onChange?.(order.slice());
|
|
announce(value);
|
|
return true;
|
|
}
|
|
|
|
function move(name, delta) {
|
|
const order = getOrder();
|
|
const index = order.indexOf(name);
|
|
const next = index + (Number(delta) < 0 ? -1 : 1);
|
|
if (index < 0 || next < 0 || next >= order.length) return order;
|
|
[order[index], order[next]] = [order[next], order[index]];
|
|
save(order);
|
|
return order.slice();
|
|
}
|
|
|
|
function reset() {
|
|
const order = DEFAULT_ORDER.slice();
|
|
if (fetchJson && key()) save(order);
|
|
else {
|
|
const accountKey = key();
|
|
if (accountKey && storage) {
|
|
try { storage.removeItem(accountKey); } catch (_error) {}
|
|
}
|
|
memory = null;
|
|
options.onChange?.(order.slice());
|
|
}
|
|
return order;
|
|
}
|
|
|
|
function adopt(snapshotValue, accountKey = key()) {
|
|
if (!accountKey || key() !== accountKey) return snapshot();
|
|
if (!snapshotValue || !Number.isInteger(snapshotValue.revision) || snapshotValue.revision < 0 || !valid(snapshotValue.order)) {
|
|
throw new Error('Queue priority response is invalid.');
|
|
}
|
|
const value = {revision:snapshotValue.revision, order:snapshotValue.order.slice(), pending:false, status:'ready', remote:null};
|
|
persist(value);
|
|
options.onChange?.(value.order.slice());
|
|
announce(value);
|
|
render();
|
|
return snapshot();
|
|
}
|
|
|
|
async function load() {
|
|
const accountKey = key();
|
|
if (!fetchJson || !accountKey) return snapshot();
|
|
try {
|
|
const remote = await fetchJson('api/v1/queue-priority');
|
|
if (key() !== accountKey) return snapshot();
|
|
const local = read();
|
|
if (local.pending) {
|
|
local.remote = valid(remote?.order) ? {revision:remote.revision, order:remote.order.slice()} : null;
|
|
persist(local);
|
|
return sync();
|
|
}
|
|
return adopt(remote, accountKey);
|
|
} catch (_error) {
|
|
if (key() !== accountKey) return snapshot();
|
|
const current = read();
|
|
current.status = current.pending ? 'pending' : 'error';
|
|
persist(current); announce(current);
|
|
return snapshot();
|
|
}
|
|
}
|
|
|
|
async function drain(accountKey) {
|
|
while (key() === accountKey) {
|
|
const current = read();
|
|
if (!current.pending) return snapshot();
|
|
const sent = {revision:current.revision, order:current.order.slice()};
|
|
current.status = 'syncing'; persist(current); announce(current);
|
|
try {
|
|
const saved = await fetchJson('api/v1/queue-priority', {
|
|
method:'PUT', headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify(sent),
|
|
});
|
|
if (key() !== accountKey) return snapshot();
|
|
if (!saved || !Number.isInteger(saved.revision) || !valid(saved.order)) {
|
|
throw new Error('Queue priority response is invalid.');
|
|
}
|
|
const latest = read();
|
|
if (latest.order.some((name, index) => name !== sent.order[index])) {
|
|
latest.revision = saved.revision; latest.pending = true;
|
|
latest.status = 'pending'; latest.remote = null;
|
|
persist(latest); announce(latest);
|
|
continue;
|
|
}
|
|
return adopt(saved, accountKey);
|
|
} catch (error) {
|
|
if (key() !== accountKey) return snapshot();
|
|
const latest = read();
|
|
const remote = error?.status === 409 && error?.payload?.detail?.snapshot;
|
|
if (remote && valid(remote.order) && Number.isInteger(remote.revision)) {
|
|
latest.status = 'conflict'; latest.pending = true;
|
|
latest.remote = {revision:remote.revision, order:remote.order.slice()};
|
|
} else {
|
|
latest.status = 'pending'; latest.pending = true;
|
|
}
|
|
persist(latest); announce(latest); render();
|
|
return snapshot();
|
|
}
|
|
}
|
|
return snapshot();
|
|
}
|
|
|
|
function sync() {
|
|
const accountKey = key();
|
|
const current = read();
|
|
if (!fetchJson || !accountKey || !current.pending) return Promise.resolve(snapshot());
|
|
if (syncFlights.has(accountKey)) return syncFlights.get(accountKey);
|
|
const flight = drain(accountKey).finally(() => {
|
|
if (syncFlights.get(accountKey) === flight) syncFlights.delete(accountKey);
|
|
});
|
|
syncFlights.set(accountKey, flight);
|
|
return flight;
|
|
}
|
|
|
|
async function useLocal() {
|
|
const current = read();
|
|
if (!current.remote) return snapshot();
|
|
current.revision = current.remote.revision;
|
|
current.remote = null; current.pending = true; current.status = 'pending';
|
|
persist(current);
|
|
return sync();
|
|
}
|
|
|
|
function useRemote() {
|
|
const current = read();
|
|
return current.remote ? adopt(current.remote) : snapshot();
|
|
}
|
|
|
|
function displayName(name) {
|
|
return labels[name] || name.charAt(0).toUpperCase() + name.slice(1);
|
|
}
|
|
|
|
function render() {
|
|
if (!options.list || !documentRef) return getOrder();
|
|
const order = getOrder();
|
|
const signedIn = Boolean(key());
|
|
const rows = order.map((name, index) => {
|
|
const row = documentRef.createElement('div');
|
|
row.setAttribute('data-queue-priority', name);
|
|
row.setAttribute('class', 'mobile-queue-priority-row');
|
|
const label = documentRef.createElement('span');
|
|
label.textContent = displayName(name);
|
|
const controls = documentRef.createElement('span');
|
|
controls.setAttribute('class', 'mobile-queue-priority-controls');
|
|
const earlier = documentRef.createElement('button');
|
|
earlier.textContent = 'Earlier'; earlier.setAttribute('type', 'button');
|
|
earlier.setAttribute('aria-label', 'Move ' + displayName(name) + ' earlier');
|
|
earlier.disabled = !signedIn || index === 0;
|
|
earlier.addEventListener('click', () => {
|
|
move(name, -1); render();
|
|
});
|
|
const later = documentRef.createElement('button');
|
|
later.textContent = 'Later'; later.setAttribute('type', 'button');
|
|
later.setAttribute('aria-label', 'Move ' + displayName(name) + ' later');
|
|
later.disabled = !signedIn || index === order.length - 1;
|
|
later.addEventListener('click', () => {
|
|
move(name, 1); render();
|
|
});
|
|
controls.append(earlier, later); row.append(label, controls);
|
|
return row;
|
|
});
|
|
options.list.replaceChildren(...rows);
|
|
if (options.resetButton) options.resetButton.disabled = !signedIn;
|
|
if (options.conflict) options.conflict.hidden = read().status !== 'conflict';
|
|
return order;
|
|
}
|
|
|
|
function start() {
|
|
options.resetButton?.addEventListener('click', () => {
|
|
reset(); render();
|
|
});
|
|
options.keepLocalButton?.addEventListener('click', () => { void useLocal(); });
|
|
options.useRemoteButton?.addEventListener('click', () => { useRemote(); });
|
|
return render();
|
|
}
|
|
|
|
return {getOrder, move, reset, render, start, load, sync, useLocal, useRemote,
|
|
state:snapshot, defaultOrder:() => DEFAULT_ORDER.slice()};
|
|
});
|