Skip to content

Commit 09d16fb

Browse files
authored
Add ability to prebundle tasks (#1602)
* add UI for task bundle id field and data submit * add prebundle support for remote files during challenge creation * store prebundle in lineByLineGeoJSON param as a blob * add rebuild method for prebundling * add support for lineByLine Geojson * fix warnings * add to gitignore * fix overpass warning location * remove console log and debugger
1 parent 6824b86 commit 09d16fb

File tree

7 files changed

+251
-17
lines changed

7 files changed

+251
-17
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,5 @@ yarn-error.log*
3838
#scala plugin
3939
/.bsp
4040
/project
41-
/target
41+
/target
42+
/.metals

src/components/AdminPane/HOCs/WithChallengeManagement/WithChallengeManagement.js

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import _map from "lodash/map";
33
import _get from "lodash/get";
44
import _isObject from "lodash/isObject";
55
import _compact from "lodash/compact";
6+
import bundleByTaskBundleId from "../../../../utils/bundleByTaskBundleId";
7+
import createBlob from "../../../../utils/createBlob";
68
import {
79
saveChallenge,
810
uploadChallengeGeoJSON,
@@ -45,6 +47,95 @@ const WithChallengeManagement = (WrappedComponent) =>
4547
"deletingTasks"
4648
);
4749

50+
/**
51+
* Method used to direct remote or local data structures into line-by-line GeoJson format
52+
* if a taskBundleIdProperty is provided. Any errors will be treated silently and return false
53+
* to allow parent methods to continue other processes.
54+
*
55+
* @private
56+
*/
57+
async function rebuildPrebundle(challenge, localFile) {
58+
try {
59+
if (challenge.taskBundleIdProperty) {
60+
if (localFile) {
61+
const data = await new Response(localFile).text();
62+
63+
const bundled = bundleByTaskBundleId(
64+
JSON.parse(data).features,
65+
challenge.taskBundleIdProperty
66+
);
67+
68+
return createBlob(bundled);
69+
}
70+
}
71+
72+
return false;
73+
} catch (e) {
74+
console.log(e);
75+
return false;
76+
}
77+
}
78+
79+
/**
80+
* Method used to direct remote or local data structures into line-by-line GeoJson format
81+
* if a taskBundleIdProperty is provided. Any errors will be treated silently and return false
82+
* to allow parent methods to continue other processes.
83+
*
84+
* @private
85+
*/
86+
async function convertAndBundleGeoJson(challenge) {
87+
try {
88+
let data = {};
89+
90+
if (challenge.taskBundleIdProperty) {
91+
if (challenge.remoteGeoJson) {
92+
data = await fetch(challenge.remoteGeoJson).then((response) =>
93+
response.json()
94+
);
95+
} else if (challenge.lineByLineGeoJSON) {
96+
const lineFile = AsLineReadableFile(challenge.lineByLineGeoJSON);
97+
let allLinesRead = false;
98+
let allLines = [];
99+
100+
while (!allLinesRead) {
101+
let taskLine = await lineFile.readLines(1);
102+
if (taskLine[0] === null) {
103+
allLinesRead = true;
104+
} else {
105+
allLines.push(taskLine[0]);
106+
}
107+
}
108+
109+
data.features = [];
110+
111+
if (allLines?.length) {
112+
allLines.forEach((taskLine) => {
113+
JSON.parse(taskLine).features.forEach((feature) => {
114+
data.features.push(feature);
115+
});
116+
});
117+
}
118+
} else if (challenge.localGeoJSON) {
119+
data = JSON.parse(challenge.localGeoJSON);
120+
}
121+
122+
if (data.features?.length) {
123+
const bundled = bundleByTaskBundleId(
124+
data.features,
125+
challenge.taskBundleIdProperty
126+
);
127+
128+
return bundled;
129+
}
130+
}
131+
132+
return false;
133+
} catch (e) {
134+
console.log(e);
135+
return false;
136+
}
137+
}
138+
48139
/**
49140
* Upload a line-by-line GeoJSON file in chunks of 100 lines/tasks, updating
50141
* the task creation progress as it goes. Note that this does not signal completion
@@ -159,6 +250,17 @@ async function deleteIncompleteTasks(dispatch, ownProps, challenge) {
159250

160251
const mapDispatchToProps = (dispatch, ownProps) => ({
161252
saveChallenge: async (challengeData) => {
253+
const prebundled = await convertAndBundleGeoJson(challengeData);
254+
255+
//If the prebundler succeeds, mutate challenge data to fit line-by-line criteria
256+
if (prebundled) {
257+
challengeData.remoteGeoJson = undefined;
258+
challengeData.localGeoJSON = undefined;
259+
challengeData.overpassTargetType = null;
260+
challengeData.source = "Local File";
261+
challengeData.lineByLineGeoJSON = createBlob(prebundled);
262+
}
263+
162264
return dispatch(saveChallenge(challengeData))
163265
.then(async (challenge) => {
164266
// If we have line-by-line GeoJSON, we need to stream that separately
@@ -191,16 +293,20 @@ const mapDispatchToProps = (dispatch, ownProps) => ({
191293
rebuildChallenge: async (challenge, localFile, dataOriginDate) => {
192294
ownProps.updateCreatingTasksProgress(true);
193295

296+
const prebundle = await rebuildPrebundle(challenge, localFile);
297+
194298
try {
195299
// For local files we need to figure out if it's line-by-line to
196300
// decide which service call to use
197-
if (localFile) {
198-
if (await AsValidatableGeoJSON(localFile).isLineByLine()) {
301+
const fileData = prebundle || localFile;
302+
303+
if (fileData) {
304+
if (await AsValidatableGeoJSON(fileData).isLineByLine()) {
199305
await uploadLineByLine(
200306
dispatch,
201307
ownProps,
202308
challenge,
203-
localFile,
309+
fileData,
204310
dataOriginDate
205311
);
206312
} else {

src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Messages.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,26 @@ will not be able to make sense of it.
587587
"first matching feature property from each task.",
588588
},
589589

590+
taskBundleIdPropertyLabel: {
591+
id: "Admin.EditChallenge.form.taskBundlePropertyId.label",
592+
defaultMessage: "Task Bundle Id Property",
593+
},
594+
595+
taskBundleIdPropertyOverpassWarning: {
596+
id: "Admin.EditChallenge.form.taskBundlePropertyId.overpassWarning",
597+
defaultMessage:
598+
"Currently not available for Overpass queries. Please select a different data location to use this feature.",
599+
},
600+
601+
taskBundleIdPropertyHelp: {
602+
id: "Admin.EditChallenge.form.taskBundlePropertyId.help",
603+
defaultMessage:
604+
"The name of the task feature property to treat as " +
605+
"a bundle ID for related tasks. " +
606+
"Tasks without this property will remain as isolated tasks. " +
607+
"Please note that this feature currently does not work with Overpass queries.",
608+
},
609+
590610
osmIdPropertyLabel: {
591611
id: "Admin.EditChallenge.form.osmIdProperty.label",
592612
defaultMessage: "OSM/External Id Property",

src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Schemas/PropertiesSchema.js

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import messages from '../Messages'
1+
import messages from "../Messages";
22

3-
const STEP_ID = "Properties"
3+
const STEP_ID = "Properties";
44

55
/**
66
* Generates a JSON Schema describing property fields of Edit Challenge
@@ -15,11 +15,21 @@ const STEP_ID = "Properties"
1515
*
1616
* @author [Neil Rotstan](https://github.com/nrotstan)
1717
*/
18-
export const jsSchema = (intl, user, challengeData, extraErrors, options={}) => {
18+
export const jsSchema = (
19+
intl,
20+
user,
21+
challengeData,
22+
extraErrors,
23+
options = {}
24+
) => {
1925
return {
20-
"$schema": "http://json-schema.org/draft-07/schema#",
26+
$schema: "http://json-schema.org/draft-07/schema#",
2127
type: "object",
2228
properties: {
29+
taskBundleIdProperty: {
30+
title: intl.formatMessage(messages.taskBundleIdPropertyLabel),
31+
type: challengeData.source === "Overpass Query" ? null : "string",
32+
},
2333
osmIdProperty: {
2434
title: intl.formatMessage(messages.osmIdPropertyLabel),
2535
type: "string",
@@ -34,8 +44,8 @@ export const jsSchema = (intl, user, challengeData, extraErrors, options={}) =>
3444
type: "string",
3545
},
3646
},
37-
}
38-
}
47+
};
48+
};
3949

