001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.io;
003
004import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
005import static org.openstreetmap.josm.tools.I18n.tr;
006
007import java.awt.event.ActionEvent;
008import java.net.HttpURLConnection;
009import java.time.Instant;
010import java.time.format.FormatStyle;
011import java.util.ArrayList;
012import java.util.Arrays;
013import java.util.Collection;
014import java.util.Collections;
015import java.util.regex.Matcher;
016import java.util.regex.Pattern;
017
018import javax.swing.JOptionPane;
019
020import org.openstreetmap.josm.actions.DownloadReferrersAction;
021import org.openstreetmap.josm.actions.UpdateDataAction;
022import org.openstreetmap.josm.actions.UpdateSelectionAction;
023import org.openstreetmap.josm.data.osm.OsmPrimitive;
024import org.openstreetmap.josm.data.osm.OsmPrimitiveType;
025import org.openstreetmap.josm.gui.ExceptionDialogUtil;
026import org.openstreetmap.josm.gui.HelpAwareOptionPane;
027import org.openstreetmap.josm.gui.HelpAwareOptionPane.ButtonSpec;
028import org.openstreetmap.josm.gui.MainApplication;
029import org.openstreetmap.josm.gui.PleaseWaitRunnable;
030import org.openstreetmap.josm.gui.layer.OsmDataLayer;
031import org.openstreetmap.josm.gui.progress.ProgressMonitor;
032import org.openstreetmap.josm.io.OsmApiException;
033import org.openstreetmap.josm.io.OsmApiInitializationException;
034import org.openstreetmap.josm.io.OsmApiPrimitiveGoneException;
035import org.openstreetmap.josm.tools.ExceptionUtil;
036import org.openstreetmap.josm.tools.ImageProvider;
037import org.openstreetmap.josm.tools.Logging;
038import org.openstreetmap.josm.tools.Pair;
039import org.openstreetmap.josm.tools.date.DateUtils;
040
041/**
042 * Abstract base class for the task of uploading primitives via OSM API.
043 *
044 * Mainly handles conflicts and certain error situations.
045 */
046public abstract class AbstractUploadTask extends PleaseWaitRunnable {
047
048    /**
049     * Constructs a new {@code AbstractUploadTask}.
050     * @param title message for the user
051     * @param ignoreException If true, exception will be silently ignored. If false then
052     * exception will be handled by showing a dialog. When this runnable is executed using executor framework
053     * then use false unless you read result of task (because exception will get lost if you don't)
054     */
055    protected AbstractUploadTask(String title, boolean ignoreException) {
056        super(title, ignoreException);
057    }
058
059    /**
060     * Constructs a new {@code AbstractUploadTask}.
061     * @param title message for the user
062     * @param progressMonitor progress monitor
063     * @param ignoreException If true, exception will be silently ignored. If false then
064     * exception will be handled by showing a dialog. When this runnable is executed using executor framework
065     * then use false unless you read result of task (because exception will get lost if you don't)
066     */
067    protected AbstractUploadTask(String title, ProgressMonitor progressMonitor, boolean ignoreException) {
068        super(title, progressMonitor, ignoreException);
069    }
070
071    /**
072     * Constructs a new {@code AbstractUploadTask}.
073     * @param title message for the user
074     */
075    protected AbstractUploadTask(String title) {
076        super(title);
077    }
078
079    /**
080     * Synchronizes the local state of an {@link OsmPrimitive} with its state on the
081     * server. The method uses an individual GET for the primitive.
082     * @param type the primitive type
083     * @param id the primitive ID
084     */
085    protected void synchronizePrimitive(final OsmPrimitiveType type, final long id) {
086        // FIXME: should now about the layer this task is running for. might
087        // be different from the current edit layer
088        OsmDataLayer layer = MainApplication.getLayerManager().getEditLayer();
089        if (layer == null)
090            throw new IllegalStateException(tr("Failed to update primitive with id {0} because current edit layer is null", id));
091        OsmPrimitive p = layer.data.getPrimitiveById(id, type);
092        if (p == null)
093            throw new IllegalStateException(
094                    tr("Failed to update primitive with id {0} because current edit layer does not include such a primitive", id));
095        MainApplication.worker.execute(new UpdatePrimitivesTask(layer, Collections.singleton(p)));
096    }
097
098    /**
099     * Synchronizes the local state of the dataset with the state on the server.
100     *
101     * Reuses the functionality of {@link UpdateDataAction}.
102     *
103     * @see UpdateDataAction#actionPerformed(ActionEvent)
104     */
105    protected void synchronizeDataSet() {
106        UpdateDataAction act = new UpdateDataAction();
107        act.actionPerformed(new ActionEvent(this, 0, ""));
108    }
109
110    /**
111     * Handles the case that a conflict in a specific {@link OsmPrimitive} was detected while
112     * uploading
113     *
114     * @param primitiveType  the type of the primitive, either <code>node</code>, <code>way</code> or
115     *    <code>relation</code>
116     * @param id  the id of the primitive
117     * @param serverVersion  the version of the primitive on the server
118     * @param myVersion  the version of the primitive in the local dataset
119     */
120    protected void handleUploadConflictForKnownConflict(final OsmPrimitiveType primitiveType, final long id, String serverVersion,
121            String myVersion) {
122        String lbl;
123        switch(primitiveType) {
124        // CHECKSTYLE.OFF: SingleSpaceSeparator
125        case NODE:     lbl = tr("Synchronize node {0} only", id); break;
126        case WAY:      lbl = tr("Synchronize way {0} only", id); break;
127        case RELATION: lbl = tr("Synchronize relation {0} only", id); break;
128        // CHECKSTYLE.ON: SingleSpaceSeparator
129        default: throw new AssertionError();
130        }
131        ButtonSpec[] spec = {
132                new ButtonSpec(
133                        lbl,
134                        new ImageProvider("updatedata"),
135                        null, null),
136                new ButtonSpec(
137                        tr("Synchronize entire dataset"),
138                        new ImageProvider("updatedata"),
139                        null, null),
140                new ButtonSpec(
141                        tr("Cancel"),
142                        new ImageProvider("cancel"),
143                        null, null)
144        };
145        String msg = tr("<html>Uploading <strong>failed</strong> because the server has a newer version of one<br>"
146                + "of your nodes, ways, or relations.<br>"
147                + "The conflict is caused by the <strong>{0}</strong> with id <strong>{1}</strong>,<br>"
148                + "the server has version {2}, your version is {3}.<br>"
149                + "<br>"
150                + "Click <strong>{4}</strong> to synchronize the conflicting primitive only.<br>"
151                + "Click <strong>{5}</strong> to synchronize the entire local dataset with the server.<br>"
152                + "Click <strong>{6}</strong> to abort and continue editing.<br></html>",
153                tr(primitiveType.getAPIName()), id, serverVersion, myVersion,
154                spec[0].text, spec[1].text, spec[2].text
155        );
156        int ret = HelpAwareOptionPane.showOptionDialog(
157                MainApplication.getMainFrame(),
158                msg,
159                tr("Conflicts detected"),
160                JOptionPane.ERROR_MESSAGE,
161                null,
162                spec,
163                spec[0],
164                "/Concepts/Conflict"
165        );
166        switch(ret) {
167        case 0: synchronizePrimitive(primitiveType, id); break;
168        case 1: synchronizeDataSet(); break;
169        default: return;
170        }
171    }
172
173    /**
174     * Handles the case that a conflict was detected while uploading where we don't
175     * know what {@link OsmPrimitive} actually caused the conflict (for whatever reason)
176     *
177     */
178    protected void handleUploadConflictForUnknownConflict() {
179        ButtonSpec[] spec = {
180                new ButtonSpec(
181                        tr("Synchronize entire dataset"),
182                        new ImageProvider("updatedata"),
183                        null, null),
184                new ButtonSpec(
185                        tr("Cancel"),
186                        new ImageProvider("cancel"),
187                        null, null)
188        };
189        String msg = tr("<html>Uploading <strong>failed</strong> because the server has a newer version of one<br>"
190                + "of your nodes, ways, or relations.<br>"
191                + "<br>"
192                + "Click <strong>{0}</strong> to synchronize the entire local dataset with the server.<br>"
193                + "Click <strong>{1}</strong> to abort and continue editing.<br></html>",
194                spec[0].text, spec[1].text
195        );
196        int ret = HelpAwareOptionPane.showOptionDialog(
197                MainApplication.getMainFrame(),
198                msg,
199                tr("Conflicts detected"),
200                JOptionPane.ERROR_MESSAGE,
201                null,
202                spec,
203                spec[0],
204                ht("/Concepts/Conflict")
205        );
206        if (ret == 0) {
207            synchronizeDataSet();
208        }
209    }
210
211    /**
212     * Handles the case that a conflict was detected while uploading where we don't
213     * know what {@link OsmPrimitive} actually caused the conflict (for whatever reason)
214     * @param changesetId changeset ID
215     * @param d changeset date
216     */
217    protected void handleUploadConflictForClosedChangeset(long changesetId, Instant d) {
218        String msg = tr("<html>Uploading <strong>failed</strong> because you have been using<br>"
219                + "changeset {0} which was already closed at {1}.<br>"
220                + "Please upload again with a new or an existing open changeset.</html>",
221                changesetId, DateUtils.getDateTimeFormatter(FormatStyle.SHORT, FormatStyle.SHORT).format(d)
222        );
223        JOptionPane.showMessageDialog(
224                MainApplication.getMainFrame(),
225                msg,
226                tr("Changeset closed"),
227                JOptionPane.ERROR_MESSAGE
228        );
229    }
230
231    /**
232     * Handles the case where deleting a node failed because it is still in use in
233     * a non-deleted way on the server.
234     * @param e exception
235     * @param conflict conflict
236     */
237    protected void handleUploadPreconditionFailedConflict(OsmApiException e, Pair<OsmPrimitive, Collection<OsmPrimitive>> conflict) {
238        ButtonSpec[] options = {
239                new ButtonSpec(
240                        tr("Prepare conflict resolution"),
241                        new ImageProvider("ok"),
242                        tr("Click to download all referring objects for {0}", conflict.a),
243                        null /* no specific help context */
244                ),
245                new ButtonSpec(
246                        tr("Cancel"),
247                        new ImageProvider("cancel"),
248                        tr("Click to cancel and to resume editing the map"),
249                        null /* no specific help context */
250                )
251        };
252        String msg = ExceptionUtil.explainPreconditionFailed(e).replace("</html>", "<br><br>" + tr(
253                "Click <strong>{0}</strong> to load them now.<br>"
254                + "If necessary JOSM will create conflicts which you can resolve in the Conflict Resolution Dialog.",
255                options[0].text)) + "</html>";
256        int ret = HelpAwareOptionPane.showOptionDialog(
257                MainApplication.getMainFrame(),
258                msg,
259                tr("Object still in use"),
260                JOptionPane.ERROR_MESSAGE,
261                null,
262                options,
263                options[0],
264                "/Action/Upload#NodeStillInUseInWay"
265        );
266        if (ret == 0) {
267            if (msg.contains("to delete")) {
268                DownloadReferrersAction.downloadReferrers(MainApplication.getLayerManager().getEditLayer(),
269                        Arrays.asList(conflict.a));
270            }
271            if (msg.contains("to upload") && !conflict.b.isEmpty()) {
272                MainApplication.worker.submit(new DownloadPrimitivesTask(
273                        MainApplication.getLayerManager().getEditLayer(), new ArrayList<>(conflict.b), false));
274            }
275        }
276    }
277
278    /**
279     * handles an upload conflict, i.e. an error indicated by a HTTP return code 409.
280     *
281     * @param e  the exception
282     */
283    protected void handleUploadConflict(OsmApiException e) {
284        final String errorHeader = e.getErrorHeader();
285        if (errorHeader != null) {
286            Pattern p = Pattern.compile("Version mismatch: Provided (\\d+), server had: (\\d+) of (\\S+) (\\d+)");
287            Matcher m = p.matcher(errorHeader);
288            if (m.matches()) {
289                handleUploadConflictForKnownConflict(OsmPrimitiveType.from(m.group(3)), Long.parseLong(m.group(4)), m.group(2), m.group(1));
290                return;
291            }
292            p = Pattern.compile("The changeset (\\d+) was closed at (.*)");
293            m = p.matcher(errorHeader);
294            if (m.matches()) {
295                handleUploadConflictForClosedChangeset(Long.parseLong(m.group(1)), DateUtils.parseInstant(m.group(2)));
296                return;
297            }
298        }
299        Logging.warn(tr("Error header \"{0}\" did not match with an expected pattern", errorHeader));
300        handleUploadConflictForUnknownConflict();
301    }
302
303    /**
304     * handles an precondition failed conflict, i.e. an error indicated by a HTTP return code 412.
305     *
306     * @param e  the exception
307     */
308    protected void handlePreconditionFailed(OsmApiException e) {
309        // in the worst case, ExceptionUtil.parsePreconditionFailed is executed trice - should not be too expensive
310        Pair<OsmPrimitive, Collection<OsmPrimitive>> conflict = ExceptionUtil.parsePreconditionFailed(e.getErrorHeader());
311        if (conflict != null) {
312            handleUploadPreconditionFailedConflict(e, conflict);
313        } else {
314            Logging.warn(tr("Error header \"{0}\" did not match with an expected pattern", e.getErrorHeader()));
315            ExceptionDialogUtil.explainPreconditionFailed(e);
316        }
317    }
318
319    /**
320     * Handles an error which is caused by a delete request for an already deleted
321     * {@link OsmPrimitive} on the server, i.e. a HTTP response code of 410.
322     * Note that an <strong>update</strong> on an already deleted object results
323     * in a 409, not a 410.
324     *
325     * @param e the exception
326     */
327    protected void handleGone(OsmApiPrimitiveGoneException e) {
328        if (e.isKnownPrimitive()) {
329            UpdateSelectionAction.handlePrimitiveGoneException(e.getPrimitiveId(), e.getPrimitiveType());
330        } else {
331            ExceptionDialogUtil.explainGoneForUnknownPrimitive(e);
332        }
333    }
334
335    /**
336     * error handler for any exception thrown during upload
337     *
338     * @param e the exception
339     */
340    protected void handleFailedUpload(Exception e) {
341        // API initialization failed. Notify the user and return.
342        //
343        if (e instanceof OsmApiInitializationException) {
344            ExceptionDialogUtil.explainOsmApiInitializationException((OsmApiInitializationException) e);
345            return;
346        }
347
348        if (e instanceof OsmApiPrimitiveGoneException) {
349            handleGone((OsmApiPrimitiveGoneException) e);
350            return;
351        }
352        if (e instanceof OsmApiException) {
353            OsmApiException ex = (OsmApiException) e;
354            if (ex.getResponseCode() == HttpURLConnection.HTTP_CONFLICT) {
355                // There was an upload conflict. Let the user decide whether and how to resolve it
356                handleUploadConflict(ex);
357                return;
358            } else if (ex.getResponseCode() == HttpURLConnection.HTTP_PRECON_FAILED) {
359                // There was a precondition failed. Notify the user.
360                handlePreconditionFailed(ex);
361                return;
362            } else if (ex.getResponseCode() == HttpURLConnection.HTTP_NOT_FOUND) {
363                // Tried to update or delete a primitive which never existed on the server?
364                ExceptionDialogUtil.explainNotFound(ex);
365                return;
366            }
367        }
368
369        ExceptionDialogUtil.explainException(e);
370    }
371}