@saltcorn/mobile-app 0.8.6-beta.1 → 0.8.6-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@saltcorn/mobile-app",
3
3
  "displayName": "Saltcorn mobile app",
4
- "version": "0.8.6-beta.1",
4
+ "version": "0.8.6-beta.3",
5
5
  "description": "Apache Cordova application with @saltcorn/markup",
6
6
  "main": "index.js",
7
7
  "scripts": {
package/www/index.html CHANGED
@@ -10,6 +10,7 @@
10
10
  <script src="js/utils/iframe_view_utils.js"></script>
11
11
  <script src="js/utils/file_helpers.js"></script>
12
12
  <script src="js/utils/table_utils.js"></script>
13
+ <script src="js/utils/offline_mode_helper.js"></script>
13
14
 
14
15
  <script src="js/mocks/request.js"></script>
15
16
  <script src="js/mocks/response.js"></script>
@@ -20,6 +21,7 @@
20
21
  <script src="js/routes/error.js"></script>
21
22
  <script src="js/routes/page.js"></script>
22
23
  <script src="js/routes/view.js"></script>
24
+ <script src="js/routes/sync.js"></script>
23
25
  <script src="js/routes/init.js"></script>
24
26
 
25
27
  <script src="js/mocks/response.js"></script>
@@ -124,7 +126,7 @@
124
126
  config.pluginHeaders.push(prepareHeader(header));
125
127
  }
