diff --git a/nullboard.html b/nullboard.html
index 0e1aca1..49cda88 100644
--- a/nullboard.html
+++ b/nullboard.html
@@ -751,6 +751,31 @@
color: #d20;
}
+ /*** ValidBoard: save state, next to the (updated) alert */
+ .logo .sync {
+ display: none;
+ font-style: normal;
+ margin-left: 5px;
+ }
+
+ .logo.sync-busy .sync,
+ .logo.sync-err .sync {
+ display: inline-block;
+ }
+
+ .logo.sync-busy .sync {
+ color: #999;
+ }
+
+ /* An unsaved board is worth breaking the logo's usual fade-out for. */
+ .logo.sync-err {
+ opacity: 1;
+ }
+
+ .logo.sync-err .sync {
+ color: #d20;
+ }
+
/***/
.config {
position: absolute;
@@ -1547,17 +1572,28 @@
≡✔
@@ -2444,6 +2480,368 @@
}
}
+ /*
+ * ValidBoard: boards live in SQLite on the server.
+ *
+ * The Storage contract above is synchronous — setItem() has to say yes or
+ * no there and then — and a round trip can't answer that fast. So this
+ * keeps the whole keyspace in memory and treats the server as somewhere to
+ * push it: reads are memory reads, writes are optimistic and batched.
+ *
+ * The initial copy is embedded in the page by the server (see #nb-bootstrap
+ * in the body), so it is already here by the time openInner() runs and no
+ * blocking request is needed at load.
+ *
+ * Keys are the same names Storage_Local uses, minus its 'nullboard.'
+ * prefix — the server's table is ours alone and needs no namespacing.
+ */
+ class Storage_Server extends Storage
+ {
+ constructor()
+ {
+ super();
+
+ this.type = 'Server';
+ this.items = new Map(); // key -> value; the copy the app reads
+
+ this.pending = new Map(); // key -> op, queued for the next batch
+ this.inflight = null; // the batch currently being POSTed
+ this.timer = null;
+ this.failures = 0;
+ this.status = 'ok'; // 'ok' | 'busy' | 'error'
+ this.warned = false; // "session expired" alert is once-only
+ }
+
+ /*
+ * Storage interface
+ */
+ getItem(name)
+ {
+ var val = this.items.get(name);
+ return (val === undefined) ? null : val;
+ }
+
+ setItem(name, val)
+ {
+ this.items.set(name, val);
+ this.enqueue(name, { op: 'set', key: name, value: val });
+ return true;
+ }
+
+ delItem(name)
+ {
+ this.items.delete(name);
+ this.enqueue(name, { op: 'del', key: name });
+ return true;
+ }
+
+ openInner()
+ {
+ if (! this.hydrate())
+ return false;
+
+ // Decide this before saving a default config, or the write below
+ // would make every new install look like an existing one.
+ var newInstall = (this.items.size == 0);
+ var conf = this.getJson('config');
+
+ if (conf)
+ {
+ this.conf = Object.assign(new AppConfig(), conf);
+ }
+ else if (! this.setJson('config', this.conf))
+ {
+ this.conf = null;
+ return false;
+ }
+
+ this.boardIndex = new Map();
+
+ // Array.from() because rebuildMeta() may write while we iterate.
+ for (var k of Array.from(this.items.keys()))
+ {
+ var m = k.match(/^board\.(\d+)\.meta$/);
+
+ if (! m)
+ continue;
+
+ var board_id = parseInt(m[1]);
+ var meta = this.getJson('board.' + board_id + '.meta');
+
+ if (! meta || ! meta.hasOwnProperty('history'))
+ {
+ console.log( `Invalid meta for board ${board_id}` );
+ continue;
+ }
+
+ for (var rev of meta.history)
+ if (! this.getJson('board.' + board_id + '.' + rev))
+ {
+ console.log( `Invalid revision ${rev} in history of ${board_id}` );
+ meta = this.rebuildMeta(board_id);
+ break;
+ }
+
+ if (! meta)
+ continue;
+
+ delete meta.backingUp; // run-time var
+ delete meta.needsBackup; // ditto
+
+ this.boardIndex.set(board_id, Object.assign(new BoardMeta(), meta));
+ }
+
+ this.fixupConfig(newInstall);
+
+ this.type = 'Server';
+
+ return true;
+ }
+
+ wipeInner()
+ {
+ this.items.clear();
+ this.pending.clear();
+ this.inflight = null;
+
+ var self = this;
+
+ fetch('/api/items', { method: 'DELETE', credentials: 'same-origin' })
+ .then(function(rsp){ if (! rsp.ok) throw new Error('server said ' + rsp.status); })
+ .catch(function(err){ self.setStatus('error', 'wipe failed: ' + err.message); });
+
+ this.conf = new AppConfig();
+ this.boardIndex = new Map();
+ }
+
+ /*
+ * private
+ */
+ hydrate()
+ {
+ var el = document.getElementById('nb-bootstrap');
+
+ if (! el)
+ {
+ alert("ValidBoard: this page is missing its board data.\n\nReload to try again.");
+ return false;
+ }
+
+ try
+ {
+ var data = JSON.parse(el.textContent);
+
+ for (var k in data)
+ this.items.set(k, data[k]);
+ }
+ catch (x)
+ {
+ alert("ValidBoard: couldn't read the boards sent by the server.\n\n" + x);
+ return false;
+ }
+
+ console.log( `Loaded ${this.items.size} key(s) from the server` );
+
+ // Nothing else needs it, and leaving a copy of every board sitting
+ // in the DOM only invites confusion when debugging.
+ el.textContent = '{}';
+
+ return true;
+ }
+
+ // Same recovery as Storage_Local: a board whose meta has gone stale is
+ // worth rebuilding from whatever revisions did survive.
+ rebuildMeta(board_id)
+ {
+ var meta = new BoardMeta();
+
+ console.log( `Rebuilding meta for ${board_id} ...` );
+
+ meta.current = this.getItem('board.' + board_id); // may be null
+
+ var re = new RegExp('^board\\.' + board_id + '\\.(\\d+)$');
+ var revs = new Array();
+
+ for (var k of this.items.keys())
+ {
+ var m = k.match(re);
+ if (m) revs.push( parseInt(m[1]) );
+ }
+
+ if (! revs.length)
+ {
+ console.log('* No revisions found');
+ this.delItem('board.' + board_id);
+ return false;
+ }
+
+ revs.sort(function(a,b){ return b-a; });
+ meta.history = revs;
+
+ if (! meta.history.includes(meta.current))
+ meta.current = meta.history[meta.history.length-1];
+
+ var board = this.getJson('board.' + board_id + '.' + meta.current)
+ meta.title = (board.title || '(untitled board)');
+
+ this.setJson('board.' + board_id + '.meta', meta);
+
+ return meta;
+ }
+
+ /*
+ * write-behind
+ *
+ * Changes are collected by key — a note edited five times in a second
+ * is one write — and flushed as a single transaction shortly after.
+ */
+ enqueue(key, op)
+ {
+ this.pending.set(key, op);
+ this.scheduleFlush();
+ }
+
+ // No argument: batch up whatever lands in the next FLUSH_DELAY ms, but
+ // don't let a steady stream of edits push the flush back forever.
+ // With one: a retry, which overrides any sooner flush already queued.
+ scheduleFlush(delay)
+ {
+ if (delay === undefined)
+ {
+ if (this.timer !== null)
+ return;
+
+ delay = Storage_Server.FLUSH_DELAY;
+ }
+ else if (this.timer !== null)
+ {
+ clearTimeout(this.timer);
+ }
+
+ var self = this;
+
+ this.timer = setTimeout(function(){
+ self.timer = null;
+ self.flush();
+ }, delay);
+ }
+
+ flush()
+ {
+ if (this.inflight || ! this.pending.size)
+ return;
+
+ this.inflight = this.pending;
+ this.pending = new Map();
+
+ var self = this;
+ var ops = Array.from(this.inflight.values());
+
+ this.setStatus('busy');
+
+ fetch('/api/items', {
+ method : 'POST',
+ headers : { 'Content-Type': 'application/json' },
+ credentials : 'same-origin',
+ body : JSON.stringify({ ops: ops })
+ })
+ .then(function(rsp){
+ if (rsp.status == 401)
+ {
+ self.onSessionLost();
+ throw new Error('not signed in');
+ }
+
+ if (! rsp.ok)
+ throw new Error('server said ' + rsp.status);
+
+ self.inflight = null;
+ self.failures = 0;
+
+ if (self.pending.size)
+ {
+ self.setStatus('busy');
+ self.scheduleFlush();
+ }
+ else
+ {
+ self.setStatus('ok');
+ }
+ })
+ .catch(function(err){
+ // The batch goes back on the queue, so nothing is lost while
+ // the server is unreachable — it just keeps trying.
+ self.requeue();
+ self.failures++;
+ self.setStatus('error', err.message);
+
+ var delay = Math.min(30000, 1000 * Math.pow(2, self.failures - 1));
+
+ console.log( `Save failed (${err.message}), retrying in ${delay}ms` );
+ self.scheduleFlush(delay);
+ });
+ }
+
+ requeue()
+ {
+ if (! this.inflight)
+ return;
+
+ var merged = this.inflight;
+
+ // Anything queued while the batch was in flight is newer, so it
+ // wins over the copy going back on the queue.
+ this.pending.forEach(function(op, key){ merged.set(key, op); });
+
+ this.pending = merged;
+ this.inflight = null;
+ }
+
+ // The cookie expired, or you signed out in another tab. Keep retrying
+ // rather than dropping the edits — signing back in anywhere makes the
+ // next attempt succeed.
+ onSessionLost()
+ {
+ if (this.warned)
+ return;
+
+ this.warned = true;
+
+ alert("ValidBoard: your session has expired, so recent changes aren't saved yet.\n\n" +
+ "Sign in again in another tab and they'll be saved automatically.");
+ }
+
+ hasUnsavedChanges()
+ {
+ return (this.pending.size > 0) || (this.inflight !== null);
+ }
+
+ setStatus(status, detail)
+ {
+ this.status = status;
+
+ var $logo = $('.logo').removeClass('sync-busy sync-err');
+ var $sync = $('.logo .sync').attr('title', detail || '');
+
+ if (status == 'busy')
+ {
+ $logo.addClass('sync-busy');
+ $sync.text('saving...');
+ }
+ else if (status == 'error')
+ {
+ $logo.addClass('sync-err');
+ $sync.text('not saved');
+ }
+ else
+ {
+ $sync.text('');
+ }
+ }
+ }
+
+ Storage_Server.FLUSH_DELAY = 300; // ms
+
/*
*
*/
@@ -5112,7 +5510,7 @@
}
};
- NB.storage = new Storage_Local();
+ NB.storage = new Storage_Server();
if (! NB.storage.open())
{
@@ -5120,6 +5518,28 @@
throw new Error();
}
+ /*
+ * ValidBoard: writes are asynchronous now, so a tab can be closed while a
+ * batch is still queued. Say so rather than losing it.
+ */
+ window.addEventListener('beforeunload', function(e){
+ if (! NB.storage.hasUnsavedChanges())
+ return;
+
+ e.preventDefault();
+ e.returnValue = '';
+ return '';
+ });
+
+ $('.logo .sign-out').click(function(){
+ if (NB.storage.hasUnsavedChanges() &&
+ ! confirm("Some changes haven't reached the server yet.\n\nSign out anyway?"))
+ return false;
+
+ $('.sign-out-form').submit();
+ return false;
+ });
+
var boards = NB.storage.getBoardIndex();
boards.forEach( function(meta, board_id) {