text: import ds from '@wq/store';
  import modelModule from '@wq/model';
  import outbox from '@wq/outbox';
  import router from '@wq/router';
  import spinner from './spinner';
  import auth from './auth';
  import Mustache from 'mustache';
  import deepcopy from 'deepcopy';
  
  var app = {
      OFFLINE: 'offline',
      FAILURE: 'failure',
      ERROR: 'error',
      get wq_config() {
          return this.getAuthState().config || this.config;
      },
      get user() {
          return this.getAuthState().user;
      },
      get csrftoken() {
          return this.getAuthState().csrftoken;
      }
  };
  
  const SERVER = '@@SERVER';
  const CORE_PLUGINS = ['renderer'];
  
  app.models = {};
  app.plugins = {};
  
  var _register = {};
  
  app.init = function (config) {
      if (!config) {
          config = {};
      }
      if (!config.pages) {
          config.pages = {};
      }
      CORE_PLUGINS.forEach(type => {
          if (!app[type]) {
              throw new Error(`Register a ${type} with app.use()`);
          }
      });
      app.use(spinner);
      app.use(syncUpdateUrl);
      if (config.pages.login && !app.plugins.auth) {
          // FIXME: Require explicit auth registration in 2.0
          app.use(auth);
      }
      router.addRouteInfo(_extendRouteInfo);
      router.addContext(() => spinner.start() && {});
      router.addContext(_getSyncInfo);
  
      // Router (wq/router.js) configuration
      if (!config.router) {
          config.router = {
              base_url: ''
          };
      }
      config.router.getTemplateName = name => name.split(':')[0];
      if (config.site_title && !config.router.site_title) {
          config.router.site_title = config.site_title;
      }
  
      // Store (wq/store.js) configuration
      if (!config.store) {
          config.store = {
              service: config.router.base_url,
              defaults: { format: 'json' }
          };
      }
      if (!config.store.fetchFail) {
          config.fetchFail = _fetchFail;
      }
  
      Object.entries(app.plugins).forEach(([name, plugin]) => {
          if (plugin.ajax) {
              config.store.ajax = plugin.ajax.bind(plugin);
          }
          if (plugin.reducer) {
              let persist = false,
                  restore = null;
              if (typeof plugin.persist === 'function') {
                  persist = plugin.persist.bind(plugin);
                  restore = plugin.restore
                      ? plugin.restore.bind(plugin)
                      : state => state;
              } else if (plugin.persist) {
                  persist = true;
              }
              ds.addReducer(
                  name,
                  (state, action) => plugin.reducer(state, action),
                  persist,
                  restore
              );
          }
          if (plugin.actions) {
              Object.assign(plugin, ds.bindActionCreators(plugin.actions));
          }
          if (plugin.thunks) {
              router.addThunks(plugin.thunks, plugin);
          }
          if (plugin.subscriber) {
              ds.subscribe(() => plugin.subscriber(ds.getState()));
          }
          if (plugin.pages) {
              Object.assign(config.pages, plugin.pages);
          }
      });
  
      app.spin = {
          start: (msg, duration, opts) => spinner.start(msg, duration, opts),
          forSeconds: duration => spinner.start(null, duration),
          stop: msg => spinner.stop(msg)
      };
  
      // Outbox (wq/outbox.js) configuration
      if (!config.outbox) {
          config.outbox = {};
      }
  
      // Propagate debug setting to other modules
      if (config.debug) {
          config.router.debug = config.debug;
          config.store.debug = config.debug;
          CORE_PLUGINS.forEach(type => {
              if (config[type]) {
                  config[type].debug = config.debug;
              }
          });
      }
  
      // Load missing (non-local) content as JSON, or as server-rendered HTML?
      // Default (as of 1.0) is to load JSON and render on client.
      config.loadMissingAsJson =
          config.loadMissingAsJson || !config.loadMissingAsHtml;
      config.loadMissingAsHtml = !config.loadMissingAsJson;
  
      // After a form submission, sync in the background, or wait before
      // continuing?  Default is to sync in the background.
      if (config.backgroundSync === undefined) {
          config.backgroundSync = true;
      }
  
      app.config = config;
  
      app['native'] = !!window.cordova;
  
      // Initialize wq/router.js
      router.init(config.router);
      app.base_url = router.base_url;
  
      app.store = ds;
      app.outbox = outbox;
      outbox.app = app;
      app.router = router;
  
      // Option to submit forms in the background rather than wait for each post
      if (config.backgroundSync) {
          if (config.backgroundSync === -1) {
              outbox.pause();
          } else if (config.backgroundSync > 1) {
              console.warn('Sync interval is now controlled by redux-offline');
          }
      }
  
      // Deprecated hooks
      const deprecated = {
          noBackgroundSync: 'backgroundSync: false',
          postsave: 'a postsaveurl() plugin hook or a postsave page config',
          saveerror: 'an onsync() plugin hook',
          showOutboxErrors: 'an onsync() and/or run() hook',
          _addOutboxItemsToContext: "@wq/outbox's IMMEDIATE mode",
          presync: 'the template context',
          postsync: 'the template context or an onsync() plugin hook'
      };
      Object.entries(deprecated).forEach(([hook, alternative]) => {
          // TODO: Make this an error in 2.0
          if (config[hook]) {
              console.warn(
                  new Error(
                      `config.${hook} has no effect.  Use ${alternative} instead.  See wq.app 1.2 release notes for more info.`
                  )
              );
          }
      });
      if (app.hasPlugin('onsave')) {
          console.warn(
              new Error(
                  'An onsave() plugin hook has no effect.  Use an onsync() hook instead.  See wq.app 1.2 release notes for more info.'
              )
          );
      }
  
      Object.keys(app.config.pages).forEach(function (page) {
          app.config.pages[page].name = page;
      });
  
      app.callPlugins('init');
  
      // Register routes with wq/router.js
      var root = false;
      Object.keys(app.config.pages).forEach(function (page) {
          var conf = _getBaseConf(page);
          if (!conf.url) {
              root = true;
          }
          if (conf.list) {
              conf.modes.forEach(function (mode) {
                  var register = _register[mode] || _register.detail;
                  register(page, mode);
              });
              (conf.server_modes || []).forEach(function (mode) {
                  _register.detail(page, mode, _serverContext);
              });
              app.models[page] = modelModule(conf);
          } else if (conf) {
              _registerOther(page);
          }
      });
  
      // Register outbox
      router.register('outbox/', 'outbox_list', () => outbox.loadItems());
      router.register('outbox/<slug>', 'outbox_detail', _renderOutboxItem);
      router.register('outbox/<slug>/edit', 'outbox_edit', _renderOutboxItem);
  
      // Fallback index page
      if (!root && !app.config.pages.index) {
          router.registerLast('', 'index');
      }
  
      app.use({
          context(ctx, routeInfo) {
              // FIXME: Remove in 2.0, in favor of useSitemap()
              if (routeInfo.name !== 'index') {
                  return;
              }
  
              var context = {};
              context.pages = Object.keys(app.config.pages).map(function (page) {
                  var conf = app.config.pages[page];
                  return {
                      name: page,
                      url: conf.url,
                      list: conf.list
                  };
              });
              return context;
          }
      });
  
      // Fallback for all other URLs
      router.registerLast(':path*', SERVER, _serverContext);
  
      Object.entries(app.plugins).forEach(([name, plugin]) => {
          if (plugin.context) {
              router.addContext(ctx => {
                  return plugin.context(ctx, ctx.router_info);
              });
          }
      });
  
      router.addContext(() => spinner.stop() && {});
  
      // Initialize wq/store.js and wq/outbox.js
      ds.init(config.store);
      app.service = ds.service;
  
      var ready = ds.ready.then(() => outbox.init(config.outbox));
  
      if (app.config.jqmInit) {
          // FIXME: Remove in 2.0
          ready = ready.then(app.jqmInit);
      }
  
      if (app.config.autoStart !== false) {
          ready = ready.then(app.start);
      }
  
      return ready;
  };
  
  var pcount = 0;
  app.use = function (plugin) {
      if (Array.isArray(plugin)) {
          plugin.forEach(p => app.use(p));
          return;
      }
      if (plugin.dependencies) {
          app.use(plugin.dependencies);
      }
      if (app.plugins[plugin.name]) {
          if (app.plugins[plugin.name] === plugin) {
              return;
          } else {
              throw new Error(`App already has a plugin named ${plugin.name}!`);
          }
      }
      pcount++;
      if (!plugin.name) {
          plugin.name = 'plugin' + pcount;
      }
      app.plugins[plugin.name] = plugin;
      plugin.app = app;
      if (plugin.type) {
          if (app[plugin.type]) {
              throw new Error(
                  `App already has a ${plugin.type} (${app[plugin.type].name})`
              );
          }
          app[plugin.type] = plugin;
      }
  };
  
  app.prefetchAll = async function (message) {
      if (message) {
          app.spin.start(message);
      }
      const result = await Promise.all(
          Object.keys(app.models).map(function (name) {
              return app.models[name].prefetch();
          })
      );
      if (message) {
          app.spin.stop(message);
      }
      return result;
  };
  
  app.jqmInit = function () {
      console.warn(new Error('jqmInit() renamed to start()'));
      app.start();
  };
  
  app.start = function () {
      router.start();
      app.callPlugins('start');
  };
  
  async function _getSyncInfo() {
      const unsynced = await outbox.unsynced();
      return {
          svc: app.service,
          native: app['native'],
          syncing: app.syncing,
          unsynced: unsynced
      };
  }
  
  app.go = function () {
      throw new Error('app.go() has been removed.  Use app.nav() instead');
  };
  
  // Sync outbox and handle result
  app.sync = function (retryAll) {
      if (retryAll) {
          console.warn('app.sync(true) renamed to app.retryAll()');
          app.retryAll();
      } else {
          throw new Error('app.sync() no longer used.');
      }
  };
  app.retryAll = function () {
      app.outbox.unsynced().then(function (unsynced) {
          if (!unsynced) {
              return;
          }
          app.outbox.retryAll();
      });
  };
  
  app.emptyOutbox = function (confirmFirst) {
      /* global confirm */
      if (confirmFirst) {
          if (navigator.notification && navigator.notification.confirm) {
              navigator.notification.confirm('Empty Outbox?', function (button) {
                  if (button == 1) {
                      app.emptyOutbox();
                  }
              });
              return;
          } else {
              if (!confirm('Empty Outbox?')) {
                  return;
              }
          }
      }
      return outbox.empty();
  };
  
  app.confirmSubmit = function (form, message) {
      /* global confirm */
      if (navigator.notification && navigator.notification.confirm) {
          if (form.dataset.wqConfirmSubmit) {
              return true;
          }
          navigator.notification.confirm(message, function (button) {
              if (button == 1) {
                  form.dataset.wqConfirmSubmit = true;
                  form.submit();
              }
          });
      } else {
          if (confirm(message)) {
              return true;
          }
      }
      return false;
  };
  
  // Handle navigation after form submission
  app.postSaveNav = function (item, alreadySynced) {
      var url;
  
      const pluginUrl = app
          .callPlugins('postsaveurl', [item, alreadySynced])
          .filter(item => !!item);
  
      if (pluginUrl.length) {
          url = pluginUrl[0];
      } else {
          url = app.postsaveurl(item, alreadySynced);
      }
  
      // Navigate to computed URL
      if (app.config.debug) {
          console.log('Successfully saved; continuing to ' + url);
      }
      router.push(url);
  };
  
  app.postsaveurl = function (item, alreadySynced) {
      var postsave, pconf, match, mode, url, itemid, modelConf;
  
      // conf.postsave can be set redirect to another page
      modelConf = item.options.modelConf;
      if (item.deletedId) {
          postsave = modelConf.postdelete;
      } else {
          postsave = modelConf.postsave;
      }
      if (!postsave) {
          // Otherwise, default is to return the page for the item just saved
          if (!alreadySynced || item.deletedId) {
              // If backgroundSync, return to list view while syncing
              postsave = modelConf.name + '_list';
          } else {
              // If !backgroundSync, return to the newly synced item
              postsave = modelConf.name + '_detail';
          }
      }
  
      // conf.postsave should explicitly indicate which template mode to use
      /* eslint no-useless-escape: off */
      match = postsave.match(/^([^\/]+)_([^_\/]+)$/);
      if (match) {
          postsave = match[1];
          mode = match[2];
      }
  
      // Retrieve configuration for postsave page, if any
      pconf = _getConf(postsave, true);
  
      // Compute URL
      if (!pconf) {
          // If conf.postsave is not the name of a list page, assume it's a
          // simple page or a URL
          var urlContext;
          if (item.deletedId) {
              urlContext = { deleted: true, ...router.getContext() };
          } else {
              urlContext = { ...item.data, ...item.result };
          }
          url = app.base_url + '/' + Mustache.render(postsave, urlContext);
      } else if (!pconf.list) {
          url = app.base_url + '/' + pconf.url;
      } else {
          if (pconf.modes.concat(pconf.server_modes || []).indexOf(mode) == -1) {
              throw 'Unknown template mode!';
          }
  
          // For list pages, the url can differ depending on the mode
          url = app.base_url + '/' + pconf.url + '/';
  
          if (mode != 'list') {
              // Detail or edit view; determine item id and add to url
              if (postsave == modelConf.name && !item.synced) {
                  // Config indicates return to detail/edit view of the model
                  // that was just saved, but the item hasn't been synced yet.
                  // Navigate to outbox URL instead.
                  url = app.base_url + '/outbox/' + item.id;
                  if (mode != 'edit' && item.error) {
                      // Return to edit form if there was an error
                      mode = 'edit';
                  }
              } else {
                  // Item has been successfully synced
                  if (postsave == modelConf.name) {
                      // If postsave page is the same as the item's page, use the
                      // new id
                      itemid = item.result && item.result.id;
                  } else {
                      // Otherwise, look for a foreign key reference
                      // FIXME: what if the foreign key has a different name?
                      itemid = item.result && item.result[postsave + '_id'];
                  }
                  if (!itemid) {
                      throw 'Could not find ' + postsave + ' id in result!';
                  }
                  url += itemid;
              }
              if (mode != 'detail') {
                  url += '/' + mode;
              }
          }
      }
      return url;
  };
  
  const syncUpdateUrl = {
      onsync(obitem) {
          const context = router.getContext() || {},
              { router_info: routeInfo = {} } = context,
              {
                  page,
                  mode,
                  outbox_id: routeOutboxId,
                  base_url,
                  page_config,
                  full_path,
                  item_id,
                  parent_id
              } = routeInfo,
              { id: outboxId, result } = obitem || {},
              { id: resultId } = result || {},
              outboxSlug = `outbox-${outboxId}`;
  
          if (!resultId) {
              return;
          }
          if (
              routeOutboxId === outboxId &&
              page === 'outbox' &&
              page_config &&
              page_config.url
          ) {
              const modePath = mode === 'detail' ? '' : `/${mode}`;
              router.push(
                  `${base_url}/${page_config.url}/${resultId}${modePath}`
              );
          }
          if (item_id === outboxSlug || parent_id === outboxSlug) {
              router.push(full_path.replace(outboxSlug, resultId));
          }
      }
  };
  
  // Return a list of all foreign key fields
  app.getParents = function (page) {
      var conf = _getBaseConf(page);
      return conf.form
          .filter(function (field) {
              return field['wq:ForeignKey'];
          })
          .map(function (field) {
              return field['wq:ForeignKey'];
          });
  };
  
  // Shortcuts for $.mobile.changePage
  app.nav = function (url) {
      url = app.base_url + '/' + url;
      router.push(url);
  };
  
  app.replaceState = function (url) {
      throw new Error('app.replaceState() no longer supported.');
  };
  
  app.refresh = function () {
      router.refresh();
  };
  
  app.hasPlugin = function (method) {
      var plugin,
          fn,
          hasPlugin = false;
      for (plugin in app.plugins) {
          fn = app.plugins[plugin][method];
          if (fn) {
              hasPlugin = true;
          }
      }
      return hasPlugin;
  };
  
  app.callPlugins = function (method, args) {
      var plugin,
          fn,
          fnArgs,
          queue = [];
      for (plugin in app.plugins) {
          fn = app.plugins[plugin][method];
          if (args) {
              fnArgs = args;
          } else {
              fnArgs = [app.config[plugin]];
          }
          if (fn) {
              queue.push(fn.apply(app.plugins[plugin], fnArgs));
          }
      }
      return queue;
  };
  
  // Internal variables and functions
  app.splitRoute = function (routeName) {
      const match = routeName.match(/^(.+)_([^_]+)$/);
      let page, mode, variant;
      if (match) {
          page = match[1];
          mode = match[2];
          if (mode.indexOf(':') > -1) {
              [mode, variant] = mode.split(':');
          } else {
              variant = null;
          }
      } else {
          page = routeName;
          mode = null;
          variant = null;
      }
      return [page, mode, variant];
  };
  
  function _joinRoute(page, mode, variant) {
      if (variant) {
          return page + '_' + mode + ':' + variant;
      } else if (mode) {
          return page + '_' + mode;
      } else {
          return page;
      }
  }
  
  function _extendRouteInfo(routeInfo) {
      const routeName = routeInfo.name,
          itemid = (page !== 'outbox' && routeInfo.slugs.slug) || null;
      var [page, mode, variant] = app.splitRoute(routeName),
          conf = _getConf(page, true, true),
          pageid = null;
  
      if (conf) {
          if (mode && mode !== 'list') {
              pageid =
                  page +
                  '_' +
                  mode +
                  (variant ? '_' + variant : '') +
                  (itemid ? '_' + itemid : '') +
                  '-page';
          }
      } else if (page === 'outbox') {
          conf = {
              name: 'outbox',
              url: 'outbox',
              page: 'outbox',
              form: [],
              modes: ['list', 'detail', 'edit']
          };
      } else {
          page = routeName;
          mode = null;
          conf = {
              name: page,
              page: page,
              form: [],
              modes: []
          };
      }
      return {
          ...routeInfo,
          page: page,
          page_config: conf,
          mode: mode,
          variant: variant,
          item_id: itemid,
          dom_id: pageid
      };
  }
  
  // Generate list view context and render with [url]_list template;
  // handles requests for [url] and [url]/
  _register.list = function (page) {
      const conf = _getBaseConf(page),
          register = conf.url === '' ? router.registerLast : router.register,
          url = conf.url === '' ? '' : conf.url + '/';
      register(url, _joinRoute(page, 'list'), ctx => _displayList(ctx));
  
      // Special handling for /[parent_list_url]/[parent_id]/[url]
      app.getParents(page).forEach(function (ppage) {
          var pconf = _getBaseConf(ppage);
          var url = pconf.url;
          var registerParent;
          if (url === '') {
              registerParent = router.registerLast;
          } else {
              registerParent = router.register;
              url += '/';
          }
          url += ':parent_id/' + conf.url;
          registerParent(url, _joinRoute(page, 'list', ppage), parentContext);
      });
  
      async function parentContext(ctx) {
          const { router_info: routeInfo } = ctx,
              { page, variant: ppage } = routeInfo,
              parent_id = routeInfo.slugs.parent_id,
              pconf = _getConf(ppage),
              pitem = await app.models[ppage].find(parent_id);
          var parentUrl;
          if (pitem) {
              parentUrl = pconf.url + '/' + pitem.id;
          } else if (parent_id.indexOf('outbox-') == -1) {
              parentUrl = 'outbox/' + parent_id.split('-')[1];
          } else {
              parentUrl = null;
          }
          var parentInfo = {
              parent_id,
              parent_url: parentUrl,
              parent_label: pitem && pitem.label,
              parent_page: ppage,
              parent_conf: pconf
          };
          parentInfo['parent_is_' + ppage] = true;
  
          return _displayList(ctx, {
              ...parentInfo,
              router_info: {
                  ...routeInfo,
                  ...parentInfo
              }
          });
      }
  };
  
  app._addOutboxItemsToContext = function (context, unsyncedItems) {
      // Add any outbox items to context
      context.unsynced = unsyncedItems.length;
      context.unsyncedItems = unsyncedItems;
  };
  
  async function _displayList(ctx, parentInfo) {
      const { router_info: routeInfo } = ctx,
          { page, params, full_path: url } = routeInfo,
          conf = _getConf(page),
          model = app.models[page];
      var pnum = model.opts.page,
          limit = null,
          next = null,
          prev = null,
          filter;
      if (params || parentInfo) {
          if (params && params.page) {
              pnum = params.page;
          }
          if (params && params.limit) {
              limit = params.limit;
          }
          filter = {};
          for (var key in params || {}) {
              if (key != 'page' && key != 'limit') {
                  filter[key] = params[key];
              }
          }
          if (parentInfo) {
              conf.form.forEach(function (field) {
                  if (field['wq:ForeignKey'] == parentInfo.parent_page) {
                      filter[field.name + '_id'] = parentInfo.parent_id;
                  }
              });
          }
      }
      if (filter && !Object.keys(filter).length) {
          filter = null;
      }
  
      // Load from server if data might not exist locally
      if (app.config.loadMissingAsHtml) {
          if (!model.opts.client) {
              return _loadFromServer(url);
          }
          if (filter && model.opts.server) {
              return _loadFromServer(url);
          }
          if (pnum > model.opts.page) {
              return _loadFromServer(url);
          }
      } else if (filter && pnum > model.opts.page) {
          filter.page = pnum;
          if (limit) {
              filter.limit = limit;
          }
      }
  
      if (!pnum && (!model.opts.client || filter)) {
          pnum = 1;
      }
  
      const showUnsynced =
          pnum == model.opts.page || (pnum == 1 && !model.opts.client);
  
      function getData() {
          if (filter) {
              return model.filterPage(filter);
          } else if (pnum > model.opts.page) {
              return model.page(pnum, limit);
          } else {
              return model.load();
          }
      }
      async function getUnsynced() {
          if (showUnsynced) {
              return model.unsyncedItems();
          } else {
              return [];
          }
      }
  
      // If the number of unsynced records changes while loading the data,
      // load the data a second time to make sure the list is up to date.
      // (this is rare except for cache=none lists with background sync)
      const unsynced1 = await getUnsynced();
      const data1 = await getData();
      const unsynced2 = await getUnsynced();
      var data;
      if (unsynced1 && unsynced2 && unsynced1.length != unsynced2.length) {
          data = await getData();
      } else {
          data = data1;
      }
      const unsyncedItems = unsynced2;
      var prevIsLocal, currentIsLocal;
  
      if (pnum > model.opts.page && (model.opts.client || pnum > 1)) {
          if (+pnum - 1 > model.opts.page && (model.opts.client || pnum > 2)) {
              let prevp = filter ? { ...filter } : {};
              prevp.page = +pnum - 1;
              prev = conf.url + '/?' + new URLSearchParams(prevp).toString();
          } else if (pnum == 1 && !filter) {
              prev = conf.url + '/';
              prevIsLocal = true;
          }
      }
  
      if (pnum < data.pages && (model.opts.server || pnum)) {
          let nextp = filter ? { ...filter } : {};
          nextp.page = +pnum + 1;
          next = conf.url + '/?' + new URLSearchParams(nextp).toString();
          if (nextp.page == 1) {
              currentIsLocal = true;
          }
      }
  
      var context = {
          ...data,
          ...parentInfo,
          previous: prev ? '/' + prev : null,
          next: next ? '/' + next : null,
          multiple: prev || next ? true : false,
          page: pnum,
          show_unsynced: showUnsynced,
          previous_is_local: prevIsLocal,
          current_is_local: currentIsLocal
      };
  
      app._addOutboxItemsToContext(context, unsyncedItems);
  
      return _addLookups(page, context, false);
  }
  
  // Generate item detail view context and render with [url]_detail template;
  // handles requests for [url]/[id]
  _register.detail = function (page, mode, contextFn = _displayItem) {
      var conf = _getBaseConf(page);
      var url = _getDetailUrl(conf.url, mode);
      const register = conf.url === '' ? router.registerLast : router.register;
      register(url, _joinRoute(page, mode), contextFn);
  };
  
  function _getDetailUrl(url, mode) {
      if (url) {
          url += '/';
      }
      url += '<slug>';
      if (mode != 'detail') {
          url += '/' + mode;
      }
      return url;
  }
  
  // Generate item edit context and render with [url]_edit template;
  // handles requests for [url]/[id]/edit and [url]/new
  _register.edit = function (page) {
      var conf = _getBaseConf(page);
      const register = conf.url === '' ? router.registerLast : router.register;
      register(
          _getDetailUrl(conf.url, 'edit'),
          _joinRoute(page, 'edit'),
          _displayItem
      );
      router.registerFirst(
          conf.url + '/new',
          _joinRoute(page, 'edit', 'new'),
          _displayItem
      );
  };
  
  async function _displayItem(ctx) {
      const { router_info: routeInfo } = ctx,
          { item_id: itemid, page, mode, variant, full_path: url } = routeInfo,
          conf = _getConf(page),
          model = app.models[page];
  
      var item;
      if (mode == 'edit' && variant == 'new') {
          item = {
              ...routeInfo.params,
              ...conf.defaults
          };
      } else {
          const localOnly = !app.config.loadMissingAsJson;
          item = await model.find(itemid, localOnly);
          if (!item) {
              if (model.opts.server && app.config.loadMissingAsHtml) {
                  return _loadFromServer(url);
              } else {
                  return router.notFound();
              }
          }
      }
  
      if (item) {
          item.local = true;
          if (mode == 'edit') {
              if (variant == 'new') {
                  // Create new item
                  return _addLookups(page, item, 'new');
              } else {
                  return _addLookups(page, item, true);
              }
          } else {
              return _addLookups(page, item, false);
          }
      } else {
          if (model.opts.server && app.config.loadMissingAsHtml) {
              // opts.server indicates that the local list does not represent
              // the entire dataset; if an item is not found, attempt to load
              // HTML directly from the server (using built-in jQM loader)
              return _loadFromServer(url);
          } else {
              // If opts.server is false, locally stored list is assumed to
              // contain the entire dataset, so the item probably does not exist.
              return router.notFound();
          }
      }
  }
  
  // Render non-list pages with with [url] template;
  // handles requests for [url] and [url]/
  function _registerOther(page) {
      var conf = _getBaseConf(page);
      router.register(
          conf.url,
          page,
          conf.context || _displayOther,
          undefined,
          conf.thunk || null
      );
      async function _displayOther() {
          if (conf.server_only) {
              return _loadFromServer(app.base_url + '/' + conf.url);
          } else {
              return {};
          }
      }
  }
  
  async function _renderOutboxItem(ctx) {
      // Display outbox item using model-specific detail/edit view
      const { router_info: routeInfo } = ctx,
          mode = routeInfo.mode,
          item = await outbox.loadItem(+routeInfo.slugs.slug);
  
      if (!item || !item.options || !item.options.modelConf) {
          return router.notFound();
      }
  
      var id,
          page = item.options.modelConf.name,
          template = page + '_' + mode,
          idMatch = item.options.url.match(
              new RegExp(item.options.modelConf.url + '/([^/]+)$')
          );
      if (item.data.id) {
          id = item.data.id;
      } else if (idMatch) {
          id = idMatch[1];
      } else {
          id = 'new';
      }
      var context = {
          outbox_id: item.id,
          label: item.label,
          error: item.error,
          router_info: {
              ...routeInfo,
              page_config: item.options.modelConf,
              template: template,
              outbox_id: item.id
          },
          ...deepcopy(item.data)
      };
      if (id != 'new') {
          context.id = id;
      }
      return _addLookups(page, context, mode === 'edit');
  }
  
  app.isRegistered = function (url) {
      if (_getConfByUrl(url, true)) {
          return true;
      } else {
          return false;
      }
  };
  
  app.submitForm = async function ({
      url,
      storage,
      backgroundSync,
      has_files,
      outboxId,
      preserve,
      data: vals
  }) {
      const conf = _getConfByUrl(url, true);
  
      var options = {
          url: url
      };
  
      if (storage) {
          options.storage = storage;
      } else if (!backgroundSync) {
          options.storage = 'temporary';
      } else if (has_files) {
          options.storage = 'store';
      }
  
      if (_hasNestedObject(vals)) {
          if (has_files) {
              vals = _flattenJson(vals);
          } else {
              options.json = true;
          }
      }
  
      if (outboxId) {
          options.id = outboxId;
          if (preserve && preserve.split) {
              options.preserve = preserve.split(/,/);
          }
      }
      if (vals._method) {
          options.method = vals._method;
          delete vals._method;
      } else {
          options.method = 'POST';
      }
  
      if (conf) {
          options.modelConf = conf;
          if (conf.label_template) {
              if (typeof conf.label_template === 'function') {
                  options.label = conf.label_template(vals);
              } else {
                  options.label = Mustache.render(conf.label_template, vals);
              }
          }
      }
  
      options.csrftoken = app.csrftoken;
      var item = await outbox.save(vals, options);
      if (backgroundSync) {
          // Send user to next screen while app syncs in background
          app.postSaveNav(item, false);
          return [item, null];
      }
  
      // Submit form immediately and wait for server to respond
      app.spin.start();
      item = await outbox.waitForItem(item.id);
      app.spin.stop();
      if (!item) {
          return [item, app.FAILURE];
      }
      if (item.synced) {
          // Item was synced
          app.postSaveNav(item, true);
          return [item, null];
      }
      // Something went wrong
      if (!item.error) {
          // Save failed without server error: probably offline
          // FIXME: waitForItem() probably doesn't resolve until back online.
          app.postSaveNav(item, false);
          return [item, null];
      } else if (typeof item.error === 'string') {
          // Save failed and error information is not in JSON format
          // (likely a 500 server failure)
          return [item, app.FAILURE];
      } else {
          // Save failed and error information is in JSON format
          // (likely a 400 bad data error)
          return [item, app.ERROR];
      }
  };
  
  function _hasNestedObject(value) {
      return Object.values(value).some(
          val =>
              typeof val === 'object' &&
              (!Array.isArray(val) || typeof val[0] === 'object')
      );
  }
  
  function _flattenJson(value, prefix = '') {
      const result = {};
      if (prefix) {
          if (value === null || value === undefined) {
              return { [prefix]: '' };
          } else if (typeof value !== 'object') {
              return { [prefix]: value };
          }
      }
      Object.entries(value).forEach(([key, val]) => {
          const fullKey = prefix ? `${prefix}[${key}]` : key;
          if (Array.isArray(val)) {
              val.forEach((row, i) =>
                  Object.assign(result, _flattenJson(row, `${fullKey}[${i}]`))
              );
          } else if (typeof val === 'object') {
              if (_isFile(val)) {
                  result[fullKey] = val;
              } else if (_isGeometry(val)) {
                  result[fullKey] = JSON.stringify(val);
              } else {
                  Object.assign(result, _flattenJson(val, fullKey));
              }
          } else {
              result[fullKey] = val;
          }
      });
      return result;
  }
  
  function _isFile(val) {
      return val && val.type && val.name && val.body;
  }
  
  function _isGeometry(val) {
      return val && val.type && val.coordinates;
  }
  
  app.getAuthState = function () {
      return (this.plugins.auth && this.plugins.auth.getState()) || {};
  };
  
  // Add various callback functions to context object to automate foreign key
  // lookups within templates
  function _addLookups(page, context, editable) {
      var conf = _getConf(page);
      var lookups = {};
  
      function addLookups(field, nested) {
          var fname = nested || field.name;
          // Choice (select/radio) lookups
          if (field.choices) {
              lookups[fname + '_label'] = _choice_label_lookup(
                  field.name,
                  field.choices
              );
              if (editable) {
                  lookups[fname + '_choices'] = _choice_dropdown_lookup(
                      field.name,
                      field.choices
                  );
              }
          }
  
          // Foreign key lookups
          if (field['wq:ForeignKey']) {
              var nkey;
              if (nested) {
                  nkey = fname.match(/^\w+\.(\w+)\[(\w+)\]$/);
              } else {
                  nkey = fname.match(/^(\w+)\[(\w+)\]$/);
              }
              if (!nkey) {
                  if (nested) {
                      lookups[fname] = _this_parent_lookup(field);
                  } else {
                      lookups[fname] = _parent_lookup(field, context);
                  }
                  if (!context[fname + '_label']) {
                      lookups[fname + '_label'] = _parent_label_lookup(field);
                  }
              }
              if (editable) {
                  lookups[fname + '_list'] = _parent_dropdown_lookup(
                      field,
                      context,
                      nkey
                  );
              }
          }
  
          // Load types/initial list of nested forms
          // (i.e. repeats/attachments/EAV/child model)
          if (field.children) {
              field.children.forEach(function (child) {
                  var fname = field.name
                      ? field.name + '.' + child.name
                      : child.name;
                  addLookups(child, fname);
              });
              if (editable == 'new' && !context[field.name]) {
                  lookups[field.name] = _default_attachments(field, context);
              }
          }
      }
      conf.form.forEach(function (field) {
          addLookups(field, false);
      });
  
      // Process lookup functions
      var keys = Object.keys(lookups);
      var queue = keys.map(function (key) {
          return lookups[key];
      });
      return Promise.all(queue)
          .then(function (results) {
              results.forEach(function (result, i) {
                  var key = keys[i];
                  context[key] = result;
              });
              results.forEach(function (result, i) {
                  var parts = keys[i].split('.'),
                      nested;
                  if (parts.length != 2) {
                      return;
                  }
                  nested = context[parts[0]];
                  if (!nested) {
                      return;
                  }
                  if (!Array.isArray(nested)) {
                      nested = [nested];
                  }
                  nested.forEach(function (row) {
                      row[parts[1]] = row[parts[1]] || result;
                  });
              });
          })
          .then(function () {
              return context;
          });
  }
  
  // Preset list of choices
  function _choice_label_lookup(name, choices) {
      function choiceLabel() {
          var label;
          choices.forEach(function (choice) {
              if (choice.name == this[name]) {
                  label = choice.label;
              }
          }, this);
          return label;
      }
      return Promise.resolve(choiceLabel);
  }
  
  function _choice_dropdown_lookup(name, choices) {
      choices = choices.map(function (choice) {
          return { ...choice };
      });
      function choiceDropdown() {
          choices.forEach(function (choice) {
              if (choice.name == this[name]) {
                  choice.selected = true;
              } else {
                  choice.selected = false;
              }
          }, this);
          return choices;
      }
      return Promise.resolve(choiceDropdown);
  }
  
  // Simple foreign key lookup
  function _parent_lookup(field, context) {
      var model = app.models[field['wq:ForeignKey']];
      var id = context[field.name + '_id'];
      if (id) {
          if (id.match && id.match(/^outbox/)) {
              return _getOutboxRecord(model, id);
          } else {
              return model.find(id);
          }
      } else {
          return null;
      }
  }
  
  // Foreign key lookup for objects other than root
  function _this_parent_lookup(field) {
      var model = app.models[field['wq:ForeignKey']];
      return Promise.all([_getOutboxRecordLookup(model), model.load()]).then(
          function (results) {
              const obRecords = results[0];
              var existing = {};
              results[1].list.forEach(item => {
                  existing[item.id] = item;
              });
              return function () {
                  var parentId = this[field.name + '_id'];
                  return obRecords[parentId] || existing[parentId];
              };
          }
      );
  }
  
  // Foreign key label
  function _parent_label_lookup(field) {
      return _this_parent_lookup(field).then(function (lookup) {
          return function () {
              var p = lookup.call(this);
              return p && p.label;
          };
      });
  }
  
  // List of all potential foreign key values (useful for generating dropdowns)
  function _parent_dropdown_lookup(field, context, nkey) {
      var model = app.models[field['wq:ForeignKey']];
      var result;
      if (field.filter) {
          result = model.filter(_computeFilter(field.filter, context));
      } else {
          result = model.load().then(function (data) {
              return _getOutboxRecords(model).then(function (records) {
                  return records.concat(data.list);
              });
          });
      }
      return result.then(function (choices) {
          return function () {
              var parents = [],
                  current;
              if (nkey) {
                  current = this[nkey[1]] && this[nkey[1]][nkey[2]];
              } else {
                  current = this[field.name + '_id'];
              }
              choices.forEach(function (v) {
                  var item = { ...v };
                  if (item.id == current) {
                      item.selected = true; // Currently selected item
                  }
                  parents.push(item);
              }, this);
              return parents;
          };
      });
  }
  
  function _getOutboxRecords(model) {
      return model.unsyncedItems().then(function (items) {
          return items.map(function (item) {
              return {
                  id: 'outbox-' + item.id,
                  label: item.label,
                  outbox_id: item.id,
                  outbox: true
              };
          });
      });
  }
  
  function _getOutboxRecordLookup(model) {
      return _getOutboxRecords(model).then(function (records) {
          var lookup = {};
          records.forEach(function (record) {
              lookup[record.id] = record;
          });
          return lookup;
      });
  }
  
  function _getOutboxRecord(model, id) {
      return _getOutboxRecordLookup(model).then(function (records) {
          return records[id];
      });
  }
  
  // List of empty annotations for new objects
  function _default_attachments(field, context) {
      if (field.type != 'repeat') {
          return Promise.resolve({});
      }
      if (!field.initial) {
          return Promise.resolve([]);
      }
      if (typeof field.initial == 'string' || typeof field.initial == 'number') {
          var attachments = [];
          for (var i = 0; i < +field.initial; i++) {
              attachments.push({
                  '@index': i,
                  new_attachment: true
              });
          }
          return Promise.resolve(attachments);
      }
      var typeField;
      field.children.forEach(function (tf) {
          if (tf.name == field.initial.type_field) {
              typeField = tf;
          }
      });
      if (!typeField) {
          return Promise.resolve([]);
      }
  
      var model = app.models[typeField['wq:ForeignKey']];
      var filterConf = field.initial.filter;
      if (!filterConf || !Object.keys(filterConf).length) {
          if (typeField.filter) {
              filterConf = typeField.filter;
          }
      }
      var filter = _computeFilter(filterConf, context);
      return model.filter(filter).then(function (types) {
          var attachments = [];
          types.forEach(function (t, i) {
              var obj = {
                  '@index': i,
                  new_attachment: true
              };
              obj[typeField.name + '_id'] = t.id;
              obj[typeField.name + '_label'] = t.label;
              attachments.push(obj);
          });
          return attachments;
      });
  }
  
  // Load configuration based on page id
  function _getBaseConf(page) {
      return _getConf(page, false, true);
  }
  
  function _getConf(page, silentFail, baseConf) {
      var conf = (baseConf ? app.config : app.wq_config).pages[page];
      if (!conf && !baseConf) {
          return _getConf(page, silentFail, true);
      }
  
      if (!conf) {
          if (silentFail) {
              return;
          } else {
              throw new Error('Configuration for "' + page + '" not found!');
          }
      }
      return {
          page: page,
          form: [],
          modes: conf.list ? ['list', 'detail', 'edit'] : [],
          ...conf
      };
  }
  
  // Helper to load configuration based on URL
  function _getConfByUrl(url, silentFail) {
      var parts = url.split('/');
      var conf;
      for (var p in app.wq_config.pages) {
          if (app.wq_config.pages[p].url == parts[0]) {
              conf = app.wq_config.pages[p];
          }
      }
      if (!conf) {
          if (silentFail) {
              return;
          } else {
              throw 'Configuration for "/' + url + '" not found!';
          }
      }
      return conf;
  }
  
  function _computeFilter(filter, context) {
      var computedFilter = {};
      Object.keys(filter).forEach(function (key) {
          var values = filter[key];
          if (!Array.isArray(values)) {
              values = [values];
          }
          values = values.map(function (value) {
              if (value && value.indexOf && value.indexOf('{{') > -1) {
                  value = Mustache.render(value, context);
                  if (value === '') {
                      return null;
                  } else if (value.match(/^\+\d+$/)) {
                      return +value.substring(1);
                  }
              }
              return value;
          });
          if (values.length > 1) {
              computedFilter[key] = values;
          } else {
              computedFilter[key] = values[0];
          }
      });
      return computedFilter;
  }
  
  async function _loadFromServer(url) {
      // options = (ui && ui.options) || {};
      if (app.config.debug) {
          console.log('Loading ' + url + ' from server');
          if (app.base_url && url.indexOf(app.base_url) !== 0) {
              console.warn(url + ' does not include ' + app.base_url);
          }
      }
      const response = await fetch(url),
          html = await response.text();
      return router.rawHTML(html);
  }
  
  function _serverContext(ctx) {
      const { router_info: routeInfo } = ctx,
          { full_path: url } = routeInfo;
      return _loadFromServer(url);
  }
  
  function _fetchFail(query, error) {
      /* eslint no-unused-vars: off */
      app.spin.alert('Error Loading Data');
  }
  
  export default app;
  