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.CheckParameterUtil.ensureParameterNotNull;
006import static org.openstreetmap.josm.tools.I18n.tr;
007import static org.openstreetmap.josm.tools.I18n.trn;
008
009import java.lang.reflect.InvocationTargetException;
010import java.util.HashSet;
011import java.util.Set;
012
013import javax.swing.JOptionPane;
014import javax.swing.SwingUtilities;
015
016import org.openstreetmap.josm.data.APIDataSet;
017import org.openstreetmap.josm.data.osm.Changeset;
018import org.openstreetmap.josm.data.osm.ChangesetCache;
019import org.openstreetmap.josm.data.osm.DefaultNameFormatter;
020import org.openstreetmap.josm.data.osm.IPrimitive;
021import org.openstreetmap.josm.data.osm.Node;
022import org.openstreetmap.josm.data.osm.OsmPrimitive;
023import org.openstreetmap.josm.data.osm.Relation;
024import org.openstreetmap.josm.data.osm.Way;
025import org.openstreetmap.josm.gui.HelpAwareOptionPane;
026import org.openstreetmap.josm.gui.HelpAwareOptionPane.ButtonSpec;
027import org.openstreetmap.josm.gui.MainApplication;
028import org.openstreetmap.josm.gui.Notification;
029import org.openstreetmap.josm.gui.layer.OsmDataLayer;
030import org.openstreetmap.josm.gui.progress.ProgressMonitor;
031import org.openstreetmap.josm.gui.util.GuiHelper;
032import org.openstreetmap.josm.gui.widgets.HtmlPanel;
033import org.openstreetmap.josm.io.ChangesetClosedException;
034import org.openstreetmap.josm.io.MaxChangesetSizeExceededPolicy;
035import org.openstreetmap.josm.io.MessageNotifier;
036import org.openstreetmap.josm.io.OsmApi;
037import org.openstreetmap.josm.io.OsmApiPrimitiveGoneException;
038import org.openstreetmap.josm.io.OsmServerWriter;
039import org.openstreetmap.josm.io.OsmTransferCanceledException;
040import org.openstreetmap.josm.io.OsmTransferException;
041import org.openstreetmap.josm.io.UploadStrategySpecification;
042import org.openstreetmap.josm.spi.preferences.Config;
043import org.openstreetmap.josm.tools.ImageProvider;
044import org.openstreetmap.josm.tools.Logging;
045
046/**
047 * The task for uploading a collection of primitives.
048 * @since 2599
049 */
050public class UploadPrimitivesTask extends AbstractUploadTask {
051    private boolean uploadCanceled;
052    private Exception lastException;
053    private final APIDataSet toUpload;
054    private OsmServerWriter writer;
055    private final OsmDataLayer layer;
056    private Changeset changeset;
057    private final Set<IPrimitive> processedPrimitives;
058    private final UploadStrategySpecification strategy;
059
060    /**
061     * Creates the task
062     *
063     * @param strategy the upload strategy. Must not be null.
064     * @param layer  the OSM data layer for which data is uploaded. Must not be null.
065     * @param toUpload the collection of primitives to upload. Set to the empty collection if null.
066     * @param changeset the changeset to use for uploading. Must not be null. changeset.getId()
067     * can be 0 in which case the upload task creates a new changeset
068     * @throws IllegalArgumentException if layer is null
069     * @throws IllegalArgumentException if toUpload is null
070     * @throws IllegalArgumentException if strategy is null
071     * @throws IllegalArgumentException if changeset is null
072     */
073    public UploadPrimitivesTask(UploadStrategySpecification strategy, OsmDataLayer layer, APIDataSet toUpload, Changeset changeset) {
074        super(tr("Uploading data for layer ''{0}''", layer.getName()), false /* don't ignore exceptions */);
075        ensureParameterNotNull(layer, "layer");
076        ensureParameterNotNull(strategy, "strategy");
077        ensureParameterNotNull(changeset, "changeset");
078        this.toUpload = toUpload;
079        this.layer = layer;
080        this.changeset = changeset;
081        this.strategy = strategy;
082        this.processedPrimitives = new HashSet<>();
083    }
084
085    /**
086     * Prompt the user about how to proceed.
087     *
088     * @return the policy selected by the user
089     */
090    protected MaxChangesetSizeExceededPolicy promptUserForPolicy() {
091        ButtonSpec[] specs = {
092                new ButtonSpec(
093                        tr("Continue uploading"),
094                        new ImageProvider("upload"),
095                        tr("Click to continue uploading to additional new changesets"),
096                        null /* no specific help text */
097                ),
098                new ButtonSpec(
099                        tr("Go back to Upload Dialog"),
100                        new ImageProvider("preference"),
101                        tr("Click to return to the Upload Dialog"),
102                        null /* no specific help text */
103                ),
104                new ButtonSpec(
105                        tr("Abort"),
106                        new ImageProvider("cancel"),
107                        tr("Click to abort uploading"),
108                        null /* no specific help text */
109                )
110        };
111        int numObjectsToUploadLeft = toUpload.getSize() - processedPrimitives.size();
112        String msg1 = tr("The server reported that the current changeset was closed.<br>"
113                + "This is most likely because the changesets size exceeded the max. size<br>"
114                + "of {0} objects on the server ''{1}''.",
115                OsmApi.getOsmApi().getCapabilities().getMaxChangesetSize(),
116                OsmApi.getOsmApi().getBaseUrl()
117        );
118        String msg2 = trn(
119                "There is {0} object left to upload.",
120                "There are {0} objects left to upload.",
121                numObjectsToUploadLeft,
122                numObjectsToUploadLeft
123        );
124        String msg3 = tr(
125                "Click ''<strong>{0}</strong>'' to continue uploading to additional new changesets.<br>"
126                + "Click ''<strong>{1}</strong>'' to return to the upload dialog.<br>"
127                + "Click ''<strong>{2}</strong>'' to abort uploading and return to map editing.<br>",
128                specs[0].text,
129                specs[1].text,
130                specs[2].text
131        );
132        String msg = "<html>" + msg1 + "<br><br>" + msg2 +"<br><br>" + msg3 + "</html>";
133        int ret = HelpAwareOptionPane.showOptionDialog(
134                MainApplication.getMainFrame(),
135                msg,
136                tr("Changeset is full"),
137                JOptionPane.WARNING_MESSAGE,
138                null, /* no special icon */
139                specs,
140                specs[0],
141                ht("/Action/Upload#ChangesetFull")
142        );
143        switch(ret) {
144        case 0: return MaxChangesetSizeExceededPolicy.AUTOMATICALLY_OPEN_NEW_CHANGESETS;
145        case 1: return MaxChangesetSizeExceededPolicy.FILL_ONE_CHANGESET_AND_RETURN_TO_UPLOAD_DIALOG;
146        case 2:
147        case JOptionPane.CLOSED_OPTION:
148        default: return MaxChangesetSizeExceededPolicy.ABORT;
149        }
150    }
151
152    /**
153     * Handles a server changeset full response.
154     * <p>
155     * Handles a server changeset full response by either aborting or opening a new changeset, if the
156     * user requested it so.
157     *
158     * @return true if the upload process should continue with the new changeset, false if the
159     *         upload should be interrupted
160     * @throws OsmTransferException "if something goes wrong."
161     */
162    protected boolean handleChangesetFullResponse() throws OsmTransferException {
163        if (processedPrimitives.size() == toUpload.getSize()) {
164            strategy.setPolicy(MaxChangesetSizeExceededPolicy.ABORT);
165            return false;
166        }
167        if (strategy.getPolicy() == null || strategy.getPolicy() == MaxChangesetSizeExceededPolicy.ABORT) {
168            strategy.setPolicy(promptUserForPolicy());
169        }
170        switch(strategy.getPolicy()) {
171        case AUTOMATICALLY_OPEN_NEW_CHANGESETS:
172            Changeset newChangeSet = new Changeset();
173            newChangeSet.setKeys(changeset.getKeys());
174            closeChangeset();
175            this.changeset = newChangeSet;
176            toUpload.removeProcessed(processedPrimitives);
177            return true;
178        case ABORT:
179        case FILL_ONE_CHANGESET_AND_RETURN_TO_UPLOAD_DIALOG:
180        default:
181            // don't continue - finish() will send the user back to map editing or upload dialog
182            return false;
183        }
184    }
185
186    /**
187     * Retries to recover the upload operation from an exception which was thrown because
188     * an uploaded primitive was already deleted on the server.
189     *
190     * @param e the exception throw by the API
191     * @param monitor a progress monitor
192     * @throws OsmTransferException if we can't recover from the exception
193     */
194    protected void recoverFromGoneOnServer(OsmApiPrimitiveGoneException e, ProgressMonitor monitor) throws OsmTransferException {
195        if (!e.isKnownPrimitive()) throw e;
196        OsmPrimitive p = layer.data.getPrimitiveById(e.getPrimitiveId(), e.getPrimitiveType());
197        if (p == null) throw e;
198        if (p.isDeleted()) {
199            // we tried to delete an already deleted primitive.
200            final String msg;
201            final String displayName = p.getDisplayName(DefaultNameFormatter.getInstance());
202            if (p instanceof Node) {
203                msg = tr("Node ''{0}'' is already deleted. Skipping object in upload.", displayName);
204            } else if (p instanceof Way) {
205                msg = tr("Way ''{0}'' is already deleted. Skipping object in upload.", displayName);
206            } else if (p instanceof Relation) {
207                msg = tr("Relation ''{0}'' is already deleted. Skipping object in upload.", displayName);
208            } else {
209                msg = tr("Object ''{0}'' is already deleted. Skipping object in upload.", displayName);
210            }
211            monitor.appendLogMessage(msg);
212            Logging.warn(msg);
213            processedPrimitives.addAll(writer.getProcessedPrimitives());
214            processedPrimitives.add(p);
215            toUpload.removeProcessed(processedPrimitives);
216            return;
217        }
218        // exception was thrown because we tried to *update* an already deleted
219        // primitive. We can't resolve this automatically. Re-throw exception,
220        // a conflict is going to be created later.
221        throw e;
222    }
223
224    protected void cleanupAfterUpload() {
225        // we always clean up the data, even in case of errors. It's possible the data was
226        // partially uploaded. Better run on EDT.
227        Runnable r = () -> {
228            boolean readOnly = layer.isLocked();
229            if (readOnly) {
230                layer.unlock();
231            }
232            try {
233                layer.cleanupAfterUpload(processedPrimitives);
234                layer.onPostUploadToServer();
235                ChangesetCache.getInstance().update(changeset);
236            } finally {
237                if (readOnly) {
238                    layer.lock();
239                }
240            }
241        };
242
243        try {
244            SwingUtilities.invokeAndWait(r);
245        } catch (InterruptedException e) {
246            Logging.trace(e);
247            lastException = e;
248            Thread.currentThread().interrupt();
249        } catch (InvocationTargetException e) {
250            Logging.trace(e);
251            lastException = new OsmTransferException(e.getCause());
252        }
253    }
254
255    @Override
256    protected void realRun() {
257        try {
258            MessageNotifier.stop();
259            uploadloop: while (true) {
260                try {
261                    getProgressMonitor().subTask(
262                            trn("Uploading {0} object...", "Uploading {0} objects...", toUpload.getSize(), toUpload.getSize()));
263                    synchronized (this) {
264                        writer = new OsmServerWriter();
265                    }
266                    writer.uploadOsm(strategy, toUpload.getPrimitives(), changeset, getProgressMonitor().createSubTaskMonitor(1, false));
267                    // If the changeset was new, now it is open.
268                    ChangesetCache.getInstance().update(changeset);
269                    // if we get here we've successfully uploaded the data. Exit the loop.
270                    break;
271                } catch (OsmTransferCanceledException e) {
272                    Logging.error(e);
273                    uploadCanceled = true;
274                    break uploadloop;
275                } catch (OsmApiPrimitiveGoneException e) {
276                    // try to recover from  410 Gone
277                    recoverFromGoneOnServer(e, getProgressMonitor());
278                } catch (ChangesetClosedException e) {
279                    if (writer != null) {
280                        processedPrimitives.addAll(writer.getProcessedPrimitives()); // OsmPrimitive in => OsmPrimitive out
281                    }
282                    switch(e.getSource()) {
283                    case UPLOAD_DATA:
284                        // Most likely the changeset is full. Try to recover and continue
285                        // with a new changeset, but let the user decide first.
286                        if (handleChangesetFullResponse()) {
287                            continue;
288                        }
289                        lastException = e;
290                        break uploadloop;
291                    case UPDATE_CHANGESET:
292                    case CLOSE_CHANGESET:
293                    case UNSPECIFIED:
294                    default:
295                        // The changeset was closed when we tried to update it. Probably, our
296                        // local list of open changesets got out of sync with the server state.
297                        // The user will have to select another open changeset.
298                        // Rethrow exception - this will be handled later.
299                        changeset.setOpen(false);
300                        ChangesetCache.getInstance().update(changeset);
301                        throw e;
302                    }
303                } finally {
304                    if (writer != null) {
305                        processedPrimitives.addAll(writer.getProcessedPrimitives());
306                    }
307                    synchronized (this) {
308                        writer = null;
309                    }
310                }
311            }
312            // if required close the changeset
313            closeChangesetIfRequired();
314        } catch (OsmTransferException e) {
315            if (uploadCanceled) {
316                Logging.info(tr("Ignoring caught exception because upload is canceled. Exception is: {0}", e.toString()));
317            } else {
318                lastException = e;
319            }
320        } finally {
321            if (MessageNotifier.PROP_NOTIFIER_ENABLED.get()) {
322                MessageNotifier.start();
323            }
324        }
325        if (uploadCanceled && processedPrimitives.isEmpty()) return;
326        cleanupAfterUpload();
327    }
328
329    /**
330     * Closes the changeset on the server and locally.
331     *
332     * @throws OsmTransferException "if something goes wrong."
333     */
334    private void closeChangeset() throws OsmTransferException {
335        if (changeset != null && !changeset.isNew() && changeset.isOpen()) {
336            try {
337                OsmApi.getOsmApi().closeChangeset(changeset, progressMonitor.createSubTaskMonitor(0, false));
338            } catch (ChangesetClosedException e) {
339                // Do not raise a stink, probably the changeset timed out.
340                Logging.trace(e);
341            } finally {
342                changeset.setOpen(false);
343                ChangesetCache.getInstance().update(changeset);
344            }
345        }
346    }
347
348    private void closeChangesetIfRequired() throws OsmTransferException {
349        if (strategy.isCloseChangesetAfterUpload()) {
350            closeChangeset();
351        }
352    }
353
354    /**
355     * Depending on the success of the upload operation and on the policy for
356     * multi changeset uploads this will send the user back to the appropriate
357     * place in JOSM, either:
358     * <ul>
359     * <li>to an error dialog,
360     * <li>to the Upload Dialog, or
361     * <li>to map editing.
362     * </ul>
363     */
364    @Override
365    protected void finish() {
366        GuiHelper.runInEDT(() -> {
367            // if the changeset is still open after this upload we want it to be selected on the next upload
368            ChangesetCache.getInstance().update(changeset);
369            if (changeset != null && changeset.isOpen()) {
370                UploadDialog.getUploadDialog().setSelectedChangesetForNextUpload(changeset);
371            }
372            if (uploadCanceled) return;
373            if (lastException == null) {
374                HtmlPanel panel = new HtmlPanel(
375                        "<h3><a href=\"" + Config.getUrls().getBaseBrowseUrl() + "/changeset/" + changeset.getId() + "\">"
376                                + tr("Upload successful!") + "</a></h3>");
377                panel.enableClickableHyperlinks();
378                panel.setOpaque(false);
379                new Notification()
380                        .setContent(panel)
381                        .setIcon(ImageProvider.get("misc", "check_large"))
382                        .show();
383                return;
384            }
385            if (lastException instanceof ChangesetClosedException) {
386                ChangesetClosedException e = (ChangesetClosedException) lastException;
387                if (e.getSource() == ChangesetClosedException.Source.UPDATE_CHANGESET) {
388                    handleFailedUpload(lastException);
389                    return;
390                }
391                if (strategy.getPolicy() == null)
392                    /* do nothing if unknown policy */
393                    return;
394                if (e.getSource() == ChangesetClosedException.Source.UPLOAD_DATA) {
395                    switch(strategy.getPolicy()) {
396                    case ABORT:
397                        break; /* do nothing - we return to map editing */
398                    case AUTOMATICALLY_OPEN_NEW_CHANGESETS:
399                        break; /* do nothing - we return to map editing */
400                    case FILL_ONE_CHANGESET_AND_RETURN_TO_UPLOAD_DIALOG:
401                        // return to the upload dialog
402                        //
403                        toUpload.removeProcessed(processedPrimitives);
404                        UploadDialog.getUploadDialog().setUploadedPrimitives(toUpload);
405                        UploadDialog.getUploadDialog().setVisible(true);
406                        break;
407                    }
408                } else {
409                    handleFailedUpload(lastException);
410                }
411            } else {
412                handleFailedUpload(lastException);
413            }
414        });
415    }
416
417    @Override protected void cancel() {
418        uploadCanceled = true;
419        synchronized (this) {
420            if (writer != null) {
421                writer.cancel();
422            }
423        }
424    }
425}