126
128
  else if (typeof pluginHeaders === "function") {
127
- const headerResult = pluginHeaders(row.configuration);
129
+ const headerResult = pluginHeaders(row.configuration || {});
128
130
  if (Array.isArray(headerResult)) {
129
131
  for (const header of headerResult)
130
132
  config.pluginHeaders.push(prepareHeader(header));
@@ -146,29 +148,15 @@
146
148
  const initJwt = async () => {
147
149
  if (!(await saltcorn.data.db.tableExists("jwt_table"))) {
148
150
  await createJwtTable();
149
- }
150
- else {
151
+ } else {
151
152
  const jwt = await getJwt();
152
- if(jwt) {
153
+ if (jwt) {
153
154
  const state = saltcorn.data.state.getState();
154
155
  state.mobileConfig.jwt = jwt;
155
156
  }
156
157
  }
157
158
  };
158
159
 
159
- const checkJWT = async () => {
160
- const state = saltcorn.data.state.getState();
161
- const jwt = state.mobileConfig.jwt;
162
- if(jwt && jwt !== "undefined") {
163
- const response = await apiCall({
164
- method: "GET",
165
- path: "/auth/authenticated",
166
- });
167
- return response.data.authenticated;
168
- }
169
- else return false;
170
- };
171
-
172
160
  const initI18Next = async () => {
173
161
  const resources = {};
174
162
  for (const key of Object.keys(
@@ -188,8 +176,38 @@
188
176
  });
189
177
  };
190
178
 
179
+ const tryDownloadServerData = async (alerts) => {
180
+ try {
181
+ await offlineHelper.downloadServerData();
182
+ } catch (error) {
183
+ alerts.push({
184
+ type: "warning",
185
+ msg: `Unable to download the server data: ${
186
+ error.message ? error.message : "unknown error"
187
+ }`,
188
+ });
189
+ }
190
+ };
191
+
192
+ // the app comes back from background
193
+ const onResume = async () => {
194
+ const state = saltcorn.data.state.getState();
195
+ const mobileConfig = state.mobileConfig;
196
+ if (mobileConfig.allowOfflineMode) {
197
+ mobileConfig.networkState = navigator.connection.type;
198
+ if (
199
+ mobileConfig.networkState === "none" &&
200
+ !mobileConfig.isOfflineMode
201
+ ) {
202
+ // starts the offline mode, if needed
203
+ await gotoEntryView();
204
+ }
205
+ }
206
+ };
207
+
191
208
  // device is ready
192
209
  const init = async () => {
210
+ document.addEventListener("resume", onResume, false);
193
211
  const config = await readJSON(
194
212
  "config",
195
213
  `${cordova.file.applicationDirectory}www`
@@ -227,28 +245,74 @@
227
245
  setVersionTtag();
228
246
  const entryPoint = config.entry_point;
229
247
  await initI18Next();
248
+ state.mobileConfig.networkState = navigator.connection.type;
249
+ document.addEventListener(
250
+ "offline",
251
+ offlineHelper.offlineCallback,
252
+ false
253
+ );
254
+ document.addEventListener(
255
+ "online",
256
+ offlineHelper.onlineCallback,
257
+ false
258
+ );
259
+ const networkDisabled = state.mobileConfig.networkState === "none";
260
+ const jwt = state.mobileConfig.jwt;
230
261
  try {
231
- if (await checkJWT()) {
262
+ const alerts = [];
263
+ if ((networkDisabled && jwt) || (await checkJWT(jwt))) {
232
264
  const mobileConfig = state.mobileConfig;
233
265
  const decodedJwt = jwt_decode(mobileConfig.jwt);
234
266
  mobileConfig.role_id = decodedJwt.user.role_id
235
267
  ? decodedJwt.user.role_id
236
- : 10;
268
+ : 100;
269
+ mobileConfig.user_id = decodedJwt.user.id;
237
270
  mobileConfig.user_name = decodedJwt.user.email;
238
271
  mobileConfig.language = decodedJwt.user.language;
239
272
  mobileConfig.isPublicUser = false;
240
273
  await i18next.changeLanguage(mobileConfig.language);
274
+ if (mobileConfig.allowOfflineMode) {
275
+ const userWithOfflineData = await offlineHelper.lastOfflineUser();
276
+ if (networkDisabled) {
277
+ if (
278
+ userWithOfflineData &&
279
+ userWithOfflineData !== mobileConfig.user_name
280
+ )
281
+ throw new Error(
282
+ `The offline mode is not available, '${userWithOfflineData}' has not yet uploaded offline data.`
283
+ );
284
+ else {
285
+ await offlineHelper.startOfflineMode();
286
+ alerts.push({ type: "info", msg: "You are in offline mode" });
287
+ }
288
+ } else {
289
+ if (userWithOfflineData) {
290
+ if (userWithOfflineData === mobileConfig.user_name)
291
+ alerts.push({
292
+ type: "info",
293
+ msg: "You have offline data, open the Sync menu to handle it.",
294
+ });
295
+ else
296
+ alerts.push({
297
+ type: "warning",
298
+ msg: `'${userWithOfflineData}' has not yet uploaded offline data.`,
299
+ });
300
+ } else await tryDownloadServerData(alerts);
301
+ }
302
+ }
241
303
  addRoute({ route: entryPoint, query: undefined });
242
304
  const page = await router.resolve({
243
305
  pathname: entryPoint,
244
306
  fullWrap: true,
307
+ alerts,
245
308
  });
246
309
  await replaceIframe(page.content);
247
310
  } else {
248
311
  const page = await router.resolve({
249
312
  pathname: "get/auth/login",
313
+ alerts,
250
314
  });
251
- replaceIframe(page.content);
315
+ await replaceIframe(page.content);
252
316
  }
253
317
  } catch (error) {
254
318
  const page = await router.resolve({
@@ -261,7 +325,6 @@
261
325
  ],
262
326
  });
263
327
  await replaceIframe(page.content);
264
- console.error(error);
265
328
  }
266
329
  };
267
330
 
@@ -1,9 +1,13 @@
1
1
  /*global i18next, saltcorn*/
2
2
 
3
- function MobileRequest({ xhr = false, files = undefined, query = undefined }) {
4
- const roleId = saltcorn.data.state.getState().mobileConfig.role_id
5
- ? saltcorn.data.state.getState().mobileConfig.role_id
6
- : 10;
3
+ function MobileRequest({
4
+ xhr = false,
5
+ files = undefined,
6
+ query = undefined,
7
+ } = {}) {
8
+ const cfg = saltcorn.data.state.getState().mobileConfig;
9
+ const roleId = cfg.role_id ? cfg.role_id : 100;
10
+ const userId = cfg.user_id ? cfg.user_id : undefined;
7
11
  const flashMessages = [];
8
12
 
9
13
  return {
@@ -21,6 +25,7 @@ function MobileRequest({ xhr = false, files = undefined, query = undefined }) {
21
25
  return mobileCfg?.language ? mobileCfg.language : "en";
22
26
  },
23
27
  user: {
28
+ id: userId,
24
29
  role_id: roleId,
25
30
  },
26
31
  flash: (type, msg) => {
@@ -1,4 +1,4 @@
1
- /*global sbAdmin2Layout, apiCall, removeJwt, saltcorn*/
1
+ /*global sbAdmin2Layout, apiCall, removeJwt, saltcorn, clearHistory*/
2
2
 
3
3
  const prepareAuthForm = () => {
4
4
  return new saltcorn.data.models.Form({
@@ -35,7 +35,7 @@ const getAuthLinks = (current, entryPoint) => {
35
35
  return links;
36
36
  };
37
37
 
38
- const renderLoginView = (entryPoint, versionTag) => {
38
+ const renderLoginView = (entryPoint, versionTag, alerts = []) => {
39
39
  const form = prepareAuthForm(entryPoint);
40
40
  form.onSubmit = `javascript:loginFormSubmit(this, '${entryPoint}')`;
41
41
  form.submitLabel = "Login";
@@ -43,7 +43,7 @@ const renderLoginView = (entryPoint, versionTag) => {
43
43
  title: "login",
44
44
  form: form,
45
45
  authLinks: getAuthLinks("login", entryPoint),
46
- alerts: [],
46
+ alerts,
47
47
  headers: [
48
48
  { css: `static_assets/${versionTag}/saltcorn.css` },
49
49
  { script: "js/utils/iframe_view_utils.js" },
@@ -69,10 +69,14 @@ const renderSignupView = (entryPoint, versionTag) => {
69
69
  });
70
70
  };
71
71
 
72
- const getLoginView = async () => {
73
- const config = saltcorn.data.state.getState().mobileConfig;
72
+ const getLoginView = async (context) => {
73
+ const mobileConfig = saltcorn.data.state.getState().mobileConfig;
74
74
  return {
75
- content: renderLoginView(config.entry_point, config.version_tag),
75
+ content: renderLoginView(
76
+ mobileConfig.entry_point,
77
+ mobileConfig.version_tag,
78
+ context.alerts ? context.alerts : []
79
+ ),
76
80
  replaceIframe: true,
77
81
  };
78
82
  };
@@ -90,6 +94,7 @@ const logoutAction = async () => {
90
94
  const response = await apiCall({ method: "GET", path: "/auth/logout" });
91
95
  if (response.data.success) {
92
96
  await removeJwt();
97
+ clearHistory();
93
98
  config.jwt = undefined;
94
99
  return {
95
100
  content: renderLoginView(config.entry_point, config.version_tag),
@@ -34,8 +34,8 @@ const sbAdmin2Layout = () => {
34
34
 
35
35
  const getMenu = (req) => {
36
36
  const state = saltcorn.data.state.getState();
37
- const mobileCfg = saltcorn.data.state.getState().mobileConfig;
38
- const role = mobileCfg.role_id || 10;
37
+ const mobileCfg = state.mobileConfig;
38
+ const role = mobileCfg.role_id || 100;
39
39
  const extraMenu = saltcorn.data.web_mobile_commons.get_extra_menu(
40
40
  role,
41
41
  req.__
@@ -80,6 +80,17 @@ const getMenu = (req) => {
80
80
  isUser: true,
81
81
  items: authItems,
82
82
  });
83
+ if (mobileCfg.allowOfflineMode)
84
+ result.push({
85
+ section: "Sync",
86
+ items: [
87
+ {
88
+ link: "javascript:execLink('/sync/sync_settings')",
89
+ icon: "fas fa-sync",
90
+ label: "Sync",
91
+ },
92
+ ],
93
+ });
83
94
  return result;
84
95
  };
85
96
 
@@ -5,7 +5,8 @@ const deleteRows = async (context) => {
5
5
  const { name, id } = context.params;
6
6
  const table = await saltcorn.data.models.Table.findOne({ name });
7
7
  const mobileConfig = saltcorn.data.state.getState().mobileConfig;
8
-
8
+ if (mobileConfig.isOfflineMode)
9
+ throw new Error(i18next.t("Deletes are not supported in offline mode."));
9
10
  if (mobileConfig.localTableIds.indexOf(table.id) >= 0) {
10
11
  if (mobileConfig.role_id <= table.min_role_write) {
11
12
  await table.deleteRows({ id });
@@ -4,11 +4,15 @@
4
4
  const postToggleField = async (context) => {
5
5
  const { name, id, field_name } = context.params;
6
6
  const table = await saltcorn.data.models.Table.findOne({ name });
7
- const mobileConfig = saltcorn.data.state.getState().mobileConfig;
8
- if (mobileConfig.localTableIds.indexOf(table.id) >= 0) {
9
- if (mobileConfig.role_id > table.min_role_write)
7
+ const state = saltcorn.data.state.getState();
8
+ const { isOfflineMode, localTableIds, user_name, role_id } =
9
+ state.mobileConfig;
10
+ if (isOfflineMode || localTableIds.indexOf(table.id) >= 0) {
11
+ if (role_id > table.min_role_write)
10
12
  throw new Error(i18next.t("Not authorized"));
11
13
  await table.toggleBool(+id, field_name);
14
+ if (isOfflineMode)
15
+ await state.setConfig("user_with_offline_data", user_name);
12
16
  } else {
13
17
  await apiCall({
14
18
  method: "POST",
@@ -1,4 +1,5 @@
1
- /*global postView, postViewRoute, getView, postToggleField, deleteRows, postPageAction, getPage, getLoginView, logoutAction, getSignupView, getErrorView, window*/
1
+ /*global postView, postViewRoute, getView, postToggleField, deleteRows, postPageAction, getPage, getLoginView, logoutAction, getSignupView, getErrorView, window, getSyncView, getSyncModalContent, getAskOverwriteDialog, getSyncSettingsView*/
2
+ // TODO module namespacese
2
3
 
3
4
  const initRoutes = async () => {
4
5
  const routes = [
@@ -46,6 +47,14 @@ const initRoutes = async () => {
46
47
  path: "get/error_page",
47
48
  action: getErrorView,
48
49
  },
50
+ {
51
+ path: "get/sync/sync_settings",
52
+ action: getSyncSettingsView,
53
+ },
54
+ {
55
+ path: "get/sync/ask_overwrite",
56
+ action: getAskOverwriteDialog,
57
+ },
49
58
  ];
50
59
  window.router = new window.UniversalRouter(routes);
51
60
  };
@@ -0,0 +1,85 @@
1
+ /*global saltcorn, wrapContents, MobileRequest, */
2
+
3
+ const getSyncSettingsView = (context) => {
4
+ const content = saltcorn.markup.div(
5
+ { class: "container" },
6
+ saltcorn.markup.div(
7
+ { class: "row" },
8
+ saltcorn.markup.div(
9
+ { class: "col-9" },
10
+ saltcorn.markup.div(
11
+ { class: "fs-6 fw-bold text-decoration-underline" },
12
+ "Upload offline data"
13
+ ),
14
+ saltcorn.markup.div(
15
+ "Upload the data from your last offline session to the server."
16
+ )
17
+ ),
18
+ saltcorn.markup.div(
19
+ { class: "col-3" },
20
+ saltcorn.markup.button(
21
+ {
22
+ class: "btn btn-primary",
23
+ type: "button",
24
+ onClick: "callUploadSync()",
25
+ },
26
+ saltcorn.markup.i({ class: "fas fa-sync" })
27
+ )
28
+ )
29
+ ),
30
+ saltcorn.markup.hr(),
31
+ saltcorn.markup.div(
32
+ { class: "row" },
33
+ saltcorn.markup.div(
34
+ { class: "col-9" },
35
+ saltcorn.markup.div(
36
+ { class: "fs-6 fw-bold text-decoration-underline" },
37
+ "Download server data"
38
+ ),
39
+ saltcorn.markup.div(
40
+ "Download the latest data for your next offline session."
41
+ )
42
+ ),
43
+ saltcorn.markup.div(
44
+ { class: "col-3" },
45
+ saltcorn.markup.button(
46
+ {
47
+ class: "btn btn-primary",
48
+ type: "button",
49
+ onClick: "callDownloadSync()",
50
+ },
51
+ saltcorn.markup.i({ class: "fas fa-sync" })
52
+ )
53
+ )
54
+ ),
55
+ saltcorn.markup.hr()
56
+ );
57
+ return wrapContents(content, "Sync Settings", context, new MobileRequest());
58
+ };
59
+
60
+ // get/sync/ask_overwrite
61
+ const getAskOverwriteDialog = (context) => {
62
+ const content = saltcorn.markup.div(
63
+ saltcorn.markup.div(
64
+ { class: "mb-3 h6" },
65
+ "This replaces your offline data."
66
+ ),
67
+ saltcorn.markup.button(
68
+ {
69
+ class: "btn btn-secondary me-2",
70
+ type: "button",
71
+ "data-bs-dismiss": "modal",
72
+ },
73
+ "Close"
74
+ ),
75
+ saltcorn.markup.button(
76
+ {
77
+ class: "btn btn-primary close",
78
+ type: "button",
79
+ onClick: "closeModal(); callDownloadSync(true)",
80
+ },
81
+ "Download anyway"
82
+ )
83
+ );
84
+ return wrapContents(content, "Warning", context, new MobileRequest());
85
+ };
@@ -18,8 +18,9 @@ const postView = async (context) => {
18
18
  const req = new MobileRequest({ xhr: context.xhr, files: context.files });
19
19
  const res = new MobileResponse();
20
20
  const state = saltcorn.data.state.getState();
21
+ const mobileCfg = state.mobileConfig;
21
22
  if (
22
- state.mobileConfig.role_id > view.min_role &&
23
+ mobileCfg.role_id > view.min_role &&
23
24
  !(await view.authorise_post({ body, req, ...view }))
24
25
  ) {
25
26
  throw new Error(req.__("Not authorized"));
@@ -34,6 +35,8 @@ const postView = async (context) => {
34
35
  },
35
36
  view.isRemoteTable()
36
37
  );
38
+ if (mobileCfg.isOfflineMode)
39
+ await state.setConfig("user_with_offline_data", mobileCfg.user_name);
37
40
  return res.getJson();
38
41
  };
39
42
 
@@ -48,9 +51,8 @@ const postViewRoute = async (context) => {
48
51
  const req = new MobileRequest({ xhr: context.xhr });
49
52
  const res = new MobileResponse();
50
53
  const state = saltcorn.data.state.getState();
51
- if (state.mobileConfig.role_id > view.min_role) {
52
- throw new Error(req.__("Not authorized"));
53
- }
54
+ const { role_id, isOfflineMode, user_name } = state.mobileConfig;
55
+ if (role_id > view.min_role) throw new Error(req.__("Not authorized"));
54
56
  await view.runRoute(
55
57
  context.params.route,
56
58
  context.data,
@@ -58,6 +60,7 @@ const postViewRoute = async (context) => {
58
60
  { req, res },
59
61
  view.isRemoteTable()
60
62
  );
63
+ if (isOfflineMode) await state.setConfig("user_with_offline_data", user_name);
61
64
  return res.getJson();
62
65
  };
63
66
 
@@ -75,9 +78,8 @@ const getView = async (context) => {
75
78
  if (
76
79
  state.mobileConfig.role_id > view.min_role &&
77
80
  !(await view.authorise_get({ query, req, ...view }))
78
- ) {
81
+ )
79
82
  throw new Error(req.__("Not authorized"));
80
- }
81
83
  const contents = await view.run_possibly_on_page(
82
84
  query,
83
85
  req,
@@ -1,4 +1,4 @@
1
- /*global axios, write, cordova, router, getDirEntry, saltcorn, document, FileReader, navigator*/
1
+ /*global offlineHelper, axios, write, cordova, router, getDirEntry, saltcorn, document, FileReader, navigator*/
2
2
 
3
3
  let routingHistory = [];
4
4
 
@@ -16,6 +16,14 @@ function addRoute(routeEntry) {
16
16
  routingHistory.push(routeEntry);
17
17
  }
18
18
 
19
+ function clearHistory() {
20
+ routingHistory = [];
21
+ }
22
+
23
+ function popRoute() {
24
+ routingHistory.pop();
25
+ }
26
+
19
27
  async function apiCall({ method, path, params, body, responseType }) {
20
28
  const config = saltcorn.data.state.getState().mobileConfig;
21
29
  const serverPath = config.server_path;
@@ -43,6 +51,13 @@ async function apiCall({ method, path, params, body, responseType }) {
43
51
  }
44
52
  }
45
53
 
54
+ function clearAlerts() {
55
+ const iframe = document.getElementById("content-iframe");
56
+ const alertsArea =
57
+ iframe.contentWindow.document.getElementById("alerts-area");
58
+ alertsArea.innerHTML = "";
59
+ }
60
+
46
61
  function showAlerts(alerts) {
47
62
  const iframe = document.getElementById("content-iframe");
48
63
  const alertsArea =
@@ -136,27 +151,58 @@ async function replaceIframeInnerContent(content) {
136
151
  }
137
152
 
138
153
  async function gotoEntryView() {
139
- const config = saltcorn.data.state.getState().mobileConfig;
140
- const entryPath = config.entry_point;
141
- const page = await router.resolve({
142
- pathname: entryPath,
143
- });
144
- addRoute({ entryPath, query: undefined });
145
- await replaceIframeInnerContent(page.content);
154
+ const mobileConfig = saltcorn.data.state.getState().mobileConfig;
155
+ try {
156
+ if (
157
+ mobileConfig.networkState === "none" &&
158
+ mobileConfig.allowOfflineMode &&
159
+ !mobileConfig.isOfflineMode
160
+ )
161
+ await offlineHelper.startOfflineMode();
162
+ const page = await router.resolve({
163
+ pathname: mobileConfig.entry_point,
164
+ alerts: mobileConfig.isOfflineMode
165
+ ? [{ type: "info", msg: "You are in offline mode" }]
166
+ : [],
167
+ });
168
+ addRoute({ route: mobileConfig.entry_point, query: undefined });
169
+ await replaceIframeInnerContent(page.content);
170
+ } catch (error) {
171
+ showAlerts([
172
+ {
173
+ type: "error",
174
+ msg: error.message ? error.message : "An error occured.",
175
+ },
176
+ ]);
177
+ }
146
178
  }
147
179
 
148
180
  async function handleRoute(route, query, files) {
181
+ const mobileConfig = saltcorn.data.state.getState().mobileConfig;
149
182
  try {
183
+ if (
184
+ mobileConfig.networkState === "none" &&
185
+ mobileConfig.allowOfflineMode &&
186
+ !mobileConfig.isOfflineMode
187
+ )
188
+ await offlineHelper.startOfflineMode();
150
189
  if (route === "/") return await gotoEntryView();
151
190
  addRoute({ route, query });
152
191
  const page = await router.resolve({
153
192
  pathname: route,
154
193
  query: query,
155
194
  files: files,
195
+ alerts: mobileConfig.isOfflineMode
196
+ ? [{ type: "info", msg: "You are in offline mode" }]
197
+ : [],
156
198
  });
157
199
  if (page.redirect) {
158
- const { path, query } = splitPathQuery(page.redirect);
159
- await handleRoute(path, query);
200
+ if (page.redirect.startsWith("http://localhost")) {
201
+ await gotoEntryView();
202
+ } else {
203
+ const { path, query } = splitPathQuery(page.redirect);
204
+ await handleRoute(path, query);
205
+ }
160
206
  } else if (page.content) {
161
207
  if (!page.replaceIframe) await replaceIframeInnerContent(page.content);
162
208
  else await replaceIframe(page.content);
@@ -168,12 +214,14 @@ async function handleRoute(route, query, files) {
168
214
  msg: error.message ? error.message : "An error occured.",
169
215
  },
170
216
  ]);
171
- console.error(error);
172
217
  }
173
218
  }
174
219
 
175
220
  async function goBack(steps = 1, exitOnFirstPage = false) {
176
- if (exitOnFirstPage && routingHistory.length === 1) {
221
+ if (
222
+ routingHistory.length === 0 ||
223
+ (exitOnFirstPage && routingHistory.length === 1)
224
+ ) {
177
225
  navigator.app.exitApp();
178
226
  } else if (routingHistory.length <= steps) {
179
227
  routingHistory = [];
@@ -198,3 +246,13 @@ function errorAlert(error) {
198
246
  ]);
199
247
  console.error(error);
200
248
  }
249
+
250
+ async function checkJWT(jwt) {
251
+ if (jwt && jwt !== "undefined") {
252
+ const response = await apiCall({
253
+ method: "GET",
254
+ path: "/auth/authenticated",
255
+ });
256
+ return response.data.authenticated;
257
+ } else return false;
258
+ }
@@ -97,27 +97,47 @@ async function login(e, entryPoint, isSignup) {
97
97
  if (typeof loginResult === "string") {
98
98
  // use it as a token
99
99
  const decodedJwt = parent.jwt_decode(loginResult);
100
- const config = parent.saltcorn.data.state.getState().mobileConfig;
101
- config.role_id = decodedJwt.user.role_id ? decodedJwt.user.role_id : 10;
100
+ const state = parent.saltcorn.data.state.getState();
101
+ const config = state.mobileConfig;
102
+ config.role_id = decodedJwt.user.role_id ? decodedJwt.user.role_id : 100;
102
103
  config.user_name = decodedJwt.user.email;
104
+ config.user_id = decodedJwt.user.id;
103
105
  config.language = decodedJwt.user.language;
104
106
  config.isPublicUser = false;
107
+ config.isOfflineMode = false;
105
108
  await parent.setJwt(loginResult);
106
109
  config.jwt = loginResult;
107
110
  await parent.i18next.changeLanguage(config.language);
111
+ const alerts = [];
112
+ if (config.allowOfflineMode) {
113
+ const userWithOfflineData = await parent.offlineHelper.lastOfflineUser();
114
+ if (!userWithOfflineData) await parent.offlineHelper.downloadServerData();
115
+ else {
116
+ if (userWithOfflineData === config.user_name) {
117
+ alerts.push({
118
+ type: "info",
119
+ msg: "You have offline data, open the Sync menu to handle it.",
120
+ });
121
+ } else {
122
+ alerts.push({
123
+ type: "warning",
124
+ msg: `'${userWithOfflineData}' has not yet uploaded offline data.`,
125
+ });
126
+ }
127
+ }
128
+ }
129
+ alerts.push({
130
+ type: "success",
131
+ msg: parent.i18next.t("Welcome, %s!", {
132
+ postProcess: "sprintf",
133
+ sprintf: [config.user_name],
134
+ }),
135
+ });
108
136
  parent.addRoute({ route: entryPoint, query: undefined });
109
137
  const page = await parent.router.resolve({
110
138
  pathname: entryPoint,
111
139
  fullWrap: true,
112
- alerts: [
113
- {
114
- type: "success",
115
- msg: parent.i18next.t("Welcome, %s!", {
116
- postProcess: "sprintf",
117
- sprintf: [config.user_name],
118
- }),
119
- },
120
- ],
140
+ alerts,
121
141
  });
122
142
  await parent.replaceIframe(page.content);
123
143
  } else if (loginResult?.alerts) {
@@ -132,7 +152,7 @@ async function publicLogin(entryPoint) {
132
152
  const loginResult = await loginRequest({ isPublic: true });
133
153
  if (typeof loginResult === "string") {
134
154
  const config = parent.saltcorn.data.state.getState().mobileConfig;
135
- config.role_id = 10;
155
+ config.role_id = 100;
136
156
  config.user_name = "public";
137
157
  config.language = "en";
138
158
  config.isPublicUser = true;
@@ -312,9 +332,9 @@ async function gopage(n, pagesize, viewIdentifier, extra) {
312
332
  );
313
333
  }
314
334
 
315
- function mobile_modal(url, opts = {}) {
335
+ async function mobile_modal(url, opts = {}) {
316
336
  if ($("#scmodal").length === 0) {
317
- $("body").append(`<div id="scmodal", class="modal">
337
+ $("body").append(`<div id="scmodal" class="modal">
318
338
  <div class="modal-dialog">
319
339
  <div class="modal-content">
320
340
  <div class="modal-header">
@@ -333,18 +353,41 @@ function mobile_modal(url, opts = {}) {
333
353
  var modal = bootstrap.Modal.getInstance(myModalEl);
334
354
  modal.dispose();
335
355
  }
336
- const { path, query } = parent.splitPathQuery(url);
337
- // submitReload ?
338
- parent.router
339
- .resolve({ pathname: `get${path}`, query: query })
340
- .then((page) => {
341
- const modalContent = page.content;
342
- const title = page.title;
343
- if (title) $("#scmodal .modal-title").html(title);
344
- $("#scmodal .modal-body").html(modalContent);
345
- new bootstrap.Modal($("#scmodal")).show();
346
- // onOpen onClose initialize_page?
356
+ try {
357
+ const { path, query } = parent.splitPathQuery(url);
358
+ // submitReload ?
359
+ const mobileConfig = parent.saltcorn.data.state.getState().mobileConfig;
360
+ if (
361
+ mobileConfig.networkState === "none" &&
362
+ mobileConfig.allowOfflineMode &&
363
+ !mobileConfig.isOfflineMode
364
+ )
365
+ await parent.offlineHelper.startOfflineMode();
366
+ const page = await parent.router.resolve({
367
+ pathname: `get${path}`,
368
+ query: query,
369
+ alerts: mobileConfig.isOfflineMode
370
+ ? [{ type: "info", msg: "You are in offline mode" }]
371
+ : [],
347
372
  });
373
+ const modalContent = page.content;
374
+ const title = page.title;
375
+ if (title) $("#scmodal .modal-title").html(title);
376
+ $("#scmodal .modal-body").html(modalContent);
377
+ new bootstrap.Modal($("#scmodal")).show();
378
+ // onOpen onClose initialize_page?
379
+ } catch (error) {
380
+ parent.showAlerts([
381
+ {
382
+ type: "error",
383
+ msg: error.message ? error.message : "An error occured.",
384
+ },
385
+ ]);
386
+ }
387
+ }
388
+
389
+ function closeModal() {
390
+ $("#scmodal").modal("toggle");
348
391
  }
349
392
 
350
393
  async function local_post(url, args) {
@@ -481,6 +524,97 @@ async function view_post(viewname, route, data, onDone) {
481
524
  }
482
525
  }
483
526
 
527
+ async function callUploadSync() {
528
+ if (!(await parent.offlineHelper.lastOfflineUser())) {
529
+ parent.showAlerts([
530
+ {
531
+ type: "error",
532
+ msg: "You don't have any offline data.",
533
+ },
534
+ ]);
535
+ } else {
536
+ showLoadSpinner();
537
+ try {
538
+ await parent.offlineHelper.uploadLocalData();
539
+ await parent.offlineHelper.endOfflineMode();
540
+ parent.clearAlerts();
541
+ parent.showAlerts([
542
+ {
543
+ type: "info",
544
+ msg: "Sucessfully uploaded your local data.",
545
+ },
546
+ ]);
547
+ } catch (error) {
548
+ parent.errorAlert(error);
549
+ } finally {
550
+ removeLoadSpinner();
551
+ }
552
+ }
553
+ }
554
+
555
+ async function callDownloadSync(force = false) {
556
+ const lastOfflineUser = await parent.offlineHelper.lastOfflineUser();
557
+ const { user_name } = parent.saltcorn.data.state.getState().mobileConfig;
558
+ if (lastOfflineUser === user_name && !force) {
559
+ await mobile_modal("/sync/ask_overwrite");
560
+ } else if (lastOfflineUser && !force) {
561
+ parent.showAlerts([
562
+ {
563
+ type: "error",
564
+ msg: `The user '${lastOfflineUser}' has offline data, the download is not available.`,
565
+ },
566
+ ]);
567
+ } else {
568
+ showLoadSpinner();
569
+ try {
570
+ await parent.offlineHelper.downloadServerData();
571
+ await parent.offlineHelper.endOfflineMode();
572
+ parent.clearAlerts();
573
+ parent.showAlerts([
574
+ {
575
+ type: "info",
576
+ msg: "Sucessfully updated your local data.",
577
+ },
578
+ ]);
579
+ } catch (error) {
580
+ parent.errorAlert(error);
581
+ } finally {
582
+ removeLoadSpinner();
583
+ }
584
+ }
585
+ }
586
+
587
+ function showLoadSpinner() {
588
+ if ($("#scspinner").length === 0) {
589
+ $("body").append(`
590
+ <div
591
+ id="scspinner"
592
+ style="position: absolute;
593
+ top: 0px;
594
+ width: 100%;
595
+ height: 100%;
596
+ z-index: 9999;"
597
+ >
598
+ <div
599
+ class="spinner-border"
600
+ role="status"
601
+ style="position: absolute;
602
+ left: 50%;
603
+ top: 50%;
604
+ height:60px;
605
+ width:60px;
606
+ margin:0px auto;"
607
+ >
608
+ <span class="visually-hidden">Loading...</span>
609
+ </div>
610
+ </div>`);
611
+ }
612
+ }
613
+
614
+ function removeLoadSpinner() {
615
+ $("#scspinner").remove();
616
+ }
617
+
484
618
  function reload_on_init() {
485
619
  console.log("not yet supported");
486
620
  }
@@ -0,0 +1,146 @@
1
+ /*global $, apiCall, saltcorn, apiCall, navigator, clearAlerts*/
2
+
3
+ var offlineHelper = (() => {
4
+ const loadServerData = async () => {
5
+ const response = await apiCall({
6
+ method: "GET",
7
+ path: "/sync/table_data",
8
+ });
9
+ return response.data;
10
+ };
11
+ const updateLocalData = async (data) => {
12
+ try {
13
+ await saltcorn.data.db.query("PRAGMA foreign_keys = OFF;");
14
+ await saltcorn.data.db.query("BEGIN TRANSACTION");
15
+ // replace all data
16
+ for (const [k, v] of Object.entries(data)) {
17
+ await saltcorn.data.db.query(
18
+ `delete from "${saltcorn.data.db.sqlsanitize(k)}"`
19
+ );
20
+ for (const row of v.rows) {
21
+ await saltcorn.data.db.insert(k, row);
22
+ }
23
+ }
24
+ await saltcorn.data.db.query("COMMIT TRANSACTION");
25
+ } catch (error) {
26
+ await saltcorn.data.db.query("ROLLBACK TRANSACTION");
27
+ throw error;
28
+ } finally {
29
+ await saltcorn.data.db.query("PRAGMA foreign_keys = ON;");
30
+ }
31
+ };
32
+
33
+ const loadLocalData = async () => {
34
+ const result = {};
35
+ const { user_id } = saltcorn.data.state.getState().mobileConfig;
36
+ const user = await saltcorn.data.models.User.findOne({ id: user_id });
37
+ if (!user) throw new Error(`The user with id '${user_id}' does not exist.`);
38
+ for (const table of await saltcorn.data.models.Table.find()) {
39
+ // ignore min_role_read, one can insert a row which is not readable
40
+ // but that invisible row should be synched
41
+ const rows =
42
+ user.role_id > table.min_role_write
43
+ ? (await table.getRows()).filter((row) => table.is_owner(user, row))
44
+ : await table.getRows();
45
+ result[table.name] = rows;
46
+ }
47
+ return result;
48
+ };
49
+ const sendToServer = async (localData) => {
50
+ const response = await apiCall({
51
+ method: "POST",
52
+ path: "/sync/table_data",
53
+ body: {
54
+ data: localData,
55
+ },
56
+ });
57
+ return response.data;
58
+ };
59
+ const applyTranslatedIds = async (translateIds) => {
60
+ try {
61
+ await saltcorn.data.db.query("PRAGMA foreign_keys = OFF;");
62
+ await saltcorn.data.db.query("BEGIN TRANSACTION");
63
+ for (const [k, v] of Object.entries(translateIds)) {
64
+ const table = saltcorn.data.models.Table.findOne({ name: k });
65
+ for (const { from, to } of v) {
66
+ await table.updateRow({ id: to }, from);
67
+ }
68
+ }
69
+ await saltcorn.data.db.query("COMMIT TRANSACTION");
70
+ } catch (error) {
71
+ await saltcorn.data.db.query("ROLLBACK TRANSACTION");
72
+ throw error;
73
+ } finally {
74
+ await saltcorn.data.db.query("PRAGMA foreign_keys = ON;");
75
+ }
76
+ };
77
+
78
+ return {
79
+ startOfflineMode: async () => {
80
+ const state = saltcorn.data.state.getState();
81
+ const mobileConfig = state.mobileConfig;
82
+ const oldOfflineUser = await offlineHelper.lastOfflineUser();
83
+ if (oldOfflineUser && oldOfflineUser !== mobileConfig.user_name) {
84
+ throw new Error(
85
+ `The offline mode is not available, '${oldOfflineUser}' has not yet uploaded offline data.`
86
+ );
87
+ } else {
88
+ mobileConfig.isOfflineMode = true;
89
+ }
90
+ },
91
+ endOfflineMode: async () => {
92
+ const state = saltcorn.data.state.getState();
93
+ const mobileConfig = state.mobileConfig;
94
+ mobileConfig.isOfflineMode = false;
95
+ await state.setConfig("user_with_offline_data", "");
96
+ },
97
+ lastOfflineUser: async () => {
98
+ const state = saltcorn.data.state.getState();
99
+ return await state.getConfig("user_with_offline_data");
100
+ },
101
+ uploadLocalData: async () => {
102
+ const lastOfflineUser = await offlineHelper.lastOfflineUser();
103
+ if (!lastOfflineUser) throw new Error("You don't have any offline data.");
104
+ const { user_name } = saltcorn.data.state.getState().mobileConfig;
105
+ if (lastOfflineUser !== user_name)
106
+ throw new Error(
107
+ `The upload is not available, '${lastOfflineUser}' has not yet uploaded offline data.`
108
+ );
109
+ const fromSqlite = await loadLocalData();
110
+ const { translateIds } = await sendToServer(fromSqlite);
111
+ if (translateIds && Object.keys(translateIds).length > 0)
112
+ await applyTranslatedIds(translateIds);
113
+ },
114
+ downloadServerData: async () => {
115
+ const fromServer = await loadServerData();
116
+ await updateLocalData(fromServer);
117
+ },
118
+ offlineCallback: async () => {
119
+ const mobileConfig = saltcorn.data.state.getState().mobileConfig;
120
+ mobileConfig.networkState = navigator.connection.type;
121
+ },
122
+ onlineCallback: async () => {
123
+ const mobileConfig = saltcorn.data.state.getState().mobileConfig;
124
+ if (mobileConfig.isOfflineMode) {
125
+ const iframeWindow = $("#content-iframe")[0].contentWindow;
126
+ if (iframeWindow) {
127
+ if (await offlineHelper.lastOfflineUser()) {
128
+ iframeWindow.notifyAlert(
129
+ `An internet connection is available, to handle your offline data Click ${saltcorn.markup.a(
130
+ {
131
+ href: "javascript:execLink('/sync/sync_settings')",
132
+ },
133
+ "here"
134
+ )}`
135
+ );
136
+ } else {
137
+ clearAlerts();
138
+ iframeWindow.notifyAlert("You are online again.");
139
+ mobileConfig.isOfflineMode = false;
140
+ }
141
+ }
142
+ }
143
+ mobileConfig.networkState = navigator.connection.type;
144
+ },
145
+ };
146
+ })();
@@ -46,18 +46,21 @@ async function updateUserDefinedTables() {
46
46
  const existingTables = await saltcorn.data.db.listUserDefinedTables();
47
47
  const tables = await saltcorn.data.models.Table.find();
48
48
  for (const table of tables) {
49
+ const sanitized = saltcorn.data.db.sqlsanitize(table.name);
49
50
  if (
50
51
  table.name !== "users" &&
51
- !existingTables.find((row) => row.name === table.name)
52
+ !existingTables.find((row) => row.name === sanitized)
52
53
  ) {
53
54
  // CREATE TABLE without inserting into _sc_tables
54
55
  await saltcorn.data.models.Table.create(table.name, {}, table.id);
55
56
  }
56
57
  const existingFields = (
57
- await saltcorn.data.db.query(`PRAGMA table_info('${table.name}')`)
58
+ await saltcorn.data.db.query(`PRAGMA table_info('${sanitized}')`)
58
59
  ).rows.map((row) => row.name);
59
60
  for (const field of await table.getFields()) {
60
- if (existingFields.indexOf(field.name) < 0) {
61
+ if (
62
+ existingFields.indexOf(saltcorn.data.db.sqlsanitize(field.name)) < 0
63
+ ) {
61
64
  // field is new
62
65
  await saltcorn.data.models.Field.create(field, false, field.id);
63
66
  }