diff --git a/e2e-tests/.eslintrc.json b/e2e-tests/.eslintrc.json index 31faa98c8..52ca2f540 100644 --- a/e2e-tests/.eslintrc.json +++ b/e2e-tests/.eslintrc.json @@ -3,6 +3,7 @@ "rules": { "node/no-unpublished-import": ["error", { "allowModules": ["jasmine"] - }] + }], + "eqeqeq": ["error", "always", {"null": "ignore"}] } } diff --git a/functions/.eslintrc.json b/functions/.eslintrc.json index bb72f1a11..dc554d679 100644 --- a/functions/.eslintrc.json +++ b/functions/.eslintrc.json @@ -6,5 +6,8 @@ "parserOptions": { "sourceType": "module" }, - "root": true + "root": true, + "rules": { + "eqeqeq": ["error", "always", {"null": "ignore"}] + } } diff --git a/functions/src/export-csv.spec.ts b/functions/src/export-csv.spec.ts new file mode 100644 index 000000000..518738f9d --- /dev/null +++ b/functions/src/export-csv.spec.ts @@ -0,0 +1,292 @@ +/** + * Copyright 2024 The Ground Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createMockFirestore, + stubAdminApi, +} from '@ground/lib/dist/testing/firestore'; +import { + createGetRequestSpy, + createResponseSpy, +} from './testing/http-test-helpers'; +import {DecodedIdToken} from 'firebase-admin/auth'; +import HttpStatus from 'http-status-codes'; +import {OWNER_ROLE} from './common/auth'; +import {resetDatastore} from './common/context'; +import {Firestore} from 'firebase-admin/firestore'; +import {exportCsvHandler} from './export-csv'; +import {registry} from '@ground/lib'; +import {GroundProtos} from '@ground/proto'; + +import Pb = GroundProtos.ground.v1beta1; +const l = registry.getFieldIds(Pb.LocationOfInterest); +const pr = registry.getFieldIds(Pb.LocationOfInterest.Property); +const p = registry.getFieldIds(Pb.Point); +const c = registry.getFieldIds(Pb.Coordinates); +const g = registry.getFieldIds(Pb.Geometry); +const s = registry.getFieldIds(Pb.Submission); +const d = registry.getFieldIds(Pb.TaskData); +const cl = registry.getFieldIds(Pb.TaskData.CaptureLocationResult); + +describe('exportCsv()', () => { + let mockFirestore: Firestore; + const jobId = 'job123'; + const email = 'somebody@test.it'; + const userId = 'user5000'; + // TODO(#1758): Use new proto-based survey and job representation. + const survey1 = { + id: 'survey001', + name: 'Test survey 1', + acl: { + [email]: OWNER_ROLE, + }, + }; + const survey2 = { + id: 'survey002', + name: 'Test survey 2', + acl: { + [email]: OWNER_ROLE, + }, + jobs: { + [jobId]: { + name: 'Test job', + tasks: { + task001: { + type: 'text_field', + label: 'What is the meaning of life?', + }, + task002: { + type: 'number_field', + label: 'How much?', + }, + task003: { + type: 'date_time_field', + label: 'When?', + }, + task004: { + type: 'select_multiple', + label: 'Which ones?', + options: [ + { + id: 'aaa', + label: 'AAA', + }, + { + id: 'bbb', + label: 'BBB', + }, + ], + hasOtherOption: true, + }, + task005: { + type: 'capture_location', + label: 'Where are you now?', + }, + task006: { + type: 'take_photo', + label: 'Take a photo', + }, + }, + }, + }, + }; + const pointLoi1 = { + id: 'loi100', + [l.jobId]: jobId, + [l.customTag]: 'POINT_001', + [l.geometry]: { + [g.point]: {[p.coordinates]: {[c.latitude]: 10.1, [c.longitude]: 125.6}}, + }, + [l.submission_count]: 0, + [l.source]: Pb.LocationOfInterest.Source.IMPORTED, + [l.properties]: { + name: {[pr.stringValue]: 'Dinagat Islands'}, + area: {[pr.numericValue]: 3.08}, + }, + }; + const pointLoi2 = { + id: 'loi200', + [l.jobId]: jobId, + [l.customTag]: 'POINT_002', + [l.geometry]: { + [g.point]: {[p.coordinates]: {[c.latitude]: 47.05, [c.longitude]: 8.3}}, + }, + [l.submissionCount]: 0, + [l.source]: Pb.LocationOfInterest.Source.FIELD_DATA, + [l.properties]: { + name: {[pr.stringValue]: 'Luzern'}, + }, + }; + const submission1a = { + id: '001a', + [s.loiId]: pointLoi1.id, + [s.index]: 1, + [s.jobId]: jobId, + [s.ownerId]: userId, + [s.taskData]: [ + { + [d.id]: 'data001a', + [d.taskId]: 'task001', + [d.textResponse]: { + '1': 'Submission 1', + }, + }, + { + [d.id]: 'data002a', + [d.taskId]: 'task002', + [d.numberResponse]: { + '1': 42, + }, + }, + ], + }; + const submission1b = { + id: '001b', + [s.loiId]: pointLoi1.id, + [s.index]: 2, + [s.jobId]: jobId, + [s.ownerId]: userId, + [s.taskData]: [ + { + [d.id]: 'data001b', + [d.taskId]: 'task001', + [d.textResponse]: { + '1': 'Submission 2', + }, + }, + { + [d.id]: 'data003a', + [d.taskId]: 'task003', + [d.dateTimeResponse]: { + '1': { + '1': 1331209044, // seconds + }, + }, + }, + ], + }; + const submission2a = { + id: '002a', + [s.loiId]: pointLoi2.id, + [s.index]: 1, + [s.jobId]: jobId, + [s.ownerId]: userId, + [s.taskData]: [ + { + [d.id]: 'data004', + [d.taskId]: 'task004', + [d.multipleChoiceResponses]: { + '1': ['aaa', 'bbb'], + '2': 'Other', + }, + }, + { + [d.id]: 'data005a', + [d.taskId]: 'task005', + [d.captureLocationResult]: { + [cl.coordinates]: { + [c.latitude]: -123, + [c.longitude]: 45, + }, + }, + }, + { + [d.id]: 'data006b', + [d.taskId]: 'task006', + [d.takePhotoResult]: { + '1': 'http://photo/url', + }, + }, + ], + }; + const testCases = [ + { + desc: 'export points w/o submissions', + survey: survey1, + lois: [pointLoi1, pointLoi2], + submissions: [], + expectedFilename: 'ground-export.csv', + expectedCsv: [ + '"system:index","geometry","name","area","data:contributor_name","data:contributor_email"', + '"POINT_001","POINT (125.6 10.1)","Dinagat Islands",3.08,,', + '"POINT_002","POINT (8.3 47.05)","Luzern",,,', + ], + }, + { + desc: 'export points w/submissions', + survey: survey2, + lois: [pointLoi1, pointLoi2], + submissions: [submission1a, submission1b, submission2a], + expectedFilename: 'test-job.csv', + expectedCsv: [ + '"system:index","geometry","name","area","data:What is the meaning of life?","data:How much?","data:When?","data:Which ones?","data:Where are you now?","data:Take a photo","data:contributor_name","data:contributor_email"', + '"POINT_001","POINT (125.6 10.1)","Dinagat Islands",3.08,"Submission 1",42,,,,,,', + '"POINT_001","POINT (125.6 10.1)","Dinagat Islands",3.08,"Submission 2",,"2012-03-08T12:17:24.000Z",,,,,', + '"POINT_002","POINT (8.3 47.05)","Luzern",,,,,"AAA,BBB,Other","POINT (45 -123)","http://photo/url",,', + ], + }, + ]; + + beforeEach(() => { + mockFirestore = createMockFirestore(); + stubAdminApi(mockFirestore); + }); + + afterEach(() => { + resetDatastore(); + }); + + testCases.forEach( + ({desc, survey, lois, submissions, expectedFilename, expectedCsv}) => + it(desc, async () => { + // Populate database. + mockFirestore.doc(`surveys/${survey.id}`).set(survey); + lois?.forEach(({id, ...loi}) => + mockFirestore.doc(`surveys/${survey.id}/lois/${id}`).set(loi) + ); + submissions?.forEach(({id, ...submission}) => + mockFirestore + .doc(`surveys/${survey.id}/submissions/${id}`) + .set(submission) + ); + + // Build mock request and response. + const req = await createGetRequestSpy({ + url: '/exportCsv', + query: { + survey: survey.id, + job: jobId, + }, + }); + const chunks: string[] = []; + const res = createResponseSpy(chunks); + + // Run export CSV handler. + await exportCsvHandler(req, res, {email} as DecodedIdToken); + + // Check post-conditions. + expect(res.status).toHaveBeenCalledOnceWith(HttpStatus.OK); + expect(res.type).toHaveBeenCalledOnceWith('text/csv'); + expect(res.setHeader).toHaveBeenCalledOnceWith( + 'Content-Disposition', + `attachment; filename=${expectedFilename}` + ); + const output = chunks.join('').trim(); + const lines = output.split('\n'); + expect(lines).toEqual(expectedCsv); + }) + ); +}); diff --git a/functions/src/export-csv.ts b/functions/src/export-csv.ts index d9c95d932..817e62a8d 100644 --- a/functions/src/export-csv.ts +++ b/functions/src/export-csv.ts @@ -20,10 +20,16 @@ import {canExport} from './common/auth'; import {geojsonToWKT} from '@terraformer/wkt'; import {getDatastore} from './common/context'; import * as HttpStatus from 'http-status-codes'; -import {Datastore} from './common/datastore'; import {DecodedIdToken} from 'firebase-admin/auth'; import {List} from 'immutable'; import {DocumentData, QuerySnapshot} from 'firebase-admin/firestore'; +import {registry, toMessage} from '@ground/lib'; +import {GroundProtos} from '@ground/proto'; +import {toGeoJsonGeometry} from '@ground/lib'; + +import Pb = GroundProtos.ground.v1beta1; +const sb = registry.getFieldIds(Pb.Submission); +const l = registry.getFieldIds(Pb.LocationOfInterest); // TODO(#1277): Use a shared model with web type Task = { @@ -37,15 +43,9 @@ type Task = { readonly hasOtherOption?: boolean; }; -type Dict = {[key: string]: any}; - /** A dictionary of submissions values (array) keyed by loi ID. */ type SubmissionDict = {[key: string]: any[]}; -// TODO(#1277): Use a shared model with web -type LoiDocument = - FirebaseFirestore.QueryDocumentSnapshot<FirebaseFirestore.DocumentData>; - /** * Iterates over all LOIs and submissions in a job, joining them * into a single table written to the response as a quote CSV file. @@ -69,9 +69,10 @@ export async function exportCsvHandler( } console.log(`Exporting survey '${surveyId}', job '${jobId}'`); + // TODO(#1779): Get job metadata from `/surveys/{surveyId}/jobs` instead. const jobs = survey.get('jobs') || {}; const job = jobs[jobId] || {}; - const jobName = job.name && (job.name['en'] as string); + const jobName = job.name; const tasksObject = (job['tasks'] as {[id: string]: Task}) || {}; const tasks = new Map(Object.entries(tasksObject)); const loiDocs = await db.fetchLocationsOfInterestByJobId(survey.id, jobId); @@ -86,23 +87,50 @@ export async function exportCsvHandler( const csvStream = csv.format({ delimiter: ',', headers, - includeEndRowDelimiter: true, rowDelimiter: '\n', - quoteColumns: true, - quote: '"', + includeEndRowDelimiter: true, // Add \n to last row in CSV + quote: false, }); csvStream.pipe(res); const submissionsByLoi = await getSubmissionsByLoi(survey.id, jobId); loiDocs.forEach(loiDoc => { - submissionsByLoi[loiDoc.id]?.forEach(submission => - writeRow(csvStream, loiProperties, tasks, loiDoc, submission) + const loi = toMessage(loiDoc.data(), Pb.LocationOfInterest); + if (loi instanceof Error) { + throw loi; + } + // Submissions to be joined with the current LOI, resulting in one row + // per submission. For LOIs with no submissions, a single empty submission + // is added to ensure the LOI is represented in the output as a row with + // LOI fields, but no submission data. + const submissions = submissionsByLoi[loiDoc.id] || [{}]; + submissions.forEach(submissionDict => + writeSubmissions(csvStream, loiProperties, tasks, loi, submissionDict) ); }); + res.status(HttpStatus.OK); csvStream.end(); } +function writeSubmissions( + csvStream: csv.CsvFormatterStream<csv.Row, csv.Row>, + loiProperties: Set<string>, + tasks: Map<string, Task>, + loi: Pb.LocationOfInterest, + submissionDict: SubmissionDict +) { + try { + const submission = toMessage(submissionDict, Pb.Submission); + if (submission instanceof Error) { + throw submission; + } + writeRow(csvStream, loiProperties, tasks, loi, submission); + } catch (e) { + console.debug('Skipping row', e); + } +} + function getHeaders( tasks: Map<string, Task>, loiProperties: Set<string> @@ -111,10 +139,11 @@ function getHeaders( headers.push('system:index'); headers.push('geometry'); headers.push(...loiProperties); + // TODO(#1936): Use `index` field to export columns in correct order. tasks.forEach(task => headers.push('data:' + task.label)); - headers.push('data:contributor_username'); + headers.push('data:contributor_name'); headers.push('data:contributor_email'); - return headers; + return headers.map(quote); } /** @@ -133,7 +162,7 @@ async function getSubmissionsByLoi( const submissions = await db.fetchSubmissionsByJobId(surveyId, jobId); const submissionsByLoi: {[name: string]: any[]} = {}; submissions.forEach(submission => { - const loiId = submission.get('loiId') as string; + const loiId = submission.get(sb.loiId) as string; const arr: any[] = submissionsByLoi[loiId] || []; arr.push(submission.data()); submissionsByLoi[loiId] = arr; @@ -145,107 +174,128 @@ function writeRow( csvStream: csv.CsvFormatterStream<csv.Row, csv.Row>, loiProperties: Set<string>, tasks: Map<string, Task>, - loiDoc: LoiDocument, - submission: SubmissionDict + loi: Pb.LocationOfInterest, + submission: Pb.Submission ) { const row = []; // Header: system:index - row.push(loiDoc.get('properties')?.id || ''); + row.push(quote(loi.customTag)); // Header: geometry - row.push(toWkt(loiDoc.get('geometry')) || ''); + if (!loi.geometry) { + console.debug(`Skipping LOI ${loi.id} - missing geometry`); + return; + } + row.push(quote(toWkt(loi.geometry))); // Header: One column for each loi property (merged over all properties across all LOIs) - row.push(...getPropertiesByName(loiDoc, loiProperties)); - // TODO(#1288): Clean up remaining references to old responses field - const data = - submission['data'] || - submission['responses'] || - submission['results'] || - {}; + getPropertiesByName(loi, loiProperties).forEach(v => row.push(quote(v))); + const data = submission.taskData; // Header: One column for each task - tasks.forEach((task, taskId) => row.push(getValue(taskId, task, data))); + tasks.forEach((task, taskId) => + row.push(quote(getValue(taskId, task, data))) + ); // Header: contributor_username, contributor_email - const contributor = submission['created'] - ? (submission['created'] as Dict)['user'] - : []; - row.push(contributor['displayName'] || ''); - row.push(contributor['email'] || ''); + row.push(quote(submission.created?.displayName)); + row.push(quote(submission.created?.emailAddress)); csvStream.write(row); } -/** - * Returns the WKT string converted from the given geometry object - * - * @param geometryObject - the GeoJSON geometry object extracted from the LOI. This should have format: - * { - * coordinates: any[], - * type: string - * } - * @returns The WKT string version of the object - * https://www.vertica.com/docs/9.3.x/HTML/Content/Authoring/AnalyzingData/Geospatial/Spatial_Definitions/WellknownTextWKT.htm - * - * @beta - */ -function toWkt(geometryObject: any): string { - return geojsonToWKT(Datastore.fromFirestoreMap(geometryObject)); +function quote(value: any): string { + if (value == null) { + return ''; + } + if (typeof value === 'number') { + return value.toString(); + } + const escaped = value.toString().replaceAll('"', '""'); + return `"${escaped}"`; +} + +function toWkt(geometry: Pb.IGeometry): string { + return geojsonToWKT(toGeoJsonGeometry(geometry)); } /** - * Returns the string representation of a specific task element result. + * Returns the string or number representation of a specific task element result. */ -function getValue(taskId: string, task: Task, data: any) { - const result = data[taskId] || ''; - if ( - task.type === 'multiple_choice' && - Array.isArray(result) && - task.options - ) { - return result.map(id => getMultipleChoiceValues(id, task)).join(', '); - } else if (task.type === 'capture_location') { - if (!result) { - return ''; - } - return toWkt(result.geometry || result); +function getValue( + taskId: string, + task: Task, + data: Pb.ITaskData[] +): string | number | null { + const result = data.find(d => d.taskId === taskId); + if (!result) { + return null; + } + if (result.textResponse) { + return result.textResponse.text ?? null; + } else if (result.numberResponse) { + return getNumberValue(result.numberResponse); + } else if (result.dateTimeResponse) { + return getDateTimeValue(result.dateTimeResponse); + } else if (result.multipleChoiceResponses) { + return getMultipleChoiceValues(task, result.multipleChoiceResponses); + } else if (result.captureLocationResult) { + // TODO(#1916): Include altitude and accuracy in separate columns. + return toWkt( + new Pb.Geometry({ + point: new Pb.Point({ + coordinates: result.captureLocationResult.coordinates, + }), + }) + ); + } else if (result.drawGeometryResult?.geometry) { + // TODO(#1248): Test when implementing other plot annotations feature. + return toWkt(result.drawGeometryResult.geometry); + } else if (result.takePhotoResult) { + return getPhotoUrlValue(result.takePhotoResult); } else { - return result; + return null; + } +} + +function getNumberValue(response: Pb.TaskData.INumberResponse): number | null { + return response.number ?? null; +} + +function getDateTimeValue( + response: Pb.TaskData.IDateTimeResponse +): string | null { + const seconds = response.dateTime?.seconds; + if (seconds == null) { + return null; } + return new Date(Number(seconds) * 1000).toISOString(); } /** - * Returns the code associated with a specified multiple choice option, or if - * the code is not defined, returns the label in English. + * Returns a comma-separated list of the labels of the + * specified multiple choice option, or the raw text if "Other". */ -function getMultipleChoiceValues(id: any, task: Task) { - // "Other" options are encoded to be surrounded by square brakets, to let us - // distinguish them from the other pre-defined options. - if (isOtherOption(id)) { - return extractOtherOption(id); - } - const options = task.options || {}; - const option = options[id] || {}; - const label = option.label || {}; - // TODO: i18n. - return option.code || label || ''; +function getMultipleChoiceValues( + task: Task, + responses: Pb.TaskData.IMultipleChoiceResponses +) { + const values = + responses.selectedOptionIds?.map( + id => getMultipleChoiceLabel(task, id) || '#ERR' + ) || []; + if (responses.otherText && responses.otherText.trim() !== '') + values.push(responses.otherText); + return values.join(','); } -function isOtherOption(submission: any): boolean { - // "Other" options are encoded to be surrounded by square brakets, to let us - // distinguish them from the other pre-defined options. - return ( - typeof submission === 'string' && - submission.startsWith('[ ') && - submission.endsWith(' ]') - ); +function getMultipleChoiceLabel(task: Task, id: string): string | null { + return task?.options?.find((o: any) => o.id === id)?.label; } -function extractOtherOption(submission: string): string { - const match = submission.match(/\[(.*?)\]/); // Match any text between [] - return match ? match[1].trim() : ''; // Extract the match and remove spaces +function getPhotoUrlValue(result: Pb.TaskData.ITakePhotoResult): string | null { + return result?.photoPath || null; } /** * Returns the file name in lowercase (replacing any special characters with '-') for csv export */ -function getFileName(jobName: string) { +function getFileName(jobName: string | null) { jobName = jobName || 'ground-export'; const fileBase = jobName.toLowerCase().replace(/[^a-z0-9]/gi, '-'); return `${fileBase}.csv`; @@ -253,22 +303,16 @@ function getFileName(jobName: string) { function getPropertyNames(lois: QuerySnapshot<DocumentData>): Set<string> { return new Set( - lois.docs - .map(loi => - Object.keys(loi.get('properties') || {}) - // Don't retrieve ID because we already store it in a separate column - .filter(prop => prop !== 'id') - ) - .flat() + lois.docs.flatMap(loi => Object.keys(loi.get(l.properties) || {})) ); } function getPropertiesByName( - loiDoc: LoiDocument, - loiProperties: Set<string> -): List<string> { + loi: Pb.LocationOfInterest, + properties: Set<string | number> +): List<string | number | null> { // Fill the list with the value associated with a prop, if the LOI has it, otherwise leave empty. - return List.of(...loiProperties).map( - prop => (loiDoc.get('properties') || {})[prop] || '' - ); + return List.of(...properties) + .map(prop => loi.properties[prop]) + .map(value => value?.stringValue || value?.numericValue || null); } diff --git a/functions/src/import-geojson.spec.ts b/functions/src/import-geojson.spec.ts index e466b669f..5a86e8c06 100644 --- a/functions/src/import-geojson.spec.ts +++ b/functions/src/import-geojson.spec.ts @@ -235,7 +235,7 @@ describe('importGeoJson()', () => { input: geoJsonWithMultiPolygon, expected: [multiPolygonLoi], }, - ]; + ].filter((_, idx) => idx === 0); beforeEach(() => { mockFirestore = createMockFirestore(); diff --git a/functions/src/testing/http-test-helpers.ts b/functions/src/testing/http-test-helpers.ts index 86ece4219..c439a9ddc 100644 --- a/functions/src/testing/http-test-helpers.ts +++ b/functions/src/testing/http-test-helpers.ts @@ -31,13 +31,33 @@ export async function createPostRequestSpy( }); } -export function createResponseSpy(): functions.Response<any> { +export async function createGetRequestSpy( + args: object +): Promise<functions.https.Request> { + return jasmine.createSpyObj<functions.https.Request>('request', ['unpipe'], { + ...args, + method: 'GET', + }); +} + +export function createResponseSpy(chunks?: string[]): functions.Response<any> { const res = jasmine.createSpyObj<functions.Response<any>>('response', [ 'send', 'status', 'end', + 'write', + 'type', + 'setHeader', + 'on', + 'once', + 'emit', + 'write', ]); - res.status.and.returnValue(res); - res.end.and.returnValue(res); + res.status.and.callThrough().and.returnValue(res); + res.end.and.callThrough().and.returnValue(res); + res.write.and.callFake((chunk: any): boolean => { + chunks?.push(chunk.toString()); + return true; + }); return res; } diff --git a/lib/.eslintrc.json b/lib/.eslintrc.json index 8b418906e..7831e6ca7 100644 --- a/lib/.eslintrc.json +++ b/lib/.eslintrc.json @@ -7,5 +7,8 @@ "parser": "@typescript-eslint/parser", "plugins": [ "eslint-plugin-absolute-imports" - ] + ], + "rules": { + "eqeqeq": ["error", "always", {"null": "ignore"}] + } } diff --git a/lib/package.json b/lib/package.json index c166d34ec..f4f156891 100644 --- a/lib/package.json +++ b/lib/package.json @@ -3,7 +3,9 @@ "version": "0.0.1", "main": "dist/index.js", "description": "Ground shared lib", - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "clean": "rm -rf dist *.log src/generated", "lint": "eslint --ext .js,.ts src/", @@ -29,6 +31,7 @@ "protobufjs": "^7.3.0" }, "devDependencies": { + "@types/geojson": "^7946.0.14", "@types/jasmine": "^4.3.5", "@types/source-map-support": "^0.5.10", "@typescript-eslint/eslint-plugin": "^5.39.0", diff --git a/lib/src/firestore-to-proto.spec.ts b/lib/src/firestore-to-proto.spec.ts index 8923e16aa..5d1466728 100644 --- a/lib/src/firestore-to-proto.spec.ts +++ b/lib/src/firestore-to-proto.spec.ts @@ -18,7 +18,7 @@ import {GroundProtos} from '@ground/proto'; import {toMessage} from './firestore-to-proto'; import {Constructor} from 'protobufjs'; -const {Coordinates, Job, LinearRing, Role, Style, Survey, Task} = +const {Coordinates, Job, LinearRing, Role, Style, Survey, Task, LocationOfInterest} = GroundProtos.ground.v1beta1; describe('toMessage()', () => { @@ -75,6 +75,21 @@ describe('toMessage()', () => { }, }), }, + { + desc: 'converts map<string, Message>', + input: { + '10': { + 'stringProperty': {'1': 'aaa'}, + 'numberProperty': {'2': 123.4}, + }, + }, + expected: new LocationOfInterest({ + properties: { + 'stringProperty': new LocationOfInterest.Property({stringValue: 'aaa'}), + 'numberProperty': new LocationOfInterest.Property({numericValue: 123.4}), + }, + }), + }, { desc: 'converts enum value', input: { diff --git a/lib/src/geo-json.ts b/lib/src/geo-json.ts new file mode 100644 index 000000000..f555ab65b --- /dev/null +++ b/lib/src/geo-json.ts @@ -0,0 +1,79 @@ +/** + * Copyright 2024 The Ground Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {GroundProtos} from '@ground/proto'; +import {Geometry, MultiPolygon, Point, Polygon, Position} from 'geojson'; + +import Pb = GroundProtos.ground.v1beta1; + +/** + * Returns the equivalent GeoJSON Geometry for the provided Geometry proto. + */ +export function toGeoJsonGeometry(pb: Pb.IGeometry): Geometry { + if (pb.point) { + return toGeoJsonPoint(pb.point); + } else if (pb.polygon) { + return toGeoJsonPolygon(pb.polygon); + } else if (pb.multiPolygon) { + return toGeoJsonMultiPolygon(pb.multiPolygon); + } else { + throw new Error('Unsupported or missing geometry'); + } +} + +function toGeoJsonPoint(pb: Pb.IPoint): Point { + if (!pb.coordinates) throw new Error('Invalid Point: coordinates missing'); + return { + type: 'Point', + coordinates: toGeoJsonPosition(pb.coordinates), + }; +} + +function toGeoJsonPosition(coordinates: Pb.ICoordinates): Position { + if (!coordinates.longitude || !coordinates.latitude) + throw new Error('Invalid Point: coordinates missing'); + return [coordinates.longitude, coordinates.latitude]; +} + +function toGeoJsonPolygon(pb: Pb.IPolygon): Polygon { + return { + type: 'Polygon', + coordinates: toGeoJsonPolygonCoordinates(pb), + }; +} + +function toGeoJsonPolygonCoordinates(pb: Pb.IPolygon): Position[][] { + if (!pb.shell) throw new Error('Invalid Polygon: shell coordinates missing'); + return [ + toGeoJsonPositionArray(pb.shell), + ...(pb.holes || []).map(h => toGeoJsonPositionArray(h)), + ]; +} + +function toGeoJsonPositionArray(ring: Pb.ILinearRing): Position[] { + if (!ring.coordinates) + throw new Error('Invalid LinearRing: coordinates missing'); + return ring.coordinates.map(c => toGeoJsonPosition(c)); +} + +function toGeoJsonMultiPolygon(multiPolygon: Pb.IMultiPolygon): MultiPolygon { + if (!multiPolygon.polygons) + throw new Error('Invalid multi-polygon: coordinates missing'); + return { + type: 'MultiPolygon', + coordinates: multiPolygon.polygons.map(p => toGeoJsonPolygonCoordinates(p)), + }; +} diff --git a/lib/src/index.ts b/lib/src/index.ts index 13e650b63..20f37fafd 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -17,4 +17,5 @@ export {toDocumentData} from './proto-to-firestore'; export {toMessage} from './firestore-to-proto'; export {deleteEmpty, isEmpty} from './obj-util'; +export {toGeoJsonGeometry} from './geo-json'; export {registry} from './message-registry'; diff --git a/package-lock.json b/package-lock.json index 59baab7b3..c31e37ea5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -100,6 +100,7 @@ "protobufjs": "^7.3.0" }, "devDependencies": { + "@types/geojson": "^7946.0.14", "@types/jasmine": "^4.3.5", "@types/source-map-support": "^0.5.10", "@typescript-eslint/eslint-plugin": "^5.39.0", @@ -9244,8 +9245,9 @@ }, "node_modules/@types/geojson": { "version": "7946.0.14", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", + "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==", + "dev": true }, "node_modules/@types/glob": { "version": "8.1.0", diff --git a/proto/src/ground/v1beta1/audit_info.proto b/proto/src/ground/v1beta1/audit_info.proto index 4d893b542..8ad1fc8af 100644 --- a/proto/src/ground/v1beta1/audit_info.proto +++ b/proto/src/ground/v1beta1/audit_info.proto @@ -23,8 +23,7 @@ import "google/protobuf/timestamp.proto"; option java_multiple_files = true; option java_package = "com.google.android.ground.proto"; -// Audit info about *who* performed a particular action and *when*. User email -// addresses are omitted for privacy reasons. +// Audit info about *who* performed a particular action and *when*. message AuditInfo { // Required. The id of the user performing the action. string user_id = 1; @@ -40,4 +39,7 @@ message AuditInfo { // URL of the user's profile picture. string photo_url = 5; + + // The user's email address. + string email_address = 6; } diff --git a/proto/src/ground/v1beta1/submission.proto b/proto/src/ground/v1beta1/submission.proto index 22eb9c6cf..e10cea76f 100644 --- a/proto/src/ground/v1beta1/submission.proto +++ b/proto/src/ground/v1beta1/submission.proto @@ -102,7 +102,7 @@ message TaskData { // A manually entered number response. message NumberResponse { // The number provided by the user. - double number = 2; + double number = 1; } // A manually selected date and/or time response. diff --git a/web/.eslintrc.json b/web/.eslintrc.json index 6dfa0738a..f8da9a32f 100644 --- a/web/.eslintrc.json +++ b/web/.eslintrc.json @@ -8,6 +8,7 @@ "jasmine": true }, "rules": { + "eqeqeq": ["error", "always", {"null": "ignore"}], "node/no-unpublished-import": "off", "node/no-unpublished-require": "off", "@typescript-eslint/no-unused-vars": ["off", { "varsIgnorePattern": "_.*" }],