4050
/**
4151
* uiSchema configuration to assist react-jsonschema-form in determining
@@ -47,17 +57,38 @@ export const jsSchema = (intl, user, challengeData, extraErrors, options={}) =>
4757
* > the form configuration will help the RJSFFormFieldAdapter generate the
4858
* > proper markup
4959
*/
50-
export const uiSchema = (intl, user, challengeData, extraErrors, options={}) => {
51-
const isCollapsed = options.longForm && (options.collapsedGroups || []).indexOf(STEP_ID) !== -1
52-
const toggleCollapsed = options.longForm && options.toggleCollapsed ? () => options.toggleCollapsed(STEP_ID) : undefined
60+
export const uiSchema = (
61+
intl,
62+
user,
63+
challengeData,
64+
extraErrors,
65+
options = {}
66+
) => {
67+
const isCollapsed =
68+
options.longForm && (options.collapsedGroups || []).indexOf(STEP_ID) !== -1;
69+
const toggleCollapsed =
70+
options.longForm && options.toggleCollapsed
71+
? () => options.toggleCollapsed(STEP_ID)
72+
: undefined;
5373

5474
return {
75+
taskBundleIdProperty: {
76+
"ui:emptyValue": "",
77+
"ui:help": intl.formatMessage(messages.taskBundleIdPropertyHelp),
78+
"ui:collapsed": isCollapsed,
79+
"ui:toggleCollapsed": toggleCollapsed,
80+
"ui:groupHeader": options.longForm
81+
? intl.formatMessage(messages.propertiesStepHeader)
82+
: undefined,
83+
"ui:description":
84+
challengeData.source === "Overpass Query"
85+
? intl.formatMessage(messages.taskBundleIdPropertyOverpassWarning)
86+
: null,
87+
},
5588
osmIdProperty: {
5689
"ui:emptyValue": "",
5790
"ui:help": intl.formatMessage(messages.osmIdPropertyDescription),
5891
"ui:collapsed": isCollapsed,
59-
"ui:toggleCollapsed": toggleCollapsed,
60-
"ui:groupHeader": options.longForm ? intl.formatMessage(messages.propertiesStepHeader) : undefined,
6192
},
6293
customTaskStyles: {
6394
"ui:field": "configureCustomTaskStyles",
@@ -69,5 +100,5 @@ export const uiSchema = (intl, user, challengeData, extraErrors, options={}) =>
69100
"ui:help": intl.formatMessage(messages.exportablePropertiesDescription),
70101
"ui:collapsed": isCollapsed,
71102
},
72-
}
73-
}
103+
};
104+
};

