Index: applications/editors/josm/plugins/imagery_offset_db/.project
===================================================================
--- applications/editors/josm/plugins/imagery_offset_db/.project	(revision 34595)
+++ applications/editors/josm/plugins/imagery_offset_db/.project	(revision 34596)
@@ -16,13 +16,7 @@
 			</arguments>
 		</buildCommand>
-		<buildCommand>
-			<name>net.sf.eclipsecs.core.CheckstyleBuilder</name>
-			<arguments>
-			</arguments>
-		</buildCommand>
 	</buildSpec>
 	<natures>
 		<nature>org.eclipse.jdt.core.javanature</nature>
-		<nature>net.sf.eclipsecs.core.CheckstyleNature</nature>
 	</natures>
 </projectDescription>
Index: applications/editors/josm/plugins/imagery_offset_db/.settings/org.eclipse.jdt.core.prefs
===================================================================
--- applications/editors/josm/plugins/imagery_offset_db/.settings/org.eclipse.jdt.core.prefs	(revision 34595)
+++ applications/editors/josm/plugins/imagery_offset_db/.settings/org.eclipse.jdt.core.prefs	(revision 34596)
@@ -9,4 +9,5 @@
 org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.8
 org.eclipse.jdt.core.compiler.compliance=1.8
+org.eclipse.jdt.core.compiler.doc.comment.support=enabled
 org.eclipse.jdt.core.compiler.problem.annotationSuperInterface=warning
 org.eclipse.jdt.core.compiler.problem.assertIdentifier=error
@@ -32,4 +33,9 @@
 org.eclipse.jdt.core.compiler.problem.incompleteEnumSwitch=warning
 org.eclipse.jdt.core.compiler.problem.indirectStaticAccess=ignore
+org.eclipse.jdt.core.compiler.problem.invalidJavadoc=warning
+org.eclipse.jdt.core.compiler.problem.invalidJavadocTags=enabled
+org.eclipse.jdt.core.compiler.problem.invalidJavadocTagsDeprecatedRef=enabled
+org.eclipse.jdt.core.compiler.problem.invalidJavadocTagsNotVisibleRef=enabled
+org.eclipse.jdt.core.compiler.problem.invalidJavadocTagsVisibility=private
 org.eclipse.jdt.core.compiler.problem.localVariableHiding=ignore
 org.eclipse.jdt.core.compiler.problem.methodWithConstructorName=warning
@@ -38,4 +44,12 @@
 org.eclipse.jdt.core.compiler.problem.missingEnumCaseDespiteDefault=disabled
 org.eclipse.jdt.core.compiler.problem.missingHashCodeMethod=ignore
+org.eclipse.jdt.core.compiler.problem.missingJavadocComments=ignore
+org.eclipse.jdt.core.compiler.problem.missingJavadocCommentsOverriding=disabled
+org.eclipse.jdt.core.compiler.problem.missingJavadocCommentsVisibility=public
+org.eclipse.jdt.core.compiler.problem.missingJavadocTagDescription=all_standard_tags
+org.eclipse.jdt.core.compiler.problem.missingJavadocTags=warning
+org.eclipse.jdt.core.compiler.problem.missingJavadocTagsMethodTypeParameters=disabled
+org.eclipse.jdt.core.compiler.problem.missingJavadocTagsOverriding=disabled
+org.eclipse.jdt.core.compiler.problem.missingJavadocTagsVisibility=private
 org.eclipse.jdt.core.compiler.problem.missingOverrideAnnotation=ignore
 org.eclipse.jdt.core.compiler.problem.missingOverrideAnnotationForInterfaceMethodImplementation=enabled
Index: applications/editors/josm/plugins/imagery_offset_db/build.xml
===================================================================
--- applications/editors/josm/plugins/imagery_offset_db/build.xml	(revision 34595)
+++ applications/editors/josm/plugins/imagery_offset_db/build.xml	(revision 34596)
@@ -9,5 +9,5 @@
 
     <property name="plugin.author" value="Ilya Zverev"/>
-    <property name="plugin.class" value="iodb.ImageryOffsetPlugin"/>
+    <property name="plugin.class" value="org.openstreetmap.josm.plugins.imagery_offset_db.ImageryOffsetPlugin"/>
     <property name="plugin.description" value="Database of imagery offsets: share and aquire imagery offsets with one button."/>
     <property name="plugin.icon" value="images/iodb.png"/>
