diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 9039536ed..cb7ca85b0 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
@@ -88,17 +88,26 @@ jobs:
- name: Run BioCollect functional tests
run: ./src/main/scripts/runFunctionalTests.sh chromeHeadless /tmp/ecodata dev
+ continue-on-error: true
env:
GITHUB_ACTOR: ${{env.GITHUB_ACTOR}}
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
+ - name: Archive log directory
+ uses: actions/upload-artifact@v4
+ if: ${{ failure() }}
+ with:
+ path: ./logs
+
- name: Clean to remove clover instrumentation
uses: gradle/gradle-build-action@v2.4.2
+ if: ${{ success() }}
with:
arguments: clean
- name: Publish the JAR to the repository
uses: gradle/gradle-build-action@v2.4.2
+ if: ${{ success() }}
with:
arguments: publish
env:
diff --git a/build.gradle b/build.gradle
index fcd17cfbb..2a3133160 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"
+ implementation "org.grails.plugins:ecodata-client-plugin:7.1"
}
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 = []
@@ -261,6 +271,7 @@ tasks.withType(Test) {
wiremock {
dir "${project.projectDir}/src/integration-test/resources/wiremock/"
params "--port=8018 --global-response-templating --local-response-templating"
+// params "--port=8018 --global-response-templating --local-response-templating --verbose --print-all-network-traffic --record-mappings"
}
assets {
diff --git a/forms/hub/agnesTurtleMonitoring/Agnes Turtle Incubation and Emergence Survey v1.json b/forms/hub/agnesTurtleMonitoring/Agnes Turtle Incubation and Emergence Survey v1.json
new file mode 100644
index 000000000..a743819d7
--- /dev/null
+++ b/forms/hub/agnesTurtleMonitoring/Agnes Turtle Incubation and Emergence Survey v1.json
@@ -0,0 +1,515 @@
+{
+ "id": "64b8d3bec9b6421068da768e",
+ "dateCreated": "2023-07-20T06:27:10Z",
+ "minOptionalSectionsCompleted": 1,
+ "supportsSites": false,
+ "tags": [],
+ "lastUpdated": "2024-11-13T01:45:46Z",
+ "createdUserId": "35",
+ "external": false,
+ "activationDate": null,
+ "supportsPhotoPoints": false,
+ "publicationStatus": "published",
+ "externalIds": null,
+ "gmsId": null,
+ "name": "Agnes Turtle Incubation and Emergence Survey",
+ "sections": [
+ {
+ "collapsedByDefault": false,
+ "template": {
+ "modelName": "Agnes Turtle Incubation and Emergence Survey",
+ "record": true,
+ "dataModel": [
+ {
+ "dataType": "date",
+ "name": "eventDate",
+ "dwcAttribute": "eventDate",
+ "description": "The date that the survey was undertaken",
+ "validate": "required,past[now]"
+ },
+ {
+ "dataType": "time",
+ "name": "surveyTime",
+ "dwcAttribute": "eventTime",
+ "description": "The time of the day that the survey was undertaken"
+ },
+ {
+ "dataType": "text",
+ "name": "recordedBy",
+ "dwcAttribute": "recordedBy",
+ "description": "The name of the person or group undertaking the survey",
+ "validate": "required"
+ },
+ {
+ "dataType": "text",
+ "name": "beachName",
+ "validate": "required",
+ "constraints": [
+ "Chinamens",
+ "Fences beach",
+ "Main beach",
+ "North of Surf Club",
+ "Workmans beach"
+ ]
+ },
+ {
+ "name": "nestNumber",
+ "dataType": "text",
+ "validate": "required"
+ },
+ {
+ "name": "tagNumber",
+ "dataType": "text"
+ },
+ {
+ "dataType": "species",
+ "name": "species",
+ "dwcAttribute": "scientificName",
+ "description": "The name of the turtle species observed.",
+ "validate": "required"
+ },
+ {
+ "dataType": "image",
+ "name": "observationPhoto",
+ "description": "Attach nest dig egg count image plus data sheet if used"
+ },
+ {
+ "dataType": "date",
+ "name": "nestEstablishedDate",
+ "description": "The date that eggs were laid",
+ "validate": "past[now]"
+ },
+ {
+ "dataType": "date",
+ "name": "juvenilesEmergedDate",
+ "description": "The date that juveniles began to emerge from the nest",
+ "validate": "past[now]"
+ },
+ {
+ "dataType": "date",
+ "name": "excavationDate",
+ "description": "The date that the nest was excavated",
+ "validate": "past[now]"
+ },
+ {
+ "dataType": "number",
+ "name": "nestDepthInCentimetres",
+ "description": "The depth in centimetres to the bottom of the nest from the general level of the immediate surrounding land",
+ "decimalPlaces": 1
+ },
+ {
+ "defaultAccuracy": 50,
+ "hideMyLocation": false,
+ "columns": [
+ {
+ "dwcAttribute": "verbatimLatitude",
+ "source": "locationLatitude"
+ },
+ {
+ "dwcAttribute": "verbatimLongitude",
+ "source": "locationLongitude"
+ },
+ {
+ "source": "Locality"
+ },
+ {
+ "source": "Accuracy"
+ },
+ {
+ "source": "Notes"
+ },
+ {
+ "source": "Source"
+ }
+ ],
+ "dataType": "geoMap",
+ "name": "location",
+ "dwcAttribute": "verbatimCoordinates",
+ "hideSiteSelection": true,
+ "zoomToProjectArea": true,
+ "validate": "required"
+ },
+ {
+ "dataType": "text",
+ "name": "habitatBeach",
+ "constraints": [
+ "HW - Below Highwater",
+ "B - Below Slope",
+ "S - Slope",
+ "D - Dune"
+ ]
+ },
+ {
+ "dataType": "text",
+ "name": "habitatVegetation",
+ "constraints": [
+ "S - Bare Sand",
+ "G - Grass Area",
+ "Sh - Under Shrub",
+ "T - Under Tree",
+ "R - In Rubble Zone"
+ ]
+ },
+ {
+ "dataType": "number",
+ "name": "emptyShellCount",
+ "validate": "integer,min[0]"
+ },
+ {
+ "dataType": "number",
+ "name": "liveHatchlingCount",
+ "validate": "integer,min[0]"
+ },
+ {
+ "dataType": "number",
+ "name": "deadHatchlingCount",
+ "validate": "integer,min[0]"
+ },
+ {
+ "dataType": "number",
+ "name": "unhatchedDevelopmentTotalCount",
+ "computed": {
+ "expression": "unhatchedDevelopmentStage2Count+unhatchedDevelopmentStage3Count+unhatchedDevelopmentStage4Count+unhatchedDevelopmentStage5Count+unhatchedDevelopmentStage6Count"
+ },
+ "readOnly": true,
+ "noEdit": true,
+ "decimalPlaces": 0
+ },
+ {
+ "dataType": "number",
+ "name": "undevelopedEggsCount",
+ "validate": "integer,min[0]"
+ },
+ {
+ "dataType": "number",
+ "name": "predatedEggsCount",
+ "validate": "integer,min[0]"
+ },
+ {
+ "dataType": "number",
+ "name": "yokelessOrMultiyokeEggsCount",
+ "validate": "integer,min[0]"
+ },
+ {
+ "dataType": "number",
+ "name": "unhatchedDevelopmentStage2Count",
+ "validate": "integer,min[0]"
+ },
+ {
+ "dataType": "number",
+ "name": "unhatchedDevelopmentStage3Count",
+ "validate": "integer,min[0]"
+ },
+ {
+ "dataType": "number",
+ "name": "unhatchedDevelopmentStage4Count",
+ "validate": "integer,min[0]"
+ },
+ {
+ "dataType": "number",
+ "name": "unhatchedDevelopmentStage5Count",
+ "validate": "integer,min[0]"
+ },
+ {
+ "dataType": "number",
+ "name": "unhatchedDevelopmentStage6Count",
+ "validate": "integer,min[0]"
+ },
+ {
+ "dataType": "number",
+ "name": "unhatchedDevelopmentTotalCount",
+ "computed": {
+ "expression": "unhatchedDevelopmentStage2Count+unhatchedDevelopmentStage3Count+unhatchedDevelopmentStage4Count+unhatchedDevelopmentStage5Count+unhatchedDevelopmentStage6Count"
+ },
+ "readOnly": true,
+ "noEdit": true,
+ "decimalPlaces": 0
+ },
+ {
+ "dataType": "text",
+ "name": "countedBy",
+ "validate": "required"
+ },
+ {
+ "dataType": "text",
+ "name": "eventRemarks",
+ "description": "",
+ "dwcAttribute": "eventRemarks",
+ "jsMain": "https://biocollect.ala.org.au/download/getScriptFile?hub=ala&filename=clearImageDateTimeHandler.js&model=agnesWaterTurtleNestMonitoring"
+ }
+ ],
+ "viewModel": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "source": "Turtle Incubation and Emergence Survey ",
+ "type": "literal"
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "type": "section",
+ "title": "Event information",
+ "items": [
+ {
+ "preLabel": "Date",
+ "source": "eventDate",
+ "type": "date"
+ },
+ {
+ "preLabel": "Time",
+ "source": "surveyTime",
+ "type": "time"
+ },
+ {
+ "preLabel": "Recorder",
+ "source": "recordedBy",
+ "type": "text"
+ },
+ {
+ "type": "image",
+ "source": "observationPhoto",
+ "css": "img-responsive",
+ "showMetadata": "false",
+ "preLabel": "Attach photos"
+ }
+ ]
+ },
+ {
+ "boxed": true,
+ "type": "section",
+ "title": "Nest Information",
+ "items": [
+ {
+ "preLabel": "Beach",
+ "source": "beachName",
+ "type": "selectOne"
+ },
+ {
+ "preLabel": "Nest marker number",
+ "source": "nestNumber",
+ "type": "text"
+ },
+ {
+ "preLabel": "Tag number",
+ "source": "tagNumber",
+ "type": "text"
+ },
+ {
+ "preLabel": "Species",
+ "source": "species",
+ "type": "speciesSelect"
+ }
+ ]
+ },
+ {
+ "boxed": true,
+ "type": "section",
+ "title": "Key Dates",
+ "items": [
+ {
+ "preLabel": "Date eggs laid",
+ "source": "nestEstablishedDate",
+ "type": "date"
+ },
+ {
+ "preLabel": "Date of emergence",
+ "source": "juvenilesEmergedDate",
+ "type": "date"
+ },
+ {
+ "preLabel": "Date nest dug/excavated",
+ "source": "excavationDate",
+ "type": "date"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "col",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "boxed": true,
+ "type": "section",
+ "title": "Nest Location",
+ "items": [
+ {
+ "includeNotes": false,
+ "orientation": "vertical",
+ "autoLocalitySearch": false,
+ "readonly": false,
+ "includeSource": false,
+ "includeAccuracy": false,
+ "source": "location",
+ "type": "geoMap",
+ "includeLocality": false
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "preLabel": "Habitat - Across Beach",
+ "source": "habitatBeach",
+ "type": "selectOne"
+ },
+ {
+ "preLabel": "Habitat - Vegetation",
+ "source": "habitatVegetation",
+ "type": "selectOne"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "type": "section",
+ "title": "Shell and Egg Counts",
+ "items": [
+ {
+ "preLabel": "Empty shells (#)",
+ "source": "emptyShellCount",
+ "type": "number"
+ },
+ {
+ "preLabel": "Live hatchlings (#)",
+ "source": "liveHatchlingCount",
+ "type": "number"
+ },
+ {
+ "preLabel": "Dead hatchlings (#)",
+ "source": "deadHatchlingCount",
+ "type": "number"
+ },
+ {
+ "preLabel": "Unhatched eggs (# of UH2 - UH6)",
+ "source": "unhatchedDevelopmentTotalCount",
+ "type": "number",
+ "noEdit": true,
+ "readOnly": true
+ },
+ {
+ "preLabel": "Undeveloped eggs (#)",
+ "source": "undevelopedEggsCount",
+ "type": "number"
+ },
+ {
+ "preLabel": "Predated eggs (#)",
+ "source": "predatedEggsCount",
+ "type": "number"
+ },
+ {
+ "preLabel": "Yokeless/multi-yoke eggs (#)",
+ "source": "yokelessOrMultiyokeEggsCount",
+ "type": "number"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "type": "section",
+ "title": "Unhatched Stage of Development",
+ "items": [
+ {
+ "preLabel": "UH2 – some sign of development (#)",
+ "source": "unhatchedDevelopmentStage2Count",
+ "type": "number"
+ },
+ {
+ "preLabel": "UH3 – unpigmented carapace (#)",
+ "source": "unhatchedDevelopmentStage3Count",
+ "type": "number"
+ },
+ {
+ "preLabel": "UH4 – yolk greater than 50% (#)",
+ "source": "unhatchedDevelopmentStage4Count",
+ "type": "number"
+ },
+ {
+ "preLabel": "UH5 – yolk less than 50% (#)",
+ "source": "unhatchedDevelopmentStage5Count",
+ "type": "number"
+ },
+ {
+ "preLabel": "UH6- pipped (#)",
+ "source": "unhatchedDevelopmentStage6Count",
+ "type": "number"
+ },
+ {
+ "preLabel": "UH2 - UH6 Total (#)",
+ "source": "unhatchedDevelopmentTotalCount",
+ "type": "number",
+ "readOnly": true,
+ "noEdit": true
+ },
+ {
+ "preLabel": "Counted by",
+ "source": "countedBy",
+ "type": "text"
+ },
+ {
+ "preLabel": "Notes",
+ "source": "eventRemarks",
+ "type": "textarea",
+ "rows": 4
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "title": "Turtle Incubation and Emergence Survey"
+ },
+ "modelName": null,
+ "templateName": "Agnes Turtle Incubation and Emergence Survey",
+ "optional": false,
+ "optionalQuestionText": null,
+ "title": null,
+ "collapsibleHeading": null,
+ "name": "Agnes Turtle Incubation and Emergence Survey",
+ "description": "Generic template for monitoring marine turtle nests"
+ }
+ ],
+ "type": "Assessment",
+ "category": "Assessment & monitoring",
+ "status": "active",
+ "lastUpdatedUserId": "35",
+ "description": "Generic template for monitoring marine turtle nests",
+ "formVersion": 1
+}
\ No newline at end of file
diff --git a/forms/hub/agnesTurtleMonitoring/Marine Turtle Template v2 v2.json b/forms/hub/agnesTurtleMonitoring/Marine Turtle Template v2 v2.json
new file mode 100644
index 000000000..bab59f4ff
--- /dev/null
+++ b/forms/hub/agnesTurtleMonitoring/Marine Turtle Template v2 v2.json
@@ -0,0 +1,414 @@
+{
+ "id": "6528cd19cd13e43eb3f713d6",
+ "dateCreated": "2023-10-13T04:52:41Z",
+ "minOptionalSectionsCompleted": 1,
+ "supportsSites": false,
+ "tags": [],
+ "lastUpdated": "2024-12-02T03:16:04Z",
+ "createdUserId": "4228",
+ "external": false,
+ "activationDate": null,
+ "supportsPhotoPoints": false,
+ "publicationStatus": "published",
+ "externalIds": null,
+ "gmsId": null,
+ "name": "Marine Turtle Template v2",
+ "sections": [
+ {
+ "collapsedByDefault": false,
+ "template": {
+ "modelName": "Marine Turtle Template v2",
+ "record": true,
+ "dataModel": [
+ {
+ "dataType": "date",
+ "name": "eventDate",
+ "dwcAttribute": "eventDate",
+ "description": "The date that the survey was undertaken",
+ "validate": "required,past[now]"
+ },
+ {
+ "dataType": "text",
+ "name": "surveyTime",
+ "dwcAttribute": "eventTime",
+ "description": "The time of the day that the survey was undertaken"
+ },
+ {
+ "dataType": "text",
+ "name": "recordedBy",
+ "dwcAttribute": "recordedBy",
+ "description": "The name of the person or group undertaking the sampling event",
+ "validate": "required"
+ },
+ {
+ "dataType": "species",
+ "name": "species",
+ "dwcAttribute": "scientificName",
+ "description": "The name of the turtle species observed.",
+ "validate": "required"
+ },
+ {
+ "name": "nestNumber",
+ "dataType": "text",
+ "description": "Enter the identification number of the nest",
+ "validate": "required"
+ },
+ {
+ "dataType": "image",
+ "name": "observationPhoto",
+ "description": "Attach 3 track photos, plus emergence/predation, etc. as observed.",
+ "validate": "required"
+ },
+ {
+ "defaultAccuracy": 50,
+ "hideMyLocation": false,
+ "columns": [
+ {
+ "dwcAttribute": "verbatimLatitude",
+ "source": "locationLatitude"
+ },
+ {
+ "dwcAttribute": "verbatimLongitude",
+ "source": "locationLongitude"
+ },
+ {
+ "source": "Locality"
+ },
+ {
+ "source": "Accuracy"
+ },
+ {
+ "source": "Notes"
+ },
+ {
+ "source": "Source"
+ }
+ ],
+ "dataType": "geoMap",
+ "name": "location",
+ "dwcAttribute": "verbatimCoordinates",
+ "hideSiteSelection": true,
+ "zoomToProjectArea": true,
+ "validate": "required"
+ },
+ {
+ "dataType": "text",
+ "name": "beachName",
+ "validate": "required",
+ "jsMain": "https://biocollect.ala.org.au/download/getScriptFile?hub=acsa&filename=beachName.js&model=marineTurtleTemplateV2"
+ },
+ {
+ "dataType": "boolean",
+ "name": "postEmergentPredationIsTrue"
+ },
+ {
+ "dataType": "text",
+ "name": "habitatBeach",
+ "validate": "required",
+ "constraints": [
+ "HW - Below Highwater",
+ "B - Below Slope",
+ "S - Slope",
+ "D - Dune"
+ ]
+ },
+ {
+ "dataType": "text",
+ "name": "hatchlingRemarks"
+ },
+ {
+ "dataType": "text",
+ "name": "habitatVegetation",
+ "validate": "required",
+ "constraints": [
+ "S - Bare Sand",
+ "G - Grass Area",
+ "Sh - Under Shrub",
+ "T - Under Tree",
+ "R - In Rubble Zone"
+ ]
+ },
+ {
+ "dataType": "text",
+ "name": "activityCode",
+ "validate": "required",
+ "constraints": [
+ "X - Laid Eggs",
+ "< - Laid/Disturbed",
+ "E - No Laying",
+ "? - Undetermined",
+ "T - Turnaround"
+ ]
+ },
+ {
+ "name": "dateOfEmergence",
+ "dataType": "date"
+ },
+ {
+ "dataType": "boolean",
+ "name": "lightDisorientationIsTrue",
+ "description": "Please enter observations – Include hatchling disorientation, predation, human interference"
+ },
+ {
+ "dataType": "stringList",
+ "name": "threats",
+ "constraints": [
+ "Vehicle",
+ "Light",
+ "Beach fires",
+ "Rubbish",
+ "Predator – specify below"
+ ]
+ },
+ {
+ "name": "dateOfImpact",
+ "dataType": "date"
+ },
+ {
+ "name": "nestDamageImpacts",
+ "dataType": "text",
+ "constraints": [
+ "Predation",
+ "Inundation",
+ "Washed away"
+ ]
+ },
+ {
+ "dataType": "text",
+ "name": "damage",
+ "constraints": [
+ "PD - Predation by dog or dingo",
+ "PF - Predation by a fox",
+ "PP - Predation by pig",
+ "PV - Predation by goanna",
+ "P - Predation by unidentified species",
+ "PU - Unsuccessful predation attempt on a clutch",
+ "PW - clutch loss from Erosion or Flooding",
+ "Other, specify"
+ ]
+ },
+ {
+ "name": "protectionActivity",
+ "dataType": "stringList",
+ "constraints": [
+ "PEX - Predator exclusion device",
+ "RZ - Relocation"
+ ]
+ },
+ {
+ "name": "incubationRemarks",
+ "description": "Record predator type, and date of PEX & RZ",
+ "dataType": "text"
+ },
+ {
+ "dataType": "text",
+ "name": "threatRemarks"
+ },
+ {
+ "dataType": "text",
+ "name": "occurrenceRemarks",
+ "dwcAttribute": "occurrenceRemarks"
+ }
+ ],
+ "viewModel": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "source": "Agnes Water Turtle Nest Monitoring ",
+ "type": "literal"
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "type": "section",
+ "title": "Event information",
+ "items": [
+ {
+ "preLabel": "Date",
+ "source": "eventDate",
+ "type": "simpleDate"
+ },
+ {
+ "preLabel": "Time",
+ "source": "surveyTime",
+ "type": "text"
+ },
+ {
+ "preLabel": "Recorder",
+ "source": "recordedBy",
+ "type": "text"
+ },
+ {
+ "type": "image",
+ "source": "observationPhoto",
+ "preLabel": "Attach 3 track photos, plus emergence/predation, etc. as observed",
+ "css": "img-responsive",
+ "showMetadata": "false",
+ "displayOptions": {
+ "showRemoveButtonWithImage": true
+ }
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "boxed": true,
+ "type": "section",
+ "title": "Location",
+ "items": [
+ {
+ "includeNotes": false,
+ "orientation": "vertical",
+ "autoLocalitySearch": false,
+ "readonly": false,
+ "includeSource": false,
+ "includeAccuracy": false,
+ "source": "location",
+ "type": "geoMap",
+ "includeLocality": false
+ },
+ {
+ "preLabel": "Beach Name",
+ "source": "beachName",
+ "type": "text"
+ }
+ ],
+ "class": ""
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "type": "section",
+ "title": "Turtle & Nest",
+ "items": [
+ {
+ "preLabel": "Nest number",
+ "source": "nestNumber",
+ "type": "text"
+ },
+ {
+ "preLabel": "Species",
+ "source": "species",
+ "type": "speciesSelect"
+ },
+ {
+ "preLabel": "Habitat - Across Beach",
+ "source": "habitatBeach",
+ "type": "selectOne"
+ },
+ {
+ "preLabel": "Habitat - Vegetation",
+ "source": "habitatVegetation",
+ "type": "selectOne"
+ },
+ {
+ "preLabel": "Activity Comments",
+ "source": "occurrenceRemarks",
+ "type": "textarea",
+ "rows": 4
+ },
+ {
+ "preLabel": "Activity Code",
+ "source": "activityCode",
+ "type": "selectOne"
+ }
+ ]
+ },
+ {
+ "boxed": true,
+ "type": "section",
+ "title": "Incubation",
+ "items": [
+ {
+ "preLabel": "Date of impact",
+ "source": "dateOfImpact",
+ "type": "date"
+ },
+ {
+ "preLabel": "Impacts",
+ "source": "nestDamageImpacts",
+ "type": "selectOne"
+ },
+ {
+ "preLabel": "Activity",
+ "source": "protectionActivity",
+ "type": "selectMany",
+ "rows": 4
+ },
+ {
+ "preLabel": "Notes",
+ "source": "incubationRemarks",
+ "type": "textarea",
+ "rows": 4
+ }
+ ]
+ },
+ {
+ "boxed": true,
+ "type": "section",
+ "title": "Emergence",
+ "items": [
+ {
+ "preLabel": "Date of emergence",
+ "source": "dateOfEmergence",
+ "type": "date"
+ },
+ {
+ "preLabel": "Light disorientation",
+ "source": "lightDisorientationIsTrue",
+ "description": "Tick the box if light disorientation occurred and make comments below",
+ "type": "boolean"
+ },
+ {
+ "preLabel": "Notes",
+ "source": "hatchlingRemarks",
+ "type": "textarea",
+ "rows": 4
+ },
+ {
+ "preLabel": "Scavenged – post emergence predation",
+ "source": "postEmergentPredationIsTrue",
+ "type": "boolean"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "title": "Marine Turtle Template v2"
+ },
+ "modelName": null,
+ "templateName": "Marine Turtle Template v2",
+ "optional": false,
+ "optionalQuestionText": null,
+ "title": null,
+ "collapsibleHeading": null,
+ "name": "Marine Turtle Template v2",
+ "description": null
+ }
+ ],
+ "type": "Assessment",
+ "category": "Assessment & monitoring",
+ "status": "active",
+ "lastUpdatedUserId": "4228",
+ "description": null,
+ "formVersion": 2
+}
\ No newline at end of file
diff --git a/forms/hub/friendsGrassTreeHealth/friendsGrassTreeHealthSurvey.json b/forms/hub/friendsGrassTreeHealth/friendsGrassTreeHealthSurvey.json
new file mode 100644
index 000000000..6e431b785
--- /dev/null
+++ b/forms/hub/friendsGrassTreeHealth/friendsGrassTreeHealthSurvey.json
@@ -0,0 +1,608 @@
+{
+ "id": "66bc3659f62dc027ba21e634",
+ "dateCreated": "2024-08-14T04:45:13Z",
+ "minOptionalSectionsCompleted": 1,
+ "supportsSites": false,
+ "tags": [],
+ "lastUpdated": "2024-08-19T05:45:50Z",
+ "createdUserId": "56575",
+ "external": false,
+ "activationDate": null,
+ "supportsPhotoPoints": false,
+ "publicationStatus": "published",
+ "externalIds": null,
+ "gmsId": null,
+ "name": "Grasstree Health Survey",
+ "sections": [
+ {
+ "collapsedByDefault": false,
+ "template": {
+ "modelName": "Grasstree Health Survey",
+ "dataModel": [
+ {
+ "dataType": "text",
+ "name": "siteDescription",
+ "description": "",
+ "validate": "required"
+ },
+ {
+ "dataType": "date",
+ "name": "surveyDate",
+ "indexName": "surveyDate",
+ "description": "",
+ "validate": "required,max[${now}]"
+ },
+ {
+ "dataType": "text",
+ "name": "participants",
+ "description": "",
+ "validate": "required"
+ },
+ {
+ "dataType": "text",
+ "name": "siteCode",
+ "description": "",
+ "validate": "required"
+ },
+ {
+ "dataType": "text",
+ "name": "drainage",
+ "constraints": [
+ "No drainage line",
+ "Minor depression",
+ "Close to creek / water pool"
+ ]
+ },
+ {
+ "dataType": "text",
+ "name": "disturbance",
+ "constraints": [
+ "Not evident",
+ "Track",
+ "Tree fallen",
+ "Fire"
+ ]
+ },
+ {
+ "dataType": "text",
+ "name": "comments"
+ },
+ {
+ "allowRowDelete": "false",
+ "columns": [
+ {
+ "dataType": "species",
+ "name": "grasstreeSpecies",
+ "dwcAttribute": "scientificName",
+ "description": ""
+ },
+ {
+ "dataType": "number",
+ "name": "healthy",
+ "defaultValue": 0,
+ "validate": "min[0]"
+ },
+ {
+ "dataType": "number",
+ "name": "minor",
+ "defaultValue": 0,
+ "validate": "min[0]"
+ },
+ {
+ "dataType": "number",
+ "name": "major",
+ "defaultValue": 0,
+ "validate": "min[0]"
+ },
+ {
+ "dataType": "number",
+ "name": "dead",
+ "defaultValue": 0,
+ "validate": "min[0]"
+ },
+ {
+ "dataType": "number",
+ "name": "collapsed",
+ "defaultValue": 0,
+ "validate": "min[0]"
+ }
+ ],
+ "dataType": "list",
+ "name": "grassTreeHealthDetails"
+ },
+ {
+ "dataType": "boolean",
+ "name": "gpsDeviceUsed"
+ },
+ {
+ "defaultAccuracy": 50,
+ "hideMyLocation": false,
+ "columns": [
+ {
+ "dwcAttribute": "verbatimLatitude",
+ "source": "locationLatitude"
+ },
+ {
+ "dwcAttribute": "verbatimLongitude",
+ "source": "locationLongitude"
+ },
+ {
+ "source": "Locality"
+ },
+ {
+ "source": "Accuracy"
+ },
+ {
+ "source": "Notes"
+ },
+ {
+ "source": "Source"
+ }
+ ],
+ "dataType": "geoMap",
+ "name": "location",
+ "dwcAttribute": "verbatimCoordinates",
+ "hideSiteSelection": false,
+ "zoomToProjectArea": true,
+ "validate": "required"
+ },
+ {
+ "allowRowDelete": "false",
+ "columns": [
+ {
+ "dataType": "species",
+ "name": "blueLilySpecies",
+ "dwcAttribute": "scientificName",
+ "description": ""
+ },
+ {
+ "dataType": "text",
+ "name": "estimatedLilyCount",
+ "constraints": [
+ "0",
+ "1-10",
+ "11-50",
+ "51-100",
+ "101-200",
+ "> 201"
+ ]
+ },
+ {
+ "dataType": "text",
+ "name": "lilyHealthyPercentage",
+ "constraints": [
+ "0%",
+ "1-10%",
+ "11-25%",
+ "26-50%",
+ "51-75%",
+ "76-100%"
+ ]
+ }
+ ],
+ "dataType": "list",
+ "name": "lilyDetails"
+ },
+ {
+ "allowRowDelete": "false",
+ "columns": [
+ {
+ "dataType": "species",
+ "name": "daphneHeathSpecies",
+ "dwcAttribute": "scientificName",
+ "description": ""
+ },
+ {
+ "dataType": "text",
+ "name": "estimatedDaphneHeathCount",
+ "constraints": [
+ "0",
+ "1-10",
+ "11-50",
+ "51-100",
+ "101-200",
+ "> 201"
+ ]
+ },
+ {
+ "dataType": "text",
+ "name": "daphneHeathHealthyPercentage",
+ "constraints": [
+ "0%",
+ "1-10%",
+ "11-25%",
+ "26-50%",
+ "51-75%",
+ "76-100%"
+ ]
+ }
+ ],
+ "dataType": "list",
+ "name": "daphneHeathDetails"
+ },
+ {
+ "allowRowDelete": "false",
+ "columns": [
+ {
+ "dataType": "species",
+ "name": "eucalyptSpecies",
+ "dwcAttribute": "scientificName",
+ "description": ""
+ },
+ {
+ "dataType": "text",
+ "name": "dieback",
+ "constraints": [
+ "Not evident",
+ "Isolated",
+ "Moderate",
+ "Severe"
+ ]
+ }
+ ],
+ "dataType": "list",
+ "name": "eucalyptDetails"
+ }
+ ],
+ "viewModel": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "source": "
Friends Grasstree Health Data Sheet ",
+ "type": "literal"
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "computed": null,
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Survey Details",
+ "type": "section",
+ "items": [
+ {
+ "computed": null,
+ "type": "row",
+ "items": [
+ {
+ "computed": null,
+ "type": "col",
+ "items": [
+ {
+ "preLabel": "Survey Date:",
+ "source": "surveyDate",
+ "type": "date"
+ },
+ {
+ "preLabel": "Site description (track details):",
+ "source": "siteDescription",
+ "type": "textarea",
+ "rows": 4
+ },
+ {
+ "preLabel": "Participants:",
+ "source": "participants",
+ "type": "textarea",
+ "rows": 4
+ },
+ {
+ "preLabel": "Site code (date-track e.g. 010924-Sunrise):",
+ "source": "siteCode",
+ "type": "text"
+ },
+ {
+ "source": "drainage",
+ "preLabel": "Drainage",
+ "type": "selectOne"
+ },
+ {
+ "source": "disturbance",
+ "preLabel": "Disturbance",
+ "type": "selectOne"
+ },
+ {
+ "source": "comments",
+ "preLabel": "Comments",
+ "type": "textarea",
+ "rows": 4
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "class": ""
+ }
+ ]
+ },
+ {
+ "computed": null,
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Location",
+ "type": "section",
+ "items": [
+ {
+ "computed": null,
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "computed": null,
+ "type": "row",
+ "items": [
+ {
+ "source": "Select existing location from the drop-down list. If your location is not on the list, you can create one and add it to the list for future use, but please avoid creating duplicate location. TO CREATE A NEW LOCATION: Zoom into the map, click on the pin marker tool (left), then find on the map the location of your survey and click on it. This should put a pin on the map. Please give the new location a unique descriptive name and save, e.g. Portsea Pier, Steeles Rocks, Frankston Pier, Brighton Baths
",
+ "type": "literal"
+ }
+ ]
+ },
+ {
+ "computed": null,
+ "type": "row",
+ "items": [
+ {
+ "includeNotes": false,
+ "orientation": "vertical",
+ "computed": null,
+ "autoLocalitySearch": false,
+ "readonly": false,
+ "includeSource": false,
+ "includeAccuracy": false,
+ "hideSiteSelection": false,
+ "source": "location",
+ "type": "geoMap",
+ "includeLocality": false
+ }
+ ]
+ },
+ {
+ "preLabel": "I used a GPS device to obtain coordinates",
+ "source": "gpsDeviceUsed",
+ "type": "boolean"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "class": ""
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "computed": null,
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Grasstree Health Count",
+ "type": "section",
+ "items": [
+ {
+ "computed": null,
+ "type": "row",
+ "items": [
+ {
+ "disableTableUpload": true,
+ "columns": [
+ {
+ "width": "10%",
+ "min-width": "100px",
+ "source": "grasstreeSpecies",
+ "title": "Grasstree Species",
+ "type": "speciesSelect"
+ },
+ {
+ "width": "10%",
+ "source": "healthy",
+ "title": "Healthy",
+ "type": "number"
+ },
+ {
+ "width": "10%",
+ "source": "minor",
+ "title": "Minor",
+ "type": "number"
+ },
+ {
+ "width": "10%",
+ "source": "major",
+ "title": "Major",
+ "type": "number"
+ },
+ {
+ "width": "10%",
+ "source": "dead",
+ "title": "Dead",
+ "type": "number"
+ },
+ {
+ "width": "10%",
+ "source": "collapsed",
+ "title": "Collapsed",
+ "type": "number"
+ }
+ ],
+ "source": "grassTreeHealthDetails",
+ "type": "table"
+ }
+ ]
+ }
+ ],
+ "class": ""
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "computed": null,
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Nodding Blue Lily",
+ "type": "section",
+ "items": [
+ {
+ "computed": null,
+ "type": "row",
+ "items": [
+ {
+ "disableTableUpload": true,
+ "columns": [
+ {
+ "width": "10%",
+ "min-width": "100px",
+ "source": "blueLilySpecies",
+ "title": "Nodding Blue Lily Species",
+ "type": "speciesSelect"
+ },
+ {
+ "width": "10%",
+ "source": "estimatedLilyCount",
+ "title": "Estimated number of plants",
+ "type": "selectOne"
+ },
+ {
+ "width": "10%",
+ "source": "lilyHealthyPercentage",
+ "title": "% Healthy",
+ "type": "selectOne"
+ }
+
+ ],
+ "source": "lilyDetails",
+ "type": "table"
+ }
+ ]
+ }
+ ],
+ "class": ""
+ }
+ ]
+ },
+ {
+ "computed": null,
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Daphne Heath",
+ "type": "section",
+ "items": [
+ {
+ "computed": null,
+ "type": "row",
+ "items": [
+ {
+ "disableTableUpload": true,
+ "columns": [
+ {
+ "width": "10%",
+ "min-width": "100px",
+ "source": "daphneHeathSpecies",
+ "title": "Daphne Heath Species",
+ "type": "speciesSelect"
+ },
+ {
+ "width": "10%",
+ "source": "estimatedDaphneHeathCount",
+ "title": "Estimated number of plants",
+ "type": "selectOne"
+ },
+ {
+ "width": "10%",
+ "source": "daphneHeathHealthyPercentage",
+ "title": "% Healthy",
+ "type": "selectOne"
+ }
+
+ ],
+ "source": "daphneHeathDetails",
+ "type": "table"
+ }
+ ]
+ }
+ ],
+ "class": ""
+ }
+ ]
+ },
+ {
+ "computed": null,
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Eucalypt",
+ "type": "section",
+ "items": [
+ {
+ "computed": null,
+ "type": "row",
+ "items": [
+ {
+ "disableTableUpload": true,
+ "columns": [
+ {
+ "width": "10%",
+ "min-width": "100px",
+ "source": "eucalyptSpecies",
+ "title": "Eucalypt Species",
+ "type": "speciesSelect"
+ },
+ {
+ "width": "10%",
+ "source": "dieback",
+ "title": "Dieback",
+ "type": "selectOne"
+ }
+ ],
+ "source": "eucalyptDetails",
+ "type": "table"
+ }
+ ]
+ }
+ ],
+ "class": ""
+ }
+ ]
+ }
+ ],
+ "title": "Grasstree Health Survey"
+ },
+ "modelName": null,
+ "record": "true",
+ "templateName": "grasstree_Health_Survey",
+ "optional": false,
+ "optionalQuestionText": null,
+ "title": null,
+ "collapsibleHeading": null,
+ "name": "Grasstree Health Survey",
+ "description": null
+ }
+ ],
+ "type": "Assessment",
+ "category": "Assessment & monitoring",
+ "status": "active",
+ "lastUpdatedUserId": "56575",
+ "description": null,
+ "formVersion": 1
+}
\ No newline at end of file
diff --git a/forms/hub/marineMetreSquared/Marine Metre Squared - Rocky Shore Survey v1.json b/forms/hub/marineMetreSquared/Marine Metre Squared - Rocky Shore Survey v1.json
new file mode 100644
index 000000000..2fa83656e
--- /dev/null
+++ b/forms/hub/marineMetreSquared/Marine Metre Squared - Rocky Shore Survey v1.json
@@ -0,0 +1,757 @@
+{
+ "id": "63cbd5ba00f69e66bb6dcdae",
+ "dateCreated": "2023-01-21T12:08:26Z",
+ "minOptionalSectionsCompleted": 1,
+ "supportsSites": false,
+ "tags": [],
+ "lastUpdated": "2024-11-13T22:54:12Z",
+ "createdUserId": "35",
+ "external": false,
+ "activationDate": null,
+ "supportsPhotoPoints": false,
+ "publicationStatus": "published",
+ "externalIds": null,
+ "gmsId": null,
+ "name": "Marine Metre Squared - Rocky Shore Survey",
+ "sections": [
+ {
+ "collapsedByDefault": false,
+ "template": {
+ "dataModel": [
+ {
+ "defaultValue": "${now}",
+ "dataType": "date",
+ "name": "eventDate",
+ "indexName": "eventDate",
+ "dwcAttribute": "eventDate",
+ "description": "The date of the Survey.",
+ "validate": "required"
+ },
+ {
+ "dataType": "time",
+ "name": "eventStartTime",
+ "dwcAttribute": "eventTime",
+ "description": "The time that the survey started.",
+ "validate": "required"
+ },
+ {
+ "indexName": "recordedBy",
+ "dataType": "text",
+ "name": "recordedBy",
+ "dwcAttribute": "recordedBy",
+ "description": "The name of the person leading the survey",
+ "validate": "required"
+ },
+ {
+ "dataType": "text",
+ "name": "otherSurveyors",
+ "description": "Names of other people involved in the survey"
+ },
+ {
+ "dataType": "number",
+ "name": "groupSize",
+ "description": "The number of people in the survey group",
+ "validate": "integer,min[0]"
+ },
+ {
+ "indexName": "groupName",
+ "dataType": "text",
+ "name": "groupName",
+ "description": "The name of the group or school"
+ },
+ {
+ "dataType": "text",
+ "name": "surveyorExperience",
+ "dwcAttribute": "measurementValue",
+ "measurementUnit": "unitless",
+ "measurementUnitID": "surveyorExperience",
+ "measurementType": "text",
+ "description": "",
+ "constraints": [
+ "Primary school level",
+ "Secondary School level",
+ "Tertiary level ",
+ "Scientifically accurate"
+ ]
+ },
+ {
+ "dataType": "text",
+ "name": "eventRemarks",
+ "dwcAttribute": "eventRemarks",
+ "description": ""
+ },
+ {
+ "indexName": "shoreLevel",
+ "dataType": "text",
+ "name": "shoreLevel",
+ "dwcAttribute": "measurementValue",
+ "measurementUnit": "unitless",
+ "measurementUnitID": "shoreLevel",
+ "measurementType": "text",
+ "description": "",
+ "constraints": [
+ "Low",
+ "Mid",
+ "High"
+ ]
+ },
+ {
+ "dataType": "text",
+ "name": "siteExposure",
+ "description": "",
+ "constraints": [
+ "Very exposed",
+ "Exposed",
+ "Sheltered"
+ ]
+ },
+ {
+ "defaultRows": [
+ {
+ "substrateType": "Reef (stable rock cover)",
+ "substratePercentCover": ""
+ },
+ {
+ "substrateType": "Boulder (head size)",
+ "substratePercentCover": ""
+ },
+ {
+ "substrateType": "Cobble (fist size)",
+ "substratePercentCover": ""
+ },
+ {
+ "substrateType": "Gravel (marble size)",
+ "substratePercentCover": ""
+ },
+ {
+ "substrateType": "Sand (like the beach)",
+ "substratePercentCover": ""
+ },
+ {
+ "substrateType": "Sediment (fine grain size)",
+ "substratePercentCover": ""
+ },
+ {
+ "substrateType": "Mud (gloopy)",
+ "substratePercentCover": ""
+ }
+ ],
+ "columns": [
+ {
+ "dataType": "text",
+ "name": "substrateType",
+ "dwcAttribute": "measurementValue",
+ "measurementUnit": "unitless",
+ "measurementUnitID": "substrateType",
+ "measurementType": "${dominantSpeciesPreIntervention.substrateType} - Substrate",
+ "noEdit": true
+ },
+ {
+ "dataType": "number",
+ "name": "substratePercentCover",
+ "validate": "integer,min[0],max[100]"
+ }
+ ],
+ "dataType": "list",
+ "userAddedRows": false,
+ "allowRowDelete": false,
+ "name": "substrateTable"
+ },
+ {
+ "dataType": "number",
+ "name": "substrateTotalPercentCover",
+ "noEdit": true,
+ "readOnly": true,
+ "decimalPlaces": 0,
+ "computed": {
+ "expression": "sum(substrateTable, \"substratePercentCover\")"
+ },
+ "validate": "min[100],max[100]"
+ },
+ {
+ "dataType": "text",
+ "name": "siteKeyFeatures",
+ "description": "(e.g. rocky headland with surf beach 3 km to south; freshwater creek 50 m to the north; upper shore modified with harbour wall etc.)"
+ },
+ {
+ "dataType": "text",
+ "name": "evidenceOfHumanInfluences",
+ "description": "(e.g. rubbish, people collecting seafood, tyre tracks on sand, dogs present,people in the water)."
+ },
+ {
+ "indexName": "locationName",
+ "dataType": "text",
+ "name": "locationName",
+ "dwcAttribute": "verbatimLocality",
+ "description": "Use town and name of shore or bay. Eg. Portobello – Latham Bay, Auckland – Campbells Bay. Please be consistent.",
+ "validate": "required"
+ },
+ {
+ "indexName": "region",
+ "dataType": "text",
+ "name": "region",
+ "dwcAttribute": "locality",
+ "description": "",
+ "constraints": [
+ "Northland (Te Tai Tokerau)",
+ "Auckland (Tāmaki-makau-rau)",
+ "Waikato",
+ "Bay of Plenty (Te Moana-a-Toi)",
+ "Gisborne (Te Tairāwhiti)",
+ "Hawke's Bay (Te Matau-a-Māui)",
+ "Taranaki",
+ "Manawatū-Whanganui",
+ "Wellington (Te Whanga-nui-a-Tara)",
+ "Tasman (Te Tai-o-Aorere)",
+ "Nelson (Whakatū)",
+ "Marlborough (Te Tauihu-o-te-waka)",
+ "West Coast (Te Tai Poutini)",
+ "Canterbury (Waitaha)",
+ "Otago (Ōtākou)",
+ "Southland (Murihiku)"
+ ]
+ },
+ {
+ "dataType": "image",
+ "name": "locationPhoto",
+ "description": "Take a photo of your m2 area and put the top of this sheet in the corner for identification later!"
+ },
+ {
+ "defaultAccuracy": 50,
+ "hideMyLocation": false,
+ "columns": [
+ {
+ "dwcAttribute": "verbatimLatitude",
+ "source": "locationLatitude"
+ },
+ {
+ "dwcAttribute": "verbatimLongitude",
+ "source": "locationLongitude"
+ },
+ {
+ "source": "Locality"
+ },
+ {
+ "source": "Accuracy"
+ },
+ {
+ "source": "Notes"
+ },
+ {
+ "source": "Source"
+ }
+ ],
+ "dataType": "geoMap",
+ "name": "location",
+ "dwcAttribute": "verbatimCoordinates",
+ "hideSiteSelection": true,
+ "zoomToProjectArea": true,
+ "validate": "required"
+ },
+ {
+ "dataType": "text",
+ "name": "photogrammetrySurveyUndertaken",
+ "description": "",
+ "constraints": [
+ "Yes",
+ "No"
+ ],
+ "validate": "required"
+ },
+ {
+ "dataType": "text",
+ "name": "photogrammetrySurveyUrl",
+ "description": "If you did a photogrammetry survey in conjunction with this survey, please insert the URL link to it here."
+ },
+ {
+ "columns": [
+ {
+ "dataType": "species",
+ "name": "seaweedSpecies",
+ "dwcAttribute": "scientificName",
+ "description": ""
+ },
+ {
+ "dataType": "number",
+ "name": "percentCover",
+ "description": "",
+ "validate": "integer,min[0],max[100]"
+ },
+ {
+ "dataType": "image",
+ "name": "seaweedPhoto",
+ "description": "",
+ "dwcAttribute": "associatedMedia"
+ }
+ ],
+ "dataType": "list",
+ "name": "plantsTable"
+ },
+ {
+ "columns": [
+ {
+ "dataType": "species",
+ "name": "animalSpecies",
+ "dwcAttribute": "scientificName",
+ "description": ""
+ },
+ {
+ "dataType": "number",
+ "name": "individualCount",
+ "dwcAttribute": "individualCount",
+ "description": "",
+ "validate": "integer,min[0]"
+ },
+ {
+ "dataType": "image",
+ "name": "animalPhoto",
+ "description": "",
+ "dwcAttribute": "associatedMedia"
+ }
+ ],
+ "dataType": "list",
+ "name": "animalsTable"
+ }
+ ],
+ "modelName": "Marine Metre Squared - Rocky Shore Survey",
+ "title": "Marine Metre Squared - Rocky Shore Survey",
+ "record": "true",
+ "viewModel": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "source": "Rocky Shore Marine Metre2 Survey ",
+ "type": "literal"
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Survey Details",
+ "type": "section",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "preLabel": "Survey date",
+ "source": "eventDate",
+ "type": "date"
+ },
+ {
+ "preLabel": "Survey time",
+ "source": "eventStartTime",
+ "type": "time"
+ },
+ {
+ "preLabel": "Survey leader name",
+ "source": "recordedBy",
+ "type": "text"
+ },
+ {
+ "preLabel": "Other surveyors",
+ "source": "otherSurveyors",
+ "type": "text"
+ },
+ {
+ "preLabel": "School/Group name",
+ "source": "groupName",
+ "type": "text"
+ },
+ {
+ "preLabel": "Surveyor experience",
+ "source": "surveyorExperience",
+ "type": "selectOne"
+ },
+ {
+ "preLabel": "Group size",
+ "source": "groupSize",
+ "type": "number"
+ },
+ {
+ "preLabel": "Survey notes",
+ "source": "eventRemarks",
+ "type": "textarea"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "class": ""
+ },
+ {
+ "boxed": true,
+ "title": "Site Details",
+ "type": "section",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "preLabel": "Shore level",
+ "source": "shoreLevel",
+ "type": "selectOne"
+ },
+ {
+ "preLabel": "Site exposure",
+ "source": "siteExposure",
+ "type": "selectOne"
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "footer": {
+ "rows": [
+ {
+ "columns": [
+ {
+ "colspan": 1,
+ "source": "Substrate total % cover (must equal 100%)",
+ "type": "literal"
+ },
+ {
+ "source": "substrateTotalPercentCover",
+ "colspan": 1,
+ "type": "number"
+ }
+ ]
+ }
+ ]
+ },
+ "source": "substrateTable",
+ "allowHeaderWrap": true,
+ "userAddedRows": false,
+ "allowRowDelete": false,
+ "disableTableUpload": true,
+ "type": "table",
+ "columns": [
+ {
+ "source": "substrateType",
+ "title": "Substrate",
+ "type": "text",
+ "width": "70%",
+ "noEdit": true
+ },
+ {
+ "source": "substratePercentCover",
+ "title": "Percentage cover (%)",
+ "type": "number",
+ "width": "30%"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "preLabel": "Key features of this site",
+ "source": "siteKeyFeatures",
+ "type": "textarea"
+ },
+ {
+ "preLabel": "Evidence of human influences",
+ "source": "evidenceOfHumanInfluences",
+ "type": "textarea"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "class": ""
+ }
+ ]
+ },
+ {
+ "type": "col",
+ "items": [
+ {
+ "computed": null,
+ "type": "row",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Location",
+ "type": "section",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "source": "To mark the survey location on the map click on the pin icon (on left) and drag to the location of your survey. Please use the zoom controls and be as accurate as possible. Click the pin icon to cancel and start again.",
+ "type": "literal"
+ }
+ ]
+ },
+ {
+ "computed": null,
+ "type": "row",
+ "items": [
+ {
+ "includeNotes": false,
+ "orientation": "vertical",
+ "computed": null,
+ "autoLocalitySearch": true,
+ "readonly": false,
+ "includeSource": false,
+ "includeAccuracy": false,
+ "hideSiteSelection": true,
+ "source": "location",
+ "type": "geoMap",
+ "includeLocality": false
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "preLabel": "Location Name",
+ "source": "locationName",
+ "type": "text"
+ },
+ {
+ "preLabel": "Region",
+ "source": "region",
+ "type": "selectOne"
+ },
+ {
+ "preLabel": "Location Photo",
+ "source": "locationPhoto",
+ "type": "image"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "class": ""
+ }
+ ]
+ },
+ {
+ "computed": null,
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Photogrammetry Survey",
+ "type": "section",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "preLabel": "Did you do a photogrammetry survey in conjunction with this survey?",
+ "source": "photogrammetrySurveyUndertaken",
+ "type": "selectOne"
+ },
+ {
+ "preLabel": "Please provide the URL link to the photogrammetry survey associated with this survey.",
+ "source": "photogrammetrySurveyUrl",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "class": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Seaweed Cover",
+ "type": "section",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "source": "Surface count (in 1m x 1m quadrat) If you find a species you cannot identify, write a description of it and where it was found in the species list. Make sure you take a photo of it and send all the information to us at marinemetresquared@gmail.com .",
+ "type": "literal"
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "source": "Record seaweeds and encrusting animals as a percentage (%) cover. ",
+ "type": "literal"
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "allowHeaderWrap": "true",
+ "computed": null,
+ "columns": [
+ {
+ "width": "60%",
+ "source": "seaweedSpecies",
+ "title": "Species",
+ "type": "speciesSelect"
+ },
+ {
+ "width": "15%",
+ "source": "percentCover",
+ "title": "% Cover",
+ "type": "number"
+ },
+ {
+ "width": "25%",
+ "source": "seaweedPhoto",
+ "title": "Photo",
+ "type": "image",
+ "css": "img-responsive",
+ "showMetadata": "false"
+ }
+ ],
+ "userAddedRows": true,
+ "disableTableUpload": true,
+ "allowRowDelete": true,
+ "defaultRows": 1,
+ "source": "plantsTable",
+ "type": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "class": ""
+ }
+ ]
+ },
+ {
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Live Animal Records",
+ "type": "section",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "source": "Count the number of living animals within the quadrat. Count only live animals. ",
+ "type": "literal"
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "allowHeaderWrap": true,
+ "disableTableUpload": true,
+ "allowRowDelete": true,
+ "userAddedRows": true,
+ "defaultRows": 1,
+ "computed": null,
+ "columns": [
+ {
+ "width": "60%",
+ "source": "animalSpecies",
+ "title": "Species",
+ "type": "speciesSelect"
+ },
+ {
+ "width": "15%",
+ "source": "individualCount",
+ "title": "Species tally (count)",
+ "type": "number"
+ },
+ {
+ "width": "25%",
+ "source": "animalPhoto",
+ "title": "Photo",
+ "type": "image",
+ "css": "img-responsive",
+ "showMetadata": "false"
+ }
+ ],
+ "source": "animalsTable",
+ "type": "table"
+ }
+ ]
+ }
+ ],
+ "class": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "modelName": null,
+ "templateName": "Marine Metre Squared - Rocky Shore Survey",
+ "optional": false,
+ "optionalQuestionText": null,
+ "title": null,
+ "collapsibleHeading": null,
+ "name": "Marine Metre Squared - Rocky Shore Survey",
+ "description": null
+ }
+ ],
+ "type": "Assessment",
+ "category": "Assessment and monitoring",
+ "status": "active",
+ "lastUpdatedUserId": "129333",
+ "description": "NZ Marine Metre Squared intertidal quadrat protocol for rock shorelines",
+ "formVersion": 1
+}
\ No newline at end of file
diff --git a/forms/hub/marineMetreSquared/Marine Metre Squared - Sandy and Muddy shore Survey v1.json b/forms/hub/marineMetreSquared/Marine Metre Squared - Sandy and Muddy shore Survey v1.json
new file mode 100644
index 000000000..973ae201b
--- /dev/null
+++ b/forms/hub/marineMetreSquared/Marine Metre Squared - Sandy and Muddy shore Survey v1.json
@@ -0,0 +1,877 @@
+{
+ "id": "63cbd4b400f69e66bb6dcdaa",
+ "dateCreated": "2023-01-21T12:04:04Z",
+ "minOptionalSectionsCompleted": 1,
+ "supportsSites": false,
+ "tags": [],
+ "lastUpdated": "2024-11-13T22:53:15Z",
+ "createdUserId": "35",
+ "external": false,
+ "activationDate": null,
+ "supportsPhotoPoints": false,
+ "publicationStatus": "published",
+ "externalIds": null,
+ "gmsId": null,
+ "name": "Marine Metre Squared - Sandy and Muddy shore Survey",
+ "sections": [
+ {
+ "collapsedByDefault": false,
+ "template": {
+ "dataModel": [
+ {
+ "defaultValue": "${now}",
+ "dataType": "date",
+ "name": "eventDate",
+ "indexName": "eventDate",
+ "dwcAttribute": "eventDate",
+ "description": "The date of the Survey.",
+ "validate": "required"
+ },
+ {
+ "dataType": "time",
+ "name": "eventStartTime",
+ "dwcAttribute": "eventTime",
+ "description": "The time that the survey started.",
+ "validate": "required"
+ },
+ {
+ "indexName": "recordedBy",
+ "dataType": "text",
+ "name": "recordedBy",
+ "dwcAttribute": "recordedBy",
+ "description": "The name of the person leading the survey",
+ "validate": "required"
+ },
+ {
+ "dataType": "text",
+ "name": "otherSurveyors",
+ "description": "Names of other people involved in the survey"
+ },
+ {
+ "dataType": "number",
+ "name": "groupSize",
+ "description": "The number of people in the survey group.",
+ "validate": "integer,min[0]"
+ },
+ {
+ "indexName": "groupName",
+ "dataType": "text",
+ "name": "groupName",
+ "description": "The name of the group or school"
+ },
+ {
+ "dataType": "text",
+ "name": "surveyorExperience",
+ "dwcAttribute": "measurementValue",
+ "measurementUnit": "unitless",
+ "measurementUnitID": "surveyorExperience",
+ "measurementType": "text",
+ "description": "",
+ "constraints": [
+ "Primary school level",
+ "Secondary School level",
+ "Tertiary level ",
+ "Scientifically accurate"
+ ]
+ },
+ {
+ "dataType": "text",
+ "name": "eventRemarks",
+ "dwcAttribute": "eventRemarks",
+ "description": ""
+ },
+ {
+ "indexName": "shoreLevel",
+ "dataType": "text",
+ "name": "shoreLevel",
+ "dwcAttribute": "measurementValue",
+ "measurementUnit": "unitless",
+ "measurementUnitID": "shoreLevel",
+ "measurementType": "text",
+ "description": "",
+ "constraints": [
+ "Low",
+ "Mid",
+ "High"
+ ]
+ },
+ {
+ "dataType": "text",
+ "name": "siteExposure",
+ "description": "",
+ "constraints": [
+ "Very exposed",
+ "Exposed",
+ "Sheltered",
+ "Estuary (freshwater input)"
+ ]
+ },
+ {
+ "defaultRows": [
+ {
+ "substrateType": "Reef (stable rock cover)",
+ "substratePercentCover": ""
+ },
+ {
+ "substrateType": "Boulder (head size)",
+ "substratePercentCover": ""
+ },
+ {
+ "substrateType": "Cobble (fist size)",
+ "substratePercentCover": ""
+ },
+ {
+ "substrateType": "Gravel (marble size)",
+ "substratePercentCover": ""
+ },
+ {
+ "substrateType": "Sand (like the beach)",
+ "substratePercentCover": ""
+ },
+ {
+ "substrateType": "Sediment (fine grain size)",
+ "substratePercentCover": ""
+ },
+ {
+ "substrateType": "Mud (gloopy)",
+ "substratePercentCover": ""
+ }
+ ],
+ "columns": [
+ {
+ "dataType": "text",
+ "dwcAttribute": "measurementValue",
+ "measurementUnit": "unitless",
+ "measurementUnitID": "substrateType",
+ "measurementType": "${dominantSpeciesPreIntervention.substrateType} - Substrate",
+ "name": "substrateType",
+ "noEdit": true
+ },
+ {
+ "dataType": "number",
+ "name": "substratePercentCover",
+ "validate": "integer,min[0],max[100]"
+ }
+ ],
+ "dataType": "list",
+ "userAddedRows": false,
+ "allowRowDelete": false,
+ "name": "substrateTable"
+ },
+ {
+ "dataType": "number",
+ "name": "substrateTotalPercentCover",
+ "noEdit": true,
+ "readOnly": true,
+ "decimalPlaces": 0,
+ "computed": {
+ "expression": "sum(substrateTable, \"substratePercentCover\")"
+ }
+ },
+ {
+ "dataType": "text",
+ "name": "siteKeyFeatures",
+ "description": "(e.g. rocky headland with surf beach 3 km to south; freshwater creek 50 m to the north; upper shore modified with harbour wall etc.)"
+ },
+ {
+ "dataType": "text",
+ "name": "evidenceOfHumanInfluences",
+ "description": "(e.g. rubbish, people collecting seafood, tyre tracks on sand, dogs present,people in the water)."
+ },
+ {
+ "indexName": "locationName",
+ "dataType": "text",
+ "name": "locationName",
+ "dwcAttribute": "verbatimLocality",
+ "description": "Use town and name of shore or bay. Eg. Portobello – Latham Bay, Auckland – Campbells Bay. Please be consistent.",
+ "validate": "required"
+ },
+ {
+ "dataType": "image",
+ "name": "locationPhoto",
+ "description": "Take a photo of your m2 area and put the top of this sheet in the corner for identification later!"
+ },
+ {
+ "indexName": "region",
+ "dataType": "text",
+ "name": "region",
+ "dwcAttribute": "locality",
+ "description": "",
+ "constraints": [
+ "Northland (Te Tai Tokerau)",
+ "Auckland (Tāmaki-makau-rau)",
+ "Waikato",
+ "Bay of Plenty (Te Moana-a-Toi)",
+ "Gisborne (Te Tairāwhiti)",
+ "Hawke's Bay (Te Matau-a-Māui)",
+ "Taranaki",
+ "Manawatū-Whanganui",
+ "Wellington (Te Whanga-nui-a-Tara)",
+ "Tasman (Te Tai-o-Aorere)",
+ "Nelson (Whakatū)",
+ "Marlborough (Te Tauihu-o-te-waka)",
+ "West Coast (Te Tai Poutini)",
+ "Canterbury (Waitaha)",
+ "Otago (Ōtākou)",
+ "Southland (Murihiku)"
+ ]
+ },
+ {
+ "defaultAccuracy": 50,
+ "hideMyLocation": false,
+ "columns": [
+ {
+ "dwcAttribute": "verbatimLatitude",
+ "source": "locationLatitude"
+ },
+ {
+ "dwcAttribute": "verbatimLongitude",
+ "source": "locationLongitude"
+ },
+ {
+ "source": "Locality"
+ },
+ {
+ "source": "Accuracy"
+ },
+ {
+ "source": "Notes"
+ },
+ {
+ "source": "Source"
+ }
+ ],
+ "dataType": "geoMap",
+ "name": "location",
+ "dwcAttribute": "verbatimCoordinates",
+ "hideSiteSelection": true,
+ "zoomToProjectArea": true,
+ "validate": "required"
+ },
+ {
+ "dataType": "text",
+ "name": "photogrammetrySurveyUndertaken",
+ "description": "",
+ "constraints": [
+ "Yes",
+ "No"
+ ],
+ "validate": "required"
+ },
+ {
+ "dataType": "text",
+ "name": "photogrammetrySurveyUrl",
+ "description": "If you did a photogrammetry survey in conjunction with this survey, please insert the URL link to it here."
+ },
+ {
+ "columns": [
+ {
+ "dataType": "species",
+ "name": "plantSpecies",
+ "dwcAttribute": "scientificName",
+ "description": ""
+ },
+ {
+ "dataType": "number",
+ "name": "percentCover",
+ "description": "",
+ "validate": "integer,min[0],max[100]"
+ },
+ {
+ "dataType": "image",
+ "name": "seaweedPhoto",
+ "description": "",
+ "dwcAttribute": "associatedMedia"
+ }
+ ],
+ "dataType": "list",
+ "name": "plantsTable"
+ },
+ {
+ "columns": [
+ {
+ "dataType": "species",
+ "name": "animalSpecies",
+ "dwcAttribute": "scientificName",
+ "description": ""
+ },
+ {
+ "dataType": "number",
+ "name": "individualCount",
+ "dwcAttribute": "individualCount",
+ "description": "",
+ "validate": "integer,min[0]"
+ },
+ {
+ "dataType": "image",
+ "name": "animalPhoto",
+ "description": "",
+ "dwcAttribute": "associatedMedia"
+ }
+ ],
+ "dataType": "list",
+ "name": "animalsTable"
+ },
+ {
+ "dataType": "number",
+ "name": "rpdLevelCoreMeasurementInMillimetres",
+ "description": "",
+ "validate": "integer,min[0]"
+ },
+ {
+ "dataType": "text",
+ "name": "rpdLevelCoreTaken",
+ "description": "",
+ "constraints": [
+ "Yes",
+ "No"
+ ],
+ "validate": "required"
+ },
+ {
+ "columns": [
+ {
+ "dataType": "species",
+ "name": "speciesInfauna",
+ "dwcAttribute": "scientificName",
+ "description": ""
+ },
+ {
+ "dataType": "number",
+ "name": "individualRpdCoreCount",
+ "description": "",
+ "validate": "integer,min[0]"
+ },
+ {
+ "dataType": "number",
+ "name": "individualRpdCoreTotalCountPerMetreSquared",
+ "dwcAttribute": "individualCount",
+ "description": "",
+ "computed": {
+ "expression": "individualRpdCoreCount*100"
+ },
+ "decimalPlaces": 0,
+ "noEdit": true,
+ "readOnly": true
+ }
+ ],
+ "dataType": "list",
+ "name": "infaunaCountsTable"
+ }
+ ],
+ "modelName": "Marine Metre Squared - Sandy and Muddy Shore Survey",
+ "title": "Marine Metre Squared - Sandy and Muddy Shore Survey",
+ "record": "true",
+ "viewModel": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "source": "Sandy and Muddy Shore Marine Metre2 Survey ",
+ "type": "literal"
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Survey Details",
+ "type": "section",
+ "items": [
+ {
+ "preLabel": "Survey date",
+ "source": "eventDate",
+ "type": "date"
+ },
+ {
+ "preLabel": "Survey time",
+ "source": "eventStartTime",
+ "type": "time"
+ },
+ {
+ "preLabel": "Survey leader name",
+ "source": "recordedBy",
+ "type": "text"
+ },
+ {
+ "preLabel": "Other surveyors",
+ "source": "otherSurveyors",
+ "type": "text"
+ },
+ {
+ "preLabel": "School/Group name",
+ "source": "groupName",
+ "type": "text"
+ },
+ {
+ "preLabel": "Surveyor experience",
+ "source": "surveyorExperience",
+ "type": "selectOne"
+ },
+ {
+ "preLabel": "Group size",
+ "source": "groupSize",
+ "type": "number"
+ },
+ {
+ "preLabel": "Survey notes",
+ "source": "eventRemarks",
+ "type": "textarea"
+ }
+ ],
+ "class": ""
+ },
+ {
+ "boxed": true,
+ "title": "Site Details",
+ "type": "section",
+ "items": [
+ {
+ "preLabel": "Shore level",
+ "source": "shoreLevel",
+ "type": "selectOne"
+ },
+ {
+ "preLabel": "Site exposure",
+ "source": "siteExposure",
+ "type": "selectOne"
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "footer": {
+ "rows": [
+ {
+ "columns": [
+ {
+ "colspan": 1,
+ "source": "Substrate total % cover (must equal 100%)",
+ "type": "literal"
+ },
+ {
+ "source": "substrateTotalPercentCover",
+ "colspan": 1,
+ "type": "number"
+ }
+ ]
+ }
+ ]
+ },
+ "source": "substrateTable",
+ "allowHeaderWrap": true,
+ "userAddedRows": false,
+ "allowRowDelete": false,
+ "disableTableUpload": true,
+ "type": "table",
+ "columns": [
+ {
+ "source": "substrateType",
+ "title": "Substrate",
+ "type": "text",
+ "width": "80%",
+ "noEdit": true
+ },
+ {
+ "source": "substratePercentCover",
+ "title": "Percentage cover (%)",
+ "type": "number",
+ "width": "20%"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "preLabel": "Key features of this site",
+ "source": "siteKeyFeatures",
+ "type": "textarea"
+ },
+ {
+ "preLabel": "Evidence of human influences",
+ "source": "evidenceOfHumanInfluences",
+ "type": "textarea"
+ }
+ ],
+ "class": ""
+ }
+ ]
+ },
+ {
+ "type": "col",
+ "items": [
+ {
+ "computed": null,
+ "type": "row",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Location",
+ "type": "section",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "source": "To mark the survey location on the map click on the pin icon (on left) and drag to the location of your survey. Please use the zoom controls and be as accurate as possible. Click the pin icon to cancel and start again.",
+ "type": "literal"
+ }
+ ]
+ },
+ {
+ "computed": null,
+ "type": "row",
+ "items": [
+ {
+ "includeNotes": false,
+ "orientation": "vertical",
+ "computed": null,
+ "autoLocalitySearch": true,
+ "readonly": false,
+ "includeSource": false,
+ "includeAccuracy": false,
+ "hideSiteSelection": true,
+ "source": "location",
+ "type": "geoMap",
+ "includeLocality": false
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "preLabel": "Location Name",
+ "source": "locationName",
+ "type": "text"
+ },
+ {
+ "preLabel": "Region",
+ "source": "region",
+ "type": "selectOne"
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "preLabel": "Location Photo",
+ "source": "locationPhoto",
+ "type": "image"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "class": ""
+ }
+ ]
+ },
+ {
+ "computed": null,
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Photogrammetry Survey",
+ "type": "section",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "preLabel": "Did you do a photogrammetry survey in conjunction with this survey?",
+ "source": "photogrammetrySurveyUndertaken",
+ "type": "selectOne"
+ },
+ {
+ "preLabel": "Please provide the URL link to the photogrammetry survey associated with this survey.",
+ "source": "photogrammetrySurveyUrl",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "class": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Seaweed Cover",
+ "type": "section",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "source": "Surface count (in 1m x 1m quadrat) If you find a species you cannot identify, write a description of it and where it was found in the species list. Make sure you take a photo of it and send all the information to us at marinemetresquared@gmail.com .",
+ "type": "literal"
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "source": "Record seaweeds and encrusting animals as a percentage (%) cover. ",
+ "type": "literal"
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "allowHeaderWrap": "true",
+ "computed": null,
+ "columns": [
+ {
+ "width": "60%",
+ "source": "plantSpecies",
+ "title": "Plant Species",
+ "type": "speciesSelect"
+ },
+ {
+ "width": "15%",
+ "source": "percentCover",
+ "title": "% Cover",
+ "type": "number"
+ },
+ {
+ "width": "25%",
+ "source": "seaweedPhoto",
+ "title": "Photo",
+ "type": "image",
+ "css": "img-responsive",
+ "showMetadata": "false"
+ }
+ ],
+ "userAddedRows": true,
+ "disableTableUpload": true,
+ "allowRowDelete": true,
+ "defaultRows": 1,
+ "source": "plantsTable",
+ "type": "table"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "class": ""
+ }
+ ]
+ },
+ {
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Live Animal Records",
+ "type": "section",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "source": "Count the number of living animals within the quadrat. Count only live animals. ",
+ "type": "literal"
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "allowHeaderWrap": true,
+ "disableTableUpload": true,
+ "allowRowDelete": true,
+ "userAddedRows": true,
+ "defaultRows": 1,
+ "computed": null,
+ "columns": [
+ {
+ "width": "60%",
+ "source": "animalSpecies",
+ "title": "Animal Species",
+ "type": "speciesSelect"
+ },
+ {
+ "width": "15%",
+ "source": "individualCount",
+ "title": "Species tally (count)",
+ "type": "number"
+ },
+ {
+ "width": "25%",
+ "source": "animalPhoto",
+ "title": "Photo",
+ "type": "image",
+ "css": "img-responsive",
+ "showMetadata": "false"
+ }
+ ],
+ "source": "animalsTable",
+ "type": "table"
+ }
+ ]
+ }
+ ],
+ "class": ""
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "title": "RPD Levels and Infauna Counts (10cm x 10cm core)",
+ "type": "section",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "source": "Take a core sample (inside your m^2). Remember to move surface life out of the way so that it is not counted twice. Slide sediment out of the core carefully. Measure from the surface to where the sediment changes colour (this is your RPD level). Place the sediment in the sieve, rinse, and count the animals living in the mud (infauna). ",
+ "type": "literal"
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "source": "rpdLevelCoreTaken",
+ "preLabel": "Did you take a RPD level core measurement?",
+ "type": "selectOne"
+ },
+ {
+ "source": "rpdLevelCoreMeasurementInMillimetres",
+ "preLabel": "RPD level - Core measurement (mm)",
+ "type": "number"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "allowHeaderWrap": true,
+ "disableTableUpload": true,
+ "userAddedRows": true,
+ "defaultRows": 1,
+ "computed": null,
+ "columns": [
+ {
+ "width": "80%",
+ "source": "speciesInfauna",
+ "title": "Infauna species",
+ "type": "speciesSelect"
+ },
+ {
+ "width": "10%",
+ "source": "individualRpdCoreCount",
+ "title": "RPD Core sample (count or average)",
+ "type": "number"
+ },
+ {
+ "width": "10%",
+ "source": "individualRpdCoreTotalCountPerMetreSquared",
+ "title": "Total animals per metre2",
+ "type": "number",
+ "readOnly": true,
+ "noEdit": true
+ }
+ ],
+ "allowRowDelete": true,
+ "source": "infaunaCountsTable",
+ "type": "table"
+ }
+ ]
+ }
+ ],
+ "class": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "modelName": null,
+ "templateName": "Marine Metre Squared - Sandy and Muddy shore Survey",
+ "optional": false,
+ "optionalQuestionText": null,
+ "title": null,
+ "collapsibleHeading": null,
+ "name": "Marine Metre Squared - Sandy and Muddy shore Survey",
+ "description": null
+ }
+ ],
+ "type": "Assessment",
+ "category": "Assessment & monitoring",
+ "status": "active",
+ "lastUpdatedUserId": "129333",
+ "description": "NZ Marine Metre Squared intertidal zone quadrat protocol",
+ "formVersion": 1
+}
\ No newline at end of file
diff --git a/forms/hub/marineMetreSquared/Marine Metre Squared Transect Survey - Rocky Shore v1.json b/forms/hub/marineMetreSquared/Marine Metre Squared Transect Survey - Rocky Shore v1.json
new file mode 100644
index 000000000..270e2cf35
--- /dev/null
+++ b/forms/hub/marineMetreSquared/Marine Metre Squared Transect Survey - Rocky Shore v1.json
@@ -0,0 +1,963 @@
+{
+ "id": "6630f6a29666624f81679889",
+ "dateCreated": "2024-04-30T13:48:18Z",
+ "minOptionalSectionsCompleted": 1,
+ "supportsSites": false,
+ "tags": [],
+ "lastUpdated": "2024-05-13T12:48:43Z",
+ "createdUserId": "35",
+ "external": false,
+ "activationDate": null,
+ "supportsPhotoPoints": false,
+ "publicationStatus": "published",
+ "externalIds": null,
+ "gmsId": null,
+ "name": "Marine Metre Squared Transect Survey - Rocky Shore",
+ "sections": [
+ {
+ "collapsedByDefault": false,
+ "template": {
+ "modelName": "Marine Metre Squared Transect Survey - Rocky Shore",
+ "dataModel": [
+ {
+ "defaultValue": "${now}",
+ "dataType": "date",
+ "name": "eventDate",
+ "dwcAttribute": "eventDate",
+ "description": "The date of the Survey.",
+ "validate": "required"
+ },
+ {
+ "dataType": "time",
+ "name": "eventTime",
+ "dwcAttribute": "eventTime",
+ "description": "The time that the survey started.",
+ "validate": "required"
+ },
+ {
+ "indexName": "recordedBy",
+ "dataType": "text",
+ "name": "recordedBy",
+ "dwcAttribute": "recordedBy",
+ "description": "The name of the person leading the survey",
+ "validate": "required"
+ },
+ {
+ "dataType": "text",
+ "name": "additionalSurveyors",
+ "description": "Names of other people involved in the survey"
+ },
+ {
+ "indexName": "groupName",
+ "dataType": "text",
+ "name": "groupName",
+ "description": "The name of the group or school"
+ },
+ {
+ "dataType": "number",
+ "name": "groupSize",
+ "description": "The number of people participating in the survey group.",
+ "validate": "min[1],integer"
+ },
+ {
+ "dataType": "text",
+ "name": "eventRemarks",
+ "dwcAttribute": "eventRemarks",
+ "description": ""
+ },
+ {
+ "dataType": "text",
+ "name": "surveyExtent",
+ "description": "",
+ "constraints": [
+ "From high to low water mark",
+ "From low to high water mark"
+ ]
+ },
+ {
+ "indexName": "surveyorExperience",
+ "dataType": "text",
+ "name": "surveyorExperience",
+ "description": "",
+ "constraints": [
+ "Scientifically accurate",
+ "Tertiary level",
+ "Secondary School level",
+ "Primary School level"
+ ]
+ },
+ {
+ "dataType": "number",
+ "name": "distanceBetweenQuadratsInMetres",
+ "description": "",
+ "decimalPlaces": 2,
+ "validate": "min[0]"
+ },
+ {
+ "indexName": "siteExposure",
+ "dataType": "text",
+ "name": "siteExposure",
+ "description": "",
+ "constraints": [
+ "Very exposed",
+ "Exposed",
+ "Sheltered",
+ "Estuary (freshwater input)"
+ ]
+ },
+ {
+ "columns": [
+ {
+ "dataType": "text",
+ "name": "quadratNumber"
+ },
+ {
+ "indexName": "mm2Substrate",
+ "dataType": "text",
+ "name": "substrateType",
+ "dwcAttribute": "measurementValue",
+ "measurementUnit": "unitless",
+ "measurementUnitID": "substrateType",
+ "measurementType": "${dominantSpeciesPreIntervention.substrateType} - Substrate",
+ "description": ".",
+ "constraints": [
+ "Rock/reef (stable hard surface)",
+ "Cobble (fist to head size)",
+ "Mixed (loose assorted stones)",
+ "Pool of water (rockpool)"
+ ]
+ }
+ ],
+ "dataType": "list",
+ "name": "quadratData",
+ "allowRowDelete": false,
+ "defaultRows": [
+ {
+ "habitat": "",
+ "quadratNumber": "1",
+ "substrate": ""
+ },
+ {
+ "habitat": "",
+ "quadratNumber": "2",
+ "substrate": ""
+ },
+ {
+ "habitat": "",
+ "quadratNumber": "3",
+ "substrate": ""
+ },
+ {
+ "habitat": "",
+ "quadratNumber": "4",
+ "substrate": ""
+ },
+ {
+ "habitat": "",
+ "quadratNumber": "5",
+ "substrate": ""
+ },
+ {
+ "habitat": "",
+ "quadratNumber": "6",
+ "substrate": ""
+ },
+ {
+ "habitat": "",
+ "quadratNumber": "7",
+ "substrate": ""
+ },
+ {
+ "habitat": "",
+ "quadratNumber": "8",
+ "substrate": ""
+ },
+ {
+ "habitat": "",
+ "quadratNumber": "9",
+ "substrate": ""
+ },
+ {
+ "habitat": "",
+ "quadratNumber": "10",
+ "substrate": ""
+ },
+ {
+ "habitat": "",
+ "quadratNumber": "11",
+ "substrate": ""
+ }
+ ]
+ },
+ {
+ "defaultAccuracy": 50,
+ "hideMyLocation": false,
+ "columns": [
+ {
+ "dwcAttribute": "verbatimLatitude",
+ "source": "locationLatitude"
+ },
+ {
+ "dwcAttribute": "verbatimLongitude",
+ "source": "locationLongitude"
+ },
+ {
+ "source": "Locality"
+ },
+ {
+ "source": "Accuracy"
+ },
+ {
+ "source": "Notes"
+ },
+ {
+ "source": "Source"
+ }
+ ],
+ "dataType": "geoMap",
+ "name": "location",
+ "dwcAttribute": "verbatimCoordinates",
+ "hideSiteSelection": true,
+ "zoomToProjectArea": true,
+ "validate": "required"
+ },
+ {
+ "indexName": "locationName",
+ "dataType": "text",
+ "name": "locationName",
+ "dwcAttribute": "verbatimLocality",
+ "description": "Enter the name of the location. Use town and name of shore or bay. Eg. Portobello – Latham Bay, Auckland – Campbells Bay. Please be consistent.",
+ "validate": "required"
+ },
+ {
+ "indexName": "region",
+ "dataType": "text",
+ "name": "region",
+ "dwcAttribute": "locality",
+ "description": "",
+ "constraints": [
+ "Northland (Te Tai Tokerau)",
+ "Auckland (Tāmaki-makau-rau)",
+ "Waikato",
+ "Bay of Plenty (Te Moana-a-Toi)",
+ "Gisborne (Te Tairāwhiti)",
+ "Hawke's Bay (Te Matau-a-Māui)",
+ "Taranaki",
+ "Manawatū-Whanganui",
+ "Wellington (Te Whanga-nui-a-Tara)",
+ "Tasman (Te Tai-o-Aorere)",
+ "Nelson (Whakatū)",
+ "Marlborough (Te Tauihu-o-te-waka)",
+ "West Coast (Te Tai Poutini)",
+ "Canterbury (Waitaha)",
+ "Otago (Ōtākou)",
+ "Southland (Murihiku)"
+ ]
+ },
+ {
+ "dataType": "text",
+ "name": "siteDescription"
+ },
+ {
+ "dataType": "image",
+ "name": "locationPhoto",
+ "description": "Photo of the survey location"
+ },
+ {
+ "dataType": "text",
+ "name": "photogrammetrySurveyUndertaken",
+ "description": "",
+ "constraints": [
+ "Yes",
+ "No"
+ ],
+ "validate": "required"
+ },
+ {
+ "dataType": "text",
+ "name": "photogrammetrySurveyUrl",
+ "description": "If you did a photogrammetry survey in conjunction with this survey, please insert the URL link to it here."
+ },
+ {
+ "columns": [
+ {
+ "dataType": "species",
+ "name": "speciesPlants",
+ "dwcAttribute": "scientificName",
+ "description": "The species name of the animal (or tracks/evidence of) observed."
+ },
+ {
+ "dataType": "number",
+ "name": "percentCoverQ1Plants",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "percentCoverQ2Plants",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "percentCoverQ3Plants",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "percentCoverQ4Plants",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "percentCoverQ5Plants",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "percentCoverQ6Plants",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "percentCoverQ7Plants",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "percentCoverQ8Plants",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "percentCoverQ9Plants",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "percentCoverQ10Plants",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "percentCoverQ11Plants",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "text",
+ "name": "occurrenceRemarksPlants",
+ "dwcAttribute": "occurrenceRemarks",
+ "description": ""
+ },
+ {
+ "dataType": "image",
+ "name": "associatedMediaPlants",
+ "dwcAttribute": "associatedMedia",
+ "description": ""
+ }
+ ],
+ "dataType": "list",
+ "name": "plantsTable"
+ },
+ {
+ "columns": [
+ {
+ "dataType": "species",
+ "name": "speciesAnimals",
+ "dwcAttribute": "scientificName",
+ "description": "The species name of the animal (or tracks/evidence of) observed."
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ1Animals",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ2Animals",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ3Animals",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ4Animals",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ5Animals",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ6Animals",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ7Animals",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ8Animals",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ9Animals",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ10Animals",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ11Animals",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "text",
+ "name": "occurrenceRemarksAnimals",
+ "dwcAttribute": "occurrenceRemarks",
+ "description": ""
+ },
+ {
+ "dataType": "image",
+ "name": "associatedMediaAnimals",
+ "dwcAttribute": "associatedMedia",
+ "description": ""
+ }
+ ],
+ "dataType": "list",
+ "name": "animalsTable"
+ }
+ ],
+ "record": "true",
+ "viewModel": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "source": "Rocky Shore Transect Survey ",
+ "type": "literal"
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Survey Details",
+ "type": "section",
+ "items": [
+ {
+ "preLabel": "Survey Date",
+ "source": "eventDate",
+ "type": "date"
+ },
+ {
+ "preLabel": "Survey Time",
+ "source": "eventTime",
+ "type": "time"
+ },
+ {
+ "preLabel": "Survey leader name",
+ "source": "recordedBy",
+ "type": "text"
+ },
+ {
+ "preLabel": "Other surveyors",
+ "source": "additionalSurveyors",
+ "type": "text"
+ },
+ {
+ "preLabel": "School/Group name",
+ "source": "groupName",
+ "type": "text"
+ },
+ {
+ "preLabel": "Surveyor Experience",
+ "source": "surveyorExperience",
+ "type": "selectOne"
+ },
+ {
+ "preLabel": "Group Size",
+ "source": "groupSize",
+ "type": "number"
+ },
+ {
+ "preLabel": "Survey notes",
+ "source": "eventRemarks",
+ "type": "textarea"
+ },
+ {
+ "preLabel": "Exposure",
+ "source": "siteExposure",
+ "type": "selectOne"
+ },
+ {
+ "preLabel": "Survey Extends",
+ "source": "surveyExtent",
+ "type": "selectOne"
+ },
+ {
+ "preLabel": "Distance between quadrats in metres",
+ "source": "distanceBetweenQuadratsInMetres",
+ "type": "number"
+ },
+ {
+ "allowRowDelete": false,
+ "disableTableUpload": true,
+ "headerRowsWrap": true,
+ "columns": [
+ {
+ "noEdit": true,
+ "source": "quadratNumber",
+ "title": "Quadrat No.",
+ "type": "text",
+ "width": "20%"
+ },
+ {
+ "source": "substrateType",
+ "title": "Main Substrate (>50% of quadrat)",
+ "type": "selectOne",
+ "width": "80%"
+ }
+ ],
+ "userAddedRows": false,
+ "source": "quadratData",
+ "type": "table"
+ }
+ ],
+ "class": ""
+ }
+ ],
+ "class": ""
+ },
+ {
+ "type": "col",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Location",
+ "type": "section",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "source": "To mark the location of the survey on the map, click on the pin icon (on left) and drag to the location of your survey. Please use the zoom controls and be as accurate as possible. Click the pin icon to cancel and start again.",
+ "type": "literal"
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "includeNotes": false,
+ "orientation": "vertical",
+ "computed": null,
+ "autoLocalitySearch": true,
+ "readonly": false,
+ "includeSource": false,
+ "includeAccuracy": false,
+ "hideSiteSelection": true,
+ "source": "location",
+ "type": "geoMap",
+ "includeLocality": false
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "preLabel": "Location Name",
+ "source": "locationName",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "preLabel": "Region",
+ "source": "region",
+ "type": "selectOne"
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "preLabel": "Site description (including environmental conditions)",
+ "source": "siteDescription",
+ "type": "textarea"
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "preLabel": "Location Photo",
+ "source": "locationPhoto",
+ "type": "image"
+ }
+ ]
+ }
+ ],
+ "class": ""
+ }
+ ]
+ },
+ {
+ "computed": null,
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Photogrammetry Survey",
+ "type": "section",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "preLabel": "Did you do a photogrammetry survey in conjunction with this survey?",
+ "source": "photogrammetrySurveyUndertaken",
+ "type": "selectOne"
+ },
+ {
+ "preLabel": "Please provide the URL link to the photogrammetry survey associated with this survey.",
+ "source": "photogrammetrySurveyUrl",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "class": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Species Records",
+ "type": "section",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "source": "Enter the number of each species counted in each quadrat sampled. Record a description of any species that you could not identify. You can also make a note of the abundance using one of the temporary 'Unknown Species' types from the lists below. (Remember to also post a picture and description on the blog to see if others can help with the id.)",
+ "type": "literal"
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Plant Species",
+ "type": "section",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "allowHeaderWrap": true,
+ "computed": null,
+ "columns": [
+ {
+ "width": "15%",
+ "source": "speciesPlants",
+ "title": "Species",
+ "type": "speciesSelect"
+ },
+ {
+ "width": "5%",
+ "source": "percentCoverQ1Plants",
+ "title": "Q1 % cover",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "percentCoverQ2Plants",
+ "title": "Q2 % cover",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "percentCoverQ3Plants",
+ "title": "Q3 % cover",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "percentCoverQ4Plants",
+ "title": "Q4 % cover",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "percentCoverQ5Plants",
+ "title": "Q5 % cover",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "percentCoverQ6Plants",
+ "title": "Q6 % cover",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "percentCoverQ7Plants",
+ "title": "Q7 % cover",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "percentCoverQ8Plants",
+ "title": "Q8 % cover",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "percentCoverQ9Plants",
+ "title": "Q9 % cover",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "percentCoverQ10Plants",
+ "title": "Q10 % cover",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "percentCoverQ11Plants",
+ "title": "Q11 % cover",
+ "type": "number"
+ },
+ {
+ "width": "10%",
+ "source": "occurrenceRemarksPlants",
+ "title": "Species notes",
+ "type": "textarea"
+ },
+ {
+ "width": "15%",
+ "source": "associatedMediaPlants",
+ "title": "Photo",
+ "type": "image"
+ }
+ ],
+ "disableTableUpload": true,
+ "allowRowDelete": true,
+ "userAddedRows": true,
+ "defaultRows": 1,
+ "source": "plantsTable",
+ "type": "table"
+ }
+ ]
+ }
+ ],
+ "class": ""
+ },
+ {
+ "boxed": true,
+ "title": "Animal Species",
+ "type": "section",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "allowHeaderWrap": true,
+ "computed": null,
+ "columns": [
+ {
+ "width": "15%",
+ "source": "speciesAnimals",
+ "title": "Species",
+ "type": "speciesSelect"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ1Animals",
+ "title": "Q1 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ2Animals",
+ "title": "Q2 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ3Animals",
+ "title": "Q3 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ4Animals",
+ "title": "Q4 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ5Animals",
+ "title": "Q5 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ6Animals",
+ "title": "Q6 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ7Animals",
+ "title": "Q7 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ8Animals",
+ "title": "Q8 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ9Animals",
+ "title": "Q9 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ10Animals",
+ "title": "Q10 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ11Animals",
+ "title": "Q11 count",
+ "type": "number"
+ },
+ {
+ "width": "10%",
+ "source": "occurrenceRemarksAnimals",
+ "title": "Species notes",
+ "type": "textarea"
+ },
+ {
+ "width": "15%",
+ "source": "associatedMediaAnimals",
+ "title": "Photo",
+ "type": "image"
+ }
+ ],
+ "disableTableUpload": true,
+ "allowRowDelete": true,
+ "userAddedRows": true,
+ "defaultRows": 1,
+ "source": "animalsTable",
+ "type": "table"
+ }
+ ]
+ }
+ ],
+ "class": ""
+ }
+ ]
+ }
+ ],
+ "class": ""
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "title": "Marine Metre Squared Transect Survey - Rocky Shore"
+ },
+ "modelName": null,
+ "templateName": "Marine Metre Squared Transect Survey - Rocky Shore",
+ "optional": false,
+ "optionalQuestionText": null,
+ "title": null,
+ "collapsibleHeading": null,
+ "name": "Marine Metre Squared Transect Survey - Rocky Shore",
+ "description": null
+ }
+ ],
+ "type": "Assessment",
+ "category": "Assessment & monitoring",
+ "status": "active",
+ "lastUpdatedUserId": "35",
+ "description": "Transect survey for rocky shorelines using the Marine Metre Squared survey protocol.",
+ "formVersion": 1
+}
diff --git a/forms/hub/marineMetreSquared/Marine Metre Squared Transect Survey v1.json b/forms/hub/marineMetreSquared/Marine Metre Squared Transect Survey v1.json
new file mode 100644
index 000000000..c972b99c0
--- /dev/null
+++ b/forms/hub/marineMetreSquared/Marine Metre Squared Transect Survey v1.json
@@ -0,0 +1,1189 @@
+{
+ "id": "5d133339e4b00a475b28e29d",
+ "dateCreated": "2019-06-26T08:56:25Z",
+ "minOptionalSectionsCompleted": 1,
+ "supportsSites": false,
+ "tags": [],
+ "lastUpdated": "2024-11-11T10:24:05Z",
+ "createdUserId": "",
+ "external": false,
+ "activationDate": null,
+ "supportsPhotoPoints": false,
+ "publicationStatus": "published",
+ "externalIds": null,
+ "gmsId": null,
+ "name": "Marine Metre Squared Transect Survey",
+ "sections": [
+ {
+ "collapsedByDefault": false,
+ "template": {
+ "dataModel": [
+ {
+ "defaultValue": "${now}",
+ "dataType": "date",
+ "name": "eventDate",
+ "dwcAttribute": "eventDate",
+ "description": "The date of the Survey.",
+ "validate": "required"
+ },
+ {
+ "dataType": "time",
+ "name": "eventTime",
+ "dwcAttribute": "eventTime",
+ "description": "The time that the survey started.",
+ "validate": "required"
+ },
+ {
+ "indexName": "recordedBy",
+ "dataType": "text",
+ "name": "recordedBy",
+ "dwcAttribute": "recordedBy",
+ "description": "The name of the person leading the survey",
+ "validate": "required"
+ },
+ {
+ "dataType": "text",
+ "name": "additionalSurveyors",
+ "description": "The names of other people or the group participating in the survey group."
+ },
+ {
+ "indexName": "groupName",
+ "dataType": "text",
+ "name": "groupName",
+ "description": "The name of the group or school"
+ },
+ {
+ "dataType": "number",
+ "name": "groupSize",
+ "description": "The number of people participating in the survey group.",
+ "validate": "min[1],integer"
+ },
+ {
+ "dataType": "text",
+ "name": "surveyExtent",
+ "description": "",
+ "constraints": [
+ "From high to low water mark",
+ "From low to high water mark"
+ ]
+ },
+ {
+ "indexName": "surveyorExperience",
+ "dataType": "text",
+ "name": "surveyorExperience",
+ "dwcAttribute": "measurementValue",
+ "measurementUnit": "unitless",
+ "measurementUnitID": "surveyorExperience",
+ "measurementType": "text",
+ "description": "",
+ "constraints": [
+ "Primary school level",
+ "Secondary School level",
+ "Tertiary level ",
+ "Scientifically accurate"
+ ]
+ },
+ {
+ "dataType": "number",
+ "name": "distanceBetweenQuadratsInMetres",
+ "description": "",
+ "decimalPlaces": 2,
+ "validate": "min[0]"
+ },
+ {
+ "indexName": "siteExposure",
+ "dataType": "text",
+ "name": "siteExposure",
+ "description": "",
+ "constraints": [
+ "Very exposed",
+ "Exposed",
+ "Sheltered",
+ "Estuary (freshwater input)"
+ ]
+ },
+ {
+ "dataType": "text",
+ "name": "eventRemarks",
+ "dwcAttribute": "eventRemarks",
+ "description": ""
+ },
+ {
+ "columns": [
+ {
+ "dataType": "text",
+ "name": "quadratNumber"
+ },
+ {
+ "indexName": "mm2Substrate",
+ "dataType": "text",
+ "name": "substrateType",
+ "dwcAttribute": "measurementValue",
+ "measurementUnit": "unitless",
+ "measurementUnitID": "substrateType",
+ "measurementType": "${dominantSpeciesPreIntervention.substrateType} - Substrate",
+ "description": "",
+ "constraints": [
+ "Mud (sink >5cm)",
+ "Sand (sink <2cm)",
+ "Mixed (sink 2-5cm)",
+ "Mixed with rocks"
+ ]
+ },
+ {
+ "dataType": "number",
+ "name": "rpdInMillimetres",
+ "description": "The RPD (Redox Potential Discontinuity) layer is the area where oxygenated substrate (light brown) changes to deoxygenated substrates (black, often smelly).",
+ "validate": "min[0],integer"
+ }
+ ],
+ "dataType": "list",
+ "name": "quadratData",
+ "allowRowDelete": false,
+ "defaultRows": [
+ {
+ "habitat": "",
+ "rpdInMillimetres": "",
+ "quadratNumber": "1",
+ "substrate": ""
+ },
+ {
+ "habitat": "",
+ "rpdInMillimetres": "",
+ "quadratNumber": "2",
+ "substrate": ""
+ },
+ {
+ "habitat": "",
+ "rpdInMillimetres": "",
+ "quadratNumber": "3",
+ "substrate": ""
+ },
+ {
+ "habitat": "",
+ "rpdInMillimetres": "",
+ "quadratNumber": "4",
+ "substrate": ""
+ },
+ {
+ "habitat": "",
+ "rpdInMillimetres": "",
+ "quadratNumber": "5",
+ "substrate": ""
+ },
+ {
+ "habitat": "",
+ "rpdInMillimetres": "",
+ "quadratNumber": "6",
+ "substrate": ""
+ },
+ {
+ "habitat": "",
+ "rpdInMillimetres": "",
+ "quadratNumber": "7",
+ "substrate": ""
+ },
+ {
+ "habitat": "",
+ "rpdInMillimetres": "",
+ "quadratNumber": "8",
+ "substrate": ""
+ },
+ {
+ "habitat": "",
+ "rpdInMillimetres": "",
+ "quadratNumber": "9",
+ "substrate": ""
+ },
+ {
+ "habitat": "",
+ "rpdInMillimetres": "",
+ "quadratNumber": "10",
+ "substrate": ""
+ },
+ {
+ "habitat": "",
+ "rpdInMillimetres": "",
+ "quadratNumber": "11",
+ "substrate": ""
+ }
+ ]
+ },
+ {
+ "defaultAccuracy": 50,
+ "hideMyLocation": false,
+ "columns": [
+ {
+ "dwcAttribute": "verbatimLatitude",
+ "source": "locationLatitude"
+ },
+ {
+ "dwcAttribute": "verbatimLongitude",
+ "source": "locationLongitude"
+ },
+ {
+ "source": "Locality"
+ },
+ {
+ "source": "Accuracy"
+ },
+ {
+ "source": "Notes"
+ },
+ {
+ "source": "Source"
+ }
+ ],
+ "dataType": "geoMap",
+ "name": "location",
+ "dwcAttribute": "verbatimCoordinates",
+ "hideSiteSelection": true,
+ "zoomToProjectArea": true,
+ "validate": "required"
+ },
+ {
+ "indexName": "locationName",
+ "dataType": "text",
+ "name": "locationName",
+ "dwcAttribute": "verbatimLocality",
+ "description": "Enter the name of the location. Use town and name of shore or bay. Eg. Portobello – Latham Bay, Auckland – Campbells Bay. Please be consistent.",
+ "validate": "required"
+ },
+ {
+ "indexName": "region",
+ "dataType": "text",
+ "name": "region",
+ "dwcAttribute": "locality",
+ "description": "",
+ "constraints": [
+ "Northland (Te Tai Tokerau)",
+ "Auckland (Tāmaki-makau-rau)",
+ "Waikato",
+ "Bay of Plenty (Te Moana-a-Toi)",
+ "Gisborne (Te Tairāwhiti)",
+ "Hawke's Bay (Te Matau-a-Māui)",
+ "Taranaki",
+ "Manawatū-Whanganui",
+ "Wellington (Te Whanga-nui-a-Tara)",
+ "Tasman (Te Tai-o-Aorere)",
+ "Nelson (Whakatū)",
+ "Marlborough (Te Tauihu-o-te-waka)",
+ "West Coast (Te Tai Poutini)",
+ "Canterbury (Waitaha)",
+ "Otago (Ōtākou)",
+ "Southland (Murihiku)"
+ ]
+ },
+ {
+ "dataType": "text",
+ "name": "siteDescription",
+ "description": ""
+ },
+ {
+ "dataType": "image",
+ "name": "locationPhoto",
+ "description": "Photo of the survey location"
+ },
+ {
+ "dataType": "text",
+ "name": "photogrammetrySurveyUndertaken",
+ "description": "",
+ "constraints": [
+ "Yes",
+ "No"
+ ],
+ "validate": "required"
+ },
+ {
+ "dataType": "text",
+ "name": "photogrammetrySurveyUrl",
+ "description": "If you did a photogrammetry survey in conjunction with this survey, please insert the URL link to it here."
+ },
+ {
+ "columns": [
+ {
+ "dataType": "species",
+ "name": "speciesPlants",
+ "dwcAttribute": "scientificName",
+ "description": "The species name of the animal (or tracks/evidence of) observed."
+ },
+ {
+ "dataType": "number",
+ "name": "percentCoverQ1Plants",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "percentCoverQ2Plants",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "percentCoverQ3Plants",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "percentCoverQ4Plants",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "percentCoverQ5Plants",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "percentCoverQ6Plants",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "percentCoverQ7Plants",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "percentCoverQ8Plants",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "percentCoverQ9Plants",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "percentCoverQ10Plants",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "percentCoverQ11Plants",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "text",
+ "name": "occurrenceRemarksPlants",
+ "dwcAttribute": "occurrenceRemarks",
+ "description": ""
+ },
+ {
+ "dataType": "image",
+ "name": "associatedMediaPlants",
+ "dwcAttribute": "associatedMedia",
+ "description": ""
+ }
+ ],
+ "dataType": "list",
+ "name": "plantsTable"
+ },
+ {
+ "columns": [
+ {
+ "dataType": "species",
+ "name": "speciesAnimals",
+ "dwcAttribute": "scientificName",
+ "description": "The species name of the animal (or tracks/evidence of) observed."
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ1Animals",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ2Animals",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ3Animals",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ4Animals",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ5Animals",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ6Animals",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ7Animals",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ8Animals",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ9Animals",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ10Animals",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ11Animals",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "text",
+ "name": "occurrenceRemarksAnimals",
+ "dwcAttribute": "occurrenceRemarks",
+ "description": ""
+ },
+ {
+ "dataType": "image",
+ "name": "associatedMediaAnimals",
+ "dwcAttribute": "associatedMedia",
+ "description": ""
+ }
+ ],
+ "dataType": "list",
+ "name": "animalsTable"
+ },
+ {
+ "columns": [
+ {
+ "dataType": "species",
+ "name": "speciesInfauna",
+ "dwcAttribute": "scientificName",
+ "description": "The species name of the animal (or tracks/evidence of) observed."
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ1Infauna",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ2Infauna",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ3Infauna",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ4Infauna",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ5Infauna",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ6Infauna",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ7Infauna",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ8Infauna",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ9Infauna",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ10Infauna",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "number",
+ "name": "individualCountQ11Infauna",
+ "description": "The number of individuals of species recorded in quadrat.",
+ "validate": "min[0],integer"
+ },
+ {
+ "dataType": "text",
+ "name": "occurrenceRemarksInfauna",
+ "dwcAttribute": "occurrenceRemarks",
+ "description": ""
+ },
+ {
+ "dataType": "image",
+ "name": "associatedMediaInfauna",
+ "dwcAttribute": "associatedMedia",
+ "description": ""
+ }
+ ],
+ "dataType": "list",
+ "name": "infaunaTable"
+ }
+ ],
+ "modelName": "marineMeterSquaredTransectSurvey",
+ "record": "true",
+ "viewModel": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "source": "Sandy and Muddy Shore Transect Survey ",
+ "type": "literal"
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Survey Details",
+ "type": "section",
+ "items": [
+ {
+ "preLabel": "Survey Date",
+ "source": "eventDate",
+ "type": "date"
+ },
+ {
+ "preLabel": "Survey Time",
+ "source": "eventTime",
+ "type": "time"
+ },
+ {
+ "preLabel": "Survey leader name",
+ "source": "recordedBy",
+ "type": "text"
+ },
+ {
+ "preLabel": "Other surveyors",
+ "source": "additionalSurveyors",
+ "type": "text"
+ },
+ {
+ "preLabel": "School/Group name",
+ "source": "groupName",
+ "type": "text"
+ },
+ {
+ "preLabel": "Surveyor Experience",
+ "source": "surveyorExperience",
+ "type": "selectOne"
+ },
+ {
+ "preLabel": "Group Size",
+ "source": "groupSize",
+ "type": "number"
+ },
+ {
+ "preLabel": "Survey notes",
+ "source": "eventRemarks",
+ "type": "textarea"
+ },
+ {
+ "preLabel": "Exposure",
+ "source": "siteExposure",
+ "type": "selectOne"
+ },
+ {
+ "preLabel": "Survey Extends",
+ "source": "surveyExtent",
+ "type": "selectOne"
+ },
+ {
+ "preLabel": "Distance between quadrats in metres",
+ "source": "distanceBetweenQuadratsInMetres",
+ "type": "number"
+ },
+ {
+ "allowRowDelete": false,
+ "headerRowsWrap": true,
+ "columns": [
+ {
+ "noEdit": true,
+ "source": "quadratNumber",
+ "title": "Quadrat No.",
+ "type": "text",
+ "width": "10%"
+ },
+ {
+ "source": "substrateType",
+ "title": "Substrate",
+ "type": "selectOne",
+ "width": "60%"
+ },
+ {
+ "source": "rpdInMillimetres",
+ "title": "RPD (mm)",
+ "type": "number",
+ "width": "30%"
+ }
+ ],
+ "userAddedRows": false,
+ "disableTableUpload": true,
+ "source": "quadratData",
+ "type": "table"
+ }
+ ],
+ "class": ""
+ }
+ ],
+ "class": ""
+ },
+ {
+ "type": "col",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Location",
+ "type": "section",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "source": "To mark the location of the survey on the map, click on the pin icon (on left) and drag to the location of your survey. Please use the zoom controls and be as accurate as possible. Click the pin icon to cancel and start again.",
+ "type": "literal"
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "includeNotes": false,
+ "orientation": "vertical",
+ "computed": null,
+ "autoLocalitySearch": true,
+ "readonly": false,
+ "includeSource": false,
+ "includeAccuracy": false,
+ "hideSiteSelection": true,
+ "source": "location",
+ "type": "geoMap",
+ "includeLocality": false
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "preLabel": "Location Name",
+ "source": "locationName",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "preLabel": "Region",
+ "source": "region",
+ "type": "selectOne"
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "preLabel": "Site Description (including environmental conditions)",
+ "source": "siteDescription",
+ "type": "textarea"
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "preLabel": "Location Photo",
+ "source": "locationPhoto",
+ "type": "image"
+ }
+ ]
+ }
+ ],
+ "class": ""
+ }
+ ]
+ },
+ {
+ "computed": null,
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Photogrammetry Survey",
+ "type": "section",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "preLabel": "Did you do a photogrammetry survey in conjunction with this survey?",
+ "source": "photogrammetrySurveyUndertaken",
+ "type": "selectOne"
+ },
+ {
+ "preLabel": "Please provide the URL link to the photogrammetry survey associated with this survey.",
+ "source": "photogrammetrySurveyUrl",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "class": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "type": "col",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Species Records",
+ "type": "section",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "source": "Enter the number of each species counted in each quadrat sampled. Record a description of any species that you could not identify. You can also make a note of the abundance using one of the temporary 'Unknown Species' types from the lists below. (Remember to also post a picture and description on the blog to see if others can help with the id.)",
+ "type": "literal"
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "items": [
+ {
+ "boxed": true,
+ "title": "Plant Species",
+ "type": "section",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "allowHeaderWrap": true,
+ "computed": null,
+ "columns": [
+ {
+ "width": "15%",
+ "source": "speciesPlants",
+ "title": "Species",
+ "type": "speciesSelect"
+ },
+ {
+ "width": "5%",
+ "source": "percentCoverQ1Plants",
+ "title": "Q1 % cover",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "percentCoverQ2Plants",
+ "title": "Q2 % cover",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "percentCoverQ3Plants",
+ "title": "Q3 % cover",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "percentCoverQ4Plants",
+ "title": "Q4 % cover",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "percentCoverQ5Plants",
+ "title": "Q5 % cover",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "percentCoverQ6Plants",
+ "title": "Q6 % cover",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "percentCoverQ7Plants",
+ "title": "Q7 % cover",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "percentCoverQ8Plants",
+ "title": "Q8 % cover",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "percentCoverQ9Plants",
+ "title": "Q9 % cover",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "percentCoverQ10Plants",
+ "title": "Q10 % cover",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "percentCoverQ11Plants",
+ "title": "Q11 % cover",
+ "type": "number"
+ },
+ {
+ "width": "10%",
+ "source": "occurrenceRemarksPlants",
+ "title": "Species notes",
+ "type": "textarea"
+ },
+ {
+ "width": "15%",
+ "source": "associatedMediaPlants",
+ "title": "Photo",
+ "type": "image"
+ }
+ ],
+ "disableTableUpload": true,
+ "allowRowDelete": true,
+ "userAddedRows": true,
+ "defaultRows": 1,
+ "source": "plantsTable",
+ "type": "table"
+ }
+ ]
+ }
+ ],
+ "class": ""
+ },
+ {
+ "boxed": true,
+ "title": "Animal Species",
+ "type": "section",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "allowHeaderWrap": true,
+ "computed": null,
+ "columns": [
+ {
+ "width": "15%",
+ "source": "speciesAnimals",
+ "title": "Species",
+ "type": "speciesSelect"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ1Animals",
+ "title": "Q1 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ2Animals",
+ "title": "Q2 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ3Animals",
+ "title": "Q3 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ4Animals",
+ "title": "Q4 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ5Animals",
+ "title": "Q5 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ6Animals",
+ "title": "Q6 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ7Animals",
+ "title": "Q7 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ8Animals",
+ "title": "Q8 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ9Animals",
+ "title": "Q9 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ10Animals",
+ "title": "Q10 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ11Animals",
+ "title": "Q11 count",
+ "type": "number"
+ },
+ {
+ "width": "10%",
+ "source": "occurrenceRemarksAnimals",
+ "title": "Species notes",
+ "type": "textarea"
+ },
+ {
+ "width": "15%",
+ "source": "associatedMediaAnimals",
+ "title": "Photo",
+ "type": "image"
+ }
+ ],
+ "disableTableUpload": true,
+ "allowRowDelete": true,
+ "userAddedRows": true,
+ "defaultRows": 1,
+ "source": "animalsTable",
+ "type": "table"
+ }
+ ]
+ }
+ ],
+ "class": ""
+ },
+ {
+ "boxed": true,
+ "title": "Infauna Species",
+ "type": "section",
+ "items": [
+ {
+ "type": "row",
+ "items": [
+ {
+ "allowHeaderWrap": true,
+ "computed": null,
+ "columns": [
+ {
+ "width": "15%",
+ "source": "speciesInfauna",
+ "title": "Species",
+ "type": "speciesSelect"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ1Infauna",
+ "title": "Q1 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ2Infauna",
+ "title": "Q2 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ3Infauna",
+ "title": "Q3 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ4Infauna",
+ "title": "Q4 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ5Infauna",
+ "title": "Q5 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ6Infauna",
+ "title": "Q6 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ7Infauna",
+ "title": "Q7 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ8Infauna",
+ "title": "Q8 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ9Infauna",
+ "title": "Q9 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ10Infauna",
+ "title": "Q10 count",
+ "type": "number"
+ },
+ {
+ "width": "5%",
+ "source": "individualCountQ11Infauna",
+ "title": "Q11 count",
+ "type": "number"
+ },
+ {
+ "width": "10%",
+ "source": "occurrenceRemarksInfauna",
+ "title": "Species notes",
+ "type": "textarea"
+ },
+ {
+ "width": "15%",
+ "source": "associatedMediaInfauna",
+ "title": "Photo",
+ "type": "image"
+ }
+ ],
+ "disableTableUpload": true,
+ "allowRowDelete": true,
+ "userAddedRows": true,
+ "defaultRows": 1,
+ "source": "infaunaTable",
+ "type": "table"
+ }
+ ]
+ }
+ ],
+ "class": ""
+ }
+ ]
+ }
+ ],
+ "class": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "modelName": null,
+ "templateName": "marineMetreSquaredTransectSurvey",
+ "optional": false,
+ "optionalQuestionText": null,
+ "title": null,
+ "collapsibleHeading": null,
+ "name": "Marine Metre Squared Transect Survey",
+ "description": null
+ }
+ ],
+ "type": "Assessment",
+ "category": "Assessment & monitoring",
+ "status": "active",
+ "lastUpdatedUserId": "",
+ "description": null,
+ "formVersion": 1
+}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
index 95fe585c8..98e6bf658 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,8 +1,8 @@
-biocollectVersion=7.0
+biocollectVersion=7.1-SNAPSHOT
grailsVersion=6.2.0
grailsGradlePluginVersion=6.1.2
assetPipelineVersion=4.3.0
-alaSecurityLibsVersion=6.3.0-SNAPSHOT
+alaSecurityLibsVersion=6.3.0
seleniumVersion=3.12.0
groovyVersion=3.0.21
gorm.version=8.1.2
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..62345c4ef 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,9 @@
// comments
//= require comment.js
+// indexDB
+// require dexiejs/dexie.js
+//= require dexiejs/dexie.min.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..931f59ae4
--- /dev/null
+++ b/grails-app/assets/javascripts/offline-list.js
@@ -0,0 +1,477 @@
+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();
+ });
+ // check if any activity is uploading
+ self.isUploading = ko.computed(function () {
+ var activities = self.activities();
+ for (var i = 0; i < activities.length; i++) {
+ if (activities[i].uploading()) {
+ return true;
+ }
+ }
+ });
+
+
+ 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 (){
+ try {
+ 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
+ });
+ });
+ });
+ }
+ catch (error) {
+ console.error(error);
+ deferred.reject();
+ alert("There was an error uploading activity");
+ }
+ }, 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 && data.resp && data.resp.activityId) {
+ deferred.resolve({data: {oldActivityId: oldActivityId, activityId: data.resp.activityId }});
+ }
+ else {
+ deferred.reject({data: {oldActivityId: oldActivityId, error: data.errors || data.error}});
+ }
+ },
+ 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(id)) {
+ 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();
+ var blob = image.getBlob();
+ var file = new File([blob], image.filename, {type: image.contentType()});
+ formData.append("files", file);
+ 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/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..7ed92167c
--- /dev/null
+++ b/grails-app/assets/javascripts/pwa-messages.js
@@ -0,0 +1,20 @@
+window.addEventListener('message', function(event) {
+ 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..0bae3f49e 100644
--- a/grails-app/conf/application.groovy
+++ b/grails-app/conf/application.groovy
@@ -101,6 +101,8 @@ environments {
grails.mail.port = 3025 // com.icegreen.greenmail.util.ServerSetupTest.SMTP.port
temp.dir="/tmp/stylesheet"
google.maps.apiKey="testGoogleApi"
+ speciesCatalog.dir="./src/integration-test/resources/data"
+ userProfile.userIdAttribute="userid"
}
production {
@@ -145,6 +147,39 @@ webservice['jwt-scopes'] = "ala/internal users/read ala/attrs ecodata/read_test
webservice['client-id']='changeMe'
webservice['client-secret'] = 'changeMe'
+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"
+ ]
+ ]
+]
+
+
dataAccessMethods = [
"oasrdfs",
"oaordfs",
@@ -687,3 +722,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..273a0962e 100644
--- a/grails-app/conf/application.yml
+++ b/grails-app/conf/application.yml
@@ -308,5 +308,32 @@ 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: [ ]
+
+---
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..718c9ebc7 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,77 @@ 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 checkUserViewPermission (Map project, Map pActivity, Map activity) {
+ Map result = [ message: "Access denied: You are not allowed to edit activity", authorized: false ]
+ String userId = userService.getCurrentUserId()
+ String projectId = project?.projectId
+ Boolean embargoed = (activity.embargoed == true) || projectActivityService.isEmbargoed(pActivity)
+
+ 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 (embargoed) {
+ result.message = "Access denied: This activity is embargoed."
+ } else if (projectService.isUserEditorForProjects(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 checkUserViewPermission(project, pActivity, activity)
+ } else {
+ return checkUserCreatePermission(project, pActivity)
+ }
+ }
+
private editActivity(String id, boolean mobile = false){
String userId = userService.getCurrentUserId()
def activity = activityService.get(id)
@@ -566,6 +811,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 +1528,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 +1555,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
}
@@ -1457,6 +1729,7 @@ class BioActivityController {
tags = "biocollect",
operationId = "activityoutputs",
summary = "Get data for an activity",
+ deprecated = true,
parameters = [
@Parameter(
name = "id",
@@ -1527,6 +1800,86 @@ class BioActivityController {
render model as JSON
}
+ /*
+ * Simplified version to get data/output for an activity
+ * Handles both session and non session based request.
+ *
+ * @param id activityId
+ *
+ * @return activity
+ *
+ */
+ @Operation(
+ method = "GET",
+ tags = "biocollect",
+ operationId = "simplifiedactivityoutputs",
+ summary = "Get data for an activity",
+ parameters = [
+ @Parameter(
+ name = "id",
+ in = ParameterIn.PATH,
+ required = true,
+ description = "Activity id"
+ )
+ ],
+ responses = [
+ @ApiResponse(
+ responseCode = "200",
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(
+ implementation = GetOutputForActivitySimplifiedResponse.class
+ )
+ ),
+ headers = [
+ @Header(name = 'Access-Control-Allow-Headers', description = "CORS header", schema = @Schema(type = "String")),
+ @Header(name = 'Access-Control-Allow-Methods', description = "CORS header", schema = @Schema(type = "String")),
+ @Header(name = 'Access-Control-Allow-Origin', description = "CORS header", schema = @Schema(type = "String"))
+ ]
+ ),
+ @ApiResponse(
+ responseCode = "401",
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(
+ implementation = ErrorResponse.class
+ )
+ ),
+ headers = [
+ @Header(name = 'Access-Control-Allow-Headers', description = "CORS header", schema = @Schema(type = "String")),
+ @Header(name = 'Access-Control-Allow-Methods', description = "CORS header", schema = @Schema(type = "String")),
+ @Header(name = 'Access-Control-Allow-Origin', description = "CORS header", schema = @Schema(type = "String"))
+ ]
+ )
+ ],
+ security = @SecurityRequirement(name="auth")
+ )
+ @Path("ws/bioactivity/data/simplified/{id}")
+ def getOutputForActivitySimplified(String id){
+ String userId = userService.getCurrentUserId()
+ def activity = activityService.get(id)
+ String projectId = activity?.projectId
+ def model = [:]
+
+ if (!userId) {
+ response.status = 401
+ model.error = "Access denied: User has not been authenticated."
+ } else if (!activity) {
+ model.error = "Invalid activity id"
+ } else if (!activity) {
+ model.error = "Invalid activity - ${id}"
+ } else if (!projectId) {
+ model.error = "No project associated with the activity"
+ } else if (projectService.isUserAdminForProject(userId, projectId) || activityService.isUserOwnerForActivity(userId, activity?.activityId)) {
+ model = [activity: activity]
+ } else {
+ response.status = 401
+ model.error = "Access denied: User is not an owner of this activity ${activity?.activityId}"
+ }
+
+ render model as JSON
+ }
+
/*
* Get activity model for a survey/projectActivity
* Handles both session and non session based request.
@@ -1607,7 +1960,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..69350cc66 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 (!id) {
+ render text: [message: "Document not found"] as JSON, status: HttpStatus.SC_NOT_FOUND
+ return
+ }
+
+ def document = documentService.get(id)
+ 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..dc9433ea6 100644
--- a/grails-app/controllers/au/org/ala/biocollect/UrlMappings.groovy
+++ b/grails-app/controllers/au/org/ala/biocollect/UrlMappings.groovy
@@ -120,6 +120,10 @@ class UrlMappings {
controller = 'document'
action = 'download'
}
+ "/document/allDocumentsSearch" {
+ controller = 'document'
+ action = 'allDocumentsSearch'
+ }
"/$hub/bulkImport" {
controller = 'bulkImport'
@@ -167,6 +171,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')
@@ -180,12 +200,47 @@ class UrlMappings {
"/ws/attachment/upload"(controller: "image", action: 'upload')
"/ws/bioactivity/model/$id"(controller: "bioActivity", action: 'getActivityModel')
"/ws/bioactivity/data/$id"(controller: "bioActivity", action: 'getOutputForActivity')
+ "/ws/bioactivity/data/simplified/$id"(controller: "bioActivity", action: 'getOutputForActivitySimplified')
"/ws/species/uniqueId"(controller: "output", action: 'getOutputSpeciesIdentifier')
"/ws/bioactivity/save"(controller: "bioActivity", action: 'ajaxUpdate')
"/ws/bioactivity/site"(controller: "site", action: 'ajaxUpdate')
"/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/SettingService.groovy b/grails-app/services/au/org/ala/biocollect/merit/SettingService.groovy
index a1744f7f3..1d5620d8e 100644
--- a/grails-app/services/au/org/ala/biocollect/merit/SettingService.groovy
+++ b/grails-app/services/au/org/ala/biocollect/merit/SettingService.groovy
@@ -81,10 +81,10 @@ class SettingService {
switch (Environment.current) {
case Environment.DEVELOPMENT:
+ case Environment.TEST:
// do nothing
break
case Environment.PRODUCTION:
- case Environment.TEST:
default:
generateStyleSheetForHubs()
break
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..0ee7aadbf 100644
--- a/grails-app/services/au/org/ala/biocollect/merit/SpeciesService.groovy
+++ b/grails-app/services/au/org/ala/biocollect/merit/SpeciesService.groovy
@@ -1,6 +1,20 @@
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 {
+ static final String COMMON_NAME = 'COMMONNAME'
+ static final String SCIENTIFIC_NAME = 'SCIENTIFICNAME'
+ static final String COMMON_NAME_SCIENTIFIC_NAME = 'COMMONNAME(SCIENTIFICNAME)'
+ static final String SCIENTIFIC_NAME_COMMON_NAME = 'SCIENTIFICNAME(COMMONNAME)'
def webService, grailsApplication
@@ -35,10 +49,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 +137,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)
@@ -178,33 +192,7 @@ class SpeciesService {
String formatSpeciesName(String displayType, Map data){
String name
if(data.guid){
- switch (displayType){
- case 'COMMONNAME(SCIENTIFICNAME)':
- if(data.commonName){
- name = "${data.commonName} (${data.scientificName})"
- } else {
- name = "${data.scientificName}"
- }
- break;
- case 'SCIENTIFICNAME(COMMONNAME)':
- if(data.commonName){
- name = "${data.scientificName} (${data.commonName})"
- } else {
- name = "${data.scientificName}"
- }
-
- break;
- case 'COMMONNAME':
- if(data.commonName){
- name = "${data.commonName}"
- } else {
- name = "${data.scientificName}"
- }
- break;
- case 'SCIENTIFICNAME':
- name = "${data.scientificName}"
- break;
- }
+ name = formatTaxonName(data, displayType)
} else {
// when no guid, append unmatched taxon string
name = "${data.rawScientificName?:''} (Unmatched taxon)"
@@ -213,7 +201,40 @@ class SpeciesService {
name
}
- Object searchSpeciesForConfig(Map speciesConfig, String q, Integer limit) {
+ /** format species by specific type **/
+ String formatTaxonName (Map data, String displayType) {
+ String name = ''
+ switch (displayType){
+ case COMMON_NAME_SCIENTIFIC_NAME:
+ if (data.commonName && data.scientificName) {
+ name = "${data.commonName} (${data.scientificName})"
+ } else if (data.commonName) {
+ name = data.commonName
+ } else if (data.scientificName) {
+ name = data.scientificName
+ }
+ break
+ case SCIENTIFIC_NAME_COMMON_NAME:
+ if (data.scientificName && data.commonName) {
+ name = "${data.scientificName} (${data.commonName})"
+ } else if (data.scientificName) {
+ name = data.scientificName
+ } else if (data.commonName) {
+ name = data.commonName
+ }
+ break
+ case COMMON_NAME:
+ name = data.commonName ?: data.scientificName ?: ""
+ break
+ case SCIENTIFIC_NAME:
+ name = data.scientificName ?: ""
+ break
+ }
+
+ name
+ }
+
+ Object searchSpeciesForConfig(Map speciesConfig, String q, Integer limit, Integer offset = 0) {
def result
switch (speciesConfig?.type) {
case 'SINGLE_SPECIES':
@@ -225,7 +246,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 +309,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> scientificNames = []
+ File speciesDir = new File(directory)
+ // setting force to true will delete all files in the species directory
+ // and recreate them from the species catalog zip file
+ // otherwise, it will only create the species files if they don't already exist
+ if (force) {
+ speciesDir.listFiles().each { file ->
+ file.delete()
+ }
+ }
+ else {
+ if (totalFileExists()) {
+ return [success: "Species files already exist"]
+ }
+ }
+
+ File file = getSpeciesCatalogFile()
+ ZipFile zipFile = new ZipFile(file)
+ try {
+ Map speciesAddedToList = [:]
+ def isPreferred
+ ZipEntry entry = zipFile.getEntry(taxonFileName)
+ List header
+ String[] line, previous
+ int count = 1, BATCH_SIZE = grailsApplication.config.getProperty('speciesCatalog.batchSize', Integer), page = 1
+ int guidIndex, scientificNameIndex, rankStringIndex
+ if (entry) {
+ InputStream is = zipFile.getInputStream(entry)
+ InputStreamReader inputStreamReader = new InputStreamReader(is, "UTF-8")
+ CSVParser parser =
+ new CSVParserBuilder()
+ .withSeparator('\t'.toCharacter())
+ .withIgnoreQuotations(true)
+ .build()
+
+ CSVReader csvReader =
+ new CSVReaderBuilder(inputStreamReader)
+ .withCSVParser(parser)
+ .build();
+ header = csvReader.readNext()
+ guidIndex = header.findIndexOf { it == guidHeaderName }
+ rankStringIndex = header.findIndexOf { it == rankStringHeaderName }
+ scientificNameIndex = header.findIndexOf { it == scientificNameHeaderName }
+
+ while (line = csvReader.readNext()) {
+ (count, scientificNames, page, speciesAddedToList) = addScientificNameToFile(header, line, count, guidIndex, scientificNames, rankStringIndex, scientificNameIndex, BATCH_SIZE, config, page, commonName, speciesAddedToList)
+ }
+
+ if (scientificNames) {
+ saveSpeciesBatchToDisk(config, page, scientificNames)
+ }
+
+ csvReader.close()
+ }
+
+ String fileName = "${config.getProperty('speciesCatalog.dir')}/total.json"
+ new File(fileName).write(([total: page] as JSON).toString())
+ result = [success: "constructed species files"]
+ }
+ catch (Exception ex) {
+ result = [error: "Error constructing species files"]
+ }
+ finally {
+ zipFile.close()
+ }
+
+ result
+ }
+
+ boolean totalFileExists() {
+ String fileName = "${grailsApplication.config.getProperty('speciesCatalog.dir')}/total.json"
+ new File(fileName).exists()
+ }
+
+ List addScientificNameToFile(List header, String[] line, int count, int guidIndex, ArrayList> scientificNames, int rankStringIndex, int scientificNameIndex, int BATCH_SIZE, config, int page, String commonName, Map speciesAddedToList = [:]) {
+ String taxonID
+ String unranked = config.getProperty('speciesCatalog.filters.exclude.unrankedValue')
+ try {
+ if (header.size() != line.size()) {
+ log.error("Error parsing line: ${line} ${count}")
+ return [count, scientificNames, page, speciesAddedToList]
+ }
+
+ // skip unranked taxa
+ if (line[rankStringIndex] == unranked) {
+ return [count, scientificNames, page, speciesAddedToList]
+ }
+
+ taxonID = line[guidIndex]
+ commonName = getCommonName(taxonID)
+ String scientificName = line[scientificNameIndex],
+ scientificNameLower = scientificName?.toLowerCase()?.trim()
+
+ if (!speciesAddedToList[scientificNameLower]) {
+ scientificNames.add([
+ guid : taxonID,
+ commonName : commonName,
+ listId : "all",
+ rankString : line[rankStringIndex],
+ scientificName: scientificName,
+ name : commonName ? "${scientificName} (${commonName})" : scientificName
+ ])
+
+ speciesAddedToList[scientificNameLower] = true
+ count++
+
+ if (count % BATCH_SIZE == 0) {
+ saveSpeciesBatchToDisk(config, page, scientificNames)
+ scientificNames = []
+ page++
+ }
+ }
+ else {
+ log.debug("duplicate found - ${scientificName}")
+ }
+ } catch (Exception ex) {
+ log.error("Error parsing line: ${line} ${count}")
+ }
+
+ [count, scientificNames, page, speciesAddedToList]
+ }
+
+ public void saveSpeciesBatchToDisk(config, int page, ArrayList> scientificNames) {
+ String fileName = "${config.getProperty('speciesCatalog.dir')}/${page}.json"
+ new File(fileName).write((scientificNames as JSON).toString())
+ }
+
+ String getCommonName(String guid) {
+ Map commonNames = getVernacularNamesGroupedByTaxonId()
+ commonNames[guid]?.size() > 0 ? commonNames[guid][0]?.vernacularName : ""
+ }
+
+ @Cacheable("vernacularNamesGroupedByTaxonId")
+ Map getVernacularNamesGroupedByTaxonId () {
+ def config = grailsApplication.config
+ Map>> vernacularNames = [:].withDefault {[]}
+ File file = getSpeciesCatalogFile()
+ ZipFile zipFile = new ZipFile(file)
+ String vernacularFileName = grailsApplication.config.getProperty('speciesCatalog.vernacularFileName'),
+ taxonIDHeaderName = config.getProperty('speciesCatalog.vernacular.headerNames.taxonID'),
+ vernacularHeaderName = config.getProperty('speciesCatalog.vernacular.headerNames.vernacularName'),
+ languageHeaderName = config.getProperty('speciesCatalog.vernacular.headerNames.language'),
+ preferredHeaderName = config.getProperty('speciesCatalog.vernacular.headerNames.preferred'),
+ languageFilter = config.getProperty("speciesCatalog.filters.language"),
+ taxonID
+ def isPreferred
+ ZipEntry entry = zipFile.getEntry(vernacularFileName)
+ List header
+ String[] line, previous
+ int count = 1
+ int taxonIDIndex, vernacularNameIndex, languageIndex, preferredIndex
+ if (entry) {
+ InputStream is = zipFile.getInputStream(entry)
+ InputStreamReader inputStreamReader = new InputStreamReader(is, "UTF-8")
+ CSVParser parser =
+ new CSVParserBuilder()
+ .withSeparator('\t'.toCharacter())
+ .withIgnoreQuotations(true)
+ .build()
+ CSVReader csvReader =
+ new CSVReaderBuilder(inputStreamReader)
+ .withCSVParser(parser)
+ .build();
+ header = csvReader.readNext()
+ taxonIDIndex = header.findIndexOf { it == taxonIDHeaderName }
+ vernacularNameIndex = header.findIndexOf { it == vernacularHeaderName }
+ languageIndex = header.findIndexOf { it == languageHeaderName }
+ preferredIndex = header.findIndexOf { it == preferredHeaderName }
+
+ while (line = csvReader.readNext()) {
+ count ++
+ try {
+ if (header.size() != line.size()) {
+ log.error ("Error parsing line: ${line} ${count}")
+ continue
+ }
+
+ taxonID = line[taxonIDIndex]
+ isPreferred = line[preferredIndex] ?: "true"
+ isPreferred = Boolean.parseBoolean(isPreferred)
+ if (languageFilter.equals(line[languageIndex]) && isPreferred) {
+ vernacularNames[taxonID].add([
+ taxonID : taxonID,
+ vernacularName: line[vernacularNameIndex],
+ language : line[languageIndex],
+ preferred : line[preferredIndex]
+ ])
+ }
+ } catch (Exception ex) {
+ log.error("Error parsing line: ${line} ${count}")
+ }
+
+ previous = line
+ }
+
+ csvReader.close()
+ }
+
+ zipFile.close()
+ vernacularNames
+ }
+
+ File getSpeciesCatalogFile() {
+ String url = grailsApplication.config.getProperty('speciesCatalog.url')
+ String directory = grailsApplication.config.getProperty('speciesCatalog.dir')
+ String fileName = grailsApplication.config.getProperty('speciesCatalog.fileName')
+ String saveFileName = directory + File.separator + fileName
+ File dir = new File(directory)
+ if (!dir.exists()) {
+ dir.mkdirs()
+ }
+
+ File file = new File(saveFileName)
+ if (!file.exists()) {
+ downloadFile(url, saveFileName)
+ }
+
+ file
+ }
+
+ static void downloadFile(String fileURL, String saveFilePath) throws IOException {
+ URL url = new URL(fileURL);
+ try (InputStream inputStream = url.openStream();
+ BufferedInputStream bufferedInputStream = new BufferedInputStream(inputStream);
+ FileOutputStream fileOutputStream = new FileOutputStream(saveFilePath)) {
+
+ byte[] dataBuffer = new byte[1024];
+ int bytesRead;
+ while ((bytesRead = bufferedInputStream.read(dataBuffer, 0, 1024)) != -1) {
+ fileOutputStream.write(dataBuffer, 0, bytesRead);
+ }
+ }
+ }
}
diff --git a/grails-app/taglib/au/org/ala/biocollect/TemplateTagLib.groovy b/grails-app/taglib/au/org/ala/biocollect/TemplateTagLib.groovy
index 067ebe2bc..97ebaa9d8 100644
--- a/grails-app/taglib/au/org/ala/biocollect/TemplateTagLib.groovy
+++ b/grails-app/taglib/au/org/ala/biocollect/TemplateTagLib.groovy
@@ -2,6 +2,7 @@ package au.org.ala.biocollect
import au.org.ala.biocollect.merit.SettingService
import au.org.ala.biocollect.merit.UserService
+import grails.converters.JSON
import grails.web.mapping.LinkGenerator
import org.grails.web.servlet.mvc.GrailsWebRequest
import org.springframework.context.MessageSource
@@ -336,6 +337,27 @@ class TemplateTagLib {
}
}
+ /**
+ * Generate links to assets like image that need to be pre-cached by PWA app.
+ */
+ def getFilesToPreCacheForPWA = { attrs ->
+ List originalFiles = grailsApplication.config.getProperty('pwa.serviceWorkerConfig.filesToPreCache', List)?.collect{
+ it
+ }
+ List resolvedFiles = originalFiles?.collect {
+ asset.assetPath(src: it)
+ }
+
+ // adding /asset to path will help finding files when running from jar files.
+ // Running app from jar file returns path with the updated name.
+ // We need the updated and original name to be cached by PWA.
+ originalFiles = originalFiles?.collect { "/assets/" + it }
+ List mixedFiles = resolvedFiles + originalFiles
+ mixedFiles = mixedFiles?.unique()
+
+ out << (mixedFiles as JSON).toString()
+ }
+
String getCurrentURLFromRequest() {
def grailsRequest = GrailsWebRequest.lookup()
grailsLinkGenerator.link(absolute: true, params: grailsRequest.originalParams, uri: request.forwardURI)
diff --git a/grails-app/views/admin/tools.gsp b/grails-app/views/admin/tools.gsp
index b92a0ebd3..7735c3460 100644
--- a/grails-app/views/admin/tools.gsp
+++ b/grails-app/views/admin/tools.gsp
@@ -129,6 +129,17 @@
alert(result.statusText);
});
});
+
+ $("#speciesCatalog").on('click', function (e) {
+ e.preventDefault();
+ $.ajax(
+ "${createLink(controller: 'species', action:'refreshSpeciesCatalog')}?force=true"
+ ).done(function(result) {
+ alert(result.success);
+ }).fail(function (result) {
+ alert(result.statusText);
+ });
+ });
});
@@ -256,6 +267,14 @@
+
+
+
+
+
+
+
+