src/lang/en-US.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,8 @@
219219
"Admin.EditChallenge.form.steps.yes.label": "Yes",
220220
"Admin.EditChallenge.form.steps.zoom.description": "Configure map zoom levels",
221221
"Admin.EditChallenge.form.steps.zoom.header": "Zoom Levels",
222+
"Admin.EditChallenge.form.taskBundlePropertyId.description": "The name of the task feature property to treat as a bundle ID for related tasks. Tasks without this property will remain as isolated tasks. [Learn more](https://learn.maproulette.org/documentation/setting-task-bundle-identifiers/).",
223+
"Admin.EditChallenge.form.taskBundlePropertyId.label": "Task Bundle Id Property",
222224
"Admin.EditChallenge.form.taskPropertyStyles.clear": "Clear",
223225
"Admin.EditChallenge.form.taskPropertyStyles.close": "Done",
224226
"Admin.EditChallenge.form.taskPropertyStyles.description": "Sets up task property style rules......",

src/utils/bundleByTaskBundleId.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
const bundleByTaskBundleId = (tasks, externalId) => {
2+
const bundled = [];
3+
let leftoverTasks = tasks;
4+
5+
while (leftoverTasks.length) {
6+
const task = leftoverTasks[0];
7+
const id = task.properties[externalId];
8+
const features = [];
9+
let infiniteLoopCount = 0;
10+
let prevLength = leftoverTasks.length;
11+
let bundledTask;
12+
13+
if (id === undefined) {
14+
bundledTask = {
15+
type: "FeatureCollection",
16+
features: [task],
17+
};
18+
bundled.push(JSON.stringify(bundledTask));
19+
leftoverTasks.shift();
20+
} else {
21+
const matchingTasks = leftoverTasks.filter((t) => {
22+
if (t.properties[externalId] === id) {
23+
return true;
24+
}
25+
26+
return false;
27+
});
28+
29+
for (let j = 0; j < matchingTasks.length; j++) {
30+
features.push(matchingTasks[j]);
31+
}
32+
33+
bundledTask = {
34+
type: "FeatureCollection",
35+
features,
36+
};
37+
38+
const nonMatchingTasks = leftoverTasks.filter((t) => {
39+
if (t.properties[externalId] !== id) {
40+
return true;
41+
}
42+
43+
return false;
44+
});
45+
46+
bundled.push(JSON.stringify(bundledTask));
47+
leftoverTasks = nonMatchingTasks;
48+
}
49+
50+
if (leftoverTasks.length === prevLength) {
51+
infiniteLoopCount++;
52+
53+
if (infiniteLoopCount > 10) {
54+
console.log(
55+
"There was a problem with your data that caused an infinite loop. Process stopped"
56+
);
57+
break;
58+
}
59+
} else {
60+
infiniteLoopCount = 0;
61+
}
62+
}
63+
64+
return bundled.join("\n");
65+
};
66+
67+
export default bundleByTaskBundleId;

src/utils/createBlob.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
const createBlob = (jsonData) => {
2+
const blob = new Blob([jsonData], { type: "application/json" });
3+
4+
return blob;
5+
};
6+
7+
export default createBlob;

0 commit comments

Comments
 (0)