diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 9039536ed..01330d576 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -27,7 +27,7 @@ jobs:
- name: Install nodejs
uses: actions/setup-node@v3
with:
- node-version: 16
+ node-version: 20
- run: npm install
diff --git a/build.gradle b/build.gradle
index ec927a789..070ce3c4a 100644
--- a/build.gradle
+++ b/build.gradle
@@ -18,6 +18,10 @@ buildscript {
}
}
+plugins {
+ id "com.gorylenko.gradle-git-properties" version "2.4.1"
+}
+
version "$biocollectVersion"
group "au.org.ala"
@@ -111,6 +115,8 @@ dependencies {
implementation('org.grails.plugins:http-builder-helper:1.1.0') {
exclude group: 'org.apache.httpcomponents', module: 'httpclient'
}
+
+ implementation 'com.opencsv:opencsv:5.7.0'
implementation "org.apache.httpcomponents:httpclient:4.5.7"
runtimeOnly 'org.webjars:jquery:1.12.4'
implementation 'dk.glasius:external-config:3.0.0'
@@ -151,10 +157,13 @@ dependencies {
runtimeOnly 'com.bertramlabs.plugins:less-asset-pipeline:3.3.1'
implementation 'com.bertramlabs.plugins:sass-asset-pipeline:3.2.5'
implementation 'org.codehaus.groovy:groovy-dateutil:2.5.0'
-
+ implementation "com.nimbusds:nimbus-jose-jwt:9.25.6"
+ implementation "io.jsonwebtoken:jjwt-impl:0.11.5"
+ implementation "io.jsonwebtoken:jjwt-jackson:0.11.5"
+ implementation "io.jsonwebtoken:jjwt-api:0.11.5"
if (!Boolean.valueOf(inplace)) {
implementation "org.grails.plugins:ala-map-plugin:3.0.1"
- implementation "org.grails.plugins:ecodata-client-plugin:7.0-SNAPSHOT"
+ implementation "org.grails.plugins:ecodata-client-plugin:7.0-PWA-SNAPSHOT"
}
testCompileOnly "org.grails:grails-test-mixins:3.3.0"
@@ -200,7 +209,8 @@ assets {
maxThreads = 6
minifyOptions = [
languageMode : 'ES6', //languageIn
- targetLanguage: 'ES5', // languageOut
+// targetLanguage: 'ES5', // languageOut
+ excludes: ['sw.js', '**/*.min.js']
]
includes = []
diff --git a/gradle.properties b/gradle.properties
index a0d10cd87..92dacab71 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,4 +1,4 @@
-biocollectVersion=7.0-SNAPSHOT
+biocollectVersion=7.0-PWA-SNAPSHOT
grailsVersion=6.2.0
grailsGradlePluginVersion=6.1.2
assetPipelineVersion=4.3.0
diff --git a/grails-app/assets/images/map-not-cached.png b/grails-app/assets/images/map-not-cached.png
new file mode 100644
index 000000000..0f474dfce
Binary files /dev/null and b/grails-app/assets/images/map-not-cached.png differ
diff --git a/grails-app/assets/javascripts/MapUtilities.js b/grails-app/assets/javascripts/MapUtilities.js
index 390e7e5bd..1f29f251b 100644
--- a/grails-app/assets/javascripts/MapUtilities.js
+++ b/grails-app/assets/javascripts/MapUtilities.js
@@ -133,7 +133,7 @@ Biocollect.MapUtilities = {
var options = {baseLayer: undefined, otherLayers: {}};
baseLayers = baseLayers || [];
baseLayers.forEach(function (baseLayer) {
- var baseConfig = Biocollect.MapUtilities.getBaseLayer(baseLayer.code);
+ var baseConfig = Biocollect.MapUtilities.getBaseLayer(baseLayer.code, baseLayer);
var title = baseConfig.title || baseLayer.displayText;
if (baseLayer.isSelected) {
options.baseLayer = baseConfig;
@@ -156,15 +156,18 @@ Biocollect.MapUtilities = {
/**
* Get {L.tileLayer | L.Google} base map for a given code.
* @param code
+ * @param config - used to get basemap url
* @returns {L.tileLayer | L.Google}
*/
- getBaseLayer: function (code) {
- var option, layer;
+ getBaseLayer: function (code, config) {
+ config = config || {};
+ var option, layer,
+ url = config.url;
switch (code) {
case 'minimal':
option = {
// See https://cartodb.com/location-data-services/basemaps/
- url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png',
+ url: url || 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png',
options: {
subdomains: "abcd",
attribution: 'Map data © OpenStreetMap, imagery © CartoDB',
@@ -177,7 +180,7 @@ Biocollect.MapUtilities = {
case 'worldimagery':
option = {
// see https://www.arcgis.com/home/item.html?id=10df2279f9684e4a9f6a7f08febac2a9
- url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
+ url: url || 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
options: {
attribution: 'Tiles from Esri — Sources: Esri, DigitalGlobe, Earthstar Geographics, CNES/Airbus DS, GeoEye, USDA FSA, USGS, Aerogrid, IGN, IGP, and the GIS User Community',
maxZoom: 21,
@@ -186,10 +189,22 @@ Biocollect.MapUtilities = {
};
layer = L.tileLayer(option.url, option.options);
break;
+ case 'maptilersatellite':
+ option = {
+ url: url || 'https://api.maptiler.com/maps/hybrid/256/{z}/{x}/{y}.jpg?key=O11Deo7fBLatChkUYGIH',
+ options: {
+ attribution: '© MapTiler © OpenStreetMap contributors',
+ maxZoom: 21,
+ maxNativeZoom: 21
+ }
+ };
+ layer = L.tileLayer(option.url, option.options);
+ break;
+
case 'detailed':
option = {
// see https://wiki.openstreetmap.org/wiki/Standard_tile_layer
- url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
+ url: url || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
options: {
subdomains: "abc",
attribution: '© OpenStreetMap contributors',
@@ -202,7 +217,7 @@ Biocollect.MapUtilities = {
case 'topographic':
option = {
// see https://www.arcgis.com/home/item.html?id=30e5fe3149c34df1ba922e6f5bbf808f
- url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}',
+ url: url || 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}',
options: {
attribution: 'Tiles from Esri — Sources: Esri, HERE, Garmin, Intermap, INCREMENT P, GEBCO, USGS, FAO, NPS, NRCAN, GeoBase, IGN, Kadaster NL, Ordnance Survey, Esri Japan, METI, Esri China (Hong Kong), © OpenStreetMap contributors, GIS User Community',
maxZoom: 21,
diff --git a/grails-app/assets/javascripts/biocollect-utils.js b/grails-app/assets/javascripts/biocollect-utils.js
new file mode 100644
index 000000000..8a5618b06
--- /dev/null
+++ b/grails-app/assets/javascripts/biocollect-utils.js
@@ -0,0 +1,115 @@
+var biocollect = {
+ utils: {
+ /**
+ * https://stackoverflow.com/a/20584396
+ * @param node
+ * @returns {*}
+ */
+ nodeScriptReplace: function nodeScriptReplace(node) {
+ if (biocollect.utils.nodeScriptIs(node) === true) {
+ node.parentNode.replaceChild(biocollect.utils.nodeScriptClone(node), node);
+ } else {
+ var i = -1, children = node.childNodes;
+ while (++i < children.length) {
+ biocollect.utils.nodeScriptReplace(children[i]);
+ }
+ }
+
+ return node;
+ },
+
+ /**
+ * https://stackoverflow.com/a/20584396
+ * @param node
+ * @returns {HTMLScriptElement}
+ */
+ nodeScriptClone: function nodeScriptClone(node) {
+ var script = document.createElement("script");
+ script.text = node.innerHTML;
+
+ var i = -1, attrs = node.attributes, attr;
+ while (++i < attrs.length) {
+ script.setAttribute((attr = attrs[i]).name, attr.value);
+ }
+ return script;
+ },
+
+ /**
+ * https://stackoverflow.com/a/20584396
+ * @param node
+ * @returns {boolean}
+ */
+ nodeScriptIs: function nodeScriptIs(node) {
+ return node.tagName === 'SCRIPT';
+ },
+ readDocument: function readDocument(file) {
+ var deferred = $.Deferred();
+ if (file) {
+ var reader = new FileReader();
+ reader.onload = function (e) {
+ var contents = e.target.result;
+ deferred.resolve({data: {blob: contents, file: file}});
+ };
+
+ reader.onerror = function (e) {
+ deferred.reject({message: "Failed to read file" + file.name});
+ };
+
+ reader.readAsArrayBuffer(file);
+ } else {
+ deferred.reject();
+ }
+
+ return deferred.promise();
+ },
+ saveDocument: function saveDocument(result) {
+ var deferred = $.Deferred(),
+ file = result.data.file,
+ blob = result.data.blob,
+ document = biocollect.utils.createDocument(file, blob);
+
+ if (window.entities)
+ window.entities.saveDocument(document).then(deferred.resolve, function (error) {
+ deferred.reject({data: document, error: error});
+ });
+ else
+ deferred.reject();
+
+ return deferred.promise();
+ },
+ fetchDocument: function fetchDocument(result) {
+ var documentId = result.data;
+ return window.entities.offlineGetDocument(documentId);
+ },
+ addObjectURL: function addObjectURL(document) {
+ var url = ImageViewModel.createObjectURL(document);
+ if (url) {
+ document.thumbnailUrl = document.url = url;
+ }
+ },
+ createDocument: function createDocument(file, blob) {
+ return {
+ blob: blob,
+ contentType: file.type,
+ filename: file.name,
+ name: file.name,
+ filesize: file.size,
+ dateTaken: new Date(file.lastModified).toISOStringNoMillis(),
+ staged: false,
+ attribution: "",
+ licence: "",
+ entityUpdated: true
+ };
+ },
+ getReturnToAddressForPWA: function getReturnToAddressForPWA() {
+ const context = new URL(window.location.href).searchParams.get('context');
+ switch (context) {
+ case 'global':
+ return fcConfig.globalReturnToAddress;
+ case 'survey':
+ default:
+ return fcConfig.surveyReturnToAddress;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/grails-app/assets/javascripts/enterBioActivityData.js b/grails-app/assets/javascripts/enterBioActivityData.js
index d4737da09..8a22cf932 100644
--- a/grails-app/assets/javascripts/enterBioActivityData.js
+++ b/grails-app/assets/javascripts/enterBioActivityData.js
@@ -12,7 +12,8 @@ function validateDateField(dateField) {
/* Master controller for page. This handles saving each model as required. */
function Master(activityId, config) {
var self = this,
- viewModel;
+ viewModel,
+ preventNavigationIfDirty = config.preventNavigationIfDirty === undefined ? true : config.preventNavigationIfDirty;
self.subscribers = [];
self.deferredObjects = [];
@@ -87,6 +88,32 @@ function Master(activityId, config) {
return activityData;
};
+ /**
+ * Does not check if model is dirty.
+ * @returns {{}|undefined}
+ */
+ self.getAllModelAsJS = function () {
+ var activityData, outputs = [];
+ $.each(this.subscribers, function(i, obj) {
+ if (obj.model === 'activityModel') {
+ activityData = obj.get();
+ }
+ else {
+ outputs.push(obj.get());
+ }
+ });
+
+ if (activityData === undefined && outputs.length == 0) {
+ return undefined;
+ }
+ if (!activityData) {
+ activityData = {};
+ }
+ activityData.outputs = outputs;
+
+ return activityData;
+ };
+
self.modelAsJSON = function() {
var jsData = self.modelAsJS();
@@ -123,8 +150,7 @@ function Master(activityId, config) {
self.listenForResolution = function () {
$.when.apply($, self.deferredObjects).then(function () {
- if (fcConfig.bulkUpload)
- window.parent.postMessage({eventName: 'viewmodelloadded', data: {}}, fcConfig.originUrl);
+ window.parent.postMessage({eventName: 'viewmodelloadded', event:'viewmodelloadded', data: {}}, "*");
});
}
@@ -141,6 +167,39 @@ function Master(activityId, config) {
* Validates the entire page before saving.
*/
self.save = function () {
+ if (config.enableOffline) {
+ isOffline().then(function(){
+ self.offlineSave();
+ }, function() {
+ self.onlineSave();
+ });
+ }
+ else {
+ self.onlineSave();
+ }
+ },
+
+ self.offlineSave = function () {
+ if ($('#validation-container').validationEngine('validate')) {
+ var toSave = this.getAllModelAsJS();
+ toSave.entityUpdated = true;
+ var projectId = toSave.projectId;
+ var projectActivityId = toSave.projectActivityId;
+ toSave = JSON.stringify(toSave);
+ toSave = JSON.parse(toSave);
+ blockUIWithMessage("Saving activity data...");
+
+ entities.saveActivity(toSave).then(function (result) {
+ var activityId = result.data;
+ if (config.enableOffline) {
+ document.location.href = config.returnTo;
+ } else
+ document.location.href = fcConfig.activityViewURL + "/" + projectActivityId + "?activityId=" + activityId + "&projectId=" + projectId;
+ });
+ }
+ },
+
+ self.onlineSave = function () {
if ($('#validation-container').validationEngine('validate')) {
var toSave = this.modelAsJS();
toSave = JSON.stringify(toSave);
@@ -174,7 +233,8 @@ function Master(activityId, config) {
} else {
unblock = false; // We will be transitioning off this page.
activityId = config.activityId || data.resp.activityId;
- config.returnTo = config.bioActivityView + activityId;
+ if (!config.enableOffline)
+ config.returnTo = config.bioActivityView + activityId;
blockUIWithMessage("Successfully submitted the record.");
self.reset();
self.saved();
@@ -229,7 +289,8 @@ function Master(activityId, config) {
}
else if (config.isMobile) {
location.href = config.returnToMobile;
- } else {
+ }
+ else {
document.location.href = config.returnTo;
}
};
@@ -260,7 +321,7 @@ function Master(activityId, config) {
return viewModel;
}
- autoSaveModel(self, null, {preventNavigationIfDirty: true});
+ autoSaveModel(self, null, {preventNavigationIfDirty: preventNavigationIfDirty});
};
function ActivityHeaderViewModel (act, site, project, metaModel, pActivity, config) {
@@ -275,6 +336,7 @@ function ActivityHeaderViewModel (act, site, project, metaModel, pActivity, conf
self.bulkImportId = ko.observable(act.bulkImportId);
self.embargoed = ko.observable(false);
self.projectId = act.projectId;
+ self.projectActivityId = pActivity ? pActivity.projectActivityId : null;
// check if project activity requires manual verification by admin
var verificationStatus = pActivity.adminVerification ? 'not verified' : 'not applicable';
@@ -297,29 +359,8 @@ function ActivityHeaderViewModel (act, site, project, metaModel, pActivity, conf
}
return true;
};
- self.siteId = ko.vetoableObservable(act.siteId, self.confirmSiteChange);
-
- self.siteId.subscribe(function (siteId) {
-
- var matchingSite = $.grep(self.transients.pActivitySites, function (site) {
- return siteId == site.siteId
- })[0];
-
- if (matchingSite && matchingSite.extent && matchingSite.extent.geometry) {
- var geometry = matchingSite.extent.geometry;
- if (geometry.pid) {
- activityLevelData.siteMap.addWmsLayer(geometry.pid);
- } else {
- var geoJson = ALA.MapUtils.wrapGeometryInGeoJSONFeatureCol(geometry);
- activityLevelData.siteMap.setGeoJSON(geoJson);
- }
- }
- self.transients.site(matchingSite);
- if (metaModel.supportsPhotoPoints) {
- self.updatePhotoPointModel(matchingSite);
- }
- });
+ self.siteId = ko.vetoableObservable(act.siteId, self.confirmSiteChange);
self.goToProject = function () {
if (self.projectId) {
diff --git a/grails-app/assets/javascripts/forms-manifest.js b/grails-app/assets/javascripts/forms-manifest.js
index 4775f880d..ec7a5796e 100644
--- a/grails-app/assets/javascripts/forms-manifest.js
+++ b/grails-app/assets/javascripts/forms-manifest.js
@@ -1,3 +1,6 @@
+// from plugin
+//= require utils.js
+
// leaflet
//= require leaflet-manifest.js
@@ -31,6 +34,7 @@
//= require forms.js
// activity
+//= require biocollect-utils.js
//= require outputs.js
//= require parser.js
@@ -67,4 +71,8 @@
// comments
//= require comment.js
+// indexDB
+//= require dexiejs/dexie.js
+//= require entities.js
+
// audio to be included
\ No newline at end of file
diff --git a/grails-app/assets/javascripts/images.js b/grails-app/assets/javascripts/images.js
deleted file mode 100644
index ec20c95b9..000000000
--- a/grails-app/assets/javascripts/images.js
+++ /dev/null
@@ -1,81 +0,0 @@
-function ImageViewModel(prop, skipFindingDocument) {
- var self = this, document;
- var documents
-
- // used by image gallery plugin. document is passed to the function.
- if (!skipFindingDocument) {
- // activityLevelData is a global variable
- documents = activityLevelData.activity.documents;
- // dereferencing the document using documentId
- documents && documents.forEach(function (doc) {
- // newer implementation is passing document object.
- var docId = prop.documentId || prop;
- if (doc.documentId === docId) {
- prop = doc;
- }
- });
- }
-
- if (typeof prop !== 'object') {
- console.error('Could not find the required document.')
- return;
- }
-
- self.dateTaken = ko.observable(prop.dateTaken || (new Date()).toISOStringNoMillis()).extend({simpleDate: false});
- self.contentType = ko.observable(prop.contentType);
- self.url = prop.url;
- self.filesize = prop.filesize;
- self.thumbnailUrl = prop.thumbnailUrl || prop.url;
- self.filename = prop.filename;
- self.attribution = ko.observable(prop.attribution);
- self.licence = ko.observable(prop.licence);
- self.licenceDescription = prop.licenceDescription;
- self.notes = ko.observable(prop.notes || '');
- self.name = ko.observable(prop.name);
- self.formattedSize = formatBytes(prop.filesize);
- self.staged = prop.staged || false;
- self.documentId = prop.documentId || '';
- self.status = ko.observable(prop.status || 'active');
- self.projectName = prop.projectName;
- self.projectId = prop.projectId;
- self.activityName = prop.activityName;
- self.activityId = prop.activityId;
- self.isEmbargoed = prop.isEmbargoed;
- self.identifier = prop.identifier;
-
-
- self.remove = function (images, data, event) {
- if (data.documentId) {
- // change status when image is already in ecodata
- data.status('deleted')
- } else {
- images.remove(data);
- }
- }
-
- self.getActivityLink = function () {
- return fcConfig.activityViewUrl + '/' + self.activityId;
- }
-
- self.getProjectLink = function () {
- return fcConfig.projectIndexUrl + '/' + self.projectId;
- }
-
- self.getImageViewerUrl = function () {
- // Let the image viewer render high res image.
- self.url = self.url ? self.url.split("/image/proxyImageThumbnailLarge?imageId=").join("/image/proxyImage?imageId=") : self.url;
- return fcConfig.imageLeafletViewer + '?file=' + encodeURIComponent(self.url);
- }
-
- self.summary = function () {
- var picBy = 'Picture by ' + self.attribution() + '. ';
- var takenOn = 'Taken on ' + self.dateTaken.formattedDate() + '.';
- var message = '';
- if (self.attribution()) {
- message += picBy;
- }
-
- message += takenOn;
- return "
" + self.notes() + '
' + message + '';
- }
-}
\ No newline at end of file
diff --git a/grails-app/assets/javascripts/knockout-custom-bindings.js b/grails-app/assets/javascripts/knockout-custom-bindings.js
index 05b91c8a7..8c02359af 100644
--- a/grails-app/assets/javascripts/knockout-custom-bindings.js
+++ b/grails-app/assets/javascripts/knockout-custom-bindings.js
@@ -701,11 +701,10 @@ ko.bindingHandlers.ticks = {
ko.bindingHandlers.fileUploadNoImage = {
init: function (element, options) {
-
+ var dropzone = $(element).parent();
var defaults = {autoUpload: true};
var settings = {
- pasteZone: null,
- dropZone: null
+ pasteZone: null, dropZone: dropzone
};
$.extend(settings, defaults, options());
$(element).fileupload(settings).on('fileuploadadd', function (e, data) {
diff --git a/grails-app/assets/javascripts/offline-list.js b/grails-app/assets/javascripts/offline-list.js
new file mode 100644
index 000000000..573b8741b
--- /dev/null
+++ b/grails-app/assets/javascripts/offline-list.js
@@ -0,0 +1,455 @@
+function ActivitiesViewModel (config) {
+ var self = this;
+ var projectActivityId = config.projectActivityId;
+ var projectId = config.projectId,
+ calledFromContext = projectActivityId === undefined ? "global" : "survey",
+ cancelOfflineCheck;
+ self.activities = ko.observableArray();
+ self.pagination = new PaginationViewModel({}, self);
+ self.online = ko.observable(true);
+ self.disableUpload = ko.computed(function () {
+ return self.activities().length === 0 || !self.online();
+ });
+
+
+ self.init = function() {
+ document.addEventListener("online", function() {
+ self.online(true);
+ });
+ document.addEventListener("offline", function() {
+ self.online(false);
+ });
+
+ cancelOfflineCheck = checkOfflineForIntervalAndTriggerEvents();
+ }
+
+ self.load = function(offset) {
+ if (projectActivityId) {
+ return self.getActivitiesOfProjectActivity(self.pagination.resultsPerPage() ,offset);
+ }
+ else if (projectId) {
+ return self.getActivitiesForProject(self.pagination.resultsPerPage(), offset);
+ }
+ else {
+ return self.getAllActivities(self.pagination.resultsPerPage(), offset);
+ }
+ };
+
+ self.refreshPage = function (offset) {
+ self.load(offset);
+ }
+
+ self.getAllActivities = function(max, offset) {
+ return entities.offlineGetAllActivities(max, offset).then(function(result) {
+ var activities = result.data.activities,
+ total = result.data.total,
+ container = [];
+
+ activities.forEach(function(activity) {
+ container.push(new ActivityViewModel(activity, self));
+ });
+
+ self.activities(container);
+ self.pagination.loadOffset(offset, total);
+ });
+ }
+
+ self.getActivitiesForProject = function(max, offset) {
+ return entities.offlineGetActivitiesForProject(projectId, max, offset).then(function(result) {
+ var activities = result.data.activities,
+ total = result.data.total,
+ container = [];
+
+ activities.forEach(function(activity) {
+ container.push(new ActivityViewModel(activity, self));
+ });
+
+ self.activities(container);
+ self.pagination.loadOffset(offset, total);
+ });
+ }
+
+ self.getActivitiesOfProjectActivity = function(max, offset) {
+ return entities.getActivitiesForProjectActivity(projectActivityId, max, offset).then(function(result) {
+ var activities = result.data.activities,
+ total = result.data.total,
+ container = [];
+
+ activities.forEach(function(activity) {
+ container.push(new ActivityViewModel(activity, self));
+ });
+
+ self.activities(container);
+ self.pagination.loadPagination(offset, total);
+ });
+ }
+
+ self.uploadAllHandler = function() {
+ var activities = self.activities(),
+ index = 0;
+
+ self.uploadAnActivity(activities, index);
+ }
+
+ self.uploadAnActivity = function (activities, index) {
+ if (index < activities.length) {
+ activities[index].upload().then(function () {
+ self.uploadAnActivity(activities, index + 1);
+ }, function (error) {
+ console.error(error);
+ self.uploadAnActivity(activities, index + 1);
+ });
+ } else {
+ // calling load with offset 0 will load the next batch of activities since current batch of activities are
+ // deleted from db.
+ if (self.pagination.totalResults() !== 0)
+ self.load(0).then(self.uploadAllHandler, function (error) {
+ console.error("Error loading next page of activities" + error);
+ });
+ }
+ }
+
+ /**
+ * Soft delete an activity from list
+ * @param activity
+ */
+ self.remove = function(activity) {
+ self.activities.remove(activity);
+ }
+
+ self.transients = {
+ addActivityUrl: function() {
+ return fcConfig.addActivityUrl + "/" + projectActivityId + "?context=" + calledFromContext;
+ },
+ isProjectActivity: !!projectActivityId
+ }
+
+ self.init();
+ self.pagination.first();
+};
+
+function ActivityViewModel (activity, parent) {
+ const IMAGE_DELETED_STATUS = 'deleted'
+ var self = this, images, loadPromise,
+ calledFromContext = getParameters().projectActivityId === undefined ? "global" : "survey";
+ self.activityId = activity.activityId;
+ self.projectId = activity.projectId;
+ self.projectActivityId = activity.projectActivityId;
+ self.featureImage = ko.observable();
+ self.species = ko.observableArray();
+ self.surveyDate = ko.observable().extend({simpleDate: false});
+ self.uploading = ko.observable(false);
+ self.disableUpload = ko.computed(function () {
+ return self.uploading() || !parent.online();
+ });
+ self.metaModel;
+ self.imageViewModels = [];
+ self.transients = {
+ viewActivityUrl: function() {
+ return fcConfig.activityViewUrl + "/" + self.projectActivityId + "?projectId=" + self.projectId + "&activityId=" + self.activityId + "&context=" + calledFromContext;
+ },
+ editActivityUrl: function() {
+ return fcConfig.activityEditUrl + "/" + self.projectActivityId + "?unpublished=true&projectId=" + self.projectId + "&activityId=" + self.activityId + "&context=" + calledFromContext;
+ }
+ }
+
+ self.load = function() {
+ loadPromise = entities.offlineGetMetaModel(activity.type).done(function(result) {
+ var metaModel = result.data,
+ imageViewModel, surveyDate;
+ self.metaModel = new MetaModel(metaModel);
+ self.species(self.metaModel.getDataForType("species", activity));
+ surveyDate = self.metaModel.getDataForType("date", activity)
+ surveyDate = surveyDate && surveyDate[0]
+ if (surveyDate) {
+ self.surveyDate(surveyDate);
+ }
+
+ self.imageViewModels = [];
+ images = self.metaModel.getDataForType("image", activity);
+ if (images && images.length > 0) {
+ images.forEach(function(image) {
+ imageViewModel = new ImageViewModel(image, true);
+ self.imageViewModels.push(imageViewModel);
+ if (!self.featureImage()) {
+ self.featureImage(imageViewModel);
+ }
+ });
+ }
+ });
+ }
+
+ self.upload = function() {
+ var promises = [],
+ deferred = $.Deferred(),
+ forceOnline = false;
+ isOffline().then(function () {
+ alert("You are offline. Please connect to the internet and try again.");
+ deferred.reject();
+ }, function () {
+ loadPromise.then(function (){
+ self.uploading(true);
+ promises.push(self.uploadImages());
+ promises.push(self.uploadSite().then(self.updateActivityWithSiteId).then(self.saveAsNewSite));
+ $.when.apply($, promises).then(function (imagesToDelete, oldSitesToDelete) {
+ self.saveActivityToDB().then(self.uploadActivity).then(self.deleteActivityFromDB).then(self.removeMeFromList).then(async function () {
+ self.uploading(false);
+ await self.deleteImages(imagesToDelete);
+ await self.deleteOldSite(oldSitesToDelete);
+ deferred.resolve({data: { activityId: activity.activityId} });
+ });
+ }, function (error) {
+ self.saveActivityToDB().then(function () {
+ self.uploading(false);
+ deferred.reject({data: { activityId: activity.activityId}, message: "There was an error uploading activity", error: error});
+ });
+ });
+ }, function () {
+ deferred.reject();
+ alert("There was an error fetching metadata for activity");
+ });
+ });
+
+ return deferred.promise();
+ }
+
+ /**
+ * Hard delete an activity from the database
+ */
+ self.deleteActivity = function() {
+ bootbox.confirm("This operation cannot be reversed. Are you sure you want to delete this activity?", function (result) {
+ if (result) {
+ self.uploading(true);
+ images = images || [];
+ var documentIds = images.map(image => image.documentId);
+ self.deleteImages({data:documentIds}).then(self.deleteSite).then(self.deleteActivityById).then(function (){
+ parent.refreshPage(0);
+ }).then(function () {
+ self.uploading(false);
+ }, function () {
+ self.uploading(false);
+ });
+ }
+ })
+ }
+
+ self.deleteSite = function () {
+ return entities.deleteSites([activity.siteId]);
+ }
+
+ self.deleteActivityById = function () {
+ return self.deleteActivityFromDB({data: {oldActivityId: activity.activityId}});
+ }
+
+ self.removeMeFromList = function() {
+ parent.remove(self);
+ }
+
+ self.uploadActivity = function() {
+ var oldActivityId = self.activityId;
+ if (entities.utils.isDexieEntityId(activity.activityId)) {
+ activity.activityId = undefined;
+ }
+
+ var toSave = JSON.stringify(activity),
+ deferred = $.Deferred(),
+ url = fcConfig.bioActivityUpdate + "?pActivityId=" + activity.projectActivityId,
+ ajaxRequestParams = {
+ url: url,
+ type: 'POST',
+ data: toSave,
+ contentType: 'application/json',
+ success: function success(data) {
+ if (data.errors || data.error) {
+ deferred.reject({data: {oldActivityId: oldActivityId, error: data.errors || data.error}});
+ }
+ else {
+ deferred.resolve({data: {oldActivityId: oldActivityId, activityId: data.resp.activityId }});
+ }
+ },
+ error: function (jqXHR, status, error) {
+ deferred.reject({data: {activity: activity.activityId, error: error}})
+ }
+ };
+
+ $.ajax(ajaxRequestParams);
+ return deferred.promise();
+ }
+
+ self.saveActivityToDB = function() {
+ return entities.saveActivity(activity);
+ }
+
+ self.deleteActivityFromDB = function(result) {
+ var activityId = result.data.oldActivityId;
+ return entities.deleteActivities([activityId]);
+ }
+
+ self.updateActivityWithSiteId = function(result) {
+ var siteId = result.data.siteId;
+ activity.siteId = siteId;
+ var sourceNames = self.metaModel.getNamesForDataType("geoMap");
+ self.metaModel.updateDataForSources(sourceNames, activity, siteId);
+ return result;
+ }
+
+ self.saveAsNewSite = function(result) {
+ var site = result.data.site;
+ if (site && isUuid(site.siteId)) {
+ entities.saveSite(site);
+ }
+
+ return result;
+ }
+
+ self.deleteOldSite = function(result) {
+ var siteId = result.data.oldSiteId;
+ if(entities.utils.isDexieEntityId(siteId)) {
+ return entities.deleteSites([siteId]);
+ }
+ else {
+ return $.Deferred().resolve(result);
+ }
+ }
+
+ self.uploadSite = function() {
+ var siteId = self.metaModel.getDataForType("geoMap", activity)[0] || activity.siteId;
+ return entities.getSite(siteId).then(function(result) {
+ var site = result.data,
+ data = {
+ site: site,
+ pActivityId: activity.projectActivityId
+ },
+ id = siteId,
+ deferred = $.Deferred();
+ site['asyncUpdate'] = true; // aysnc update site metadata for performance improvement
+ if (entities.utils.isDexieEntityId(site.siteId)) {
+ id = site.siteId = undefined;
+ }
+
+ $.ajax({
+ method: 'POST',
+ url: id ? fcConfig.updateSiteUrl + "?id=" + id : fcConfig.updateSiteUrl,
+ data: JSON.stringify(data),
+ contentType: 'application/json',
+ dataType: 'json'
+ }).then(function (result) {
+ if (result.id) {
+ deferred.resolve({data: {siteId: result.id, oldSiteId: siteId, site: site}});
+ }
+ else {
+ deferred.reject({data: result, error : "Site update failed."});
+ }
+ }, function (jqXHR, status, error) {
+ // if site update fails, reject the promise only if it is a new site.
+ // if existing site is update is reject, resolve the promise with the site id. This helps sync the activity.
+ // update can be rejected if user does not have permission on all the project the site is associated.
+ if (entities.utils.isDexieEntityId(site.siteId)) {
+ deferred.reject({error : error});
+ }
+ else {
+ deferred.resolve({data: {siteId: siteId, oldSiteId: siteId, site: site}});
+ }
+ });
+
+ return deferred.promise();
+ });
+ }
+
+ self.uploadImages = async function() {
+ var uploadedImages = [],
+ promises = [], deferred = $.Deferred();
+
+ for (var index = 0 ; index < self.imageViewModels.length; index++) {
+ var imageVM = self.imageViewModels[index];
+ if (imageVM.isBlobDocument()) {
+ var image = images[index], promise;
+ if (image.documentId && entities.utils.isDexieEntityId(image.documentId)) {
+ if (imageVM.status() !== IMAGE_DELETED_STATUS)
+ uploadedImages.push(image.documentId);
+ }
+
+ if (imageVM.status() !== IMAGE_DELETED_STATUS)
+ promise = self.uploadImage(imageVM).then(self.updateImageMetadata.bind(self, imageVM, image))
+ promises.push(promise)
+ await promise;
+ }
+
+ }
+
+ $.when.apply($, promises).then(function () {
+ deferred.resolve({data:uploadedImages});
+ }, function (){
+ deferred.reject({error: "Image upload failed."});
+ });
+
+ return deferred.promise();
+ }
+
+ self.updateImageMetadata = function(imageVM, image, stagedMetadata) {
+ $.extend(image, stagedMetadata);
+ imageVM.load(image, true);
+ if (entities.utils.isDexieEntityId(image.documentId)) {
+ // clear documentId so that BioCollect will create a new document for the image
+ image.documentId = undefined;
+ }
+
+ return image;
+ }
+
+ self.uploadImage = function(image) {
+ var formData = new FormData();
+ formData.append("files", image.getBlob());
+ return $.ajax({
+ url: fcConfig.imageUploadUrl,
+ type: "POST",
+ data: formData,
+ processData: false,
+ contentType: false
+ })
+ .then(function (result) {
+ return (result.files && result.files[0]) || {};
+ });
+ }
+
+ self.deleteImages = function(result) {
+ var imageIds = result.data;
+ return entities.bulkDeleteDocuments(imageIds).then(function() {
+ console.log("Successfully deleted images - " + imageIds.toString());
+ }, function () {
+ console.error("Failed to delete images");
+ });
+ }
+
+ self.load();
+}
+
+function getParameters (activity) {
+ var url = new URL(window.location.href);
+ return {
+ projectId: url.searchParams.get("projectId"),
+ projectActivityId: url.searchParams.get("projectActivityId")
+ }
+}
+
+document.addEventListener("credential-saved", startInitialising);
+document.addEventListener("credential-failed", function () {
+ alert("Error occurred while saving credentials. Please close modal and try again.");
+});
+
+window.addEventListener('load', function (){
+ setTimeout(startInitialising, 2000);
+ // two event attributes for backward compatibility
+ window.parent && window.parent.postMessage({eventName: 'viewmodelloadded', event: 'viewmodelloadded', data: {}}, "*");
+});
+
+function startInitialising () {
+ window.uninitialised = window.uninitialised || false;
+ entities.getCredentials().then(function (result) {
+ var config = getParameters(),
+ activitiesViewModel = new ActivitiesViewModel(config);
+
+ !window.uninitialised && ko.applyBindings(activitiesViewModel);
+ window.uninitialised = true;
+ })
+}
\ No newline at end of file
diff --git a/grails-app/assets/javascripts/outputs.js b/grails-app/assets/javascripts/outputs.js
index 7cefb6a54..6e2279629 100644
--- a/grails-app/assets/javascripts/outputs.js
+++ b/grails-app/assets/javascripts/outputs.js
@@ -320,10 +320,31 @@ ko.bindingHandlers.imageUpload = {
}
window.decreaseAsyncCounter && window.decreaseAsyncCounter();
}).on('fileuploadfail', function(e, data) {
- error(data.errorThrown);
+ if (fcConfig.enableOffline) {
+ isOffline().then(function () {
+ var file = data.files[0];
+ file && biocollect.utils.readDocument(file).then(biocollect.utils.saveDocument).then(biocollect.utils.fetchDocument).then(addToViewModel);
+ },
+ function () {
+ error(data.errorThrown);
+ });
+ }
+ else {
+ error(data.errorThrown);
+ }
+
window.decreaseAsyncCounter && window.decreaseAsyncCounter();
});
+ function addToViewModel(result) {
+ var viewModel;
+ biocollect.utils.addObjectURL(result.data);
+ viewModel = new ImageViewModel(result.data, true);
+ target.push(viewModel);
+ complete(true);
+ return viewModel;
+ };
+
ko.applyBindingsToDescendants(innerContext, element);
return { controlsDescendantBindings: true };
diff --git a/grails-app/assets/javascripts/project-activity-index.js b/grails-app/assets/javascripts/project-activity-index.js
new file mode 100644
index 000000000..b2e8f6827
--- /dev/null
+++ b/grails-app/assets/javascripts/project-activity-index.js
@@ -0,0 +1,99 @@
+// todo: Delete?
+var projectPromise = entities.getProject();
+var paPromise = entities.getProjectActivity();
+$.when(projectPromise, paPromise).done(function (projectResult, paResult) {
+ var project = projectResult.data,
+ pa = paResult.data,
+ paViewModel = new pActivityInfo(pa),
+ actvitiesViewModel = new ActivitiesViewModel(),
+ paID = "pActivity",
+ activitiesID = "activities";
+
+ ko.applyBindings(paViewModel, document.getElementById(paID));
+ ko.applyBindings(actvitiesViewModel, document.getElementById(activitiesID));
+
+ entities.getActivitiesForProjectActivity().then(function (result) {
+ actvitiesViewModel.transients.load(result.data.activities);
+ });
+})
+
+function ActivitiesViewModel() {
+ var self = this;
+
+ self.activities = ko.observableArray();
+
+ self.transients = {
+ load: function (activities) {
+ var models = []
+ activities && activities.forEach(function (activity) {
+ if (activity instanceof ActivityViewModel) {
+ models.push(activity);
+ }
+ else {
+ models.push(new ActivityViewModel(activity));
+ }
+ });
+
+ self.activities(models);
+ }
+ }
+}
+
+function ActivityViewModel(activity) {
+ var self = this;
+ self.rawData = activity;
+ self.activityId = activity.activityId;
+ self.showCrud = ko.observable(activity.showCrud);
+ self.userCanModerate = activity.userCanModerate;
+ self.projectActivityId = activity.projectActivityId;
+ self.name = activity.name;
+ self.type = activity.type;
+ self.lastUpdated = ko.observable(activity.lastUpdated).extend({simpleDate: true});
+ self.ownerName = activity.activityOwnerName;
+ self.userId = activity.userId;
+ self.siteId = ko.observable(activity.siteId);
+ self.embargoed = activity.embargoed;
+ self.embargoUntil = ko.observable(activity.embargoUntil).extend({simpleDate: false});
+ self.projectName = activity.projectName;
+ self.projectId = activity.projectId;
+ self.projectType = activity.projectType;
+ self.records = ko.observableArray();
+ self.isWorksProject = ko.pureComputed(function () {
+ return self.projectType === "works"
+ });
+
+ self.transients = {
+ viewUrl: ko.observable((self.isWorksProject() ? fcConfig.worksActivityViewUrl : fcConfig.activityViewUrl) + "&activityId=" + self.activityId).extend({returnTo: fcConfig.returnTo, dataVersion: fcConfig.version}),
+ editUrl: ko.observable((self.isWorksProject() ? fcConfig.worksActivityEditUrl : fcConfig.activityEditUrl) + "&activityId=" + self.activityId).extend({returnTo: fcConfig.returnTo}),
+ addUrl: ko.observable(fcConfig.activityAddUrl + "?projectActivityId=" + self.projectActivityId).extend({returnTo: fcConfig.returnTo}),
+ thumbnailUrl: ko.observable(activity.thumbnailUrl || fcConfig.imageLocation + "font-awesome/5.15.4/svgs/regular/image.svg"),
+ loadRecords: function (records) {
+ var allRecords = $.map(activity.records ? activity.records : [], function (record, index) {
+ record.parent = self;
+ record.thumbnailUrl = self.transients.thumbnailUrl();
+ return new RecordViewModel(record);
+ });
+
+ self.records(allRecords);
+ }
+ }
+
+ self.transients.loadRecords(activity.records);
+}
+
+function RecordViewModel (record) {
+ var self = this;
+ if (!record) record = {};
+ self.rawData = record;
+ self.parent = record.parent;
+ self.occurrenceID = record.occurrenceID;
+ self.guid = ko.observable(record.guid);
+ self.name = ko.observable(record.name);
+ self.commonName = record.commonName;
+ self.coordinates = record.coordinates;
+ self.multimedia = record.multimedia || [];
+ self.eventTime = record.eventTime;
+ self.individualCount = record.individualCount;
+ self.eventDate = ko.observable(record.eventDate).extend({simpleDate: false});
+ self.thumbnailUrl = record.thumbnailUrl;
+};
\ No newline at end of file
diff --git a/grails-app/assets/javascripts/pwa-bio-activity-create-or-edit-manifest.js b/grails-app/assets/javascripts/pwa-bio-activity-create-or-edit-manifest.js
new file mode 100644
index 000000000..64d98087c
--- /dev/null
+++ b/grails-app/assets/javascripts/pwa-bio-activity-create-or-edit-manifest.js
@@ -0,0 +1,8 @@
+//= require base-bs4.js
+//= require jstz/jstz.min.js
+//= require common-bs4.js
+//= require forms-manifest.js
+//= require enterBioActivityData.js
+//= require biocollect-utils.js
+//= require pwa-messages.js
+//= require pwa-form-initialisation-script.js
diff --git a/grails-app/assets/javascripts/pwa-bio-activity-index-manifest.js b/grails-app/assets/javascripts/pwa-bio-activity-index-manifest.js
new file mode 100644
index 000000000..e0c8f885b
--- /dev/null
+++ b/grails-app/assets/javascripts/pwa-bio-activity-index-manifest.js
@@ -0,0 +1,9 @@
+//= require jstz/jstz.min.js
+//= require base-bs4.js
+//= require common-bs4.js
+//= require knockout-custom-bindings.js
+//= require forms-manifest.js
+//= require enterBioActivityData.js
+//= require biocollect-utils.js
+//= require pwa-messages.js
+//= require pwa-form-initialisation-script.js
\ No newline at end of file
diff --git a/grails-app/assets/javascripts/pwa-cache.js b/grails-app/assets/javascripts/pwa-cache.js
new file mode 100644
index 000000000..4a3e7027a
--- /dev/null
+++ b/grails-app/assets/javascripts/pwa-cache.js
@@ -0,0 +1,65 @@
+var projectPromise, paPromise;
+var surveyName = "Dung Beetle Monitoring", listId = "dr2683", limit=5, query = "", offset = 0;
+var db = getDB();
+
+function fetchProject() {
+ if (!projectPromise) {
+ projectPromise = $.ajax({
+ url: fcConfig.projectURL
+ });
+ }
+
+ return projectPromise;
+}
+
+function saveProject (project) {
+ var deferred = $.Deferred();
+
+ db.project.put(project).then(function (projectId) {
+ deferred.resolve({message: "Saved project to db", success: true, data: project});
+ }).catch(function () {
+ deferred.reject({message: "Failed to save project to db", success: false});
+ });
+
+ return deferred.promise();
+}
+
+function fetchProjectActivity () {
+ if (!paPromise) {
+ paPromise = $.ajax({
+ url: fcConfig.projectActivityURL
+ });
+ }
+
+ return paPromise;
+}
+
+function saveProjectActivity (pa) {
+ var deferred = $.Deferred();
+
+ db.projectActivity.put(pa).then(function () {
+ deferred.resolve({message: "Saved project activity to db", success: true, data: pa});
+ }).catch(function () {
+ deferred.reject({message: "Failed to save project activity to db", success: false});
+ });
+
+ return deferred.promise();
+}
+
+fetchProject().then(saveProject).then(fetchProjectActivity).then(saveProjectActivity).done(function (result) {
+ var pa = result.data;
+ var promises = [];
+ pa.speciesFields && pa.speciesFields.forEach(function (field) {
+ console.log("fetching species");
+ promises.push(updateDBForField(field.dataFieldName, field.output));
+ });
+
+ $.when.apply($, promises).always(function () {
+ console.log("Starting to fetch sites");
+ fetchSites(pa.sites, 0).done(function (result) {
+ console.log(result.message);
+ });
+ });
+}).fail(function () {
+ alert("Failed to fetch species configuration for survey");
+});
\ No newline at end of file
diff --git a/grails-app/assets/javascripts/pwa-form-initialisation-script.js b/grails-app/assets/javascripts/pwa-form-initialisation-script.js
new file mode 100644
index 000000000..c32ec47a1
--- /dev/null
+++ b/grails-app/assets/javascripts/pwa-form-initialisation-script.js
@@ -0,0 +1,45 @@
+var initialisedSuccessfully = false,
+ delay = 2000;
+document.addEventListener("credential-saved", renderPage);
+document.addEventListener("credential-failed", function (e) {
+ alert("Error occurred while saving credentials");
+});
+
+window.addEventListener("load", function (e) {
+ setTimeout(renderPage, delay);
+});
+
+function renderPage() {
+ if (initialisedSuccessfully) {
+ return;
+ }
+
+ entities.getCredentials().then(function (result) {
+ var credentials = result.data;
+ if (credentials && credentials.length > 0) {
+ var credential = credentials[0];
+ var authorization = "Bearer " + credential.token;
+ $.ajax({
+ url: fcConfig.htmlFragmentURL,
+ dataType: 'html',
+ headers: {
+ 'Authorization': authorization
+ },
+ success: function (html) {
+ // makes sure comments are not removed. Important from KnockoutJS perspective.
+ const constHtml = html;
+ initialisedSuccessfully = true;
+ var element = document.querySelector("#form-placeholder");
+ element.innerHTML = constHtml;
+ biocollect.utils.nodeScriptReplace(element);
+ getMetadataAndInitialise();
+ },
+ error: function (){
+ alert("Error occurred while getting content. Close the modal and try again. If the problem persists, contact the administrator.");
+ }
+ });
+ }
+ }, function () {
+ alert("Error occurred while getting credentials");
+ });
+}
\ No newline at end of file
diff --git a/grails-app/assets/javascripts/pwa-index.js b/grails-app/assets/javascripts/pwa-index.js
new file mode 100644
index 000000000..661c7b1f6
--- /dev/null
+++ b/grails-app/assets/javascripts/pwa-index.js
@@ -0,0 +1,750 @@
+async function downloadMapTiles(bounds, tileUrl, minZoom, maxZoom, callback) {
+ minZoom = minZoom || 0; // Minimum zoom level
+ maxZoom = maxZoom || 20; // Maximum zoom level
+ const MAX_PARALLEL_REQUESTS = 10;
+
+ var deferred = $.Deferred(), requestArray = [];
+ // Check if the browser supports the Cache API
+ if ('caches' in window) {
+ // Function to fetch and cache the vector basemap tiles for a bounding box at different zoom levels
+ try {
+ // Loop through each zoom level
+ for (let zoom = minZoom; zoom <= maxZoom; zoom++) {
+ // Loop through the tiles within the bounding box at the current zoom level
+ var coordinates = getTileCoordinatesForBoundsAtZoom(bounds, zoom),
+ xMin = coordinates[0][0],
+ xMax = coordinates[1][0],
+ yMin = coordinates[0][1],
+ yMax = coordinates[1][1];
+
+ for (let x = xMin; x <= xMax; x++) {
+ for (let y = yMin; y <= yMax; y++) {
+ try {
+ const requestUrl = tileUrl.replace('{z}', zoom).replace('{x}', x).replace('{y}', y);
+
+ // Open the cache
+ const cache = await caches.open(cacheName);
+
+ // Check if the tile is already cached
+ const cachedResponse = await cache.match(requestUrl);
+
+ if (!cachedResponse) {
+ console.log(`Tile at zoom ${zoom}, x ${x}, y ${y} not found in cache. Fetching and caching...`);
+
+ // run x number of queries in parallel
+ if (requestArray.length <= MAX_PARALLEL_REQUESTS) {
+ requestArray.push(fetch(requestUrl).then(function (response) {
+ // Clone the response, as it can only be consumed once
+ const responseClone = response.clone();
+
+ // Cache the response
+ cache.put(requestUrl, responseClone);
+ }));
+ } else {
+ await Promise.all(requestArray);
+ requestArray = [];
+ }
+
+ console.log(`Tile at zoom ${zoom}, x ${x}, y ${y} cached.`);
+ } else {
+ console.log(`Tile at zoom ${zoom}, x ${x}, y ${y} found in cache.`);
+ }
+ } catch (e) {
+ console.error("Error fetching tiles" + e);
+ }
+
+ callback && callback();
+ }
+ }
+ }
+
+ if (requestArray.length > 0) {
+ await Promise.all(requestArray);
+ }
+ console.log('Vector basemap tiles cached for the bounding box.');
+ deferred.resolve();
+ } catch (error) {
+ console.error('Error caching vector basemap: ' + error);
+ deferred.reject();
+ } // Call the function to cache the vector basemap tiles for the bounding box
+ } else {
+ console.log('Cache API not supported in this browser.');
+ deferred.reject();
+ }
+
+ return deferred.promise();
+}
+
+async function deleteMapTiles(bounds, tileUrl, minZoom, maxZoom, callback) {
+ minZoom = minZoom || 0; // Minimum zoom level
+ maxZoom = maxZoom || 20; // Maximum zoom level
+
+ var deferred = $.Deferred();
+ // Check if the browser supports the Cache API
+ if ('caches' in window) {
+ // Function to fetch and cache the vector basemap tiles for a bounding box at different zoom levels
+ try {
+ // Loop through each zoom level
+ for (let zoom = minZoom; zoom <= maxZoom; zoom++) {
+ // Loop through the tiles within the bounding box at the current zoom level
+ var coordinates = getTileCoordinatesForBoundsAtZoom(bounds, zoom),
+ xMin = coordinates[0][0],
+ xMax = coordinates[1][0],
+ yMin = coordinates[0][1],
+ yMax = coordinates[1][1];
+
+ for (let x = xMin; x <= xMax; x++) {
+ for (let y = yMin; y <= yMax; y++) {
+ const requestUrl = tileUrl.replace('{z}', zoom).replace('{x}', x).replace('{y}', y);
+
+ // Open the cache
+ const cache = await caches.open(cacheName);
+
+ // Check if the tile is already cached
+ await cache.delete(requestUrl);
+
+ callback && callback();
+ }
+ }
+ }
+
+ console.log('Vector basemap tiles cached for the bounding box.');
+ deferred.resolve();
+ } catch (error) {
+ console.error('Error caching vector basemap: ' + error);
+ deferred.reject();
+ } // Call the function to cache the vector basemap tiles for the bounding box
+ } else {
+ console.log('Cache API not supported in this browser.');
+ deferred.reject();
+ }
+
+ return deferred.promise();
+}
+
+function getTileCoordinatesForBoundsAtZoom (bounds, zoom) {
+ var nLat = bounds.getNorth(),
+ sLat = bounds.getSouth(),
+ wLng = bounds.getWest(),
+ eLng = bounds.getEast(),
+ xWest = tileXCoordinateFromLatLng(nLat, wLng, zoom),
+ xEast = tileXCoordinateFromLatLng(sLat, eLng, zoom),
+ yNorth = tileYCoordinateFromLatLng(nLat, eLng, zoom),
+ ySouth = tileYCoordinateFromLatLng(sLat, wLng, zoom),
+ xMin = Math.min(xWest, xEast),
+ xMax = Math.max(xWest, xEast),
+ yMin = Math.min(yNorth, ySouth),
+ yMax = Math.max(yNorth, ySouth);
+
+ // each zoom level should have at least 25 tiles i.e. 5 in each axis
+ if ((yMax - yMin) * (xMax - xMin) < minNumberOfTilesPerZoom) {
+ var xDiff = xMax - xMin,
+ yDiff = yMax - yMin,
+ xAddEachSide = Math.floor((maxTilesPerAxis - xDiff) / 2),
+ yAddEachSide = Math.floor((maxTilesPerAxis - yDiff) / 2),
+ max = Math.pow(2, zoom) - 1;
+
+ xMin -= xAddEachSide;
+ xMax += xAddEachSide;
+ yMin -= yAddEachSide;
+ yMax += yAddEachSide;
+ xMin = xMin < 0 ? 0 : xMin;
+ xMax = xMax > max ? max : xMax;
+ yMin = yMin < 0 ? 0 : yMin;
+ yMax = yMax > max ? max : yMax;
+ }
+
+ return [[xMin, yMin], [xMax, yMax]];
+}
+
+function totalTilesForBoundsAtZoom (bounds, zoom) {
+ var coordinates = getTileCoordinatesForBoundsAtZoom(bounds, zoom),
+ xMin = coordinates[0][0],
+ xMax = coordinates[1][0],
+ yMin = coordinates[0][1],
+ yMax = coordinates[1][1];
+
+ return (xMax - xMin + 1) * (yMax - yMin + 1);
+}
+
+function totalTilesForBounds(bounds, minZoom, maxZoom) {
+ var totalTiles = 0;
+ for (var zoom = minZoom; zoom <= maxZoom; zoom++) {
+ totalTiles += totalTilesForBoundsAtZoom(bounds, zoom);
+ }
+
+ return totalTiles;
+}
+
+// Function to convert longitude to tile coordinate
+function tileXCoordinateFromLatLng(lat, lng, zoom) {
+ var latLng = L.latLng(lat, lng);
+ return Math.floor(crs.latLngToPoint(latLng, zoom).x / tileSize);
+}
+
+// Function to convert latitude to tile coordinate
+function tileYCoordinateFromLatLng(lat, lng, zoom) {
+ var latLng = L.latLng(lat, lng);
+ return Math.floor(crs.latLngToPoint(latLng, zoom).y / tileSize);
+}
+
+function OfflineViewModel(config) {
+ var self = this,
+ minZoom = config.minZoom || 0,
+ maxZoom = config.maxZoom || 20,
+ mapId = config.mapId,
+ overlayLayersMapControlConfig = Biocollect.MapUtilities.getOverlayConfig(),
+ mapOptions = {
+ autoZIndex: false,
+ zoomToObject: true,
+ preserveZIndex: true,
+ addLayersControlHeading: false,
+ drawControl: false,
+ showReset: false,
+ draggableMarkers: false,
+ useMyLocation: true,
+ maxAutoZoom: maxZoom,
+ maxZoom: maxZoom,
+ minZoom: minZoom,
+ allowSearchLocationByAddress: true,
+ allowSearchRegionByAddress: true,
+ trackWindowHeight: false,
+ baseLayer: L.tileLayer(config.baseMapUrl, config.baseMapOptions),
+ wmsFeatureUrl: overlayLayersMapControlConfig.wmsFeatureUrl,
+ wmsLayerUrl: overlayLayersMapControlConfig.wmsLayerUrl
+ },
+ alaMap = new ALA.Map(mapId, mapOptions),
+ mapImpl = alaMap.getMapImpl(),
+ pa = null,
+ project = null,
+ mapSection = config.mapSection || "mapSection";
+
+ self.stages = {metadata: 'metadata', species: 'species', map: 'map', form: 'form', sites: "sites"};
+ self.statuses = {done: 'downloaded', doing: 'downloading', error: 'error', wait: 'waiting'};
+ self.currentStage = ko.observable();
+ self.metadataStatus = ko.observable(self.statuses.wait);
+ self.speciesStatus = ko.observable(self.statuses.wait);
+ self.sitesStatus = ko.observable(self.statuses.wait);
+ self.mapStatus = ko.observable(self.statuses.wait);
+ self.formStatus = ko.observable(self.statuses.wait);
+ self.isOnline = ko.observable(true);
+ self.name = ko.observable();
+ self.downloading = ko.observable(false);
+ self.offlineMaps = ko.observableArray([]);
+ self.bounds = ko.observable(mapImpl.getBounds());
+ self.areaInKmOfBounds = ko.pureComputed(function () {
+ var bounds = self.bounds();
+ return bounds && (area( bounds ) / Math.pow(10, 6));
+ })
+ self.numberOfTilesDownloaded = ko.observable(0);
+ self.totalNumberOfTiles = ko.observable(1);
+ self.progress = ko.observable(0);
+ self.totalCount = ko.observable(1);
+ self.numberOfFormsDownloaded = ko.observable(0);
+ self.totalFormDownload = ko.observable(1);
+ self.loadMetadata = ko.observable(false);
+ self.totalSiteTilesDownload = ko.observable(1);
+ self.numberOfSiteTilesDownloaded = ko.observable(0);
+ self.percentageFormDownloaded = ko.pureComputed(function (){
+ return Math.round(self.numberOfFormsDownloaded() / self.totalFormDownload() * 100);
+ });
+ self.percentageSitesDownloaded = ko.pureComputed(function (){
+ return Math.round(self.numberOfSiteTilesDownloaded() / self.totalSiteTilesDownload() * 100);
+ });
+ self.isSurveyOfflineCapable = ko.computed(function () {
+ var statuses = [self.metadataStatus(), self.formStatus(), self.speciesStatus(), self.mapStatus(), self.sitesStatus()]
+ if (statuses.every(item => item === self.statuses.done)) {
+ window.parent && window.parent.postMessage && window.parent.postMessage({event: "download-complete"}, "*");
+ }
+ else {
+ window.parent && window.parent.postMessage && window.parent.postMessage({event: "download-removed"}, "*");
+ }
+ });
+
+ self.canMapBeOffline = ko.pureComputed(function () {
+ return self.offlineMaps().length > 0;
+ });
+ self.showSpeciesProgressBar = ko.pureComputed(function () {
+ return self.progress() > 0;
+ });
+ self.downloadPercentageComplete = ko.pureComputed(function () {
+ return Math.round(self.numberOfTilesDownloaded() / self.totalNumberOfTiles() * 100);
+ });
+ self.canDownload = ko.computed(function () {
+ return !!self.name() && self.isBoundsWithinMaxArea();
+ });
+ self.isBoundsWithinMaxArea = ko.pureComputed(function () {
+ var bounds = self.bounds();
+ return area(bounds) <= maxArea;
+ });
+
+ self.speciesDownloadPercentageComplete = ko.pureComputed(function (){
+ return Math.round(self.progress() / self.totalCount() * 100);
+ });
+
+ self.offlineMaps.subscribe(offlineMapCheck);
+ self.currentStage.subscribe(function (stage) {
+ switch (stage) {
+ case self.stages.metadata:
+ getProjectActivityMetadata();
+ break;
+ case self.stages.form:
+ startDownloadingSurveyForms();
+ break;
+ case self.stages.species:
+ startDownloadingSpecies();
+ break;
+ case self.stages.sites:
+ startDownloadingSites();
+ break;
+ case self.stages.map:
+ offlineMapCheck();
+ break;
+ }
+ });
+
+ function offlineMapCheck() {
+ if (self.canMapBeOffline()) {
+ self.mapStatus(self.statuses.done);
+ }
+ else {
+ self.mapStatus(self.statuses.error);
+ }
+ }
+
+ self.clickSpeciesDownload = function () {
+ self.progress(0);
+ self.totalCount(1);
+ entities.deleteSpeciesForProjectActivity(pa).then(function () {
+ entities.getSpeciesForProjectActivity(pa, updateSpeciesProgressBar).then(completedSpeciesDownload, errorSpeciesDownload);
+ });
+ }
+
+ self.clickDownload = function () {
+ if (self.canDownload()) {
+ var bounds = self.bounds(),
+ baseMapUrl = config.baseMapUrl,
+ minZoom = config.minZoom || 0;
+
+ self.downloading(true);
+ self.numberOfTilesDownloaded(0);
+ self.totalNumberOfTiles(totalTilesForBounds(bounds, minZoom, maxZoom));
+ downloadMapTiles(bounds, config.baseMapUrl, minZoom, maxZoom, function () {
+ self.numberOfTilesDownloaded(self.numberOfTilesDownloaded() + 1);
+ }).finally(function () {
+ self.numberOfTilesDownloaded(0);
+ self.totalNumberOfTiles(1);
+ self.downloading(false);
+ entities.saveMap({
+ name: self.name(),
+ bounds: self.getBoundsArray(bounds),
+ baseMapUrl: baseMapUrl
+ }).then(function (result) {
+ self.getOfflineMaps();
+ });
+ });
+ }
+ }
+
+ self.getOfflineMaps = function () {
+ entities.getMaps().then(function (result) {
+ var maps = result.data;
+ self.offlineMaps(maps);
+ });
+ }
+
+ self.checkSiteInOfflineDownload = function (data) {
+ var deferred = $.Deferred();
+ entities.getMaps().then(function (result) {
+ var maps = result.data;
+ var found = maps.find(function (map) {
+ return map.name === data.name;
+ });
+
+ deferred.resolve(!!found, data);
+ }, deferred.reject);
+
+ return deferred.promise();
+ }
+
+ self.getBoundsArray = function (bounds) {
+ return [{lat: bounds.getNorth(), lng:bounds.getWest()}, {lat: bounds.getSouth(), lng:bounds.getEast()}];
+ }
+
+ self.getBoundsFromArray = function (boundsArray) {
+ var nw = L.latLng(boundsArray[0]),
+ se = L.latLng(boundsArray[1]);
+
+ return L.latLngBounds(nw, se);
+ }
+
+ self.preview = function (data) {
+ var data = this,
+ boundsArray = data.bounds,
+ bounds = self.getBoundsFromArray(boundsArray);
+
+ bounds && mapImpl.fitBounds(bounds);
+ }
+
+ self.removeMe = function () {
+ var data = this,
+ boundsArray = data.bounds,
+ bounds = self.getBoundsFromArray(boundsArray),
+ minZoom = 14;
+
+ if (data && data.id) {
+ deleteMapTiles(bounds, data.baseMapUrl).finally(function () {
+ entities.deleteMap(data.id).then(function (){
+ self.offlineMaps.remove(data);
+ });
+ });
+ }
+ }
+
+ self.scrollToMapSection = function () {
+ var offset = $("#" + mapSection).offset();
+
+ if (offset) {
+ $("html, body").animate({
+ scrollTop: offset.top + 'px'
+ });
+ }
+ }
+
+ function area(bounds) {
+ var pt1 = new L.LatLng(bounds.getNorth(), bounds.getWest()),
+ pt2 = new L.LatLng(bounds.getNorth(), bounds.getEast()),
+ pt3 = new L.LatLng(bounds.getSouth(), bounds.getEast()),
+ pt4 = new L.LatLng(bounds.getSouth(), bounds.getWest()),
+ area = pt1.distanceTo(pt2) * pt3.distanceTo(pt4);
+
+ console.log(area);
+ return area;
+ }
+
+ function SiteSelectionViewModel(sites){
+ var self = this,
+ deferred = $.Deferred();
+ self.chosenSites = ko.observableArray();
+ self.sites = ko.observableArray(sites);
+ self.ok = function () {
+ self.close();
+ deferred.resolve(self.chosenSites());
+ }
+ self.siteSearchValue = ko.observable("");
+ self.tempSearchValue = ko.observable("");
+ self.searchSitesHandler = function () {
+ self.tempSearchValue(self.siteSearchValue());
+ }
+ self.clearSearch = function () {
+ self.siteSearchValue("");
+ self.tempSearchValue("");
+ }
+ self.isSiteVisible = function (site) {
+ var name = (site.name || "").trim().toLowerCase(),
+ query = self.tempSearchValue().trim().toLowerCase();
+ return name.indexOf(query) > -1;
+ };
+
+ self.cancel = function () {
+ self.close();
+ deferred.resolve();
+ }
+
+ self.close = function () {
+ self.modal && self.modal.close();
+ }
+
+ self.promise = deferred.promise();
+ }
+
+ /**
+ * Downloads base map tiles and wms layer of a site for offline use.
+ * It is done for all sites of a project activity.
+ * @returns {Promise}
+ */
+ async function startDownloadingSites() {
+ const TIMEOUT = 3000, // 3 seconds
+ MAP_LOAD_TIMEOUT = 1000, // 1 seconds
+ MAX_ZOOM=20,
+ MIN_ZOOM= 10,
+ MAX_SITES_DOWNLOADABLE = 30;
+ var sites = pa.sites || [], zoom = 15, mapZoomedInIndicator, tileLoadedPromise, cancelTimer,
+ selectedSites = [],
+ callback = function () {
+ cancelTimer && clearTimeout(cancelTimer);
+ cancelTimer = null;
+ // resolve it in the next event loop
+ if(mapZoomedInIndicator && mapZoomedInIndicator.state() == 'pending') {
+ // setTimeout(function () {
+ mapZoomedInIndicator && mapZoomedInIndicator.resolve();
+ // }, 0);
+ }
+ };
+ self.currentStage(self.stages.sites);
+ self.sitesStatus(self.statuses.doing);
+ alaMap.registerListener('dataload', callback);
+ sites.sort(function (a, b) {
+ var aName = (a.name || "").trim(),
+ bName = (b.name || "").trim();
+ return aName.localeCompare(bName)
+ });
+
+ if (sites.length > MAX_SITES_DOWNLOADABLE) {
+ var selectionModel = new SiteSelectionViewModel(sites);
+ var modal = Biocollect.Modals.showModal({
+ viewModel: selectionModel,
+ template: 'ChooseSites'
+ });
+
+ selectedSites = await selectionModel.promise;
+ selectedSites = selectedSites || [];
+ } else {
+ selectedSites = sites;
+ }
+
+ try {
+ self.numberOfSiteTilesDownloaded(0);
+ self.totalSiteTilesDownload(selectedSites.length);
+ for (var i = 0; i < selectedSites.length; i++) {
+ try {
+ var site = selectedSites[i],
+ geoJson = Biocollect.MapUtilities.featureToValidGeoJson(site.extent.geometry),
+ geoJsonLayer = alaMap.setGeoJSON(geoJson, {
+ wmsFeatureUrl: overlayLayersMapControlConfig.wmsFeatureUrl,
+ wmsLayerUrl: overlayLayersMapControlConfig.wmsLayerUrl,
+ maxZoom: MAX_ZOOM
+ }),
+ bounds;
+
+ // so that layer zooms beyond default max zoom of 18
+ geoJsonLayer.options.maxZoom = MAX_ZOOM;
+ mapZoomedInIndicator = $.Deferred();
+ // cancel waiting for map to load feature data
+ cancelTimer = setTimeout(function () {
+ mapZoomedInIndicator && mapZoomedInIndicator.resolve();
+ }, TIMEOUT);
+
+ // no need to wait if promise is resolved.
+ if (mapZoomedInIndicator && mapZoomedInIndicator.state() == 'pending') {
+ // wait for map layer to load feature data from spatial server for pid.
+ await mapZoomedInIndicator.promise();
+ }
+
+ // zoom into to map to get tiles and feature from spatial server
+ for (zoom = MIN_ZOOM; zoom <= MAX_ZOOM; zoom++) {
+ tileLoadedPromise = $.Deferred();
+ mapImpl.setZoom(zoom, {animate: false});
+ timer(MAP_LOAD_TIMEOUT, tileLoadedPromise);
+ if (zoom === MIN_ZOOM)
+ bounds = mapImpl.getBounds();
+ await tileLoadedPromise.promise();
+ }
+
+ // save site to offline map list
+ self.checkSiteInOfflineDownload({
+ name: site.name,
+ bounds: self.getBoundsArray(bounds)
+ }).then(function (found, data) {
+ if (!found) {
+ entities.saveMap({
+ name: data.name,
+ bounds: data.bounds,
+ baseMapUrl: config.baseMapUrl
+ }).then(function (result) {
+ self.getOfflineMaps();
+ });
+ }
+ })
+ alaMap.clearLayers();
+ self.numberOfSiteTilesDownloaded(self.numberOfSiteTilesDownloaded() + 1);
+ bounds = null;
+ }
+ catch (e) {
+ console.log("Error downloading site " + selectedSites[i].siteId + " " + selectedSites[i].name);
+ }
+ }
+
+ alaMap.removeListener('dataload', callback);
+ completedSitesDownload();
+ } catch (e) {
+ console.error(e);
+ errorSitesDownload();
+ }
+ }
+
+ function timer(ms, deferred) {
+ return setTimeout(deferred.resolve, ms);
+ }
+
+ function completedSitesDownload() {
+ updateSitesProgressBar(self.totalCount(), self.totalCount());
+ self.sitesStatus(self.statuses.done);
+ if (self.mapStatus() != self.statuses.done) {
+ self.mapStatus(self.statuses.doing);
+ self.currentStage(self.stages.map);
+ }
+ }
+
+ function errorSitesDownload() {
+ self.sitesStatus(self.statuses.error);
+ showReloadPrompt();
+ }
+
+ function startDownloadingSpecies() {
+ self.currentStage(self.stages.species);
+ self.speciesStatus(self.statuses.doing);
+ entities.getSpeciesForProjectActivity(pa, updateSpeciesProgressBar).then(completedSpeciesDownload, errorSpeciesDownload);
+ }
+
+ function updateSitesProgressBar (total, count) {
+ self.totalCount(total);
+ self.progress(count);
+ }
+
+ function completedSpeciesDownload() {
+ updateSpeciesProgressBar(self.totalCount(), self.totalCount());
+ self.speciesStatus(self.statuses.done);
+ self.sitesStatus(self.statuses.doing);
+ self.currentStage(self.stages.sites);
+ }
+
+ function errorSpeciesDownload() {
+ self.speciesStatus(self.statuses.error);
+ showReloadPrompt();
+ }
+
+ function updateSpeciesProgressBar (total, count) {
+ self.totalCount(total);
+ self.progress(count);
+ }
+
+ function startDownloadingSurveyForms() {
+ if ('serviceWorker' in navigator) {
+ navigator.serviceWorker.getRegistration().then( function (){
+ self.formStatus(self.statuses.doing);
+ downloadProjectActivityArtefacts(self).then(completedFormDownload, errorFormDownload);
+ }, errorFormDownload);
+ }
+ else {
+ errorFormDownload();
+ }
+ }
+
+ function completedFormDownload() {
+ self.formStatus(self.statuses.done);
+ self.currentStage(self.stages.species);
+ self.speciesStatus(self.statuses.doing);
+ }
+
+ function errorFormDownload() {
+ self.formStatus(self.statuses.error);
+ showReloadPrompt();
+ }
+
+ function showReloadPrompt () {
+ bootbox.confirm({
+ title: 'Failed to take survey offline',
+ message: 'Encountered an error while taking survey offline. Click reload to try again. Contact administrator if problem persists.',
+ buttons: {
+ cancel: {
+ label: ' Cancel'
+ },
+ confirm: {
+ label: ' Reload'
+ }
+ },
+ callback: function (result) {
+ if (result) {
+ window.location.reload();
+ }
+ }
+ });
+ }
+
+ function getProjectActivityMetadata() {
+ self.metadataStatus(self.statuses.doing);
+ return entities.getProjectActivityMetadata(config.projectActivityId, undefined).then(function (result) {
+ var data = result.data,
+ deferred = $.Deferred();
+ pa = data.pActivity;
+ project = data.project;
+ entities.saveSites(pa.sites).then(completedMetadataDownload, errorMetadataDownload);
+ return deferred.promise();
+ });
+ }
+
+ function completedMetadataDownload() {
+ self.metadataStatus(self.statuses.done);
+ self.currentStage(self.stages.form);
+ self.formStatus(self.statuses.doing);
+ }
+
+ function errorMetadataDownload() {
+ self.metadataStatus(self.statuses.error);
+ showReloadPrompt();
+ }
+
+ if (!config.doNotInit) {
+ (function init() {
+ alaMap.registerListener('zoomend', function () {
+ self.bounds(mapImpl.getBounds());
+ })
+
+ self.getOfflineMaps();
+ self.currentStage(self.stages.metadata);
+ })();
+ }
+}
+
+
+function downloadProjectActivityArtefacts(viewModel) {
+ var IFRAME_ID = 'form-content',
+ iframeWindow,
+ delay = 4 * 60 * 1000, // four minutes
+ deferred = $.Deferred();
+
+ var urls = [fcConfig.createActivityUrl, fcConfig.indexActivityUrl, fcConfig.offlineListUrl, fcConfig.settingsUrl],
+ urlsIndex = 0;
+
+ document.addEventListener('view-model-loaded',function () {
+ increaseFormDownloadedCount();
+ ++urlsIndex;
+ loadIframe();
+ });
+
+ function loadIframe () {
+ if (urlsIndex < urls.length) {
+ var url = urls[urlsIndex],
+ iframe = document.getElementById(IFRAME_ID);
+ iframe.src = url;
+ iframeWindow = iframe.contentWindow;
+ rejectPromiseIfErrorLoadingPage(urlsIndex);
+ increaseFormDownloadedCount();
+ } else {
+ console.info("Finished downloading artefacts!");
+ deferred.resolve();
+ }
+ }
+
+ function increaseFormDownloadedCount () {
+ viewModel.numberOfFormsDownloaded(viewModel.numberOfFormsDownloaded() + 1);
+ }
+
+ function rejectPromiseIfErrorLoadingPage (index) {
+ setTimeout(function () {
+ if (index == urlsIndex) {
+ deferred.reject();
+ }
+ }, delay);
+ }
+
+ function init(){
+ viewModel.totalFormDownload(urls.length * 2);
+ viewModel.numberOfFormsDownloaded(0);
+ loadIframe();
+ }
+
+ init();
+ return deferred.promise();
+};
\ No newline at end of file
diff --git a/grails-app/assets/javascripts/pwa-manifest.js b/grails-app/assets/javascripts/pwa-manifest.js
new file mode 100644
index 000000000..967050932
--- /dev/null
+++ b/grails-app/assets/javascripts/pwa-manifest.js
@@ -0,0 +1,13 @@
+//= require base-bs4.js
+//= require knockout/3.4.0/knockout-3.4.0.js
+//= require knockout-custom-bindings.js
+//= require knockout-custom-extenders.js
+//= require bootbox/bootbox.min.js
+//= require utils.js
+//= require dexiejs/dexie.js
+//= require ala-map-no-jquery-us.js
+//= require MapUtilities.js
+//= require entities.js
+//= require modals.js
+//= require pwa-messages.js
+//= require pwa-index.js
diff --git a/grails-app/assets/javascripts/pwa-messages.js b/grails-app/assets/javascripts/pwa-messages.js
new file mode 100644
index 000000000..82483779f
--- /dev/null
+++ b/grails-app/assets/javascripts/pwa-messages.js
@@ -0,0 +1,24 @@
+window.addEventListener('message', function(event) {
+ var origins = [fcConfig.originUrl, fcConfig.pwaAppUrl]
+ if (origins.indexOf(event.origin) == -1)
+ return
+
+ var type = event.data.event;
+ switch (type) {
+ case 'viewmodelloadded':
+ // fired by the iframe when the view model is loaded
+ var viewModelLoadedEvent = new Event('view-model-loaded');
+ document.dispatchEvent(viewModelLoadedEvent);
+ break;
+ case 'credentials':
+ entities.saveCredentials(event.data.data).then(function (){
+ var credentialSavedEvent = new Event('credential-saved');
+ document.dispatchEvent(credentialSavedEvent);
+ }, function (){
+ var credentialFailedEvent = new Event('credential-failed');
+ document.dispatchEvent(credentialFailedEvent);
+ });
+ break;
+ }
+
+})
\ No newline at end of file
diff --git a/grails-app/assets/javascripts/pwa-offline-list-manifest.js b/grails-app/assets/javascripts/pwa-offline-list-manifest.js
new file mode 100644
index 000000000..856900c51
--- /dev/null
+++ b/grails-app/assets/javascripts/pwa-offline-list-manifest.js
@@ -0,0 +1,18 @@
+//= require jquery/3.4.1/jquery-3.4.1.min.js
+//= require knockout/3.4.0/knockout-3.4.0.js
+//= require dexiejs/dexie.js
+//= require emitter/emitter.js
+//= require moment/moment.min.js
+//= require moment/moment-timezone-with-data.min.js
+//= require bootstrap4/js/bootstrap.bundle.min.js
+//= require bootbox/bootbox.min.js
+//= require knockout-dates.js
+//= require fieldcapture-application.js
+//= require enterBioActivityData.js
+//= require images.js
+//= require entities.js
+//= require metamodel.js
+//= require utils.js
+//= require pagination.js
+//= require pwa-messages.js
+//= require offline-list.js
\ No newline at end of file
diff --git a/grails-app/assets/javascripts/pwa-settings-manifest.js b/grails-app/assets/javascripts/pwa-settings-manifest.js
new file mode 100644
index 000000000..a3c358b5d
--- /dev/null
+++ b/grails-app/assets/javascripts/pwa-settings-manifest.js
@@ -0,0 +1,8 @@
+//= require jquery/3.4.1/jquery-3.4.1.min.js
+//= require knockout/3.4.0/knockout-3.4.0.js
+//= require utils.js
+//= require dexiejs/dexie.js
+//= require bootstrap4/js/bootstrap.bundle.min.js
+//= require bootbox/bootbox.min.js
+//= require entities.js
+//= require pwa-settings.js
\ No newline at end of file
diff --git a/grails-app/assets/javascripts/pwa-settings.js b/grails-app/assets/javascripts/pwa-settings.js
new file mode 100644
index 000000000..3c9d97612
--- /dev/null
+++ b/grails-app/assets/javascripts/pwa-settings.js
@@ -0,0 +1,88 @@
+function StorageViewModel() {
+ var self = this,
+ deleteSteps = ['cache', 'db'];
+ self.maximum = ko.observable();
+ self.used = ko.observable();
+ self.free = ko.observable();
+ self.percentage = ko.computed(function () {
+ return Math.round(self.used() / self.maximum() * 100);
+ });
+ self.isOffline = ko.observable(false);
+ self.deleteProgress = ko.observable(0);
+ self.deleteSteps = ko.observable(deleteSteps.length);
+ self.deletePercentage = ko.computed(function () {
+ return Math.round(self.deleteProgress() / self.deleteSteps() * 100);
+ });
+ self.supported = ko.observable(true);
+ self.refresh = function () {
+ if (navigator.storage && navigator.storage.estimate) {
+ navigator.storage.estimate().then(
+ ({ usage, quota }) => {
+ var gbUnit = 1024 * 1024 * 1024;
+ self.maximum(quota / gbUnit);
+ self.used(usage / gbUnit);
+ self.free(self.maximum() - self.used());
+ },
+ error => console.warn(`error estimating quota: ${error.name}: ${error.message}`)
+ );
+ }
+ else {
+ self.supported(false);
+ }
+ }
+
+ self.clearAll = function () {
+ self.deleteProgress(0);
+ bootbox.confirm("This operation cannot be reversed. Are you sure you want to delete?", function (result) {
+ if (result) {
+ self.deleteCache().then(self.deleteDBEntries).then(function () {
+ self.deleteProgress(self.deleteSteps());
+ notifyParent();
+ });
+ }
+ });
+ }
+
+ self.deleteCache = function () {
+ return caches.keys().then(function (cacheNames) {
+ return Promise.all(
+ cacheNames.map(function (cacheName) {
+ return caches.delete(cacheName);
+ })
+ );
+ }).then(function () {
+ self.refresh();
+ self.deleteProgress(self.deleteProgress() + 1);
+ });
+ }
+
+ self.deleteDBEntries = function () {
+ return entities.deleteTable('offlineMap').then(function () {
+ return entities.deleteTable('taxon').then(function () {
+ self.deleteProgress(self.deleteProgress() + 1);
+ });
+ });
+ }
+
+ function notifyParent() {
+ window.parent && window.parent.postMessage({event: "surveys-removed"}, "*");
+ }
+
+ document.addEventListener('offline', function () {
+ self.isOffline(true);
+ });
+
+ document.addEventListener('online', function () {
+ self.isOffline(false);
+ });
+
+ self.refresh();
+}
+
+function initialise() {
+ var storageViewModel = new StorageViewModel();
+ ko.applyBindings(storageViewModel, document.getElementById('storage-settings'));
+ checkOfflineForIntervalAndTriggerEvents(5000);
+}
+
+$(document).ready(initialise);
\ No newline at end of file
diff --git a/grails-app/assets/javascripts/sw.js b/grails-app/assets/javascripts/sw.js
new file mode 100644
index 000000000..75244aa4c
--- /dev/null
+++ b/grails-app/assets/javascripts/sw.js
@@ -0,0 +1,111 @@
+console.debug("SW Script: start reading");
+importScripts("/pwa/config.js");
+self.addEventListener('install', e => {
+ // activate SW immediately. This avoids the need to close pages controlled by old SW.
+ self.skipWaiting();
+ // Remove unwanted caches
+ e.waitUntil(
+ caches.keys().then(cacheNames => {
+ return Promise.all(
+ cacheNames.map(cache => {
+ if (pwaConfig.oldCacheToDelete === cache) {
+ console.log('Service Worker: Clearing Old Cache');
+ return caches.delete(cache);
+ }
+ })
+ );
+ })
+ );
+
+ e.waitUntil(precache());
+ console.log("SW: Install");
+});
+self.addEventListener('activate', e => {
+ e.waitUntil(self.clients.claim());
+ console.log("SW: Activated");
+});
+
+self.addEventListener('fetch', e => {
+ console.log('Service Worker: Fetching');
+ e.respondWith(
+ fetch(e.request)
+ .then(res => {
+ // Make copy/clone of response
+ const resClone = res.clone();
+ // Open cache
+ if (res.ok) {
+ caches.open(pwaConfig.cacheName).then(cache => {
+ var path = getPath(e.request.url);
+ if (!ignoreCachingForPath(path)) {
+ path = getCachePath(e.request.url);
+ cache.put(path, resClone);
+ }
+ });
+ }
+
+ return res;
+ })
+ .catch(err => {
+ var path = getPath(e.request.url);
+ if (!ignoreCachingForPath(path)) {
+ path = getCachePath(e.request.url);
+ return caches.match(path).then(res => {
+ if (res) {
+ return res;
+ }
+ else if (isFetchingBaseMap(e.request.url)) {
+ return caches.match(pwaConfig.noCacheTileFile).then(res => {
+ if (res) {
+ return res;
+ }
+ });
+ }
+ });
+ }
+
+ return err;
+ })
+ );
+});
+console.debug("SW Script: completed registering listeners");
+function getPath(url) {
+ return new URL(url).pathname;
+}
+
+function getCachePath(url) {
+ var path = new URL(url).pathname;
+ for (var i in pwaConfig.cachePathForRequestsStartingWith) {
+ var cachePath = pwaConfig.cachePathForRequestsStartingWith[i];
+ if (path.indexOf(cachePath) === 0) {
+ return path;
+ }
+ }
+
+ return url;
+}
+
+function ignoreCachingForPath(urlPath) {
+ for (var i in pwaConfig.pathsToIgnoreCache) {
+ var path = pwaConfig.pathsToIgnoreCache[i];
+ if (urlPath.indexOf(path) == 0) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+function isFetchingBaseMap (url) {
+ return url.indexOf(pwaConfig.baseMapPrefixUrl) === 0;
+}
+
+async function precache() {
+ const cache = await caches.open(pwaConfig.cacheName);
+
+ for(var i = 0; i < pwaConfig.filesToPreCache.length; i++) {
+ await cache.delete(pwaConfig.filesToPreCache[i]);
+ }
+
+ return cache.addAll(pwaConfig.filesToPreCache);
+}
+console.debug("SW Script: end reading");
\ No newline at end of file
diff --git a/grails-app/assets/javascripts/works.js b/grails-app/assets/javascripts/works.js
index 421e028e7..b7ba6fd7c 100644
--- a/grails-app/assets/javascripts/works.js
+++ b/grails-app/assets/javascripts/works.js
@@ -434,6 +434,39 @@ function PlanViewModel(config) {
placeholder = config.placeholder,
sites = config.sites;
+ /**
+ * Moved resolveSites since this function conflict with resolveSites from utils.js
+ * It is for projects which contain a list of site ids instead of sites
+ * e.g workprojects
+ * @param sites
+ * @param addNotFoundSite
+ * @returns {Array}
+ */
+ window.resolveSites = function resolveSites(sites, addNotFoundSite) {
+ var resolved = [];
+ sites = sites || [];
+
+ sites.forEach(function (siteId) {
+ var site;
+ if(typeof siteId === 'string'){
+ site = lookupSite(siteId);
+
+ if(site){
+ resolved.push(site);
+ } else if(addNotFoundSite && siteId) {
+ resolved.push({
+ name: 'User created site',
+ siteId: siteId
+ });
+ }
+ } else if(typeof siteId === 'object'){
+ resolved.push(siteId);
+ }
+ });
+
+ return resolved;
+ }
+
self.userIsCaseManager = ko.observable(fcConfig.isCaseManager);
self.selectedWorksActivityViewModel = ko.observable();
self.canEditOutputTargets = ko.computed(function() {
@@ -787,12 +820,10 @@ function PlanStage(stage, activities, planViewModel, isCurrentStage, project) {
});
};
-
function lookupSiteName (siteId) {
var site = lookupSite(siteId) || {};
return site.name;
}
-
function lookupSite (siteId) {
var site;
if (siteId !== undefined && siteId !== '') {
@@ -805,38 +836,6 @@ function lookupSite (siteId) {
}
}
}
-/**
-* It is for projects which contain a list of site ids instead of sites
- * e.g workprojects
-* @param sites
-* @param addNotFoundSite
-* @returns {Array}
- */
-function resolveSites(sites, addNotFoundSite) {
- var resolved = [];
- sites = sites || [];
-
- sites.forEach(function (siteId) {
- var site;
- if(typeof siteId === 'string'){
- site = lookupSite(siteId);
-
- if(site){
- resolved.push(site);
- } else if(addNotFoundSite && siteId) {
- resolved.push({
- name: 'User created site',
- siteId: siteId
- });
- }
- } else if(typeof siteId === 'object'){
- resolved.push(siteId);
- }
- });
-
- return resolved;
-}
-
function drawGanttChart(ganttData) {
if (ganttData.length > 0) {
$("#gantt-container").gantt({
diff --git a/grails-app/assets/stylesheets/pwa-bio-activity-create-or-edit-manifest.css b/grails-app/assets/stylesheets/pwa-bio-activity-create-or-edit-manifest.css
new file mode 100644
index 000000000..eec2edd51
--- /dev/null
+++ b/grails-app/assets/stylesheets/pwa-bio-activity-create-or-edit-manifest.css
@@ -0,0 +1,4 @@
+/*
+ *= require base-bs4.css
+ *= require forms-manifest.css
+ */
\ No newline at end of file
diff --git a/grails-app/assets/stylesheets/pwa-bio-activity-index-manifest.css b/grails-app/assets/stylesheets/pwa-bio-activity-index-manifest.css
new file mode 100644
index 000000000..7a34b0103
--- /dev/null
+++ b/grails-app/assets/stylesheets/pwa-bio-activity-index-manifest.css
@@ -0,0 +1,5 @@
+/*
+ *= require base-bs4.css
+ *= require forms-manifest.css
+ *= require mobile_activity.css
+ */
\ No newline at end of file
diff --git a/grails-app/assets/stylesheets/pwa-manifest.css b/grails-app/assets/stylesheets/pwa-manifest.css
new file mode 100644
index 000000000..851d55e95
--- /dev/null
+++ b/grails-app/assets/stylesheets/pwa-manifest.css
@@ -0,0 +1,7 @@
+/*
+*= require base-bs4.css
+*= require all.css
+*= require v4-shims.css
+*= require ala-map.css
+*= require Control.FullScreen.css
+*/
\ No newline at end of file
diff --git a/grails-app/assets/stylesheets/pwa-offline-list-manifest.css b/grails-app/assets/stylesheets/pwa-offline-list-manifest.css
new file mode 100644
index 000000000..2d3c4f7b0
--- /dev/null
+++ b/grails-app/assets/stylesheets/pwa-offline-list-manifest.css
@@ -0,0 +1,3 @@
+/**
+ *= require base-bs4.css
+ */
\ No newline at end of file
diff --git a/grails-app/assets/stylesheets/pwa-settings-manifest.css b/grails-app/assets/stylesheets/pwa-settings-manifest.css
new file mode 100644
index 000000000..2d3c4f7b0
--- /dev/null
+++ b/grails-app/assets/stylesheets/pwa-settings-manifest.css
@@ -0,0 +1,3 @@
+/**
+ *= require base-bs4.css
+ */
\ No newline at end of file
diff --git a/grails-app/assets/vendor/responsive-table-stacked/stacked.js b/grails-app/assets/vendor/responsive-table-stacked/stacked.js
index 8c29af798..9be7a6326 100644
--- a/grails-app/assets/vendor/responsive-table-stacked/stacked.js
+++ b/grails-app/assets/vendor/responsive-table-stacked/stacked.js
@@ -14,13 +14,16 @@
*
* Created by Temi on 26/02/16.
*/
-$(document).ready(function() {
- $('table:not(.not-stacked-table)').each(function(index, item){
+$(document).ready(initResponsiveTable).on('form-initialised', initResponsiveTable);
+$(window).resize(initResponsiveTable);
+
+function initResponsiveTable(){
+ $('table:not(.not-stacked-table):not(.responsive-table-stacked)').each(function(index, item){
$(this).addClass('responsive-table-stacked').parent().addClass('overflow-table');
addAttributeToTd(item)
watch(this, addAttributeToTd)
});
-});
+}
/**
* adding data-th attribute to td elements of the table. this attribute is used to display
diff --git a/grails-app/conf/application.groovy b/grails-app/conf/application.groovy
index 55e423b07..3019c9073 100644
--- a/grails-app/conf/application.groovy
+++ b/grails-app/conf/application.groovy
@@ -687,3 +687,11 @@ if (!app.file.script.path) {
app.file.script.path = "/data/biocollect/scripts"
}
script.read.extensions.list = ['js','min.js','png', 'json', 'jpg', 'jpeg']
+
+// yml interpreter doesn't evaluate expression in deep nested objects such as baseLayers below
+pwaMapConfig = { def config ->
+ Map pwa = config.getProperty('pwa', Map)
+ Map mapConfig = pwa.mapConfig
+ mapConfig.baseLayers.getAt(0).url = pwa.baseMapUrl + pwa.apiKey
+ mapConfig
+}
\ No newline at end of file
diff --git a/grails-app/conf/application.yml b/grails-app/conf/application.yml
index b2aced28e..e1fdc90ff 100644
--- a/grails-app/conf/application.yml
+++ b/grails-app/conf/application.yml
@@ -308,5 +308,57 @@ grails:
cors:
enabled: true
+---
+#pwa
+pwa:
+ appUrl: "http://localhost:5173"
+ cache:
+ ignore: ["/image/upload", "/ws/attachment/upload"]
+ maxAreaInKm: 25
+ tileSize: 256
+ apiKey: ""
+ cacheVersion: "v3"
+ oldCacheToDelete: "v2"
+ serviceWorkerConfig:
+ pathsToIgnoreCache: [ "/image/upload", "/ws/attachment/upload", "/ajax/keepSessionAlive", "/noop", '/pwa/sw.js', '/pwa/config.js', "/ws/species/speciesDownload" ]
+ cachePathForRequestsStartingWith: [ "/pwa/bioActivity/edit/", "/pwa/createOrEditFragment/", "/pwa/bioActivity/index/", "/pwa/indexFragment/", "/pwa/offlineList" ]
+ filesToPreCache: ["webjars/leaflet/0.7.7/dist/images/layers.png", "webjars/leaflet/0.7.7/dist/images/layers-2x.png", "webjars/leaflet/0.7.7/dist/images/marker-icon.png", "webjars/leaflet/0.7.7/dist/images/marker-icon-2x.png", "webjars/leaflet/0.7.7/dist/images/marker-shadow.png", "map-not-cached.png", "font-awesome/5.15.4/svgs/regular/image.svg"]
+ baseMapPrefixUrl: "https://api.maptiler.com/maps/hybrid/256"
+ noCacheTileFile: "map-not-cached.png"
+ baseMapUrl: "${pwa.serviceWorkerConfig.baseMapPrefixUrl}/{z}/{x}/{y}.jpg?key="
+ mapConfig:
+ baseLayers:
+ - code: 'maptilersatellite'
+ displayText: 'Satellite'
+ isSelected: true
+ attribution: '© MapTiler © OpenStreetMap contributors'
+ overlays: [ ]
+
+---
+speciesCatalog:
+ url: "To be set"
+ fileName: "combined.zip"
+ vernacularFileName: "vernacularname.txt"
+ taxonFileName: "taxon.txt"
+ totalFileName: "total.json"
+ batchSize: 1000
+ dir: "/data/biocollect/speciesCatalog"
+ filters:
+ language: "en"
+ exclude:
+ unrankedValue: "unranked"
+ taxon:
+ headerNames:
+ guid: "taxonID"
+ scientificName: "scientificName"
+ rankString: "taxonRank"
+ name: "scientificName"
+ vernacular:
+ headerNames:
+ taxonID: "taxonID"
+ vernacularName: "vernacularName"
+ language: "language"
+ preferred: "isPreferredName"
+
fathom:
- enabled: true
\ No newline at end of file
+ enabled: true
diff --git a/grails-app/controllers/au/org/ala/biocollect/BioActivityController.groovy b/grails-app/controllers/au/org/ala/biocollect/BioActivityController.groovy
index 7f56c6162..bff159c8e 100644
--- a/grails-app/controllers/au/org/ala/biocollect/BioActivityController.groovy
+++ b/grails-app/controllers/au/org/ala/biocollect/BioActivityController.groovy
@@ -25,6 +25,7 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement
import io.swagger.v3.oas.annotations.security.SecurityScheme
import org.apache.commons.io.FilenameUtils
import org.apache.http.HttpStatus
+import org.apache.http.entity.ContentType
import org.grails.web.json.JSONArray
import org.springframework.context.MessageSource
import org.springframework.web.multipart.MultipartFile
@@ -244,6 +245,179 @@ class BioActivityController {
model
}
+ def pwaCreateOrEdit(String projectActivityId) {
+ Map model = [projectActivityId: projectActivityId, activityId: ""]
+ if(projectActivityId) {
+ Map pActivity = projectActivityService.get(projectActivityId, "all", params?.version)
+
+ if(!pActivity.error) {
+ model.title = messageSource.getMessage('pwa.record.create.title', [].toArray(), '', Locale.default)
+ String projectId = model.projectId = pActivity.projectId
+ Map project = projectService.get(projectId, "brief", params?.version)
+ if (!project.error) {
+ model.project = project
+ model.pActivity = pActivity
+ model.type = pActivity.pActivityFormName
+ // disable showing verification status on pwa
+ model.isUserAdminModeratorOrEditor = false
+ render view: "pwaBioActivityCreateOrEdit", model: model
+ return
+ } else {
+ flash.message = "Project associated with project activity not found"
+ render status: HttpStatus.SC_NOT_FOUND
+ return
+ }
+ } else {
+ flash.message = "Project Activity not found"
+ render status: HttpStatus.SC_NOT_FOUND
+ return
+ }
+ } else {
+ flash.message = "Project Activity Id must be provided"
+ render status: HttpStatus.SC_BAD_REQUEST
+ }
+ }
+
+ @PreAuthorise(accessLevel = "loggedInUser")
+ def pwaCreateOrEditFragment(String projectActivityId) {
+ Map model = [projectActivityId: projectActivityId, activityId: ""]
+ if(projectActivityId) {
+ Map pActivity = projectActivityService.get(projectActivityId, "all", params?.version)
+
+ if(!pActivity.error) {
+ model.title = messageSource.getMessage('pwa.record.create.title', [].toArray(), '', Locale.default)
+ String projectId = model.projectId = pActivity.projectId
+ Map project = projectService.get(projectId, "brief", params?.version)
+ if (!project.error) {
+ model.project = project
+ model.pActivity = pActivity
+ model.type = pActivity.pActivityFormName
+ // disable showing verification status on pwa
+ model.isUserAdminModeratorOrEditor = false
+ addOutputModel(model, model.type)
+ render view: "pwaBioActivityCreateOrEditFragment", model: model
+ return
+ } else {
+ flash.message = "Project associated with project activity not found"
+ render status: HttpStatus.SC_NOT_FOUND
+ return
+ }
+ } else {
+ flash.message = "Project Activity not found"
+ render status: HttpStatus.SC_NOT_FOUND
+ return
+ }
+ } else {
+ flash.message = "Project Activity Id must be provided"
+ render status: HttpStatus.SC_BAD_REQUEST
+ }
+ }
+
+ def getProjectActivityMetadata (String projectActivityId, String activityId) {
+ Map activity
+ String userId = userService.getCurrentUserId()
+ Map pActivity = projectActivityService.get(projectActivityId, "all")
+ if (pActivity.error) {
+ render text: [message: "An error occurred when accessing project activity"] as JSON, status: HttpStatus.SC_INTERNAL_SERVER_ERROR, contentType: ContentType.APPLICATION_JSON
+ return
+ }
+
+ String projectId = pActivity?.projectId
+ String type = pActivity.pActivityFormName
+ Map project = projectService.get(projectId)
+ if(project.error) {
+ render text: [message: "An error occurred when accessing project"] as JSON, status: HttpStatus.SC_INTERNAL_SERVER_ERROR, contentType: ContentType.APPLICATION_JSON
+ return
+ }
+
+ if (activityId) {
+ activity = activityService.get(activityId, params?.version, userId, true)
+ if(activity.error) {
+ render text: [message: "An error occurred when accessing activity"] as JSON, status: HttpStatus.SC_INTERNAL_SERVER_ERROR, contentType: ContentType.APPLICATION_JSON
+ return
+ }
+ } else {
+ activity = [activityId: '', siteId: '', projectId: projectId, type: type]
+ }
+
+ Map userPermission = checkUserPermission(project, pActivity, activityId ? activity : null)
+ if (!userPermission.authorized) {
+ render text: [message: userPermission.message] as JSON, status: HttpStatus.SC_UNAUTHORIZED, contentType: ContentType.APPLICATION_JSON
+ return
+ }
+
+ Map model = activityAndOutputModel(activity, projectId, 'view', params?.version, pActivity?.pActivityFormName)
+ model.pActivity = pActivity
+ model.project = project
+ model.speciesConfig = [surveyConfig: [speciesFields: pActivity?.speciesFields]]
+ model.projectName = project.name
+ model.isUserAdminModeratorOrEditor = false
+
+ render text: model as JSON, status: HttpStatus.SC_OK, contentType: ContentType.APPLICATION_JSON
+ }
+
+ @PreAuthorise(accessLevel = "loggedInUser")
+ def pwaIndexFragment(String projectActivityId) {
+ String projectId
+ def model = [:]
+ def pActivity = projectActivityService.get(projectActivityId, "all", params?.version)
+ if(pActivity.error) {
+ render text: [message: "An error occurred when accessing project activity"] as JSON, contentType: ContentType.APPLICATION_JSON, status: HttpStatus.SC_INTERNAL_SERVER_ERROR
+ return
+ }
+
+ model.pActivity = pActivity
+ model.projectActivityId = projectActivityId
+ projectId = model.projectId = pActivity?.projectId
+ String type = pActivity.pActivityFormName
+ Map project = projectService.get(projectId)
+ if (project.error) {
+ render text: [message: "An error occurred when accessing project"] as JSON, status: HttpStatus.SC_INTERNAL_SERVER_ERROR, contentType: ContentType.APPLICATION_JSON
+ return
+ }
+
+ addOutputModel(model, type)
+ model.project = project
+ model.id = projectActivityId
+ render view: 'pwaBioActivityIndexFragment', model: model
+ }
+
+ def pwaIndex(String projectActivityId) {
+ String projectId
+ def model = [:]
+ def pActivity = projectActivityService.get(projectActivityId, "all", params?.version)
+ if(pActivity.error) {
+ render text: [message: "An error occurred when accessing project activity"] as JSON, contentType: ContentType.APPLICATION_JSON, status: HttpStatus.SC_INTERNAL_SERVER_ERROR
+ return
+ }
+
+ model.pActivity = pActivity
+ model.projectActivityId = projectActivityId
+ projectId = model.projectId = pActivity?.projectId
+ String type = pActivity.pActivityFormName
+ Map project = projectService.get(projectId)
+ if (project.error) {
+ render text: [message: "An error occurred when accessing project"] as JSON, status: HttpStatus.SC_INTERNAL_SERVER_ERROR, contentType: ContentType.APPLICATION_JSON
+ return
+ }
+
+ model.project = project
+ model.id = projectActivityId
+ render view: 'pwaBioActivityIndex', model: model
+ }
+
+ def pwaOfflineList() {
+ }
+
+ def pwa () {
+ }
+
+ def pwaConfig () {
+ }
+
+ def pwaSettings () {
+ }
+
/**
* Preview activity survey form template
* @param formName Survey form name
@@ -365,7 +539,7 @@ class BioActivityController {
model.speciesConfig = [surveyConfig: [speciesFields: pActivity?.speciesFields]]
model.projectName = project.name
model.returnTo = params.returnTo ? params.returnTo : g.createLink(controller: 'project', id: projectId)
- model.autocompleteUrl = "${request.contextPath}/search/searchSpecies/${pActivity.projectActivityId}?limit=10"
+ model.autocompleteUrl = "${request.contextPath}/search/searchSpecies?projectActivityId=${pActivity.projectActivityId}&limit=10"
model.isUserAdminModeratorOrEditor = projectService.isUserAdminForProject(userId, projectId) || projectService.isUserModeratorForProject(userId, projectId) || projectService.isUserEditorForProject(userId, projectId)
addOutputModel(model)
addDefaultSpecies(activity)
@@ -378,6 +552,57 @@ class BioActivityController {
model
}
+ /**
+ * Check if user can create an activity or edit an activity
+ */
+ private Map checkUserCreatePermission (Map project, Map pActivity) {
+ Map result = [ message: "Access denied: You are not allowed to create activity", authorized: false ]
+ String userId = userService.getCurrentUserId()
+ String projectId = project?.projectId
+
+ if (!userId) {
+ result.message = "Access denied: You are not logged in."
+ }
+ else if (isProjectActivityClosed(pActivity)) {
+ result.message = "Access denied: This survey is closed."
+ }
+ else if (!pActivity.publicAccess && !projectService.canUserEditProject(userId, projectId, false)) {
+ result.message = "Access denied: Only members associated to this project can submit record. For more information, please contact ${grailsApplication.config.biocollect.support.email.address}"
+ }
+ else if (projectService.canUserEditProject(userId, projectId, false) ||
+ (pActivity.publicAccess && userId)) {
+ result.message = "User is authorized to create or edit activity"
+ result.authorized = true
+ }
+
+ return result
+ }
+
+ private Map checkUserEditPermission (Map project, Map activity) {
+ Map result = [ message: "Access denied: You are not allowed to edit activity", authorized: false ]
+ String userId = userService.getCurrentUserId()
+ String projectId = project?.projectId
+
+ if (!userId) {
+ result.message = "Only members associated to this project can submit record. For more information, please contact ${grailsApplication.config.biocollect.support.email.address}"
+ } else if (!activity || activity.error) {
+ result.message = "Invalid activity - ${id}"
+ } else if (projectService.canUserModerateProjects(userId, projectId) || activityService.isUserOwnerForActivity(userId, activity?.activityId)) {
+ result.message = "User is authorized to edit activity"
+ result.authorized = true
+ }
+
+ return result
+ }
+
+ private Map checkUserPermission (Map project, Map pActivity, Map activity) {
+ if (activity) {
+ return checkUserEditPermission(project, activity)
+ } else {
+ return checkUserCreatePermission(project, pActivity)
+ }
+ }
+
private editActivity(String id, boolean mobile = false){
String userId = userService.getCurrentUserId()
def activity = activityService.get(id)
@@ -566,6 +791,32 @@ class BioActivityController {
}
}
+ def ajaxGet(String id) {
+ String userId = userService.getCurrentUserId()
+ def activity = activityService.get(id, params?.version, userId, true)
+ if (activity.error) {
+ render status: HttpStatus.SC_INTERNAL_SERVER_ERROR, text: [message: activity.error] as JSON, contentType: ContentType.APPLICATION_JSON
+ return
+ }
+
+ def pActivity = projectActivityService.get(activity?.projectActivityId, "all", params?.version)
+ boolean embargoed = (activity.embargoed == true) || projectActivityService.isEmbargoed(pActivity)
+ boolean userIsOwner = userId && activityService.isUserOwnerForActivity(userId, id)
+ boolean userIsModerator = userId && projectService.canUserModerateProjects(userId, pActivity?.projectId)
+ boolean userIsAlaAdmin = userService.userIsAlaOrFcAdmin()
+
+ if (activity && pActivity) {
+ if (embargoed && !userIsModerator && !userIsOwner && !userIsAlaAdmin) {
+ def payload = [message: "Access denied: You do not have permission to access the requested resource."]
+ render status: HttpStatus.SC_UNAUTHORIZED, text: payload as JSON, contentType: ContentType.APPLICATION_JSON
+ } else {
+ render text: activity as JSON, contentType: ContentType.APPLICATION_JSON
+ }
+ } else {
+ render status: HttpStatus.SC_NOT_FOUND, text: [message: "Activity not found"] as JSON, contentType: ContentType.APPLICATION_JSON
+ }
+ }
+
/**
* List all activity associated to the user based on their role.
* @param id activity id
@@ -1257,7 +1508,7 @@ class BioActivityController {
private Map activityModel(activity, projectId, mode = '', version = null) {
Map model = [activity: activity, returnTo: params.returnTo, mode: mode]
model.site = model.activity?.siteId ? siteService.get(model.activity.siteId, [view: 'brief', version: version]) : null
- model.project = projectId ? projectService.get(model.activity.projectId, null, false, version) : null
+ model.project = projectId ? projectService.get(projectId, null, false, version) : null
model.projectSite = model.project?.sites?.find { it.siteId == model.project.projectSiteId }
// Add the species lists that are relevant to this activity.
@@ -1284,15 +1535,16 @@ class BioActivityController {
model
}
- private Map activityAndOutputModel(activity, projectId, mode = '', version = null) {
+ private Map activityAndOutputModel(activity, projectId, mode = '', version = null, type = null) {
def model = activityModel(activity, projectId, mode, version)
- addOutputModel(model)
+ addOutputModel(model, type)
model
}
- def addOutputModel(model) {
- model.putAll(activityFormService.getActivityAndOutputMetadata(model.activity.type))
+ private def addOutputModel(model ,type = null) {
+ type = type ?: model.activity.type
+ model.putAll(activityFormService.getActivityAndOutputMetadata(type))
model
}
@@ -1607,7 +1859,7 @@ class BioActivityController {
model = activityModel(activity, projectId)
model.pActivity = pActivity
model.returnTo = params.returnTo ? params.returnTo : g.createLink(controller: 'project', id: projectId)
- model.autocompleteUrl = "${request.contextPath}/search/searchSpecies/${pActivity.projectActivityId}?limit=10"
+ model.autocompleteUrl = "${request.contextPath}/search/searchSpecies?projectActivityId=${pActivity.projectActivityId}&limit=10"
addOutputModel(model)
}
diff --git a/grails-app/controllers/au/org/ala/biocollect/DocumentController.groovy b/grails-app/controllers/au/org/ala/biocollect/DocumentController.groovy
index ac700dddf..5042c259b 100644
--- a/grails-app/controllers/au/org/ala/biocollect/DocumentController.groovy
+++ b/grails-app/controllers/au/org/ala/biocollect/DocumentController.groovy
@@ -21,6 +21,22 @@ class DocumentController {
WebService webService
GrailsApplication grailsApplication
+ def get(String id) {
+ if (!documentId) {
+ render text: [message: "Document not found"] as JSON, status: HttpStatus.SC_NOT_FOUND
+ return
+ }
+
+ def document = documentService.get(documentId)
+ if (!document.error) {
+ render text: document as JSON, status: HttpStatus.SC_OK
+ }
+ else {
+ render text: [message: "Document error"] as JSON, status: HttpStatus.SC_INTERNAL_SERVER_ERROR
+ return
+ }
+ }
+
/**
* This function does an elastic search for documents. All elastic search parameters are supported like fq, max etc.
* @return
diff --git a/grails-app/controllers/au/org/ala/biocollect/ProjectActivityController.groovy b/grails-app/controllers/au/org/ala/biocollect/ProjectActivityController.groovy
index 863c36c56..7008ac6bf 100644
--- a/grails-app/controllers/au/org/ala/biocollect/ProjectActivityController.groovy
+++ b/grails-app/controllers/au/org/ala/biocollect/ProjectActivityController.groovy
@@ -35,8 +35,8 @@ class ProjectActivityController {
}
}
- def ajaxGet(String id) {
- def pActivity = projectActivityService.get(params.id)
+ def ajaxGet(String id, String view) {
+ def pActivity = projectActivityService.get(id, view)
render pActivity as JSON
}
diff --git a/grails-app/controllers/au/org/ala/biocollect/UrlMappings.groovy b/grails-app/controllers/au/org/ala/biocollect/UrlMappings.groovy
index dbb68b009..370362b0a 100644
--- a/grails-app/controllers/au/org/ala/biocollect/UrlMappings.groovy
+++ b/grails-app/controllers/au/org/ala/biocollect/UrlMappings.groovy
@@ -167,6 +167,22 @@ class UrlMappings {
format = 'json'
}
+ "/pwa" (controller: 'bioActivity', action: 'pwa')
+
+ "/sw.js" (uri: '/assets/sw.js')
+ "/pwa/config.js" (controller: 'bioActivity', action: 'pwaConfig')
+
+ "/pwa/bioActivity/edit/$projectActivityId" (controller: 'bioActivity', action: 'pwaCreateOrEdit')
+
+ "/pwa/createOrEditFragment/$projectActivityId" (controller: 'bioActivity', action: 'pwaCreateOrEditFragment')
+
+ "/pwa/bioActivity/index/$projectActivityId" (controller: 'bioActivity', action: 'pwaIndex')
+
+ "/pwa/indexFragment/$projectActivityId" (controller: 'bioActivity', action: 'pwaIndexFragment')
+
+ "/pwa/offlineList" ( controller: 'bioActivity', action: 'pwaOfflineList' )
+ "/pwa/settings" (controller: 'bioActivity', action: 'pwaSettings')
+
"/referenceAssessment/requestRecords"(controller: "referenceAssessment", action: [POST: "requestRecords"])
"500"(controller:'error', action:'response500')
@@ -186,6 +202,40 @@ class UrlMappings {
"/ws/bioactivity/delete/$id"(controller: "bioActivity", action: 'delete')
"/ws/bioactivity/search"(controller: "bioActivity", action: 'searchProjectActivities')
"/ws/bioactivity/map"(controller: "bioActivity", action: 'getProjectActivitiesRecordsForMapping')
+ "/ws/project/$id" {
+ controller = 'project'
+ action = 'ajaxGet'
+ }
+ "/ws/projectActivity/$id" {
+ controller = 'projectActivity'
+ action = 'ajaxGet'
+ }
+ "/ws/projectActivity/activity" {
+ controller = 'bioActivity'
+ action = 'getProjectActivityMetadata'
+ }
+ "/ws/activity/$id" {
+ controller = 'bioActivity'
+ action = 'ajaxGet'
+ }
+ "/ws/site/$id" {
+ controller = 'site'
+ action = 'index'
+ format = 'json'
+ levelOfDetail = 'brief'
+ }
+ "/ws/document/$id" {
+ controller = 'document'
+ action = 'get'
+ }
+ "/ws/species/speciesDownload" {
+ controller = 'species'
+ action = 'speciesDownload'
+ }
+ "/ws/species/totalSpecies" {
+ controller = 'species'
+ action = 'totalSpecies'
+ }
}
}
diff --git a/grails-app/controllers/au/org/ala/biocollect/merit/ProjectController.groovy b/grails-app/controllers/au/org/ala/biocollect/merit/ProjectController.groovy
index 729878029..c463f4f55 100644
--- a/grails-app/controllers/au/org/ala/biocollect/merit/ProjectController.groovy
+++ b/grails-app/controllers/au/org/ala/biocollect/merit/ProjectController.groovy
@@ -38,7 +38,7 @@ import static org.apache.http.HttpStatus.*
)
@SSO
class ProjectController {
-
+ static final String UNPUBLISHED = "unpublished", PUBLISHED = "published"
ProjectService projectService
MetadataService metadataService
OrganisationService organisationService
@@ -122,6 +122,29 @@ class ProjectController {
render projectActivities as JSON
}
+ /**
+ * Get a project by id. It will not get project if it is a MERIT project or project is in draft mode.
+ * @param id
+ * @return
+ */
+ @NoSSO
+ def ajaxGet (String id) {
+ if (id) {
+ def project = projectService.get(id)
+ if (project && !project.error) {
+ if (project.isMERIT || (project.projLifecycleStatus == UNPUBLISHED)) {
+ render text: [message: "You are not authorised"] as JSON, status: HttpStatus.SC_FORBIDDEN, contentType: "application/json"
+ } else {
+ render project as JSON, contentType: "application/json"
+ }
+ } else {
+ render text: [message: "Project not found"] as JSON, status: HttpStatus.SC_NOT_FOUND, contentType: "application/json"
+ }
+ } else {
+ render text: [message: "Project not found"] as JSON, status: HttpStatus.SC_NOT_FOUND, contentType: "application/json"
+ }
+ }
+
/*
* Get list of surveys/project activities for a given project
*
@@ -1437,6 +1460,7 @@ class ProjectController {
}
//Search species by project activity species constraint.
+ @NoSSO
def searchSpecies(String id, String q, Integer limit, String output, String dataFieldName, String surveyName){
def result = projectService.searchSpecies(id, q, limit, output, dataFieldName, surveyName)
diff --git a/grails-app/controllers/au/org/ala/biocollect/merit/SearchController.groovy b/grails-app/controllers/au/org/ala/biocollect/merit/SearchController.groovy
index 43dc840e2..142e298b1 100644
--- a/grails-app/controllers/au/org/ala/biocollect/merit/SearchController.groovy
+++ b/grails-app/controllers/au/org/ala/biocollect/merit/SearchController.groovy
@@ -1,7 +1,7 @@
package au.org.ala.biocollect.merit
+
import grails.converters.JSON
import org.apache.commons.lang.StringUtils
-import org.springframework.http.HttpStatus
class SearchController {
def searchService, webService, speciesService, commonService, projectActivityService
@@ -31,10 +31,21 @@ class SearchController {
render speciesService.searchSpeciesList(sort, max, offset, guid, order, searchTerm) as JSON
}
- //Search species by project activity species constraint.
- def searchSpecies(String id, String q, Integer limit, String output, String dataFieldName){
+ /**
+ * Search species based on species field configuration of the project activity.
+ * @param projectActivityId
+ * @param q
+ * @param limit
+ * @param output
+ * @param dataFieldName
+ * @param offset
+ * @return
+ */
+ def searchSpecies(String projectActivityId, String q, Integer limit, String output, String dataFieldName, Integer offset){
try {
- def result = projectActivityService.searchSpecies(id, q, limit, output, dataFieldName)
+ // backward compatibility - id was replaced with projectActivityId
+ projectActivityId = projectActivityId ?: params.id
+ def result = projectActivityService.searchSpecies(projectActivityId, q, limit, output, dataFieldName, offset)
render result as JSON
} catch (Exception ex){
log.error (ex.message.toString(), ex)
diff --git a/grails-app/controllers/au/org/ala/biocollect/merit/SiteController.groovy b/grails-app/controllers/au/org/ala/biocollect/merit/SiteController.groovy
index abdbb96e2..38ae63f2c 100644
--- a/grails-app/controllers/au/org/ala/biocollect/merit/SiteController.groovy
+++ b/grails-app/controllers/au/org/ala/biocollect/merit/SiteController.groovy
@@ -18,6 +18,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import org.apache.commons.lang.StringUtils
import org.apache.http.HttpStatus
+import org.apache.http.entity.ContentType
import static javax.servlet.http.HttpServletResponse.SC_CONFLICT
import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT
@@ -85,11 +86,11 @@ class SiteController {
}
@NoSSO
- def index(String id) {
-
+ def index(String id, String levelOfDetail) {
+ levelOfDetail = levelOfDetail ?: 'projects'
// Include activities only when biocollect starts supporting NRM based projects.
- def site = siteService.get(id, [view: 'projects'])
- if (site && site.status != 'deleted') {
+ def site = siteService.get(id, [view: levelOfDetail])
+ if (site && !site.error && (site.status != 'deleted')) {
// inject the metadata model for each activity
site.activities = site.activities ?: []
site.activities?.each {
@@ -113,9 +114,20 @@ class SiteController {
result
} else {
- //forward(action: 'list', model: [error: 'no such id'])
- flash.message = "Site not found."
- redirect(controller: 'site', action: 'list')
+ switch (params.format) {
+ case 'json':
+ if (site.statusCode) {
+ render text: [message: "Site not found."] as JSON, status: site.statusCode, contentType: ContentType.APPLICATION_JSON
+ }
+ else {
+ render text: [message: "An error occurred while getting site."] as JSON, status: HttpStatus.SC_INTERNAL_SERVER_ERROR, contentType: ContentType.APPLICATION_JSON
+ }
+ break
+ default:
+ flash.message = "Site not found."
+ redirect(controller: 'site', action: 'list')
+ break
+ }
}
}
diff --git a/grails-app/controllers/au/org/ala/biocollect/merit/SpeciesController.groovy b/grails-app/controllers/au/org/ala/biocollect/merit/SpeciesController.groovy
index 57d40434d..a229d522b 100644
--- a/grails-app/controllers/au/org/ala/biocollect/merit/SpeciesController.groovy
+++ b/grails-app/controllers/au/org/ala/biocollect/merit/SpeciesController.groovy
@@ -1,6 +1,7 @@
package au.org.ala.biocollect.merit
import grails.converters.JSON
+import org.apache.http.HttpStatus
import javax.servlet.http.HttpServletResponse
@@ -45,4 +46,36 @@ class SpeciesController {
Map results = speciesService.searchBie(params.q, params.fq, params.limit ?: 10)
render results as JSON
}
+
+ @PreAuthorise(accessLevel = 'alaAdmin')
+ def refreshSpeciesCatalog(boolean force) {
+ force = force ?: false
+ Map result = speciesService.constructSpeciesFiles(force)
+ if (result.success) {
+ render text: result as JSON, status: HttpStatus.SC_OK, contentType: org.apache.http.entity.ContentType.APPLICATION_JSON
+ }
+ else {
+ render text: result as JSON, status: HttpStatus.SC_INTERNAL_SERVER_ERROR, contentType: org.apache.http.entity.ContentType.APPLICATION_JSON
+ }
+ }
+
+ def speciesDownload (Integer page) {
+ File file = new File("${grailsApplication.config.getProperty('speciesCatalog.dir')}/${page}.json")
+ if (file.exists()) {
+ render text: file.text, status: HttpStatus.SC_OK, contentType: org.apache.http.entity.ContentType.APPLICATION_JSON
+ }
+ else {
+ render text: [message: "Species file not found"] as JSON, status: HttpStatus.SC_NOT_FOUND, contentType: org.apache.http.entity.ContentType.APPLICATION_JSON
+ }
+ }
+
+ def totalSpecies () {
+ File file = new File("${grailsApplication.config.getProperty('speciesCatalog.dir')}/${grailsApplication.config.getProperty('speciesCatalog.totalFileName')}")
+ if (file.exists()) {
+ render text: file.text, status: HttpStatus.SC_OK, contentType: org.apache.http.entity.ContentType.APPLICATION_JSON
+ }
+ else {
+ render text: [message: "Total file not found"] as JSON, status: HttpStatus.SC_NOT_FOUND, contentType: org.apache.http.entity.ContentType.APPLICATION_JSON
+ }
+ }
}
diff --git a/grails-app/i18n/messages.properties b/grails-app/i18n/messages.properties
index 9365e81ad..7219283e5 100644
--- a/grails-app/i18n/messages.properties
+++ b/grails-app/i18n/messages.properties
@@ -740,6 +740,7 @@ project.myrecords.title=My sightings
project.userrecords.title=Sightings of
allrecords.title=All records
myrecords.title=My records
+pwa.record.create.title=Create or edit a record
record.create.title=Create a record
record.edit.title=Edit a sighting
record.view.title=View a sighting
@@ -977,3 +978,81 @@ project.survey.bulkupload=Bulk import data
bulkimport.stepone.describe=Form template
bulkimport.steptwo.describe.helptext=Please include title, description of data set, date range and spatial coverage. Remember to create new bulk import for each spreadsheet.
bulkimport.admin.actions.title=Actions
+pwa.map.name=Enter name
+pwa.map.name.help=Give the region you selected a unique name for later reference.
+pwa.map.download=Download progress
+pwa.map.area=Selection area (km2)
+pwa.map.area.help=Area of selected region on map in kilometers. You can only download map if it is below {0} km2.
+pwa.map.cache.title=Map tiles
+pwa.map.downloaded.regions=Downloaded map tiles
+pwa.map.downloaded.regions.serial=Serial number
+pwa.map.downloaded.regions.name=Name
+pwa.map.downloaded.regions.actions=Actions
+pwa.map.downloaded.regions.preview=View on map
+pwa.map.downloaded.regions.delete=Delete
+pwa.offline.no.maps=No cached maps found
+pwa.species.download=Species
+pwa.map.download.species.progress=Species download progress
+pwa.species.download.offline=Delete species and download again
+pwa.species.cached.count=Total species downloaded
+pwa.form.download=Survey form
+pwa.form.download.progress=Survey form need to be cached before you can use it offline. Once the download has begun, you can track its progress using progress bar.
+pwa.form.download.error=An error occurred while downloading survey form. Firstly, check if you are logged in. Lastly, check with project administrator if you have permission to access survey.
+pwa.download.status.error=An error occurred while downloading artefact
+pwa.download.status.success=Download completed successfully
+pwa.download.status.inprogress=Download in progress
+pwa.offline.checklist=Offline checklist
+pwa.offline.checklist.intro=A survey will work offline once all items in checklist are green.
+pwa.metadata.download=Survey metadata
+pwa.metadata.download.intro=Downloads survey metadata, sites associated with survey, project metadata etc.
+pwa.metadata.download.error=Error downloading metadata
+pwa.species.download.error=An error occurred while downloading species.
+pwa.species.download.intro=Downloads species information relevant to survey such as lists associated with a field or the whole species dataset. Downloading the whole species dataset can take several mintues.
+pwa.map.download.intro=Downloads map tiles for offline use. You atleast need one region downloaded to enable offline access.
+pwa.map.download.error=Cannot enable offline access since no map tiles has been downloaded. Scroll down to map download section below to download map.
+pwa.offline.options=Advanced options
+admin.species.catalog=Regenerate species
+admin.species.helptext=Downloads species catalog and transforms it into a downloadable format for PWA mobile clients.
+pwa.species.refresh=Refresh species database
+pwa.map.download.href=Scroll to map section
+pwa.map.download.help=Download map tiles for offline use. You can only download if selection area field is green. \
+ Download is restricted to a maximum area of {0} km2 because map tiles can take up a lot of space. \
+ However, you can download multiple regions. You need at least one downloaded region to enable offline access. \
+ We are investigating ways to improve access to offline map.
+pwa.upload.all=Upload all
+pwa.add.records=Add record
+pwa.activities.empty.msg=Unpublished records not found
+pwa.unpublished.heading=Unpublished records
+pwa.buttons.actions=Actions
+pwa.view.record=View record
+pwa.edit.record=Create record
+pwa.btn.back=Back to records
+pwa.sites.cache.title=Survey sites
+pwa.sites.download.intro=Get map tiles for survey sites. This help map tiles to display when a site is selected offline. \
+ This can take several minutes depending on the number of sites associated with the survey.
+pwa.sites.download.error=Failed to download map tiles for sites associated with survey.
+pwa.offlinelist.surveydate.heading=Survey date
+pwa.offlinelist.image.heading=Image
+pwa.offlinelist.actions.heading=Actions
+pwa.offlinelist.species.heading=Species
+pwa.offlinelist.record.image.alt=Image associated with record
+pwa.offlinelist.record.noimage.alt=No image associated with record
+label.upload=Upload
+bioactivity.save=Save
+pwa.settings.heading=Settings
+pwa.settings.storage.heading=Storage quota
+pwa.settings.storage.total=Maximum storage (GB)
+pwa.settings.storage.totalPercentage=Percentage used
+pwa.settings.storage.used=Disk used (GB)
+pwa.settings.storage.free=Free space (GB)
+pwa.settings.storage.alert.heading=Unsupported
+pwa.settings.storage.alert.message=Your browser does not support storage estimation. Some browsers that support this feature are - Safari (17), Chrome (61), Edge(79), Firefox(57) etc.
+pwa.settings.storage.btn.refresh=Refresh
+pwa.settings.manage.title=Manage storage
+pwa.settings.manage.alert.heading=Delete items
+pwa.settings.manage.alert.message=Delete items to make disk space. The following items will be deleted. Unpublished records will not be deleted. You will have to re-download surveys for them to wrok offline.
+pwa.settings.manage.btn.clearAll=Delete
+pwa.settings.manage.delete.progress=Deletion progress
+pwa.map.btn.download=Download map
+pwa.sites.choose.download.msg=Large number of sites associated with this survey. Since it can take a long time to download, please select the minimum ones you want offline.
+g.clear=Clear
\ No newline at end of file
diff --git a/grails-app/services/au/org/ala/biocollect/ProjectActivityService.groovy b/grails-app/services/au/org/ala/biocollect/ProjectActivityService.groovy
index bfb276626..2b1824255 100644
--- a/grails-app/services/au/org/ala/biocollect/ProjectActivityService.groovy
+++ b/grails-app/services/au/org/ala/biocollect/ProjectActivityService.groovy
@@ -265,11 +265,11 @@ class ProjectActivityService {
* @param dataFieldName Identity of field for specific configuration.
* @return json structure containing search results suitable for use by the species autocomplete widget on a survey form.
*/
- def searchSpecies(String id, String q, Integer limit, String output, String dataFieldName) {
+ def searchSpecies(String id, String q, Integer limit, String output, String dataFieldName, Integer offset = 0) {
def pActivity = get(id)
Map speciesConfig = getSpeciesConfigForProjectActivity(pActivity, output, dataFieldName)
if (speciesConfig) {
- def result = speciesService.searchSpeciesForConfig(speciesConfig, q, limit)
+ def result = speciesService.searchSpeciesForConfig(speciesConfig, q, limit, offset)
speciesService.formatSpeciesNameInAutocompleteList(speciesConfig.speciesDisplayFormat, result)
}
}
diff --git a/grails-app/services/au/org/ala/biocollect/merit/SpeciesService.groovy b/grails-app/services/au/org/ala/biocollect/merit/SpeciesService.groovy
index ec6f814a5..7d9742cbf 100644
--- a/grails-app/services/au/org/ala/biocollect/merit/SpeciesService.groovy
+++ b/grails-app/services/au/org/ala/biocollect/merit/SpeciesService.groovy
@@ -1,5 +1,15 @@
package au.org.ala.biocollect.merit
+import com.opencsv.CSVParser
+import com.opencsv.CSVReader
+import com.opencsv.CSVReaderBuilder
+import com.opencsv.CSVParserBuilder
+import grails.converters.JSON
+import grails.plugin.cache.Cacheable
+
+import java.util.zip.ZipEntry
+import java.util.zip.ZipFile
+
class SpeciesService {
def webService, grailsApplication
@@ -35,10 +45,10 @@ class SpeciesService {
* @param speciesConfig
* @return
*/
- def searchSpeciesInLists(String searchTerm, Map speciesConfig = [:], limit = 10){
+ def searchSpeciesInLists(String searchTerm, Map speciesConfig = [:], limit = 10, offset = 0){
List druids = speciesConfig.speciesLists?.collect{it.dataResourceUid}
Map fields = getSpeciesListAutocompleteLookupFields(speciesConfig)
- List listResults = searchSpeciesListOnFields(searchTerm, druids, fields.fieldList, limit)
+ List listResults = searchSpeciesListOnFields(searchTerm, druids, fields.fieldList, limit, offset)
formatSpeciesListResultToAutocompleteFormat(listResults, fields.fieldMap)
}
@@ -123,8 +133,8 @@ class SpeciesService {
* @param listId the id of the list to search.
* @return
*/
- private def searchSpeciesListOnFields(String query, List listId = [], List fields = [], limit = 10) {
- def listContents = webService.getJson("${grailsApplication.config.lists.baseURL}/ws/queryListItemOrKVP?druid=${listId.join(',')}&fields=${URLEncoder.encode(fields.join(','), "UTF-8")}&q=${URLEncoder.encode(query, "UTF-8")}&includeKVP=true&limit=${limit}")
+ private def searchSpeciesListOnFields(String query, List listId = [], List fields = [], limit = 10, offset = 0) {
+ def listContents = webService.getJson("${grailsApplication.config.lists.baseURL}/ws/queryListItemOrKVP?druid=${listId.join(',')}&fields=${URLEncoder.encode(fields.join(','), "UTF-8")}&q=${URLEncoder.encode(query, "UTF-8")}&includeKVP=true&max=${limit}&offset=${offset}")
if(listContents.hasProperty('error')){
throw new Exception(listContents.error)
@@ -213,7 +223,7 @@ class SpeciesService {
name
}
- Object searchSpeciesForConfig(Map speciesConfig, String q, Integer limit) {
+ Object searchSpeciesForConfig(Map speciesConfig, String q, Integer limit, Integer offset = 0) {
def result
switch (speciesConfig?.type) {
case 'SINGLE_SPECIES':
@@ -225,7 +235,7 @@ class SpeciesService {
break
case 'GROUP_OF_SPECIES':
- result = searchSpeciesInLists(q, speciesConfig, limit)
+ result = searchSpeciesInLists(q, speciesConfig, limit, offset)
break
default:
result = [autoCompleteList: []]
@@ -288,4 +298,247 @@ class SpeciesService {
def url = "${grailsApplication.config.bieWs.baseURL}/ws/species/shortProfile/${id}"
webService.getJson(url)
}
+
+ Map constructSpeciesFiles (Boolean force = false) {
+ def config = grailsApplication.config,
+ result
+ String taxonFileName = config.getProperty('speciesCatalog.taxonFileName'),
+ guidHeaderName = config.getProperty('speciesCatalog.taxon.headerNames.guid'),
+ scientificNameHeaderName = config.getProperty('speciesCatalog.taxon.headerNames.scientificName'),
+ rankStringHeaderName = config.getProperty('speciesCatalog.taxon.headerNames.rankString'),
+ directory = config.getProperty('speciesCatalog.dir'),
+ taxonID, commonName
+ List
+