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    protected MaxChangesetSizeExceededPolicy askMaxChangesetSizeExceedsPolicy() {
086        ButtonSpec[] specs = new ButtonSpec[] {
087                new ButtonSpec(
088                        tr("Continue uploading"),
089                        new ImageProvider("upload"),
090                        tr("Click to continue uploading to additional new changesets"),
091                        null /* no specific help text */
092                ),
093                new ButtonSpec(
094                        tr("Go back to Upload Dialog"),
095                        new ImageProvider("dialogs", "uploadproperties"),
096                        tr("Click to return to the Upload Dialog"),
097                        null /* no specific help text */
098                ),
099                new ButtonSpec(
100                        tr("Abort"),
101                        new ImageProvider("cancel"),
102                        tr("Click to abort uploading"),
103                        null /* no specific help text */
104                )
105        };
106        int numObjectsToUploadLeft = toUpload.getSize() - processedPrimitives.size();
107        String msg1 = tr("The server reported that the current changeset was closed.<br>"
108                + "This is most likely because the changesets size exceeded the max. size<br>"
109                + "of {0} objects on the server ''{1}''.",
110                OsmApi.getOsmApi().getCapabilities().getMaxChangesetSize(),
111                OsmApi.getOsmApi().getBaseUrl()
112        );
113        String msg2 = trn(
114                "There is {0} object left to upload.",
115                "There are {0} objects left to upload.",
116                numObjectsToUploadLeft,
117                numObjectsToUploadLeft
118        );
119        String msg3 = tr(
120                "Click ''<strong>{0}</strong>'' to continue uploading to additional new changesets.<br>"
121                + "Click ''<strong>{1}</strong>'' to return to the upload dialog.<br>"
122                + "Click ''<strong>{2}</strong>'' to abort uploading and return to map editing.<br>",
123                specs[0].text,
124                specs[1].text,
125                specs[2].text
126        );
127        String msg = "<html>" + msg1 + "<br><br>" + msg2 +"<br><br>" + msg3 + "</html>";
128        int ret = HelpAwareOptionPane.showOptionDialog(
129                MainApplication.getMainFrame(),
130                msg,
131                tr("Changeset is full"),
132                JOptionPane.WARNING_MESSAGE,
133                null, /* no special icon */
134                specs,
135                specs[0],
136                ht("/Action/Upload#ChangesetFull")
137        );
138        switch(ret) {
139        case 0: return MaxChangesetSizeExceededPolicy.AUTOMATICALLY_OPEN_NEW_CHANGESETS;
140        case 1: return MaxChangesetSizeExceededPolicy.FILL_ONE_CHANGESET_AND_RETURN_TO_UPLOAD_DIALOG;
141        case 2:
142        case JOptionPane.CLOSED_OPTION:
143        default: return MaxChangesetSizeExceededPolicy.ABORT;
144        }
145    }
146
147    /**
148     * Opens a new changeset.
149     */
150    protected void openNewChangeset() {
151        // make sure the current changeset is removed from the upload dialog.
152        ChangesetCache.getInstance().update(changeset);
153        Changeset newChangeSet = new Changeset();
154        newChangeSet.setKeys(this.changeset.getKeys());
155        this.changeset = newChangeSet;
156    }
157
158    protected boolean recoverFromChangesetFullException() throws OsmTransferException {
159        if (toUpload.getSize() - processedPrimitives.size() == 0) {
160            strategy.setPolicy(MaxChangesetSizeExceededPolicy.ABORT);
161            return false;
162        }
163        if (strategy.getPolicy() == null || strategy.getPolicy() == MaxChangesetSizeExceededPolicy.ABORT) {
164            strategy.setPolicy(askMaxChangesetSizeExceedsPolicy());
165        }
166        switch(strategy.getPolicy()) {
167        case AUTOMATICALLY_OPEN_NEW_CHANGESETS:
168            // prepare the state of the task for a next iteration in uploading.
169            closeChangesetIfRequired();
170            openNewChangeset();
171            toUpload.removeProcessed(processedPrimitives);
172            return true;
173        case ABORT:
174        case FILL_ONE_CHANGESET_AND_RETURN_TO_UPLOAD_DIALOG:
175        default:
176            // don't continue - finish() will send the user back to map editing or upload dialog
177            return false;
178        }
179    }
180
181    /**
182     * Retries to recover the upload operation from an exception which was thrown because
183     * an uploaded primitive was already deleted on the server.
184     *
185     * @param e the exception throw by the API
186     * @param monitor a progress monitor
187     * @throws OsmTransferException if we can't recover from the exception
188     */
189    protected void recoverFromGoneOnServer(OsmApiPrimitiveGoneException e, ProgressMonitor monitor) throws OsmTransferException {
190        if (!e.isKnownPrimitive()) throw e;
191        OsmPrimitive p = layer.data.getPrimitiveById(e.getPrimitiveId(), e.getPrimitiveType());
192        if (p == null) throw e;
193        if (p.isDeleted()) {
194            // we tried to delete an already deleted primitive.
195            final String msg;
196            final String displayName = p.getDisplayName(DefaultNameFormatter.getInstance());
197            if (p instanceof Node) {
198                msg = tr("Node ''{0}'' is already deleted. Skipping object in upload.", displayName);
199            } else if (p instanceof Way) {
200                msg = tr("Way ''{0}'' is already deleted. Skipping object in upload.", displayName);
201            } else if (p instanceof Relation) {
202                msg = tr("Relation ''{0}'' is already deleted. Skipping object in upload.", displayName);
203            } else {
204                msg = tr("Object ''{0}'' is already deleted. Skipping object in upload.", displayName);
205            }
206            monitor.appendLogMessage(msg);
207            Logging.warn(msg);
208            processedPrimitives.addAll(writer.getProcessedPrimitives());
209            processedPrimitives.add(p);
210            toUpload.removeProcessed(processedPrimitives);
211            return;
212        }
213        // exception was thrown because we tried to *update* an already deleted
214        // primitive. We can't resolve this automatically. Re-throw exception,
215        // a conflict is going to be created later.
216        throw e;
217    }
218
219    protected void cleanupAfterUpload() {
220        // we always clean up the data, even in case of errors. It's possible the data was
221        // partially uploaded. Better run on EDT.
222        Runnable r = () -> {
223            boolean readOnly = layer.isLocked();
224            if (readOnly) {
225                layer.unlock();
226            }
227            try {
228                layer.cleanupAfterUpload(processedPrimitives);
229                layer.onPostUploadToServer();
230                ChangesetCache.getInstance().update(changeset);
231            } finally {
232                if (readOnly) {
233                    layer.lock();
234                }
235            }
236        };
237
238        try {
239            SwingUtilities.invokeAndWait(r);
240        } catch (InterruptedException e) {
241            Logging.trace(e);
242            lastException = e;
243            Thread.currentThread().interrupt();
244        } catch (InvocationTargetException e) {
245            Logging.trace(e);
246            lastException = new OsmTransferException(e.getCause());
247        }
248    }
249
250    @Override
251    protected void realRun() {
252        try {
253            MessageNotifier.stop();
254            uploadloop: while (true) {
255                try {
256                    getProgressMonitor().subTask(
257                            trn("Uploading {0} object...", "Uploading {0} objects...", toUpload.getSize(), toUpload.getSize()));
258                    synchronized (this) {
259                        writer = new OsmServerWriter();
260                    }
261                    writer.uploadOsm(strategy, toUpload.getPrimitives(), changeset, getProgressMonitor().createSubTaskMonitor(1, false));
262
263                    // if we get here we've successfully uploaded the data. Exit the loop.
264                    break;
265                } catch (OsmTransferCanceledException e) {
266                    Logging.error(e);
267                    uploadCanceled = true;
268                    break uploadloop;
269                } catch (OsmApiPrimitiveGoneException e) {
270                    // try to recover from  410 Gone
271                    recoverFromGoneOnServer(e, getProgressMonitor());
272                } catch (ChangesetClosedException e) {
273                    if (writer != null) {
274                        processedPrimitives.addAll(writer.getProcessedPrimitives()); // OsmPrimitive in => OsmPrimitive out
275                    }
276                    switch(e.getSource()) {
277                    case UPLOAD_DATA:
278                        // Most likely the changeset is full. Try to recover and continue
279                        // with a new changeset, but let the user decide first (see
280                        // recoverFromChangesetFullException)
281                        if (recoverFromChangesetFullException()) {
282                            continue;
283                        }
284                        lastException = e;
285                        break uploadloop;
286                    case UNSPECIFIED:
287                    case UPDATE_CHANGESET:
288                    default:
289                        // The changeset was closed when we tried to update it. Probably, our
290                        // local list of open changesets got out of sync with the server state.
291                        // The user will have to select another open changeset.
292                        // Rethrow exception - this will be handled later.
293                        changeset.setOpen(false);
294                        throw e;
295                    }
296                } finally {
297                    if (writer != null) {
298                        processedPrimitives.addAll(writer.getProcessedPrimitives());
299                    }
300                    synchronized (this) {
301                        writer = null;
302                    }
303                }
304            }
305            // if required close the changeset
306            closeChangesetIfRequired();
307        } catch (OsmTransferException e) {
308            if (uploadCanceled) {
309                Logging.info(tr("Ignoring caught exception because upload is canceled. Exception is: {0}", e.toString()));
310            } else {
311                lastException = e;
312            }
313        } finally {
314            if (MessageNotifier.PROP_NOTIFIER_ENABLED.get()) {
315                MessageNotifier.start();
316            }
317        }
318        if (uploadCanceled && processedPrimitives.isEmpty()) return;
319        cleanupAfterUpload();
320    }
321
322    private void closeChangesetIfRequired() throws OsmTransferException {
323        if (strategy.isCloseChangesetAfterUpload() && changeset != null && !changeset.isNew() && changeset.isOpen()) {
324            OsmApi.getOsmApi().closeChangeset(changeset, progressMonitor.createSubTaskMonitor(0, false));
325        }
326    }
327
328    @Override protected void finish() {
329
330        // depending on the success of the upload operation and on the policy for
331        // multi changeset uploads this will sent the user back to the appropriate
332        // place in JOSM, either
333        // - to an error dialog
334        // - to the Upload Dialog
335        // - to map editing
336        GuiHelper.runInEDT(() -> {
337            // if the changeset is still open after this upload we want it to be selected on the next upload
338            ChangesetCache.getInstance().update(changeset);
339            if (changeset != null && changeset.isOpen()) {
340                UploadDialog.getUploadDialog().setSelectedChangesetForNextUpload(changeset);
341            }
342            if (uploadCanceled) return;
343            if (lastException == null) {
344                HtmlPanel panel = new HtmlPanel(
345                        "<h3><a href=\"" + Config.getUrls().getBaseBrowseUrl() + "/changeset/" + changeset.getId() + "\">"
346                                + tr("Upload successful!") + "</a></h3>");
347                panel.enableClickableHyperlinks();
348                panel.setOpaque(false);
349                new Notification()
350                        .setContent(panel)
351                        .setIcon(ImageProvider.get("misc", "check_large"))
352                        .show();
353                return;
354            }
355            if (lastException instanceof ChangesetClosedException) {
356                ChangesetClosedException e = (ChangesetClosedException) lastException;
357                if (e.getSource() == ChangesetClosedException.Source.UPDATE_CHANGESET) {
358                    handleFailedUpload(lastException);
359                    return;
360                }
361                if (strategy.getPolicy() == null)
362                    /* do nothing if unknown policy */
363                    return;
364                if (e.getSource() == ChangesetClosedException.Source.UPLOAD_DATA) {
365                    switch(strategy.getPolicy()) {
366                    case ABORT:
367                        break; /* do nothing - we return to map editing */
368                    case AUTOMATICALLY_OPEN_NEW_CHANGESETS:
369                        break; /* do nothing - we return to map editing */
370                    case FILL_ONE_CHANGESET_AND_RETURN_TO_UPLOAD_DIALOG:
371                        // return to the upload dialog
372                        //
373                        toUpload.removeProcessed(processedPrimitives);
374                        UploadDialog.getUploadDialog().setUploadedPrimitives(toUpload);
375                        UploadDialog.getUploadDialog().setVisible(true);
376                        break;
377                    }
378                } else {
379                    handleFailedUpload(lastException);
380                }
381            } else {
382                handleFailedUpload(lastException);
383            }
384        });
385    }
386
387    @Override protected void cancel() {
388        uploadCanceled = true;
389        synchronized (this) {
390            if (writer != null) {
391                writer.cancel();
392            }
393        }
394    }
395}