Index: applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/CalibrationLayer.java
===================================================================
--- applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/CalibrationLayer.java	(revision 34596)
+++ applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/CalibrationLayer.java	(revision 34596)
@@ -0,0 +1,226 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.imagery_offset_db;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.BasicStroke;
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.Point;
+import java.awt.RenderingHints;
+import java.awt.Stroke;
+import java.awt.event.ActionEvent;
+import java.awt.geom.GeneralPath;
+
+import javax.swing.AbstractAction;
+import javax.swing.Action;
+import javax.swing.Icon;
+
+import org.openstreetmap.josm.actions.AutoScaleAction;
+import org.openstreetmap.josm.data.Bounds;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.MapView;
+import org.openstreetmap.josm.gui.dialogs.LayerListDialog;
+import org.openstreetmap.josm.gui.dialogs.LayerListPopup;
+import org.openstreetmap.josm.gui.layer.Layer;
+import org.openstreetmap.josm.tools.ImageProvider;
+
+/**
+ * A layer that displays calibration geometry for an offset.
+ *
+ * @author Zverik
+ * @license WTFPL
+ */
+public class CalibrationLayer extends Layer {
+    private Color color;
+    private Icon icon;
+    private CalibrationObject obj;
+    private LatLon center;
+
+    public CalibrationLayer(CalibrationObject obj) {
+        super(tr("Calibration Layer"));
+        color = Color.RED;
+        this.obj = obj;
+    }
+
+    /**
+     * Draw the calibration geometry with thin bright lines (or a crosshair
+     * in case of a point).
+     */
+    @Override
+    public void paint(Graphics2D g, MapView mv, Bounds box) {
+        g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+        Stroke oldStroke = g.getStroke();
+        g.setColor(color);
+        g.setStroke(new BasicStroke(1));
+        LatLon[] geometry = obj.getGeometry();
+        if (geometry.length == 1) {
+            // draw crosshair
+            Point p = mv.getPoint(geometry[0]);
+            g.drawLine(p.x, p.y, p.x, p.y);
+            g.drawLine(p.x - 10, p.y, p.x - 20, p.y);
+            g.drawLine(p.x + 10, p.y, p.x + 20, p.y);
+            g.drawLine(p.x, p.y - 10, p.x, p.y - 20);
+            g.drawLine(p.x, p.y + 10, p.x, p.y + 20);
+        } else if (geometry.length > 1) {
+            // draw a line
+            GeneralPath path = new GeneralPath();
+            for (int i = 0; i < geometry.length; i++) {
+                Point p = mv.getPoint(geometry[i]);
+                if (i == 0)
+                    path.moveTo(p.x, p.y);
+                else
+                    path.lineTo(p.x, p.y);
+            }
+            g.draw(path);
+        }
+        g.setStroke(oldStroke);
+    }
+
+    @Override
+    public Icon getIcon() {
+        if (icon == null)
+            icon = ImageProvider.get("calibration_layer");
+        return icon;
+    }
+
+    @Override
+    public void mergeFrom(Layer from) {
+    }
+
+    @Override
+    public boolean isMergable(Layer other) {
+        return false;
+    }
+
+    /**
+     * This is for determining a bounding box for the layer.
+     */
+    @Override
+    public void visitBoundingBox(BoundingXYVisitor v) {
+        for (LatLon ll : obj.getGeometry()) {
+            v.visit(ll);
+        }
+    }
+
+    /**
+     * A simple tooltip with geometry type, status and author.
+     */
+    @Override
+    public String getToolTipText() {
+        if (obj.isDeprecated())
+            return tr("A deprecated calibration geometry of {0} nodes by {1}", obj.getGeometry().length, obj.getAuthor());
+        else
+            return tr("A calibration geometry of {0} nodes by {1}", obj.getGeometry().length, obj.getAuthor());
+    }
+
+    @Override
+    public Object getInfoComponent() {
+        return OffsetInfoAction.getInformationObject(obj);
+    }
+
+    /**
+     * This method returns standard actions plus "zoom to layer" and "change color".
+     */
+    @Override
+    public Action[] getMenuEntries() {
+        return new Action[] {
+                LayerListDialog.getInstance().createShowHideLayerAction(),
+                LayerListDialog.getInstance().createDeleteLayerAction(),
+                SeparatorLayerAction.INSTANCE,
+                new ZoomToLayerAction(),
+                new SelectColorAction(Color.RED),
+                new SelectColorAction(Color.CYAN),
+                new SelectColorAction(Color.YELLOW),
+                SeparatorLayerAction.INSTANCE,
+                new LayerListPopup.InfoAction(this)
+        };
+    }
+
+    /**
+     * This method pans to the geometry, preserving zoom. It is used
+     * from {@link GetImageryOffsetAction}, because {@link AutoScaleAction}
+     * doesn't have a relevant method.
+     */
+    public void panToCenter() {
+        if (center == null) {
+            LatLon[] geometry = obj.getGeometry();
+            double lat = 0.0;
+            double lon = 0.0;
+            for (LatLon ll : geometry) {
+                lon += ll.lon();
+                lat += ll.lat();
+            }
+            center = new LatLon(lat / geometry.length, lon / geometry.length);
+        }
+        MainApplication.getMap().mapView.zoomTo(center);
+    }
+
+    /**
+     * An action to change a color of a geometry. The color
+     * is specified in the constuctor. See {@link #getMenuEntries()} for
+     * the list of enabled colors.
+     */
+    class SelectColorAction extends AbstractAction {
+        private Color c;
+
+        SelectColorAction(Color color) {
+            super(tr("Change Color"));
+            putValue(SMALL_ICON, new SingleColorIcon(color));
+            this.c = color;
+        }
+
+        @Override
+        public void actionPerformed(ActionEvent e) {
+            color = c;
+            MainApplication.getMap().mapView.repaint();
+        }
+    }
+
+    /**
+     * A simple icon with a colored rectangle.
+     */
+    static class SingleColorIcon implements Icon {
+        private Color color;
+
+        SingleColorIcon(Color color) {
+            this.color = color;
+        }
+
+        @Override
+        public void paintIcon(Component c, Graphics g, int x, int y) {
+            g.setColor(color);
+            g.fillRect(x, y, 24, 24);
+        }
+
+        @Override
+        public int getIconWidth() {
+            return 24;
+        }
+
+        @Override
+        public int getIconHeight() {
+            return 24;
+        }
+    }
+
+    /**
+     * An action that calls {@link AutoScaleAction} which in turn
+     * uses {@link #visitBoundingBox} to pan and zoom to the calibration geometry.
+     */
+    static class ZoomToLayerAction extends AbstractAction {
+        ZoomToLayerAction() {
+            super(tr("Zoom to {0}", tr("layer"))); // to use translation from AutoScaleAction
+            putValue(SMALL_ICON, ImageProvider.get("dialogs/autoscale/layer"));
+        }
+
+        @Override
+        public void actionPerformed(ActionEvent e) {
+            AutoScaleAction.autoScale("layer");
+        }
+    }
+}
Index: applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/CalibrationObject.java
===================================================================
--- applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/CalibrationObject.java	(revision 34596)
+++ applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/CalibrationObject.java	(revision 34596)
@@ -0,0 +1,71 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.imagery_offset_db;
+
+import java.util.Map;
+
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.osm.Node;
+import org.openstreetmap.josm.data.osm.OsmPrimitive;
+import org.openstreetmap.josm.data.osm.Way;
+
+/**
+ * A calibration geometry data type. It was called an object long ago,
+ * when it contained an information on an OSM object. I decided not to rename
+ * this class.
+ *
+ * @author Zverik
+ * @license WTFPL
+ */
+public class CalibrationObject extends ImageryOffsetBase {
+    private LatLon[] geometry;
+
+    /**
+     * Initialize a calibration object from the array of nodes.
+     * @param geometry array of lat/lon coordinates forming the geometry
+     */
+    public CalibrationObject(LatLon[] geometry) {
+        this.geometry = geometry;
+    }
+
+    /**
+     * Initialize a calibration object from OSM primitive.
+     * @param p OSM primitive
+     */
+    public CalibrationObject(OsmPrimitive p) {
+        if (p instanceof Node)
+            geometry = new LatLon[] {((Node) p).getCoor()};
+        else if (p instanceof Way) {
+            geometry = new LatLon[((Way) p).getNodesCount()];
+            for (int i = 0; i < geometry.length; i++) {
+                geometry[i] = ((Way) p).getNode(i).getCoor();
+            }
+        } else
+            throw new IllegalArgumentException("Calibration Object can be created either from node or a way");
+    }
+
+    /**
+     * Get an array of points for this geometry.
+     * @return array of lat/lon coordinates forming the geometry
+     */
+    public LatLon[] getGeometry() {
+        return geometry;
+    }
+
+    @Override
+    public void putServerParams(Map<String, String> map) {
+        super.putServerParams(map);
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < geometry.length; i++) {
+            if (i > 0)
+                sb.append(',');
+            sb.append(geometry[i].lon()).append(' ').append(geometry[i].lat());
+        }
+        map.put("geometry", sb.toString());
+    }
+
+    @Override
+    public String toString() {
+        return "CalibrationObject{" + geometry.length + "nodes; position=" + position + ", date=" + date + ", author=" + author +
+                ", description=" + description + ", abandonDate=" + abandonDate + '}';
+    }
+}
Index: applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/DeprecateOffsetAction.java
===================================================================
--- applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/DeprecateOffsetAction.java	(revision 34596)
+++ applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/DeprecateOffsetAction.java	(revision 34596)
@@ -0,0 +1,111 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.imagery_offset_db;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.event.ActionEvent;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+
+import javax.swing.AbstractAction;
+import javax.swing.JOptionPane;
+
+import org.openstreetmap.josm.data.UserIdentityManager;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.tools.ImageProvider;
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ * A context-dependent action to deprecate an offset.
+ *
+ * @author Zverik
+ * @license WTFPL
+ */
+public class DeprecateOffsetAction extends AbstractAction {
+    private ImageryOffsetBase offset;
+    private QuerySuccessListener listener;
+
+    /**
+     * Initialize an action with an offset object.
+     * @param offset offset object
+     */
+    public DeprecateOffsetAction(ImageryOffsetBase offset) {
+        super(tr("Deprecate Offset"));
+        putValue(SMALL_ICON, ImageProvider.get("dialogs", "delete"));
+        this.offset = offset;
+        setEnabled(offset != null && !offset.isDeprecated());
+    }
+
+    /**
+     * Asks a user if they really want to deprecate an offset (since this
+     * action is virtually irreversible) and calls
+     * {@link #deprecateOffset(ImageryOffsetBase, QuerySuccessListener)}
+     * on a positive answer.
+     */
+    @Override
+    public void actionPerformed(ActionEvent e) {
+        if (!MainApplication.isDisplayingMapView() || !MainApplication.getMap().isVisible())
+            return;
+
+        String desc = offset instanceof ImageryOffset ?
+                tr("Are you sure this imagery offset is wrong?") :
+                    tr("Are you sure this calibration geometry is aligned badly?");
+                if (JOptionPane.showConfirmDialog(MainApplication.getMainFrame(),
+                        tr("Warning: deprecation is basically irreversible!")+ "\n" + desc,
+                        ImageryOffsetTools.DIALOG_TITLE, JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE) != JOptionPane.YES_OPTION) {
+                    return;
+                }
+                deprecateOffset(offset, listener);
+    }
+
+    /**
+     * Installs a listener to process successful deprecation event.
+     * @param listener success listener
+     */
+    public void setListener(QuerySuccessListener listener) {
+        this.listener = listener;
+    }
+
+    /**
+     * Deprecate the given offset.
+     * @param offset offset object
+     * @see #deprecateOffset(ImageryOffsetBase, QuerySuccessListener)
+     */
+    public static void deprecateOffset(ImageryOffsetBase offset) {
+        deprecateOffset(offset, null);
+    }
+
+    /**
+     * Deprecate the given offset and call listener on success. Asks user the reason
+     * and executes {@link SimpleOffsetQueryTask} with a query to deprecate the offset.
+     * @param offset offset object
+     * @param listener success listener
+     */
+    public static void deprecateOffset(ImageryOffsetBase offset, QuerySuccessListener listener) {
+        String userName = UserIdentityManager.getInstance().getUserName();
+        if (userName == null) {
+            JOptionPane.showMessageDialog(MainApplication.getMainFrame(), tr("To store imagery offsets you must be a registered OSM user."),
+                    ImageryOffsetTools.DIALOG_TITLE, JOptionPane.ERROR_MESSAGE);
+            return;
+        }
+
+        String message = offset instanceof ImageryOffset
+                ? tr("Please enter the reason why you mark this imagery offset as deprecated")
+                        : tr("Please enter the reason why you mark this calibration geometry as deprecated");
+                String reason = StoreImageryOffsetAction.queryDescription(message + ":");
+                if (reason == null)
+                    return;
+
+                try {
+                    String query = "deprecate?id=" + offset.getId()
+                    + "&author=" + URLEncoder.encode(userName, "UTF8")
+                    + "&reason=" + URLEncoder.encode(reason, "UTF8");
+                    SimpleOffsetQueryTask depTask = new SimpleOffsetQueryTask(query, tr("Notifying the server of the deprecation..."));
+                    if (listener != null)
+                        depTask.setListener(listener);
+                    MainApplication.worker.submit(depTask);
+                } catch (UnsupportedEncodingException ex) {
+                    Logging.error(ex);
+                }
+    }
+}
Index: applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/GetImageryOffsetAction.java
===================================================================
--- applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/GetImageryOffsetAction.java	(revision 34596)
+++ applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/GetImageryOffsetAction.java	(revision 34596)
@@ -0,0 +1,172 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.imagery_offset_db;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.event.ActionEvent;
+import java.awt.event.KeyEvent;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.util.List;
+
+import javax.swing.Action;
+import javax.swing.Icon;
+import javax.swing.JOptionPane;
+
+import org.openstreetmap.josm.actions.JosmAction;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.coor.conversion.DecimalDegreesCoordinateFormat;
+import org.openstreetmap.josm.data.projection.Projection;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.layer.AbstractTileSourceLayer;
+import org.openstreetmap.josm.spi.preferences.Config;
+import org.openstreetmap.josm.tools.ImageProvider;
+import org.openstreetmap.josm.tools.Shortcut;
+import org.xml.sax.SAXException;
+
+/**
+ * Download a list of imagery offsets for the current position, let user choose which one to use.
+ *
+ * @author Zverik
+ * @license WTFPL
+ */
+public class GetImageryOffsetAction extends JosmAction implements ImageryOffsetWatcher.OffsetStateListener {
+    private Icon iconOffsetOk;
+    private Icon iconOffsetBad;
+
+    /**
+     * Initialize the action. Sets "Ctrl+Alt+I" shortcut: the only shortcut in this plugin.
+     * Also registers itself with {@link ImageryOffsetWatcher}.
+     */
+    public GetImageryOffsetAction() {
+        super(tr("Get Imagery Offset..."), "getoffset", tr("Download offsets for current imagery from a server"),
+                Shortcut.registerShortcut("imageryoffset:get", tr("Imagery: {0}", tr("Get Imagery Offset...")),
+                        KeyEvent.VK_I, Shortcut.ALT_CTRL), true);
+        iconOffsetOk = new ImageProvider("getoffset").setSize(ImageProvider.ImageSizes.MENU).get();
+        iconOffsetBad = new ImageProvider("getoffsetnow").setSize(ImageProvider.ImageSizes.MENU).get();
+        ImageryOffsetWatcher.getInstance().register(this);
+    }
+
+    /**
+     * The action just executes {@link DownloadOffsetsTask}.
+     */
+    @Override
+    public void actionPerformed(ActionEvent e) {
+        if (!MainApplication.isDisplayingMapView() || !MainApplication.getMap().isVisible())
+            return;
+        Projection proj = MainApplication.getMap().mapView.getProjection();
+        LatLon center = proj.eastNorth2latlon(MainApplication.getMap().mapView.getCenter());
+        AbstractTileSourceLayer<?> layer = ImageryOffsetTools.getTopImageryLayer();
+        String imagery = ImageryOffsetTools.getImageryID(layer);
+        if (imagery == null)
+            return;
+
+        DownloadOffsetsTask download = new DownloadOffsetsTask(center, layer, imagery);
+        MainApplication.worker.submit(download);
+    }
+
+    /**
+     * This action is enabled when there's a map, mapView and one of the layers
+     * is an imagery layer.
+     */
+    @Override
+    protected void updateEnabledState() {
+        boolean state = true;
+        if (!MainApplication.isDisplayingMapView() || !MainApplication.getMap().isVisible())
+            state = false;
+        AbstractTileSourceLayer<?> layer = ImageryOffsetTools.getTopImageryLayer();
+        if (ImageryOffsetTools.getImageryID(layer) == null)
+            state = false;
+        setEnabled(state);
+    }
+
+    /**
+     * Display a dialog for choosing between offsets. If there are no offsets in
+     * the list, displays the relevant message instead.
+     * @param offsets List of offset objects to choose from.
+     */
+    private void showOffsetDialog(List<ImageryOffsetBase> offsets) {
+        if (offsets.isEmpty()) {
+            JOptionPane.showMessageDialog(MainApplication.getMainFrame(),
+                    tr("No data for this region. Please adjust imagery layer and upload an offset."),
+                    ImageryOffsetTools.DIALOG_TITLE, JOptionPane.INFORMATION_MESSAGE);
+            return;
+        }
+        OffsetDialog offsetDialog = new OffsetDialog(offsets);
+        if (offsetDialog.showDialog() != null)
+            offsetDialog.applyOffset();
+    }
+
+    /**
+     * Update action icon based on an offset state.
+     */
+    @Override
+    public void offsetStateChanged(boolean isOffsetGood) {
+        putValue(Action.SMALL_ICON, isOffsetGood ? iconOffsetOk : iconOffsetBad);
+    }
+
+    /**
+     * Remove offset listener.
+     */
+    @Override
+    public void destroy() {
+        ImageryOffsetWatcher.getInstance().unregister(this);
+        super.destroy();
+    }
+
+    /**
+     * A task that downloads offsets for a given position and imagery layer,
+     * then parses resulting XML and calls
+     * {@link #showOffsetDialog(java.util.List)} on success.
+     */
+    private class DownloadOffsetsTask extends SimpleOffsetQueryTask {
+        private List<ImageryOffsetBase> offsets;
+
+        /**
+         * Initializes query object from the parameters.
+         * @param center A center point of a map view.
+         * @param layer The topmost imagery layer.
+         * @param imagery Imagery ID for the layer.
+         */
+        DownloadOffsetsTask(LatLon center, AbstractTileSourceLayer<?> layer, String imagery) {
+            super(null, tr("Loading imagery offsets..."));
+            try {
+                String query = "get?lat=" + DecimalDegreesCoordinateFormat.INSTANCE.latToString(center)
+                + "&lon=" + DecimalDegreesCoordinateFormat.INSTANCE.lonToString(center)
+                + "&imagery=" + URLEncoder.encode(imagery, "UTF8");
+                int radius = Config.getPref().getInt("iodb.radius", -1);
+                if (radius > 0)
+                    query = query + "&radius=" + radius;
+                setQuery(query);
+            } catch (UnsupportedEncodingException e) {
+                throw new IllegalArgumentException(e);
+            }
+        }
+
+        /**
+         * Displays offset dialog on success.
+         */
+        @Override
+        protected void afterFinish() {
+            if (!cancelled && offsets != null)
+                showOffsetDialog(offsets);
+        }
+
+        /**
+         * Parses the response with {@link IODBReader}.
+         * @param inp Response input stream.
+         * @throws org.openstreetmap.josm.plugins.imagery_offset_db.SimpleOffsetQueryTask.UploadException Thrown on XML parsing error.
+         */
+        @Override
+        protected void processResponse(InputStream inp) throws UploadException {
+            offsets = null;
+            try {
+                offsets = new IODBReader(inp).parse();
+            } catch (IOException | SAXException e) {
+                throw new UploadException(tr("Error processing XML response: {0}", e.getMessage()));
+            }
+        }
+    }
+}
Index: applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/IODBReader.java
===================================================================
--- applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/IODBReader.java	(revision 34596)
+++ applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/IODBReader.java	(revision 34596)
@@ -0,0 +1,251 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.imagery_offset_db;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.parsers.SAXParserFactory;
+
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.io.UTFInputStreamReader;
+import org.openstreetmap.josm.tools.Logging;
+import org.xml.sax.Attributes;
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.DefaultHandler;
+
+/**
+ * Parses the server response. It expects XML in UTF-8 with several &lt;offset&gt;
+ * and &lt;calibration&gt; elements.
+ *
+ * @author Zverik
+ * @license WTFPL
+ */
+public class IODBReader {
+    private List<ImageryOffsetBase> offsets;
+    private InputSource source;
+
+    /**
+     * Initializes the parser. This constructor creates an input source on the input
+     * stream, so it may throw an exception (though it's highly improbable).
+     * @param source An input stream with XML.
+     * @throws IOException Thrown when something's wrong with the stream.
+     */
+    public IODBReader(InputStream source) throws IOException {
+        this.source = new InputSource(UTFInputStreamReader.create(source, "UTF-8"));
+        this.offsets = new ArrayList<>();
+    }
+
+    /**
+     * Parses the XML input stream. Creates {@link Parser} to do it.
+     * @return The list of offsets.
+     * @throws SAXException Thrown when the XML is malformed.
+     * @throws IOException Thrown when the input stream fails.
+     */
+    public List<ImageryOffsetBase> parse() throws SAXException, IOException {
+        Parser parser = new Parser();
+        try {
+            SAXParserFactory factory = SAXParserFactory.newInstance();
+            factory.newSAXParser().parse(source, parser);
+            return offsets;
+        } catch (ParserConfigurationException e) {
+            throw new SAXException(e);
+        }
+    }
+
+    /**
+     * The SAX handler for XML from the imagery offset server.
+     * Calls {@link IOFields#constructObject()} for every complete object
+     * and appends the result to offsets array.
+     */
+    private class Parser extends DefaultHandler {
+        private StringBuffer accumulator = new StringBuffer();
+        private IOFields fields;
+        private boolean parsingOffset;
+        private boolean parsingDeprecate;
+        private SimpleDateFormat dateParser = new SimpleDateFormat("yyyy-MM-dd");
+
+        /**
+         * Initialize all fields.
+         */
+        @Override
+        public void startDocument() throws SAXException {
+            fields = new IOFields();
+            offsets.clear();
+            parsingOffset = false;
+        }
+
+        /**
+         * Parses latitude and longitude from tag attributes.
+         * It expects to find them in "lat" and "lon" attributes
+         * as decimal degrees. Note that it does not check whether
+         * the resulting object is valid: it may not be, especially
+         * for locations near the Poles and 180th meridian.
+         * @param atts attributes
+         * @return lat/lon object
+         */
+        private LatLon parseLatLon(Attributes atts) {
+            return new LatLon(
+                    Double.parseDouble(atts.getValue("lat")),
+                    Double.parseDouble(atts.getValue("lon")));
+        }
+
+        @Override
+        public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
+            if (!parsingOffset) {
+                if (qName.equals("offset") || qName.equals("calibration")) {
+                    parsingOffset = true;
+                    parsingDeprecate = false;
+                    fields.clear();
+                    fields.position = parseLatLon(attributes);
+                    fields.id = Integer.parseInt(attributes.getValue("id"));
+                    if (attributes.getValue("flagged") != null && attributes.getValue("flagged").equals("yes"))
+                        fields.flagged = true;
+                }
+            } else {
+                if (qName.equals("node")) {
+                    fields.geometry.add(parseLatLon(attributes));
+                } else if (qName.equals("imagery-position")) {
+                    fields.imageryPos = parseLatLon(attributes);
+                } else if (qName.equals("imagery")) {
+                    String minZoom = attributes.getValue("minzoom");
+                    String maxZoom = attributes.getValue("maxzoom");
+                    if (minZoom != null)
+                        fields.minZoom = Integer.parseInt(minZoom);
+                    if (maxZoom != null)
+                        fields.maxZoom = Integer.parseInt(maxZoom);
+                } else if (qName.equals("deprecated")) {
+                    parsingDeprecate = true;
+                }
+            }
+            accumulator.setLength(0);
+        }
+
+        @Override
+        public void characters(char[] ch, int start, int length) throws SAXException {
+            if (parsingOffset)
+                accumulator.append(ch, start, length);
+        }
+
+        @Override
+        public void endElement(String uri, String localName, String qName) throws SAXException {
+            if (parsingOffset) {
+                if (qName.equals("author")) {
+                    if (!parsingDeprecate)
+                        fields.author = accumulator.toString();
+                    else
+                        fields.abandonAuthor = accumulator.toString();
+                } else if (qName.equals("description")) {
+                    fields.description = accumulator.toString();
+                } else if (qName.equals("reason") && parsingDeprecate) {
+                    fields.abandonReason = accumulator.toString();
+                } else if (qName.equals("date")) {
+                    try {
+                        if (!parsingDeprecate)
+                            fields.date = dateParser.parse(accumulator.toString());
+                        else
+                            fields.abandonDate = dateParser.parse(accumulator.toString());
+                    } catch (ParseException ex) {
+                        throw new SAXException(ex);
+                    }
+                } else if (qName.equals("deprecated")) {
+                    parsingDeprecate = false;
+                } else if (qName.equals("imagery")) {
+                    fields.imagery = accumulator.toString();
+                } else if (qName.equals("offset") || qName.equals("calibration")) {
+                    // store offset
+                    try {
+                        offsets.add(fields.constructObject());
+                    } catch (IllegalArgumentException ex) {
+                        // On one hand, we don't care, but this situation is one
+                        // of those "it can never happen" cases.
+                        Logging.warn(ex);
+                    }
+                    parsingOffset = false;
+                }
+            }
+        }
+    }
+
+    /**
+     * An accumulator for parsed fields. When there's enough data, it can construct
+     * an offset object. All fields are public to deliver us from tons of getters
+     * and setters.
+     */
+    private static class IOFields {
+        public int id;
+        public LatLon position;
+        public Date date;
+        public String author;
+        public String description;
+        public Date abandonDate;
+        public String abandonAuthor;
+        public String abandonReason;
+        public LatLon imageryPos;
+        public String imagery;
+        public int minZoom, maxZoom;
+        public boolean flagged;
+        public List<LatLon> geometry;
+
+        /**
+         * A constructor just calls {@link #clear()}.
+         */
+        IOFields() {
+            clear();
+        }
+
+        /**
+         * Clear all fields to <tt>null</tt> and <tt>-1</tt>.
+         */
+        public void clear() {
+            id = -1;
+            position = null;
+            date = null;
+            author = null;
+            description = null;
+            abandonDate = null;
+            abandonAuthor = null;
+            abandonReason = null;
+            imageryPos = null;
+            imagery = null;
+            minZoom = -1;
+            maxZoom = -1;
+            flagged = false;
+            geometry = new ArrayList<>();
+        }
+
+        /**
+         * Creates an offset object from the fields. Also validates them, but not vigorously.
+         * @return A new offset object.
+         */
+        public ImageryOffsetBase constructObject() {
+            if (author == null || description == null || position == null || date == null)
+                throw new IllegalArgumentException("Not enought arguments to build an object");
+            ImageryOffsetBase result;
+            if (geometry.isEmpty()) {
+                if (imagery == null || imageryPos == null)
+                    throw new IllegalArgumentException("Both imagery and imageryPos should be specified for the offset");
+                result = new ImageryOffset(imagery, imageryPos);
+                if (minZoom >= 0)
+                    ((ImageryOffset) result).setMinZoom(minZoom);
+                if (maxZoom >= 0)
+                    ((ImageryOffset) result).setMaxZoom(maxZoom);
+            } else {
+                result = new CalibrationObject(geometry.toArray(new LatLon[0]));
+            }
+            if (id >= 0)
+                result.setId(id);
+            result.setBasicInfo(position, author, description, date);
+            result.setDeprecated(abandonDate, abandonAuthor, abandonReason);
+            if (flagged)
+                result.setFlagged(flagged);
+            return result;
+        }
+    }
+}
Index: applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/ImageryIdGenerator.java
===================================================================
--- applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/ImageryIdGenerator.java	(revision 34596)
+++ applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/ImageryIdGenerator.java	(revision 34596)
@@ -0,0 +1,97 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.imagery_offset_db;
+
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+import org.openstreetmap.josm.data.imagery.ImageryInfo.ImageryType;
+
+/**
+ * Generate unique imagery identifier based on its type and URL.
+ *
+ * @author Zverik
+ * @license WTFPL
+ */
+public final class ImageryIdGenerator {
+
+    private ImageryIdGenerator() {
+        // Hide default constructor for utilities classes
+    }
+
+    public static String getImageryID(String url, ImageryType type) {
+        if (url == null)
+            return null;
+
+        // predefined layers
+        if (ImageryType.BING.equals(type) || url.contains("tiles.virtualearth.net"))
+            return "bing";
+
+        if (ImageryType.SCANEX.equals(type) && url.toLowerCase().equals("irs"))
+            return "scanex_irs";
+
+        if (ImageryType.TMS.equals(type) && url.toLowerCase().matches(".+tiles\\.mapbox\\.com/v[3-9]/openstreetmap\\.map.*"))
+            return "mapbox";
+
+        boolean isWMS = ImageryType.WMS.equals(type);
+
+        //        System.out.println(url);
+
+        // Remove protocol
+        int i = url.indexOf("://");
+        if (i > 0) {
+            url = url.substring(i + 3);
+        }
+
+        // Split URL into address and query string
+        i = url.indexOf('?');
+        String query = "";
+        if (i > 0) {
+            query = url.substring(i);
+            url = url.substring(0, i);
+        }
+
+        // Parse query parameters into a sorted map
+        final Set<String> removeWMSParams = new TreeSet<>(Arrays.asList(new String[] {
+                "srs", "width", "height", "bbox", "service", "request", "version", "format", "styles", "transparent"
+        }));
+        Map<String, String> qparams = new TreeMap<>();
+        String[] qparamsStr = query.length() > 1 ? query.substring(1).split("&") : new String[0];
+        for (String param : qparamsStr) {
+            String[] kv = param.split("=");
+            kv[0] = kv[0].toLowerCase();
+            // WMS: if this is WMS, remove all parameters except map and layers
+            if (isWMS && removeWMSParams.contains(kv[0]))
+                continue;
+            // TMS: skip parameters with variable values and Mapbox's access token
+            if ((kv.length > 1 && kv[1].indexOf('{') >= 0 && kv[1].indexOf('}') > 0) || kv[0].equals("access_token"))
+                continue;
+            qparams.put(kv[0].toLowerCase(), kv.length > 1 ? kv[1] : null);
+        }
+
+        // Reconstruct query parameters
+        StringBuilder sb = new StringBuilder();
+        for (String qk : qparams.keySet()) {
+            if (sb.length() > 0)
+                sb.append('&');
+            else if (query.length() > 0)
+                sb.append('?');
+            sb.append(qk).append('=').append(qparams.get(qk));
+        }
+        query = sb.toString();
+
+        // TMS: remove /{zoom} and /{y}.png parts
+        url = url.replaceAll("\\/\\{[^}]+\\}(?:\\.\\w+)?", "");
+        // TMS: remove variable parts
+        url = url.replaceAll("\\{[^}]+\\}", "");
+        while (url.contains("..")) {
+            url = url.replace("..", ".");
+        }
+        if (url.startsWith("."))
+            url = url.substring(1);
+
+        return url + query;
+    }
+}
Index: applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/ImageryOffset.java
===================================================================
--- applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/ImageryOffset.java	(revision 34596)
+++ applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/ImageryOffset.java	(revision 34596)
@@ -0,0 +1,70 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.imagery_offset_db;
+
+import java.util.Map;
+
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.coor.conversion.DecimalDegreesCoordinateFormat;
+
+/**
+ * An imagery offset. Contains imagery identifier, zoom bracket and a location
+ * of the position point on the imagery layer. The offset is then calculated
+ * as a difference between the two.
+ *
+ * @author Zverik
+ * @license WTFPL
+ */
+public class ImageryOffset extends ImageryOffsetBase {
+    private LatLon imageryPos;
+    private String imagery;
+    private int minZoom, maxZoom;
+
+    public ImageryOffset(String imagery, LatLon imageryPos) {
+        this.imageryPos = imageryPos;
+        this.imagery = imagery;
+        this.minZoom = 0;
+        this.maxZoom = 30;
+    }
+
+    public void setMaxZoom(int maxZoom) {
+        this.maxZoom = maxZoom;
+    }
+
+    public void setMinZoom(int minZoom) {
+        this.minZoom = minZoom;
+    }
+
+    public LatLon getImageryPos() {
+        return imageryPos;
+    }
+
+    public String getImagery() {
+        return imagery;
+    }
+
+    public int getMaxZoom() {
+        return maxZoom;
+    }
+
+    public int getMinZoom() {
+        return minZoom;
+    }
+
+    @Override
+    public void putServerParams(Map<String, String> map) {
+        super.putServerParams(map);
+        map.put("imagery", imagery);
+        map.put("imlat", DecimalDegreesCoordinateFormat.INSTANCE.latToString(imageryPos));
+        map.put("imlon", DecimalDegreesCoordinateFormat.INSTANCE.lonToString(imageryPos));
+        if (minZoom > 0)
+            map.put("minzoom", String.valueOf(minZoom));
+        if (maxZoom < 30)
+            map.put("maxzoom", String.valueOf(maxZoom));
+    }
+
+    @Override
+    public String toString() {
+        return "ImageryOffset{" + "imageryPos=" + imageryPos + ", imagery=" + imagery + "position=" + position + ", date=" + date +
+                ", author=" + author + ", description=" + description + ", abandonDate=" + abandonDate + '}';
+    }
+}
Index: applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/ImageryOffsetBase.java
===================================================================
--- applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/ImageryOffsetBase.java	(revision 34596)
+++ applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/ImageryOffsetBase.java	(revision 34596)
@@ -0,0 +1,128 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.imagery_offset_db;
+
+import java.util.Date;
+import java.util.Map;
+
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.coor.conversion.DecimalDegreesCoordinateFormat;
+
+/**
+ * Stores one offset record. It is the superclass for {@link ImageryOffset}
+ * and {@link CalibrationObject} classes and contains common fields
+ * like position, author and description.
+ *
+ * @author Zverik
+ * @license WTFPL
+ */
+public class ImageryOffsetBase {
+    protected long offsetId;
+    protected LatLon position;
+    protected Date date;
+    protected String author;
+    protected String description;
+    protected Date abandonDate;
+    protected String abandonAuthor;
+    protected String abandonReason;
+    protected boolean flagged;
+
+    /**
+     * Initialize object with the basic information. It's offset location, author, date
+     * and description.
+     * @param position offset location
+     * @param author author name
+     * @param description description
+     * @param date creation date
+     */
+    public void setBasicInfo(LatLon position, String author, String description, Date date) {
+        this.position = position;
+        this.author = author;
+        this.description = description;
+        this.date = date;
+        this.abandonDate = null;
+        this.flagged = false;
+    }
+
+    public void setId(long id) {
+        this.offsetId = id;
+    }
+
+    public long getId() {
+        return offsetId;
+    }
+
+    /**
+     * Mark the offset as deprecated. Though there is no exact field for "isDeprecated",
+     * it is deduced from abandonDate, author and reason being not null.
+     * @param abandonDate abandon date
+     * @param author author name
+     * @param reason reason why
+     */
+    public void setDeprecated(Date abandonDate, String author, String reason) {
+        this.abandonDate = abandonDate;
+        this.abandonAuthor = author;
+        this.abandonReason = reason;
+    }
+
+    public boolean isFlagged() {
+        return flagged;
+    }
+
+    public void setFlagged(boolean flagged) {
+        this.flagged = flagged;
+    }
+
+    public Date getAbandonDate() {
+        return abandonDate;
+    }
+
+    public String getAbandonAuthor() {
+        return abandonAuthor;
+    }
+
+    public String getAbandonReason() {
+        return abandonReason;
+    }
+
+    /**
+     * Check that {@link #getAbandonDate()} is not null. Note that
+     * is doesn't say anything about abandonAuthor or abandonReason.
+     * @return {@code true} if this is deprecated (abandoned)
+     */
+    public boolean isDeprecated() {
+        return abandonDate != null;
+    }
+
+    public String getAuthor() {
+        return author;
+    }
+
+    public Date getDate() {
+        return date;
+    }
+
+    public String getDescription() {
+        return description;
+    }
+
+    public void setDescription(String description) {
+        this.description = description;
+    }
+
+    public LatLon getPosition() {
+        return position;
+    }
+
+    public void putServerParams(Map<String, String> map) {
+        map.put("lat", DecimalDegreesCoordinateFormat.INSTANCE.latToString(position));
+        map.put("lon", DecimalDegreesCoordinateFormat.INSTANCE.lonToString(position));
+        map.put("author", author);
+        map.put("description", description);
+    }
+
+    @Override
+    public String toString() {
+        return "ImageryOffsetBase{" + "position=" + position + ", date=" + date + ", author=" + author +
+                ", description=" + description + ", abandonDate=" + abandonDate + '}';
+    }
+}
Index: applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/ImageryOffsetPlugin.java
===================================================================
--- applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/ImageryOffsetPlugin.java	(revision 34596)
+++ applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/ImageryOffsetPlugin.java	(revision 34596)
@@ -0,0 +1,62 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.imagery_offset_db;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.event.KeyEvent;
+import java.util.LinkedList;
+import java.util.List;
+
+import javax.swing.JMenu;
+
+import org.openstreetmap.josm.data.Version;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.preferences.ToolbarPreferences;
+import org.openstreetmap.josm.plugins.Plugin;
+import org.openstreetmap.josm.plugins.PluginInformation;
+import org.openstreetmap.josm.spi.preferences.Config;
+
+/**
+ * A plugin to request and store imagery offsets in the centralized database.
+ *
+ * @author Zverik
+ * @license WTFPL
+ */
+public class ImageryOffsetPlugin extends Plugin {
+    private GetImageryOffsetAction getAction;
+    private StoreImageryOffsetAction storeAction;
+
+    /**
+     * Add both actions to their own menu. This creates
+     * "Offset" menu, because "Imagery" is constantly rebuilt,
+     * losing all changes, and other menus are either too long already,
+     * or completely unsuitable for imagery offset actions.
+     * @param info Plugin information
+     */
+    public ImageryOffsetPlugin(PluginInformation info) {
+        super(info);
+
+        getAction = new GetImageryOffsetAction();
+        storeAction = new StoreImageryOffsetAction();
+
+        // before 5803 imagery menu was constantly regenerated, erasing extra items
+        // before 5729 it was regenerated only when the imagery list was modified (also bad)
+        int version = Version.getInstance().getVersion();
+        JMenu offsetMenu = version < 5803
+                ? MainApplication.getMenu().addMenu("Offset", tr("Offset"), KeyEvent.VK_O, 6, "help")
+                        : MainApplication.getMenu().imageryMenu;
+                offsetMenu.add(getAction);
+                offsetMenu.add(storeAction);
+
+                // an ugly hack to add this plugin to the toolbar
+                if (Config.getPref().getBoolean("iodb.modify.toolbar", true)) {
+                    List<String> toolbar = new LinkedList<>(ToolbarPreferences.getToolString());
+                    if (!toolbar.contains("getoffset")) {
+                        toolbar.add("getoffset");
+                        Config.getPref().putList("toolbar", toolbar);
+                        MainApplication.getToolbar().refreshToolbarControl();
+                    }
+                    Config.getPref().putBoolean("iodb.modify.toolbar", false);
+                }
+    }
+}
Index: applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/ImageryOffsetTools.java
===================================================================
--- applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/ImageryOffsetTools.java	(revision 34596)
+++ applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/ImageryOffsetTools.java	(revision 34596)
@@ -0,0 +1,179 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.imagery_offset_db;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.text.MessageFormat;
+import java.util.List;
+
+import org.openstreetmap.josm.data.coor.EastNorth;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.imagery.OffsetBookmark;
+import org.openstreetmap.josm.data.projection.Projection;
+import org.openstreetmap.josm.data.projection.ProjectionRegistry;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.MapView;
+import org.openstreetmap.josm.gui.layer.AbstractTileSourceLayer;
+
+/**
+ * Some common static methods for querying and processing imagery layers.
+ *
+ * @author Zverik
+ * @license WTFPL
+ */
+public final class ImageryOffsetTools {
+    /**
+     * A title for all dialogs created in this plugin.
+     */
+    public static final String DIALOG_TITLE = tr("Imagery Offset Database");
+
+    private ImageryOffsetTools() {
+        // Hide default constructor for utilities classes
+    }
+
+    /**
+     * Returns the topmost visible imagery layer.
+     * @return the layer, or null if it hasn't been found.
+     */
+    public static AbstractTileSourceLayer<?> getTopImageryLayer() {
+        if (!MainApplication.isDisplayingMapView())
+            return null;
+        @SuppressWarnings("rawtypes")
+        List<AbstractTileSourceLayer> layers = MainApplication.getLayerManager().getLayersOfType(AbstractTileSourceLayer.class);
+        for (AbstractTileSourceLayer<?> layer : layers) {
+            String url = layer.getInfo().getUrl();
+            if (layer.isVisible() && url != null && !url.contains("gps-")) {
+                return layer;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Calculates the center of a visible map area.
+     * @return the center point, or (0; 0) if there's no map on the screen.
+     */
+    public static LatLon getMapCenter() {
+        Projection proj = ProjectionRegistry.getProjection();
+        return !MainApplication.isDisplayingMapView()
+                ? new LatLon(0, 0) : proj.eastNorth2latlon(MainApplication.getMap().mapView.getCenter());
+    }
+
+    /**
+     * Calculates an imagery layer offset.
+     * @param layer imagery layer
+     * @param center The center of a visible map area.
+     * @return Coordinates of a point on the imagery which correspond to the
+     * center point on the map.
+     * @see #applyLayerOffset
+     */
+    public static LatLon getLayerOffset(AbstractTileSourceLayer<?> layer, LatLon center) {
+        Projection proj = ProjectionRegistry.getProjection();
+        EastNorth offsetCenter = MainApplication.getMap().mapView.getCenter();
+        EastNorth centerOffset = offsetCenter.add(-layer.getDisplaySettings().getDx(),
+                -layer.getDisplaySettings().getDy());
+        LatLon offsetLL = proj.eastNorth2latlon(centerOffset);
+        return offsetLL;
+    }
+
+    /**
+     * Applies the offset to the imagery layer.
+     * @param layer imagery layer
+     * @param offset offset object
+     * @see #calculateOffset(ImageryOffset)
+     * @see #getLayerOffset
+     */
+    public static void applyLayerOffset(AbstractTileSourceLayer<?> layer, ImageryOffset offset) {
+        OffsetBookmark bookmark = calculateOffset(offset);
+        layer.getDisplaySettings().setOffsetBookmark(bookmark);
+    }
+
+    /**
+     * Calculate dx and dy for imagery offset.
+     * @param offset offset object
+     * @return An array of [dx, dy].
+     * @see #applyLayerOffset
+     */
+    public static OffsetBookmark calculateOffset(ImageryOffset offset) {
+        Projection proj = ProjectionRegistry.getProjection();
+        EastNorth center = proj.latlon2eastNorth(offset.getPosition());
+        EastNorth offsetPos = proj.latlon2eastNorth(offset.getImageryPos());
+        EastNorth offsetXY = new EastNorth(center.getX() - offsetPos.getX(), center.getY() - offsetPos.getY());
+        OffsetBookmark b = new OffsetBookmark(proj.toCode(), null, offset.getImagery(), "Autogenerated",
+                offsetXY.getX(), offsetXY.getY(), offset.getPosition().lon(), offset.getPosition().lat());
+        return b;
+    }
+
+    /**
+     * Generate unique imagery identifier based on its type and URL.
+     * @param layer imagery layer.
+     * @return imagery id.
+     */
+    public static String getImageryID(AbstractTileSourceLayer<?> layer) {
+        return layer == null ? null :
+            ImageryIdGenerator.getImageryID(layer.getInfo().getUrl(), layer.getInfo().getImageryType());
+    }
+
+    // Following three methods were snatched from TMSLayer
+    private static double latToTileY(double lat, int zoom) {
+        double l = lat / 180 * Math.PI;
+        double pf = Math.log(Math.tan(l) + (1 / Math.cos(l)));
+        return Math.pow(2.0, zoom - 1) * (Math.PI - pf) / Math.PI;
+    }
+
+    private static double lonToTileX(double lon, int zoom) {
+        return Math.pow(2.0, zoom - 3) * (lon + 180.0) / 45.0;
+    }
+
+    public static int getCurrentZoom() {
+        if (!MainApplication.isDisplayingMapView()) {
+            return 1;
+        }
+        MapView mv = MainApplication.getMap().mapView;
+        LatLon topLeft = mv.getLatLon(0, 0);
+        LatLon botRight = mv.getLatLon(mv.getWidth(), mv.getHeight());
+        double x1 = lonToTileX(topLeft.lon(), 1);
+        double y1 = latToTileY(topLeft.lat(), 1);
+        double x2 = lonToTileX(botRight.lon(), 1);
+        double y2 = latToTileY(botRight.lat(), 1);
+
+        int screenPixels = mv.getWidth() * mv.getHeight();
+        double tilePixels = Math.abs((y2 - y1) * (x2 - x1) * 256 * 256);
+        if (screenPixels == 0 || tilePixels == 0) {
+            return 1;
+        }
+        double factor = screenPixels / tilePixels;
+        double result = Math.log(factor) / Math.log(2) / 2 + 1;
+        int intResult = (int) Math.floor(result);
+        return intResult;
+    }
+
+    /**
+     * Converts distance in meters to a human-readable string.
+     * @param d distance in meters
+     * @return d as a human-readable string
+     */
+    public static String formatDistance(double d) {
+        // CHECKSTYLE.OFF: SingleSpaceSeparator
+        if (d < 0.0095) return formatDistance(d * 1000, tr("mm"), true);
+        if (d < 0.095)  return formatDistance(d * 100,  tr("cm"), true);
+        if (d < 0.95)   return formatDistance(d * 100,  tr("cm"), false);
+        if (d < 9.5)    return formatDistance(d,        tr("m"),  true);
+        if (d < 950)    return formatDistance(d,        tr("m"),  false);
+        if (d < 9500)   return formatDistance(d / 1000, tr("km"), true);
+        if (d < 1e6)    return formatDistance(d / 1000, tr("km"), false);
+        // CHECKSTYLE.ON: SingleSpaceSeparator
+        return "\u221E";
+    }
+
+    /**
+     * Constructs a distance string.
+     * @param d Distance.
+     * @param si Units of measure for distance.
+     * @param floating Whether a floating point is needed.
+     * @return A formatted string.
+     */
+    private static String formatDistance(double d, String si, boolean floating) {
+        return MessageFormat.format(floating ? "{0,number,0.0} {1}" : "{0,number,0} {1}", d, si);
+    }
+}
Index: applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/ImageryOffsetWatcher.java
===================================================================
--- applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/ImageryOffsetWatcher.java	(revision 34596)
+++ applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/ImageryOffsetWatcher.java	(revision 34596)
@@ -0,0 +1,298 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.imagery_offset_db;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Timer;
+import java.util.TimerTask;
+import java.util.TreeMap;
+
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.imagery.OffsetBookmark;
+import org.openstreetmap.josm.data.projection.ProjectionRegistry;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.MapView;
+import org.openstreetmap.josm.gui.NavigatableComponent.ZoomChangeListener;
+import org.openstreetmap.josm.gui.layer.AbstractTileSourceLayer;
+import org.openstreetmap.josm.gui.layer.Layer;
+import org.openstreetmap.josm.gui.layer.LayerManager.LayerAddEvent;
+import org.openstreetmap.josm.gui.layer.LayerManager.LayerChangeListener;
+import org.openstreetmap.josm.gui.layer.LayerManager.LayerOrderChangeEvent;
+import org.openstreetmap.josm.gui.layer.LayerManager.LayerRemoveEvent;
+import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeEvent;
+import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeListener;
+import org.openstreetmap.josm.gui.layer.imagery.TileSourceDisplaySettings;
+import org.openstreetmap.josm.spi.preferences.Config;
+import org.openstreetmap.josm.tools.Destroyable;
+
+/**
+ * This class watches imagery layer offsets and notifies listeners when there's a need to update offset
+ * for the current layer.
+ *
+ * @author Zverik
+ * @license WTFPL
+ */
+public final class ImageryOffsetWatcher implements ZoomChangeListener, LayerChangeListener, ActiveLayerChangeListener, Destroyable {
+    private static final double THRESHOLD = 1e-8;
+    private static ImageryOffsetWatcher instance;
+    private Map<Integer, ImageryLayerData> layers = new TreeMap<>();
+    private List<OffsetStateListener> listeners = new ArrayList<>();
+    private Timer time;
+    private double maxDistance;
+    private boolean offsetGood = true;
+
+    /**
+     * Create an instance and register it as a listener to MapView.
+     * Also starts a timer task.
+     */
+    private ImageryOffsetWatcher() {
+        maxDistance = Config.getPref().getDouble("iodb.offset.radius", 15);
+        MapView.addZoomChangeListener(this);
+        MainApplication.getLayerManager().addLayerChangeListener(this);
+        MainApplication.getLayerManager().addActiveLayerChangeListener(this);
+        checkOffset(); // we assume there's at the most one imagery layer at this moment
+        time = new Timer();
+        time.schedule(new IntervalOffsetChecker(), 0, 2000);
+    }
+
+    /**
+     * Unregister all events. This actually gets never called, but it's not a problem.
+     */
+    @Override
+    public void destroy() {
+        MapView.removeZoomChangeListener(this);
+        MainApplication.getLayerManager().removeLayerChangeListener(this);
+        MainApplication.getLayerManager().removeActiveLayerChangeListener(this);
+        time.cancel();
+    }
+
+    /**
+     * This class is a singleton, this method returns the instance,
+     * creating it if neccessary.
+     * @return unique instance
+     */
+    public static ImageryOffsetWatcher getInstance() {
+        if (instance == null) {
+            instance = new ImageryOffsetWatcher();
+        }
+        return instance;
+    }
+
+    /**
+     * Register an offset state listener.
+     * @param listener offset state listener
+     */
+    public void register(OffsetStateListener listener) {
+        listeners.add(listener);
+        listener.offsetStateChanged(offsetGood);
+    }
+
+    /**
+     * Unregister an offset state listener.
+     * @param listener offset state listener
+     */
+    public void unregister(OffsetStateListener listener) {
+        listeners.remove(listener);
+    }
+
+    /**
+     * Change stored offset state, notify listeners if needed.
+     * @param good offset state
+     */
+    private void setOffsetGood(boolean good) {
+        if (good != offsetGood) {
+            for (OffsetStateListener listener : listeners) {
+                listener.offsetStateChanged(good);
+            }
+        }
+        offsetGood = good;
+    }
+
+    /**
+     * Check if the offset state has been changed.
+     */
+    private synchronized void checkOffset() {
+        if (maxDistance <= 0) {
+            setOffsetGood(true);
+            return;
+        }
+        AbstractTileSourceLayer<?> layer = ImageryOffsetTools.getTopImageryLayer();
+        if (layer == null) {
+            setOffsetGood(true);
+            return;
+        }
+        TileSourceDisplaySettings displaySettings = layer.getDisplaySettings();
+        LatLon center = ImageryOffsetTools.getMapCenter();
+        Integer hash = layer.hashCode();
+        ImageryLayerData data = layers.get(hash);
+        if (data == null) {
+            // create entry for this layer and mark as needing alignment
+            data = new ImageryLayerData();
+            data.lastDx = displaySettings.getDx();
+            data.lastDy = displaySettings.getDy();
+            boolean r = false;
+            if (Math.abs(data.lastDx) + Math.abs(data.lastDy) > THRESHOLD) {
+                data.lastChecked = center;
+                r = true;
+            }
+            layers.put(hash, data);
+            setOffsetGood(r);
+        } else {
+            // now, we have a returning layer.
+            if (Math.abs(data.lastDx - displaySettings.getDx()) + Math.abs(data.lastDy - displaySettings.getDy()) > THRESHOLD) {
+                // offset has changed, record the current position
+                data.lastDx = displaySettings.getDx();
+                data.lastDy = displaySettings.getDy();
+                data.lastChecked = center;
+                storeLayerOffset(layer);
+                setOffsetGood(true);
+            } else {
+                setOffsetGood(data.lastChecked != null && center.greatCircleDistance(data.lastChecked) <= maxDistance * 1000);
+            }
+        }
+    }
+
+    /**
+     * Mark the current offset as good. This method is called by {@link OffsetDialog}
+     * to notify the watcher that an offset button has been clicked, and regardless of
+     * whether it has changed an offset, the currect imagery alignment is ok.
+     */
+    public void markGood() {
+        AbstractTileSourceLayer<?> layer = ImageryOffsetTools.getTopImageryLayer();
+        if (layer != null) {
+            TileSourceDisplaySettings displaySettings = layer.getDisplaySettings();
+            LatLon center = ImageryOffsetTools.getMapCenter();
+            Integer hash = layer.hashCode();
+            ImageryLayerData data = layers.get(hash);
+            if (data == null) {
+                // create entry for this layer and mark as good
+                data = new ImageryLayerData();
+                data.lastDx = displaySettings.getDx();
+                data.lastDy = displaySettings.getDy();
+                data.lastChecked = center;
+                layers.put(hash, data);
+            } else {
+                data.lastDx = displaySettings.getDx();
+                data.lastDy = displaySettings.getDy();
+                data.lastChecked = center;
+            }
+            storeLayerOffset(layer);
+        }
+        setOffsetGood(true);
+    }
+
+    /**
+     * This class stores an offset and last location for a single imagery layer.
+     * All fields are public, because this is not enterprise.
+     */
+    private static class ImageryLayerData {
+        public double lastDx = 0.0;
+        public double lastDy = 0.0;
+        public LatLon lastChecked;
+    }
+
+    @Override
+    public void zoomChanged() {
+        checkOffset();
+    }
+
+    @Override
+    public void activeOrEditLayerChanged(ActiveLayerChangeEvent e) {
+        checkOffset();
+    }
+
+    @Override
+    public void layerAdded(LayerAddEvent e) {
+        Layer newLayer = e.getAddedLayer();
+        if (newLayer instanceof AbstractTileSourceLayer)
+            loadLayerOffset((AbstractTileSourceLayer<?>) newLayer);
+        checkOffset();
+    }
+
+    @Override
+    public void layerRemoving(LayerRemoveEvent e) {
+        checkOffset();
+    }
+
+    @Override
+    public void layerOrderChanged(LayerOrderChangeEvent e) {
+    }
+
+    /**
+     * Saves the current imagery layer offset to preferences. It is stored as a
+     * collection of ':'-separated strings: imagery_id:lat:lon:dx:dy. No need for
+     * projections: nobody uses them anyway.
+     * @param layer imagery layer
+     */
+    private void storeLayerOffset(AbstractTileSourceLayer<?> layer) {
+        String id = ImageryOffsetTools.getImageryID(layer);
+        if (!Config.getPref().getBoolean("iodb.remember.offsets", true) || id == null)
+            return;
+        List<String> offsets = new LinkedList<>(Config.getPref().getList("iodb.stored.offsets"));
+        for (Iterator<String> iter = offsets.iterator(); iter.hasNext();) {
+            String[] offset = iter.next().split(":");
+            if (offset.length == 5 && offset[0].equals(id))
+                iter.remove();
+        }
+        LatLon center = ImageryOffsetTools.getMapCenter();
+        offsets.add(id + ":" + center.lat() + ":" + center.lon() + ":" +
+                layer.getDisplaySettings().getDx() + ":" + layer.getDisplaySettings().getDy());
+        Config.getPref().putList("iodb.stored.offsets", offsets);
+    }
+
+    /**
+     * Loads the current imagery layer offset from preferences.
+     * @param layer imagery layer
+     */
+    private void loadLayerOffset(AbstractTileSourceLayer<?> layer) {
+        String id = ImageryOffsetTools.getImageryID(layer);
+        if (!Config.getPref().getBoolean("iodb.remember.offsets", true) || id == null)
+            return;
+        List<String> offsets = Config.getPref().getList("iodb.stored.offsets");
+        for (String offset : offsets) {
+            String[] parts = offset.split(":");
+            if (parts.length == 5 && parts[0].equals(id)) {
+                double[] dparts = new double[4];
+                try {
+                    for (int i = 0; i < 4; i++) {
+                        dparts[i] = Double.parseDouble(parts[i+1]);
+                    }
+                } catch (NumberFormatException e) {
+                    continue;
+                }
+                LatLon lastPos = new LatLon(dparts[0], dparts[1]);
+                if (lastPos.greatCircleDistance(ImageryOffsetTools.getMapCenter()) < Math.max(maxDistance, 3.0) * 1000) {
+                    // apply offset
+                    OffsetBookmark bookmark = new OffsetBookmark(ProjectionRegistry.getProjection().toCode(),
+                            null, layer.getName(), "Restored", dparts[2], dparts[3]);
+                    layer.getDisplaySettings().setOffsetBookmark(bookmark);
+                    return;
+                }
+            }
+        }
+    }
+
+    /**
+     * This task is run every 1-2 seconds.
+     */
+    private class IntervalOffsetChecker extends TimerTask {
+        /**
+         * Reread max radius setting and update offset state.
+         */
+        @Override
+        public void run() {
+            maxDistance = Config.getPref().getDouble("iodb.offset.radius", 15);
+            checkOffset();
+        }
+    }
+
+    /**
+     * The interface for offset listeners.
+     */
+    public interface OffsetStateListener {
+        void offsetStateChanged(boolean isOffsetGood);
+    }
+}
Index: applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/OffsetDialog.java
===================================================================
--- applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/OffsetDialog.java	(revision 34596)
+++ applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/OffsetDialog.java	(revision 34596)
@@ -0,0 +1,352 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.imagery_offset_db;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.BasicStroke;
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.FlowLayout;
+import java.awt.Graphics2D;
+import java.awt.GridLayout;
+import java.awt.Point;
+import java.awt.RenderingHints;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.KeyEvent;
+import java.io.IOException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+import javax.swing.AbstractAction;
+import javax.swing.Box;
+import javax.swing.BoxLayout;
+import javax.swing.JButton;
+import javax.swing.JCheckBox;
+import javax.swing.JComponent;
+import javax.swing.JDialog;
+import javax.swing.JOptionPane;
+import javax.swing.JPanel;
+import javax.swing.JPopupMenu;
+import javax.swing.KeyStroke;
+import javax.swing.border.CompoundBorder;
+import javax.swing.border.EmptyBorder;
+
+import org.openstreetmap.josm.data.Bounds;
+import org.openstreetmap.josm.data.UserIdentityManager;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.MapView;
+import org.openstreetmap.josm.gui.NavigatableComponent.ZoomChangeListener;
+import org.openstreetmap.josm.gui.layer.AbstractTileSourceLayer;
+import org.openstreetmap.josm.gui.layer.MapViewPaintable;
+import org.openstreetmap.josm.spi.preferences.Config;
+import org.openstreetmap.josm.tools.HttpClient;
+import org.openstreetmap.josm.tools.ImageProvider;
+import org.openstreetmap.josm.tools.LanguageInfo;
+import org.openstreetmap.josm.tools.OpenBrowser;
+
+/**
+ * The dialog which presents a choice between imagery align options.
+ *
+ * @author Zverik
+ * @license WTFPL
+ */
+public class OffsetDialog extends JDialog implements ActionListener, ZoomChangeListener, MapViewPaintable {
+    protected static final String PREF_CALIBRATION = "iodb.show.calibration";
+    protected static final String PREF_DEPRECATED = "iodb.show.deprecated";
+    private static final int MAX_OFFSETS = Config.getPref().getInt("iodb.max.offsets", 4);
+
+    /**
+     * Whether to create a modal frame. It turns out, modal dialogs
+     * block swing worker thread, so offset deprecation, for example, takes
+     * place only after the dialog is closed. Very inconvenient.
+     */
+    private static final boolean MODAL = false;
+
+    private List<ImageryOffsetBase> offsets;
+    private ImageryOffsetBase selectedOffset;
+    private JPanel buttonPanel;
+
+    /**
+     * Initialize the dialog and install listeners.
+     * @param offsets The list of offset to choose from.
+     */
+    public OffsetDialog(List<ImageryOffsetBase> offsets) {
+        super(JOptionPane.getFrameForComponent(MainApplication.getMainFrame()), ImageryOffsetTools.DIALOG_TITLE,
+                MODAL ? ModalityType.DOCUMENT_MODAL : ModalityType.MODELESS);
+        setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);
+        setResizable(false);
+        this.offsets = offsets;
+
+        // make this dialog close on "escape"
+        getRootPane().registerKeyboardAction(this,
+                KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0),
+                JComponent.WHEN_IN_FOCUSED_WINDOW);
+    }
+
+    /**
+     * Creates the GUI.
+     */
+    private void prepareDialog() {
+        updateButtonPanel();
+        final JCheckBox calibrationBox = new JCheckBox(tr("Calibration geometries"));
+        calibrationBox.setSelected(Config.getPref().getBoolean(PREF_CALIBRATION, true));
+        calibrationBox.addActionListener(new ActionListener() {
+            @Override
+            public void actionPerformed(ActionEvent e) {
+                Config.getPref().putBoolean(PREF_CALIBRATION, calibrationBox.isSelected());
+                updateButtonPanel();
+            }
+        });
+        final JCheckBox deprecatedBox = new JCheckBox(tr("Deprecated offsets"));
+        deprecatedBox.setSelected(Config.getPref().getBoolean(PREF_DEPRECATED, false));
+        deprecatedBox.addActionListener(new ActionListener() {
+            @Override
+            public void actionPerformed(ActionEvent e) {
+                Config.getPref().putBoolean(PREF_DEPRECATED, deprecatedBox.isSelected());
+                updateButtonPanel();
+            }
+        });
+        Box checkBoxPanel = new Box(BoxLayout.X_AXIS);
+        checkBoxPanel.add(calibrationBox);
+        checkBoxPanel.add(deprecatedBox);
+        JButton cancelButton = new JButton(tr("Cancel"), ImageProvider.get("cancel"));
+        cancelButton.addActionListener(this);
+        JButton helpButton = new JButton(new HelpAction());
+        JPanel cancelPanel = new JPanel(new FlowLayout(FlowLayout.CENTER));
+        cancelPanel.add(cancelButton);
+        cancelPanel.add(helpButton);
+
+        Box dialog = new Box(BoxLayout.Y_AXIS);
+        dialog.add(buttonPanel);
+        dialog.add(checkBoxPanel);
+        dialog.add(cancelPanel);
+
+        dialog.setBorder(new CompoundBorder(dialog.getBorder(), new EmptyBorder(5, 5, 5, 5)));
+        setContentPane(dialog);
+        pack();
+        setLocationRelativeTo(MainApplication.getMainFrame());
+    }
+
+    /**
+     * As the name states, this method updates the button panel. It is called
+     * when a user clicks filtering checkboxes or deprecates an offset.
+     */
+    private void updateButtonPanel() {
+        List<ImageryOffsetBase> filteredOffsets = filterOffsets();
+        if (buttonPanel == null)
+            buttonPanel = new JPanel();
+        buttonPanel.removeAll();
+        buttonPanel.setLayout(new GridLayout(filteredOffsets.size(), 1, 0, 5));
+        for (ImageryOffsetBase offset : filteredOffsets) {
+            OffsetDialogButton button = new OffsetDialogButton(offset);
+            button.addActionListener(this);
+            JPopupMenu popupMenu = new JPopupMenu();
+            popupMenu.add(new OffsetInfoAction(offset));
+            if (!offset.isDeprecated()) {
+                DeprecateOffsetAction action = new DeprecateOffsetAction(offset);
+                action.setListener(new DeprecateOffsetListener(offset));
+                popupMenu.add(action);
+            }
+            button.setComponentPopupMenu(popupMenu);
+            buttonPanel.add(button);
+        }
+        pack();
+        MainApplication.getMap().mapView.repaint();
+    }
+
+    /**
+     * Make a filtered offset list out of the full one. Takes into
+     * account both checkboxes.
+     * @return filtered offset list
+     */
+    private List<ImageryOffsetBase> filterOffsets() {
+        boolean showCalibration = Config.getPref().getBoolean(PREF_CALIBRATION, true);
+        boolean showDeprecated = Config.getPref().getBoolean(PREF_DEPRECATED, false);
+        List<ImageryOffsetBase> filteredOffsets = new ArrayList<>();
+        for (ImageryOffsetBase offset : offsets) {
+            if (offset.isDeprecated() && !showDeprecated)
+                continue;
+            if (offset instanceof CalibrationObject && !showCalibration)
+                continue;
+            filteredOffsets.add(offset);
+            if (filteredOffsets.size() >= MAX_OFFSETS)
+                break;
+        }
+        return filteredOffsets;
+    }
+
+    /**
+     * This listener method is called when a user pans or zooms the map.
+     * It does nothing, only passes the event to all displayed offset buttons.
+     */
+    @Override
+    public void zoomChanged() {
+        for (Component c : buttonPanel.getComponents()) {
+            if (c instanceof OffsetDialogButton) {
+                ((OffsetDialogButton) c).updateLocation();
+            }
+        }
+    }
+
+    /**
+     * Draw dots on the map where offsets are located. I doubt it has practical
+     * value, but looks nice.
+     */
+    @Override
+    public void paint(Graphics2D g, MapView mv, Bounds bbox) {
+        if (offsets == null)
+            return;
+
+        Graphics2D g2 = (Graphics2D) g.create();
+        g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+        g2.setStroke(new BasicStroke(2));
+        for (ImageryOffsetBase offset : filterOffsets()) {
+            Point p = mv.getPoint(offset.getPosition());
+            g2.setColor(Color.BLACK);
+            g2.fillOval(p.x - 2, p.y - 2, 5, 5);
+            g2.setColor(Color.WHITE);
+            g2.drawOval(p.x - 3, p.y - 3, 7, 7);
+        }
+    }
+
+    /**
+     * Display the dialog and get the return value is case of a modal frame.
+     * Creates GUI, install a temporary map layer (see {@link #paint} and
+     * shows the window.
+     * @return Null for a non-modal dialog, the selected offset
+     * (or, again, a null value) otherwise.
+     */
+    public ImageryOffsetBase showDialog() {
+        selectedOffset = null;
+        prepareDialog();
+        MapView.addZoomChangeListener(this);
+        if (!MODAL) {
+            MainApplication.getMap().mapView.addTemporaryLayer(this);
+            MainApplication.getMap().mapView.repaint();
+        }
+        setVisible(true);
+        return selectedOffset;
+    }
+
+    /**
+     * This is a listener method for all buttons (except "Help").
+     * It assigns a selected offset value and closes the dialog.
+     * If the dialog wasn't modal, it applies the offset immediately.
+     * Should it apply the offset either way? Probably.
+     * @see #applyOffset()
+     */
+    @Override
+    public void actionPerformed(ActionEvent e) {
+        if (e.getSource() instanceof OffsetDialogButton) {
+            selectedOffset = ((OffsetDialogButton) e.getSource()).getOffset();
+        } else
+            selectedOffset = null;
+        boolean closeDialog = MODAL || selectedOffset == null
+                || selectedOffset instanceof CalibrationObject
+                || Config.getPref().getBoolean("iodb.close.on.select", true);
+        if (closeDialog) {
+            MapView.removeZoomChangeListener(this);
+            setVisible(false);
+        }
+        if (!MODAL) {
+            if (closeDialog) {
+                MainApplication.getMap().mapView.removeTemporaryLayer(this);
+                MainApplication.getMap().mapView.repaint();
+            }
+            if (selectedOffset != null) {
+                applyOffset();
+                if (!closeDialog)
+                    updateButtonPanel();
+            }
+        }
+    }
+
+    /**
+     * Either applies imagery offset or adds a calibration geometry layer.
+     * If the offset for each type was chosen for the first time ever,
+     * it displays an informational message.
+     */
+    public void applyOffset() {
+        if (selectedOffset instanceof ImageryOffset) {
+            AbstractTileSourceLayer<?> layer = ImageryOffsetTools.getTopImageryLayer();
+            ImageryOffsetTools.applyLayerOffset(layer, (ImageryOffset) selectedOffset);
+            ImageryOffsetWatcher.getInstance().markGood();
+            MainApplication.getMap().repaint();
+            if (!Config.getPref().getBoolean("iodb.offset.message", false)) {
+                JOptionPane.showMessageDialog(MainApplication.getMainFrame(),
+                        tr("The topmost imagery layer has been shifted to presumably match\n"
+                                + "OSM data in the area. Please check that the offset is still valid\n"
+                                + "by downloading GPS tracks and comparing them and OSM data to the imagery."),
+                        ImageryOffsetTools.DIALOG_TITLE, JOptionPane.INFORMATION_MESSAGE);
+                Config.getPref().putBoolean("iodb.offset.message", true);
+            }
+        } else if (selectedOffset instanceof CalibrationObject) {
+            CalibrationLayer clayer = new CalibrationLayer((CalibrationObject) selectedOffset);
+            MainApplication.getLayerManager().addLayer(clayer);
+            clayer.panToCenter();
+            if (!Config.getPref().getBoolean("iodb.calibration.message", false)) {
+                JOptionPane.showMessageDialog(MainApplication.getMainFrame(),
+                        tr("A layer has been added with a calibration geometry. Hide data layers,\n"
+                                + "find the corresponding feature on the imagery layer and move it accordingly."),
+                        ImageryOffsetTools.DIALOG_TITLE, JOptionPane.INFORMATION_MESSAGE);
+                Config.getPref().putBoolean("iodb.calibration.message", true);
+            }
+        }
+    }
+
+    /**
+     * A lisntener for successful deprecations.
+     */
+    private class DeprecateOffsetListener implements QuerySuccessListener {
+        ImageryOffsetBase offset;
+
+        /**
+         * Initialize the listener with an offset.
+         * @param offset offset object
+         */
+        DeprecateOffsetListener(ImageryOffsetBase offset) {
+            this.offset = offset;
+        }
+
+        /**
+         * Remove the deprecated offset from the offsets list. Then rebuild the button panel.
+         */
+        @Override
+        public void queryPassed() {
+            offset.setDeprecated(new Date(), UserIdentityManager.getInstance().getUserName(), "");
+            updateButtonPanel();
+        }
+    }
+
+    /**
+     * Opens a web browser with the wiki page in user's language.
+     */
+    static class HelpAction extends AbstractAction {
+
+        HelpAction() {
+            super(tr("Help"));
+            putValue(SMALL_ICON, ImageProvider.get("help"));
+        }
+
+        @Override
+        public void actionPerformed(ActionEvent e) {
+            String base = Config.getPref().get("url.openstreetmap-wiki", "https://wiki.openstreetmap.org/wiki/");
+            String lang = LanguageInfo.getWikiLanguagePrefix();
+            String page = "Imagery_Offset_Database";
+            try {
+                // this logic was snatched from {@link org.openstreetmap.josm.gui.dialogs.properties.PropertiesDialog.HelpAction}
+                HttpClient.Response conn = HttpClient.create(new URL(base + lang + page), "HEAD").connect();
+                if (conn.getResponseCode() != 200) {
+                    conn.disconnect();
+                    lang = "";
+                }
+            } catch (IOException ex) {
+                lang = "";
+            }
+            OpenBrowser.displayUrl(base + lang + page);
+        }
+    }
+}
Index: applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/OffsetDialogButton.java
===================================================================
--- applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/OffsetDialogButton.java	(revision 34596)
+++ applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/OffsetDialogButton.java	(revision 34596)
@@ -0,0 +1,298 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.imagery_offset_db;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.AlphaComposite;
+import java.awt.BasicStroke;
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.Font;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.Point;
+import java.awt.RenderingHints;
+import java.text.DateFormat;
+
+import javax.swing.Box;
+import javax.swing.BoxLayout;
+import javax.swing.Icon;
+import javax.swing.ImageIcon;
+import javax.swing.JButton;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.SwingConstants;
+
+import org.openstreetmap.josm.data.coor.EastNorth;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.projection.Projection;
+import org.openstreetmap.josm.data.projection.ProjectionRegistry;
+import org.openstreetmap.josm.gui.layer.AbstractTileSourceLayer;
+import org.openstreetmap.josm.tools.ImageProvider;
+import org.openstreetmap.josm.tools.date.DateUtils;
+
+/**
+ * A button which shows offset information. Must be spectacular, since it's the only
+ * non-JOptionPane GUI in the plugin.
+ *
+ * @author Zverik
+ * @license WTFPL
+ */
+public class OffsetDialogButton extends JButton {
+
+    private ImageryOffsetBase offset;
+
+    private JLabel distanceLabel;
+    private DirectionIcon directionArrow;
+
+    /**
+     * Initialize the button with an offset. Calculated all relevant values.
+     * @param offset An offset to display on the button.
+     */
+    public OffsetDialogButton(ImageryOffsetBase offset) {
+        this.offset = offset;
+        layoutComponents();
+    }
+
+    /**
+     * Returns the offset associated with this button.
+     * @return the offset associated with this button
+     */
+    public ImageryOffsetBase getOffset() {
+        return offset;
+    }
+
+    /**
+     * Update arrow for the offset location.
+     */
+    public void updateLocation() {
+        LatLon center = ImageryOffsetTools.getMapCenter();
+        directionArrow.updateIcon(center);
+        double distance = center.greatCircleDistance(offset.getPosition());
+        distanceLabel.setText(ImageryOffsetTools.formatDistance(distance));
+    }
+
+    /**
+     * Adds a layout to this button and places labels, images and icons.
+     */
+    private void layoutComponents() {
+        String authorAndDate = offset.isDeprecated()
+                ? tr("Deprecated by {0} on {1}", offset.getAbandonAuthor(),
+                        DateUtils.formatDate(offset.getAbandonDate(), DateFormat.DEFAULT))
+                        : tr("Created by {0} on {1}", offset.getAuthor(),
+                                DateUtils.formatDate(offset.getDate(), DateFormat.DEFAULT));
+                JLabel authorAndDateLabel = new JLabel(authorAndDate);
+                Font authorFont = new Font(authorAndDateLabel.getFont().getName(), Font.ITALIC, authorAndDateLabel.getFont().getSize());
+                authorAndDateLabel.setFont(authorFont);
+
+                directionArrow = new DirectionIcon(offset.getPosition());
+                distanceLabel = new JLabel("", directionArrow, SwingConstants.RIGHT);
+                distanceLabel.setHorizontalTextPosition(SwingConstants.LEFT);
+                Font distanceFont = new Font(distanceLabel.getFont().getName(), Font.PLAIN, distanceLabel.getFont().getSize());
+                distanceLabel.setFont(distanceFont);
+                updateLocation();
+
+                String description = offset.isDeprecated() ? offset.getAbandonReason() : offset.getDescription();
+                description = description.replace("<", "&lt;").replace(">", "&gt;");
+                JLabel descriptionLabel = new JLabel("<html><div style=\"width: 300px;\">"+description+"</div></html>");
+                Font descriptionFont = new Font(descriptionLabel.getFont().getName(), Font.BOLD, descriptionLabel.getFont().getSize());
+                descriptionLabel.setFont(descriptionFont);
+
+                OffsetIcon offsetIcon = new OffsetIcon(offset);
+                double offsetDistance = offset instanceof ImageryOffset
+                        ? offsetIcon.getDistance() : 0.0;
+                        //                ? ((ImageryOffset)offset).getImageryPos().greatCircleDistance(offset.getPosition()) : 0.0;
+                        JLabel offsetLabel = new JLabel(offsetDistance > 0.2 ? ImageryOffsetTools.formatDistance(offsetDistance) : "",
+                                offsetIcon, SwingConstants.CENTER);
+                        Font offsetFont = new Font(offsetLabel.getFont().getName(), Font.PLAIN, offsetLabel.getFont().getSize() - 2);
+                        offsetLabel.setFont(offsetFont);
+                        offsetLabel.setHorizontalTextPosition(SwingConstants.CENTER);
+                        offsetLabel.setVerticalTextPosition(SwingConstants.BOTTOM);
+
+                        Box topLine = new Box(BoxLayout.X_AXIS);
+                        topLine.add(authorAndDateLabel);
+                        topLine.add(Box.createHorizontalGlue());
+                        topLine.add(Box.createHorizontalStrut(10));
+                        topLine.add(distanceLabel);
+
+                        JPanel p = new JPanel(new BorderLayout(10, 5));
+                        p.setOpaque(false);
+                        p.add(topLine, BorderLayout.NORTH);
+                        p.add(offsetLabel, BorderLayout.WEST);
+                        p.add(descriptionLabel, BorderLayout.CENTER);
+                        add(p);
+    }
+
+    /**
+     * Calculates length and direction for two points in the imagery offset object.
+     * @param offset offset object
+     * @return length and direction
+     * @see #getLengthAndDirection(ImageryOffset, double, double)
+     */
+    private double[] getLengthAndDirection(ImageryOffset offset) {
+        AbstractTileSourceLayer<?> layer = ImageryOffsetTools.getTopImageryLayer();
+        double[] dxy = layer == null ? new double[] {0.0, 0.0} :
+            new double[] {layer.getDisplaySettings().getDx(), layer.getDisplaySettings().getDy()};
+        return getLengthAndDirection(offset, dxy[0], dxy[1]);
+    }
+
+    /**
+     * Calculates length and direction for two points in the imagery offset object
+     * taking into account an existing imagery layer offset.
+     * @param offset offset object
+     * @param dx X offset
+     * @param dy Y offset
+     * @return length and direction
+     *
+     * @see #getLengthAndDirection(ImageryOffset)
+     */
+    public static double[] getLengthAndDirection(ImageryOffset offset, double dx, double dy) {
+        Projection proj = ProjectionRegistry.getProjection();
+        EastNorth pos = proj.latlon2eastNorth(offset.getPosition());
+        LatLon correctedCenterLL = proj.eastNorth2latlon(pos.add(-dx, -dy));
+        double length = correctedCenterLL.greatCircleDistance(offset.getImageryPos());
+        double direction = length < 1e-2 ? 0.0 : -correctedCenterLL.bearing(offset.getImageryPos());
+        if (direction < 0)
+            direction += Math.PI * 2;
+        return new double[] {length, direction};
+    }
+
+    private static void drawArrow(Graphics g, int cx, int cy, double length, double direction) {
+        int dx = (int) Math.round(Math.sin(direction) * length / 2);
+        int dy = (int) Math.round(Math.cos(direction) * length / 2);
+        g.drawLine(cx - dx, cy - dy, cx + dx, cy + dy);
+        double wingLength = Math.max(length / 3, 4);
+        double d1 = direction - Math.PI / 6;
+        int dx1 = (int) Math.round(Math.sin(d1) * wingLength);
+        int dy1 = (int) Math.round(Math.cos(d1) * wingLength);
+        g.drawLine(cx + dx, cy + dy, cx + dx - dx1, cy + dy - dy1);
+        double d2 = direction + Math.PI / 6;
+        int dx2 = (int) Math.round(Math.sin(d2) * wingLength);
+        int dy2 = (int) Math.round(Math.cos(d2) * wingLength);
+        g.drawLine(cx + dx, cy + dy, cx + dx - dx2, cy + dy - dy2);
+    }
+
+    /**
+     * An offset icon. Displays a plain calibration icon for a geometry
+     * and an arrow for an imagery offset.
+     */
+    private class OffsetIcon implements Icon {
+        private boolean isDeprecated;
+        private boolean isCalibration;
+        private double direction = -1.0;
+        private double distance;
+        private ImageIcon background;
+
+        /**
+         * Initialize the icon with an offset object. Calculates length and direction
+         * of an arrow if they're needed.
+         * @param offset offset object
+         */
+        OffsetIcon(ImageryOffsetBase offset) {
+            isDeprecated = offset.isDeprecated();
+            isCalibration = offset instanceof CalibrationObject;
+            if (offset instanceof ImageryOffset) {
+                background = ImageProvider.get("offset");
+                double[] ld = getLengthAndDirection((ImageryOffset) offset);
+                distance = ld[0];
+                direction = ld[1];
+            } else {
+                background = ImageProvider.get("calibration");
+            }
+        }
+
+        public double getDistance() {
+            return distance;
+        }
+
+        /**
+         * Paints the base image and adds to it according to the offset.
+         */
+        @Override
+        public void paintIcon(Component comp, Graphics g, int x, int y) {
+            background.paintIcon(comp, g, x, y);
+
+            Graphics2D g2 = (Graphics2D) g.create();
+            g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+            if (!isCalibration) {
+                g2.setColor(Color.black);
+                Point c = new Point(x + getIconWidth() / 2, y + getIconHeight() / 2);
+                if (distance < 1e-2) {
+                    // no offset
+                    g2.fillOval(c.x - 3, c.y - 3, 7, 7);
+                } else {
+                    // draw an arrow
+                    double arrowLength = distance < 10 ? getIconWidth() / 2 - 1 : getIconWidth() - 4;
+                    g2.setStroke(new BasicStroke(2));
+                    drawArrow(g2, c.x, c.y, arrowLength, direction);
+                }
+            }
+            if (isDeprecated) {
+                // big red X
+                g2.setColor(Color.red);
+                g2.setStroke(new BasicStroke(5, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
+                g2.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.3f));
+                g2.drawLine(x + 2, y + 2, x + getIconWidth() - 2, y + getIconHeight() - 2);
+                g2.drawLine(x + 2, y + getIconHeight() - 2, x + getIconWidth() - 2, y + 2);
+            }
+        }
+
+        @Override
+        public int getIconWidth() {
+            return background.getIconWidth();
+        }
+
+        @Override
+        public int getIconHeight() {
+            return background.getIconHeight();
+        }
+    }
+
+    private static class DirectionIcon implements Icon {
+        private static final int SIZE = 10;
+
+        private LatLon to;
+        private double distance;
+        private double direction;
+
+        DirectionIcon(LatLon to) {
+            this.to = to;
+        }
+
+        public void updateIcon(LatLon from) {
+            distance = from.greatCircleDistance(to);
+            direction = -to.bearing(from);
+        }
+
+        /**
+         * Paints the base image and adds to it according to the offset.
+         */
+        @Override
+        public void paintIcon(Component comp, Graphics g, int x, int y) {
+            Graphics2D g2 = (Graphics2D) g.create();
+            g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+            g2.setColor(Color.black);
+            Point c = new Point(x + getIconWidth() / 2, y + getIconHeight() / 2);
+            if (distance < 1) {
+                // no offset
+                int r = 2;
+                g2.fillOval(c.x - r, c.y - r, r * 2 + 1, r * 2 + 1);
+            } else {
+                // draw an arrow
+                g2.setStroke(new BasicStroke(1));
+                drawArrow(g2, c.x, c.y, SIZE, direction);
+            }
+        }
+
+        @Override
+        public int getIconWidth() {
+            return SIZE;
+        }
+
+        @Override
+        public int getIconHeight() {
+            return SIZE;
+        }
+    }
+}
Index: applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/OffsetInfoAction.java
===================================================================
--- applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/OffsetInfoAction.java	(revision 34596)
+++ applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/OffsetInfoAction.java	(revision 34596)
@@ -0,0 +1,112 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.imagery_offset_db;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.event.ActionEvent;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.text.DateFormat;
+
+import javax.swing.AbstractAction;
+import javax.swing.JOptionPane;
+
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.tools.ImageProvider;
+import org.openstreetmap.josm.tools.Logging;
+import org.openstreetmap.josm.tools.date.DateUtils;
+
+/**
+ * Display an information box for an offset.
+ *
+ * @author Zverik
+ * @license WTFPL
+ */
+public class OffsetInfoAction extends AbstractAction {
+
+    ImageryOffsetBase offset;
+
+    /**
+     * Initializes the action with an offset object.
+     * Calls {@link #getInformationObject(ImageryOffsetBase)}.
+     * @param offset offset object
+     */
+    public OffsetInfoAction(ImageryOffsetBase offset) {
+        super(tr("Offset Information"));
+        putValue(SMALL_ICON, ImageProvider.get("info"));
+        this.offset = offset;
+        setEnabled(offset != null);
+    }
+
+    /**
+     * Shows a dialog with the pre-constructed message. Allows a user
+     * to report the given offset.
+     */
+    @Override
+    public void actionPerformed(ActionEvent e) {
+        Object info = offset == null ? null : getInformationObject(offset);
+        if (offset.isFlagged())
+            JOptionPane.showMessageDialog(MainApplication.getMainFrame(), info, ImageryOffsetTools.DIALOG_TITLE, JOptionPane.PLAIN_MESSAGE);
+        else {
+            int result = JOptionPane.showOptionDialog(MainApplication.getMainFrame(), info, ImageryOffsetTools.DIALOG_TITLE,
+                    JOptionPane.DEFAULT_OPTION, JOptionPane.PLAIN_MESSAGE, null,
+                    new String[] {"OK", tr("Report this offset")}, null);
+            if (result == 1) {
+                // ask for a reason
+                Object reason = JOptionPane.showInputDialog(MainApplication.getMainFrame(),
+                        tr("You are to notify moderators of this offset. Why?"),
+                        ImageryOffsetTools.DIALOG_TITLE, JOptionPane.PLAIN_MESSAGE);
+                if (reason != null && reason.toString().length() > 0) {
+                    try {
+                        String query = "report?id=" + offset.getId()
+                        + "&reason=" + URLEncoder.encode(reason.toString(), "UTF8");
+                        SimpleOffsetQueryTask reportTask =
+                                new SimpleOffsetQueryTask(query, tr("Reporting the offset..."));
+                        MainApplication.worker.submit(reportTask);
+                    } catch (UnsupportedEncodingException ex) {
+                        Logging.error(ex);
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Constructs a string with all information about the given offset.
+     * @param offset offset object
+     * @return string with all information about the given offset
+     */
+    public static Object getInformationObject(ImageryOffsetBase offset) {
+        StringBuilder sb = new StringBuilder();
+        if (offset instanceof ImageryOffset) {
+            double odist = ((ImageryOffset) offset).getImageryPos().greatCircleDistance(offset.getPosition());
+            if (odist < 1e-2) odist = 0.0;
+            sb.append(tr("An imagery offset of {0}", ImageryOffsetTools.formatDistance(odist))).append('\n');
+            sb.append(tr("Imagery ID")).append(": ").append(((ImageryOffset) offset).getImagery()).append('\n');
+        } else {
+            sb.append(tr("A calibration geometry of {0} nodes", ((CalibrationObject) offset).getGeometry().length)).append('\n');
+        }
+
+        double dist = ImageryOffsetTools.getMapCenter().greatCircleDistance(offset.getPosition());
+        sb.append(dist < 50 ? tr("Determined right here") : tr("Determined {0} away",
+                ImageryOffsetTools.formatDistance(dist)));
+
+        sb.append("\n\n");
+        sb.append(tr("Created by {0} on {1}", offset.getAuthor(),
+                DateUtils.formatDate(offset.getDate(), DateFormat.DEFAULT))).append('\n');
+        sb.append(tr("Description")).append(": ").append(offset.getDescription());
+
+        if (offset.isDeprecated()) {
+            sb.append("\n\n");
+            sb.append(tr("Deprecated by {0} on {1}", offset.getAbandonAuthor(),
+                    DateUtils.formatDate(offset.getAbandonDate(), DateFormat.DEFAULT))).append('\n');
+            sb.append(tr("Reason")).append(": ").append(offset.getAbandonReason());
+        }
+
+        if (offset.isFlagged()) {
+            sb.append("\n\n").append(tr("This entry has been reported."));
+        }
+
+        return sb.toString();
+    }
+}
Index: applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/QuerySuccessListener.java
===================================================================
--- applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/QuerySuccessListener.java	(revision 34596)
+++ applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/QuerySuccessListener.java	(revision 34596)
@@ -0,0 +1,16 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.imagery_offset_db;
+
+/**
+ * A listener for {@link SimpleOffsetQueryTask}.
+ *
+ * @author Zverik
+ * @license WTFPL
+ */
+public interface QuerySuccessListener {
+
+    /**
+     * Query has been processed and did not fail.
+     */
+    void queryPassed();
+}
Index: applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/SimpleOffsetQueryTask.java
===================================================================
--- applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/SimpleOffsetQueryTask.java	(revision 34596)
+++ applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/SimpleOffsetQueryTask.java	(revision 34596)
@@ -0,0 +1,171 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.imagery_offset_db;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Scanner;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import javax.swing.JOptionPane;
+
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.PleaseWaitRunnable;
+import org.openstreetmap.josm.spi.preferences.Config;
+
+/**
+ * A task to query the imagery offset server and process the response.
+ *
+ * @author Zverik
+ * @license WTFPL
+ */
+class SimpleOffsetQueryTask extends PleaseWaitRunnable {
+    private String query;
+    private String errorMessage;
+    private String title;
+    protected boolean cancelled;
+    private QuerySuccessListener listener;
+
+    /**
+     * Initialize the task.
+     * @param query A query string, usually starting with an action word and a question mark.
+     * @param title A title for the progress monitor.
+     */
+    SimpleOffsetQueryTask(String query, String title) {
+        super(ImageryOffsetTools.DIALOG_TITLE);
+        this.query = query;
+        this.title = title;
+        cancelled = false;
+    }
+
+    /**
+     * In case a query was not specified when the object was constructed,
+     * it can be set with this method.
+     * @param query A query string, usually starting with an action word and a question mark.
+     * @see #SimpleOffsetQueryTask(String, String)
+     */
+    public void setQuery(String query) {
+        this.query = query;
+    }
+
+    /**
+     * Install a listener for successful responses. There can be only one.
+     * @param listener success listener
+     */
+    public void setListener(QuerySuccessListener listener) {
+        this.listener = listener;
+    }
+
+    /**
+     * Remove a listener for successful responses.
+     */
+    public void removeListener() {
+        this.listener = null;
+    }
+
+    /**
+     * The main method: calls {@link #doQuery(java.lang.String)} and processes exceptions.
+     */
+    @Override
+    protected void realRun() {
+        getProgressMonitor().indeterminateSubTask(title);
+        try {
+            errorMessage = null;
+            doQuery(query);
+        } catch (UploadException e) {
+            errorMessage = tr("Server has rejected the request") + ":\n" + e.getMessage();
+        } catch (IOException e) {
+            errorMessage = tr("Unable to connect to the server") + "\n" + e.getMessage();
+        }
+    }
+
+    /**
+     * Sends a request to the imagery offset server. Processes exceptions and
+     * return codes, calls {@link #processResponse(java.io.InputStream)} on success.
+     * @param query A query string, usually starting with an action word and a question mark.
+     * @throws UploadException in case of upload error
+     * @throws IOException in case of other I/O error
+     */
+    private void doQuery(String query) throws UploadException, IOException {
+        try {
+            String serverURL = Config.getPref().get("iodb.server.url", "http://offsets.textual.ru/");
+            URL url = new URL(serverURL + query);
+            HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+            connection.connect();
+            if (connection.getResponseCode() != 200) {
+                throw new IOException("HTTP Response code " + connection.getResponseCode() + " (" + connection.getResponseMessage() + ")");
+            }
+            InputStream inp = connection.getInputStream();
+            if (inp == null)
+                throw new IOException("Empty response");
+            try {
+                if (!cancelled)
+                    processResponse(inp);
+            } finally {
+                connection.disconnect();
+            }
+        } catch (MalformedURLException ex) {
+            throw new IOException("Malformed URL: " + ex.getMessage());
+        }
+    }
+
+    /**
+     * Doesn't actually cancel, just raises a flag.
+     */
+    @Override
+    protected void cancel() {
+        cancelled = true;
+    }
+
+    /**
+     * Is called after {@link #realRun()}. Either displays an error message
+     * or notifies a listener of success.
+     */
+    @Override
+    protected void finish() {
+        if (errorMessage != null) {
+            JOptionPane.showMessageDialog(MainApplication.getMainFrame(), errorMessage,
+                    ImageryOffsetTools.DIALOG_TITLE, JOptionPane.ERROR_MESSAGE);
+        } else if (listener != null) {
+            listener.queryPassed();
+        }
+    }
+
+    /**
+     * Parse the response input stream and determine whether an operation
+     * was successful or not.
+     * @param inp input stream
+     * @throws UploadException Thrown if an error message was found.
+     */
+    protected void processResponse(InputStream inp) throws UploadException {
+        String response = "";
+        if (inp != null) {
+            Scanner sc = new Scanner(inp, StandardCharsets.UTF_8.name()).useDelimiter("\\A");
+            response = sc.hasNext() ? sc.next() : "";
+        }
+        Pattern p = Pattern.compile("<(\\w+)>([^<]+)</\\1>");
+        Matcher m = p.matcher(response);
+        if (m.find()) {
+            if (m.group(1).equals("error")) {
+                throw new UploadException(m.group(2));
+            }
+        } else {
+            throw new UploadException("No response");
+        }
+    }
+
+    /**
+     * A placeholder exception for error messages.
+     */
+    public static class UploadException extends Exception {
+        UploadException(String message) {
+            super(message);
+        }
+    }
+}
Index: applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/StoreImageryOffsetAction.java
===================================================================
--- applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/StoreImageryOffsetAction.java	(revision 34596)
+++ applications/editors/josm/plugins/imagery_offset_db/src/org/openstreetmap/josm/plugins/imagery_offset_db/StoreImageryOffsetAction.java	(revision 34596)
@@ -0,0 +1,177 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.imagery_offset_db;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.event.ActionEvent;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+
+import javax.swing.JOptionPane;
+
+import org.openstreetmap.josm.actions.JosmAction;
+import org.openstreetmap.josm.data.UserIdentityManager;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.osm.Node;
+import org.openstreetmap.josm.data.osm.OsmPrimitive;
+import org.openstreetmap.josm.data.osm.Way;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.layer.AbstractTileSourceLayer;
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ * Upload the current imagery offset or an calibration geometry information.
+ *
+ * @author Zverik
+ * @license WTFPL
+ */
+public class StoreImageryOffsetAction extends JosmAction {
+
+    /**
+     * Initializes the action.
+     */
+    public StoreImageryOffsetAction() {
+        super(tr("Store Imagery Offset..."), "storeoffset",
+                tr("Upload an offset for current imagery (or calibration object geometry) to a server"),
+                null, true);
+    }
+
+    /**
+     * Asks user for description and calls the upload task.
+     * Also calculates a lot of things, checks whether the selected object
+     * is suitable for calibration geometry, constructs a map of query parameters etc.
+     * The only thing it doesn't do is check for the real user account name.
+     * This is because all server queries should be executed in workers,
+     * and we don't have one when a user name is needed.
+     */
+    @Override
+    public void actionPerformed(ActionEvent e) {
+        if (!MainApplication.isDisplayingMapView())
+            return;
+
+        AbstractTileSourceLayer<?> layer = ImageryOffsetTools.getTopImageryLayer();
+        if (layer == null)
+            return;
+
+        String userName = UserIdentityManager.getInstance().getUserName();
+        if (userName == null || userName.length() == 0) {
+            JOptionPane.showMessageDialog(MainApplication.getMainFrame(),
+                    tr("To store imagery offsets you must be a registered OSM user."),
+                    ImageryOffsetTools.DIALOG_TITLE, JOptionPane.ERROR_MESSAGE);
+            return;
+        }
+        if (userName.indexOf('@') > 0)
+            userName = userName.replace('@', ',');
+
+        // check if an object suitable for calibration is selected
+        OsmPrimitive calibration = null;
+        if (getLayerManager().getEditDataSet() != null) {
+            Collection<OsmPrimitive> selectedObjects = getLayerManager().getEditDataSet().getSelected();
+            if (selectedObjects.size() == 1) {
+                OsmPrimitive selection = selectedObjects.iterator().next();
+                if (!selection.isIncomplete() && (selection instanceof Way ||
+                        (selection instanceof Node && !((Node) selection).isReferredByWays(1)))) {
+                    String[] options = new String[] {tr("Store calibration geometry"), tr("Store imagery offset")};
+                    int result = JOptionPane.showOptionDialog(MainApplication.getMainFrame(),
+                            tr("The selected object can be used as a calibration geometry. What do you intend to do?"),
+                            ImageryOffsetTools.DIALOG_TITLE, JOptionPane.DEFAULT_OPTION, JOptionPane.QUESTION_MESSAGE,
+                            null, options, options[0]);
+                    if (result == 2 || result == JOptionPane.CLOSED_OPTION)
+                        return;
+                    if (result == 0)
+                        calibration = selection;
+                }
+            }
+        }
+
+        Object message;
+        LatLon center = ImageryOffsetTools.getMapCenter();
+        ImageryOffsetBase offsetObj;
+        if (calibration == null) {
+            // register imagery offset
+            if (Math.abs(layer.getDisplaySettings().getDx()) < 1e-8 && Math.abs(layer.getDisplaySettings().getDy()) < 1e-8) {
+                if (JOptionPane.showConfirmDialog(MainApplication.getMainFrame(),
+                        tr("The topmost imagery layer has no offset. Are you sure you want to upload this?"),
+                        ImageryOffsetTools.DIALOG_TITLE, JOptionPane.YES_NO_OPTION) != JOptionPane.YES_OPTION)
+                    return;
+            }
+            LatLon offset = ImageryOffsetTools.getLayerOffset(layer, center);
+            offsetObj = new ImageryOffset(ImageryOffsetTools.getImageryID(layer), offset);
+            message = tr("You are registering an imagery offset. Other users in this area will be able to use it for mapping.\n"
+                    + "Please make sure it is as precise as possible, and describe a region this offset is applicable to.");
+        } else {
+            // register calibration object
+            offsetObj = new CalibrationObject(calibration);
+            message = tr("You are registering a calibration geometry. It should be the most precisely positioned object, with\n"
+                    + "clearly visible boundaries on various satellite imagery. Please describe this object and its whereabouts.");
+        }
+        String description = queryDescription(message);
+        if (description == null)
+            return;
+        offsetObj.setBasicInfo(center, userName, null, null);
+        offsetObj.setDescription(description);
+
+        // upload object info to server
+        try {
+            Map<String, String> params = new HashMap<>();
+            offsetObj.putServerParams(params);
+            StringBuilder query = null;
+            for (String key : params.keySet()) {
+                if (query == null) {
+                    query = new StringBuilder("store?");
+                } else {
+                    query.append('&');
+                }
+                query.append(key).append('=').append(URLEncoder.encode(params.get(key), "UTF8"));
+            }
+            MainApplication.worker.submit(new SimpleOffsetQueryTask(query.toString(), tr("Uploading a new offset...")));
+        } catch (UnsupportedEncodingException ex) {
+            Logging.error(ex);
+        }
+    }
+
+    /**
+     * Ask a user for a description / reason. This string should be 3 to 200 characters
+     * long, and the method enforces that.
+     * @param message A prompt for the input dialog.
+     * @return Either null or a string 3 to 200 letters long.
+     */
+    public static String queryDescription(Object message) {
+        String reason = null;
+        boolean iterated = false;
+        boolean ok = false;
+        while (!ok) {
+            Object result = JOptionPane.showInputDialog(MainApplication.getMainFrame(), message,
+                    ImageryOffsetTools.DIALOG_TITLE, JOptionPane.PLAIN_MESSAGE, null, null, reason);
+            if (result == null || result.toString().length() == 0) {
+                return null;
+            }
+            reason = result.toString();
+            if (reason.length() < 3 || reason.length() > 200) {
+                if (!iterated) {
+                    message = message + "\n" + tr("This string should be 3 to 200 letters long.");
+                    iterated = true;
+                }
+            } else
+                ok = true;
+        }
+        return reason;
+    }
+
+    /**
+     * This action is enabled when there's a map and a visible imagery layer.
+     * Note that it doesn't require edit layer.
+     */
+    @Override
+    protected void updateEnabledState() {
+        boolean state = true;
+        if (!MainApplication.isDisplayingMapView() || !MainApplication.getMap().isVisible())
+            state = false;
+        if (ImageryOffsetTools.getTopImageryLayer() == null)
+            state = false;
+        setEnabled(state);
+    }
+}
