From: Tom Hughes Date: Thu, 8 Mar 2012 18:23:27 +0000 (+0000) Subject: Merge branch 'master' into openstreetbugs X-Git-Tag: live~5117^2~120 X-Git-Url: https://git.openstreetmap.org/rails.git/commitdiff_plain/e660e609661edadc1ed5ad49d6e83e936b2f91cd?hp=cb5e6ebfaaa6e4b89ae259f502a6a3b2bc716d05 Merge branch 'master' into openstreetbugs Conflicts: Gemfile Gemfile.lock lib/migrate.rb --- diff --git a/Gemfile b/Gemfile index c1bb2f50b..65de156e1 100644 --- a/Gemfile +++ b/Gemfile @@ -21,6 +21,7 @@ gem 'composite_primary_keys', '>= 5.0.0' gem 'http_accept_language', '>= 1.0.2' gem 'paperclip', '~> 2.0' gem 'deadlock_retry', '>= 1.2.0' +gem 'jsonify-rails' # Character conversion support for ruby 1.8 gem 'iconv', :platforms => :ruby_18 diff --git a/Gemfile.lock b/Gemfile.lock index 4709cd195..e212d1003 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -61,9 +61,14 @@ GEM railties (>= 3.2.0, < 5.0) thor (~> 0.14) json (1.6.5) + jsonify (0.3.1) + multi_json (~> 1.0) + jsonify-rails (0.3.1) + actionpack + jsonify (>= 0.3.1) libv8 (3.3.10.4) libxml-ruby (2.2.2) - mail (2.4.1) + mail (2.4.3) i18n (>= 0.4.0) mime-types (~> 1.16) treetop (~> 1.4.8) @@ -91,7 +96,7 @@ GEM pg (0.13.2) polyglot (0.3.3) rack (1.4.1) - rack-cache (1.1) + rack-cache (1.2) rack (>= 0.4) rack-openid (1.3.1) rack (>= 1.1.0) @@ -141,7 +146,7 @@ GEM treetop (1.4.10) polyglot polyglot (>= 0.3.1) - tzinfo (0.3.31) + tzinfo (0.3.32) uglifier (1.2.3) execjs (>= 0.3.0) multi_json (>= 1.0.2) @@ -160,6 +165,7 @@ DEPENDENCIES httpclient iconv jquery-rails + jsonify-rails libxml-ruby (>= 2.0.5) memcached (>= 1.4.1) oauth-plugin (>= 0.4.0.pre7) diff --git a/app/assets/images/closed_note_marker.png b/app/assets/images/closed_note_marker.png new file mode 100644 index 000000000..bf6d6bb25 Binary files /dev/null and b/app/assets/images/closed_note_marker.png differ diff --git a/app/assets/images/new_note_marker.png b/app/assets/images/new_note_marker.png new file mode 100644 index 000000000..671cf424c Binary files /dev/null and b/app/assets/images/new_note_marker.png differ diff --git a/app/assets/images/open_note_marker.png b/app/assets/images/open_note_marker.png new file mode 100644 index 000000000..a58031663 Binary files /dev/null and b/app/assets/images/open_note_marker.png differ diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js new file mode 100644 index 000000000..626a16901 --- /dev/null +++ b/app/assets/javascripts/notes.js @@ -0,0 +1,854 @@ +/* + Dervied from the OpenStreetBugs client, which is available + under the following license. + + This OpenStreetBugs client is free software: you can redistribute it + and/or modify it under the terms of the GNU Affero General Public License + as published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This file is distributed in the hope that it will be useful, but + WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + License for more details. +*/ + +OpenLayers.Layer.Notes = new OpenLayers.Class(OpenLayers.Layer.Markers, { + /** + * The URL of the OpenStreetMap API. + * + * @var String + */ + serverURL : "/api/0.6/", + + /** + * Associative array (index: note ID) that is filled with the notes + * loaded in this layer. + * + * @var String + */ + notes : { }, + + /** + * The username to be used to change or create notes on OpenStreetMap. + * + * @var String + */ + username : "NoName", + + /** + * The icon to be used for an open note. + * + * @var OpenLayers.Icon + */ + iconOpen : new OpenLayers.Icon("/images/open_note_marker.png", new OpenLayers.Size(22, 22), new OpenLayers.Pixel(-11, -11)), + + /** + * The icon to be used for a closed note. + * + * @var OpenLayers.Icon + */ + iconClosed : new OpenLayers.Icon("/images/closed_note_marker.png", new OpenLayers.Size(22, 22), new OpenLayers.Pixel(-11, -11)), + + /** + * The icon to be used when adding a new note. + * + * @var OpenLayers.Icon + */ + iconNew : new OpenLayers.Icon("/images/new_note_marker.png", new OpenLayers.Size(22, 22), new OpenLayers.Pixel(-11, -11)), + + /** + * The projection of the coordinates sent by the OpenStreetMap API. + * + * @var OpenLayers.Projection + */ + apiProjection : new OpenLayers.Projection("EPSG:4326"), + + /** + * If this is set to true, the user may not commit comments or close notes. + * + * @var Boolean + */ + readonly : false, + + /** + * When the layer is hidden, all open popups are stored in this + * array in order to be re-opened again when the layer is made + * visible again. + */ + reopenPopups : [ ], + + /** + * A URL to append lon=123&lat=123&zoom=123 for the Permalinks. + * + * @var String + */ + permalinkURL : "http://www.openstreetmap.org/", + + /** + * A CSS file to be included. Set to null if you don’t need this. + * + * @var String + */ + theme : "/stylesheets/notes.css", + + /** + * @param String name + */ + initialize: function(name, options) { + OpenLayers.Layer.Markers.prototype.initialize.apply(this, [ + name, + OpenLayers.Util.extend({ + opacity: 0.7, + projection: new OpenLayers.Projection("EPSG:4326") }, options) + ]); + + putAJAXMarker.layers.push(this); + this.events.addEventType("markerAdded"); + + this.events.register("visibilitychanged", this, this.updatePopupVisibility); + this.events.register("visibilitychanged", this, this.loadNotes); + + if (this.theme) { + // check existing links for equivalent url + var addNode = true; + var nodes = document.getElementsByTagName('link'); + for (var i = 0, len = nodes.length; i < len; ++i) { + if (OpenLayers.Util.isEquivalentUrl(nodes.item(i).href, this.theme)) { + addNode = false; + break; + } + } + // only add a new node if one with an equivalent url hasn't already + // been added + if (addNode) { + var cssNode = document.createElement('link'); + cssNode.setAttribute('rel', 'stylesheet'); + cssNode.setAttribute('type', 'text/css'); + cssNode.setAttribute('href', this.theme); + document.getElementsByTagName('head')[0].appendChild(cssNode); + } + } + }, + + /** + * Called automatically called when the layer is added to a map. + * Initialises the automatic note loading in the visible bounding box. + */ + afterAdd: function() { + var ret = OpenLayers.Layer.Markers.prototype.afterAdd.apply(this, arguments); + + this.map.events.register("moveend", this, this.loadNotes); + this.loadNotes(); + + return ret; + }, + + /** + * At the moment the OpenStreetMap API responses to requests using + * JavaScript code. This way the Same Origin Policy can be worked + * around. Unfortunately, this makes communicating with the API a + * bit too asynchronous, at the moment there is no way to tell to + * which request the API actually responses. + * + * This method creates a new script HTML element that imports the + * API request URL. The API JavaScript response then executes the + * global functions provided below. + * + * @param String url The URL this.serverURL + url is requested. + */ + apiRequest: function(url) { + var script = document.createElement("script"); + script.type = "text/javascript"; + script.src = this.serverURL + url + "&nocache="+(new Date()).getTime(); + document.body.appendChild(script); + }, + + /** + * Is automatically called when the visibility of the layer + * changes. When the layer is hidden, all visible popups are + * closed and their visibility is saved. When the layer is made + * visible again, these popups are re-opened. + */ + updatePopupVisibility: function() { + if (this.getVisibility()) { + for (var i =0 ; i < this.reopenPopups.length; i++) + this.reopenPopups[i].show(); + + this.reopenPopups = [ ]; + } else { + for (var i = 0; i < this.markers.length; i++) { + if (this.markers[i].feature.popup && + this.markers[i].feature.popup.visible()) { + this.markers[i].feature.popup.hide(); + this.reopenPopups.push(this.markers[i].feature.popup); + } + } + } + }, + + /** + * Sets the user name to be used for interactions with OpenStreetMap. + */ + setUserName: function(username) { + if (this.username == username) + return; + + this.username = username; + + for (var i = 0; i < this.markers.length; i++) { + var popup = this.markers[i].feature.popup; + + if (popup) { + var els = popup.contentDom.getElementsByTagName("input"); + + for (var j = 0; j < els.length; j++) { + if (els[j].className == "username") + els[j].value = username; + } + } + } + }, + + /** + * Returns the currently set username or “NoName” if none is set. + */ + getUserName: function() { + if(this.username) + return this.username; + else + return "NoName"; + }, + + /** + * Loads the notes in the current bounding box. Is automatically + * called by an event handler ("moveend" event) that is created in + * the afterAdd() method. + */ + loadNotes: function() { + var bounds = this.map.getExtent(); + + if (bounds && this.getVisibility()) { + bounds.transform(this.map.getProjectionObject(), this.apiProjection); + + this.apiRequest("notes" + + "?bbox=" + this.round(bounds.left, 5) + + "," + this.round(bounds.bottom, 5) + + "," + this.round(bounds.right, 5) + + "," + this.round(bounds.top, 5)); + } + }, + + /** + * Rounds the given number to the given number of digits after the + * floating point. + * + * @param Number number + * @param Number digits + * @return Number + */ + round: function(number, digits) { + var scale = Math.pow(10, digits); + + return Math.round(number * scale) / scale; + }, + + /** + * Adds an OpenLayers.Marker representing a note to the map. Is + * usually called by loadNotes(). + * + * @param Number id The note ID + */ + createMarker: function(id) { + if (this.notes[id]) { + if (this.notes[id].popup && !this.notes[id].popup.visible()) + this.setPopupContent(this.notes[id].popup, id); + + if (this.notes[id].closed != putAJAXMarker.notes[id][2]) + this.notes[id].destroy(); + else + return; + } + + var lonlat = putAJAXMarker.notes[id][0].clone().transform(this.apiProjection, this.map.getProjectionObject()); + var comments = putAJAXMarker.notes[id][1]; + var closed = putAJAXMarker.notes[id][2]; + var icon = closed ? this.iconClosed : this.iconOpen; + + var feature = new OpenLayers.Feature(this, lonlat, { + icon: icon.clone(), + autoSize: true + }); + feature.popupClass = OpenLayers.Popup.FramedCloud.Notes; + feature.noteId = id; + feature.closed = closed; + this.notes[id] = feature; + + var marker = feature.createMarker(); + marker.feature = feature; + marker.events.register("click", feature, this.markerClick); + //marker.events.register("mouseover", feature, this.markerMouseOver); + //marker.events.register("mouseout", feature, this.markerMouseOut); + this.addMarker(marker); + + this.events.triggerEvent("markerAdded"); + }, + + /** + * Recreates the content of the popup of a marker. + * + * @param OpenLayers.Popup popup + * @param Number id The note ID + */ + setPopupContent: function(popup, id) { + var el1,el2,el3; + var layer = this; + + var newContent = document.createElement("div"); + + el1 = document.createElement("h3"); + el1.appendChild(document.createTextNode(putAJAXMarker.notes[id][2] ? i18n("javascripts.note.closed") : i18n("javascripts.note.open"))); + + el1.appendChild(document.createTextNode(" [")); + el2 = document.createElement("a"); + el2.href = "/browse/note/" + id; + el2.onclick = function() { + layer.map.setCenter(putAJAXMarker.notes[id][0].clone().transform(layer.apiProjection, layer.map.getProjectionObject()), 15); + }; + el2.appendChild(document.createTextNode(i18n("javascripts.note.details"))); + el1.appendChild(el2); + el1.appendChild(document.createTextNode("]")); + + if (this.permalinkURL) { + el1.appendChild(document.createTextNode(" [")); + el2 = document.createElement("a"); + el2.href = this.permalinkURL + (this.permalinkURL.indexOf("?") == -1 ? "?" : "&") + "lon="+putAJAXMarker.notes[id][0].lon+"&lat="+putAJAXMarker.notes[id][0].lat+"&zoom=15"; + el2.appendChild(document.createTextNode(i18n("javascripts.note.permalink"))); + el1.appendChild(el2); + el1.appendChild(document.createTextNode("]")); + } + newContent.appendChild(el1); + + var containerDescription = document.createElement("div"); + newContent.appendChild(containerDescription); + + var containerChange = document.createElement("div"); + newContent.appendChild(containerChange); + + var displayDescription = function() { + containerDescription.style.display = "block"; + containerChange.style.display = "none"; + popup.updateSize(); + }; + var displayChange = function() { + containerDescription.style.display = "none"; + containerChange.style.display = "block"; + popup.updateSize(); + }; + displayDescription(); + + el1 = document.createElement("dl"); + for (var i = 0; i < putAJAXMarker.notes[id][1].length; i++) { + el2 = document.createElement("dt"); + el2.className = (i == 0 ? "note-description" : "note-comment"); + el2.appendChild(document.createTextNode(i == 0 ? i18n("javascripts.note.description") : i18n("javascripts.note.comment"))); + el1.appendChild(el2); + el2 = document.createElement("dd"); + el2.className = (i == 0 ? "note-description" : "note-comment"); + el2.appendChild(document.createTextNode(putAJAXMarker.notes[id][1][i])); + el1.appendChild(el2); + if (i == 0) { + el2 = document.createElement("br"); + el1.appendChild(el2); + }; + } + containerDescription.appendChild(el1); + + if (putAJAXMarker.notes[id][2]) { + el1 = document.createElement("p"); + el1.className = "note-fixed"; + el2 = document.createElement("em"); + el2.appendChild(document.createTextNode(i18n("javascripts.note.render_warning"))); + el1.appendChild(el2); + containerDescription.appendChild(el1); + } else if (!this.readonly) { + el1 = document.createElement("div"); + el2 = document.createElement("input"); + el2.setAttribute("type", "button"); + el2.onclick = function() { + displayChange(); + }; + el2.value = i18n("javascripts.note.update"); + el1.appendChild(el2); + containerDescription.appendChild(el1); + + var el_form = document.createElement("form"); + el_form.onsubmit = function() { + if (inputComment.value.match(/^\s*$/)) + return false; + layer.submitComment(id, inputComment.value); + layer.hidePopup(popup); + return false; + }; + + el1 = document.createElement("dl"); + el2 = document.createElement("dt"); + el2.appendChild(document.createTextNode(i18n("javascripts.note.nickname"))); + el1.appendChild(el2); + el2 = document.createElement("dd"); + var inputUsername = document.createElement("input"); + var inputUsername = document.createElement("input");; + if (typeof loginName === "undefined") { + inputUsername.value = this.username; + } else { + inputUsername.value = loginName; + inputUsername.setAttribute("disabled", "true"); + } + inputUsername.className = "username"; + inputUsername.onkeyup = function() { + layer.setUserName(inputUsername.value); + }; + el2.appendChild(inputUsername); + el3 = document.createElement("a"); + el3.setAttribute("href", "login"); + el3.className = "hide_if_logged_in"; + el3.appendChild(document.createTextNode(i18n("javascripts.note.login"))); + el2.appendChild(el3) + el1.appendChild(el2); + + el2 = document.createElement("dt"); + el2.appendChild(document.createTextNode(i18n("javascripts.note.comment"))); + el1.appendChild(el2); + el2 = document.createElement("dd"); + var inputComment = document.createElement("textarea"); + inputComment.setAttribute("cols",40); + inputComment.setAttribute("rows",3); + + el2.appendChild(inputComment); + el1.appendChild(el2); + + el_form.appendChild(el1); + + el1 = document.createElement("ul"); + el1.className = "buttons"; + el2 = document.createElement("li"); + el3 = document.createElement("input"); + el3.setAttribute("type", "button"); + el3.onclick = function() { + this.form.onsubmit(); + return false; + }; + el3.value = i18n("javascripts.note.add_comment"); + el2.appendChild(el3); + el1.appendChild(el2); + + el2 = document.createElement("li"); + el3 = document.createElement("input"); + el3.setAttribute("type", "button"); + el3.onclick = function() { + this.form.onsubmit(); + layer.closeNote(id); + popup.hide(); + return false; + }; + el3.value = i18n("javascripts.note.close"); + el2.appendChild(el3); + el1.appendChild(el2); + el_form.appendChild(el1); + containerChange.appendChild(el_form); + + el1 = document.createElement("div"); + el2 = document.createElement("input"); + el2.setAttribute("type", "button"); + el2.onclick = function(){ displayDescription(); }; + el2.value = i18n("javascripts.note.cancel"); + el1.appendChild(el2); + containerChange.appendChild(el1); + } + + popup.setContentHTML(newContent); + }, + + /** + * Creates a new note. + * + * @param OpenLayers.LonLat lonlat The coordinates in the API projection. + * @param String description + */ + createNote: function(lonlat, description) { + this.apiRequest("note/create" + + "?lat=" + encodeURIComponent(lonlat.lat) + + "&lon=" + encodeURIComponent(lonlat.lon) + + "&text=" + encodeURIComponent(description) + + "&name=" + encodeURIComponent(this.getUserName()) + + "&format=js"); + }, + + /** + * Adds a comment to a note. + * + * @param Number id + * @param String comment + */ + submitComment: function(id, comment) { + this.apiRequest("note/" + encodeURIComponent(id) + "/comment" + + "?text=" + encodeURIComponent(comment) + + "&name=" + encodeURIComponent(this.getUserName()) + + "&format=js"); + }, + + /** + * Marks a note as fixed. + * + * @param Number id + */ + closeNote: function(id) { + this.apiRequest("note/" + encodeURIComponent(id) + "/close" + + "?format=js"); + }, + + /** + * Removes the content of a marker popup (to reduce the amount of + * needed resources). + * + * @param OpenLayers.Popup popup + */ + resetPopupContent: function(popup) { + if (popup) + popup.setContentHTML(document.createElement("div")); + }, + + /** + * Makes the popup of the given marker visible. Makes sure that + * the popup content is created if it does not exist yet. + * + * @param OpenLayers.Feature feature + */ + showPopup: function(feature) { + var popup = feature.popup; + + if (!popup) { + popup = feature.createPopup(true); + + popup.events.register("close", this, function() { + this.resetPopupContent(popup); + }); + } + + this.setPopupContent(popup, feature.noteId); + + if (!popup.map) + this.map.addPopup(popup); + + popup.updateSize(); + + if (!popup.visible()) + popup.show(); + }, + + /** + * Hides the popup of the given marker. + * + * @param OpenLayers.Feature feature + */ + hidePopup: function(feature) { + if (feature.popup && feature.popup.visible()) { + feature.popup.hide(); + feature.popup.events.triggerEvent("close"); + } + }, + + /** + * Is run on the “click” event of a marker in the context of its + * OpenLayers.Feature. Toggles the visibility of the popup. + */ + markerClick: function(e) { + var feature = this; + + if (feature.popup && feature.popup.visible()) + feature.layer.hidePopup(feature); + else + feature.layer.showPopup(feature); + + OpenLayers.Event.stop(e); + }, + + /** + * Is run on the “mouseover” event of a marker in the context of + * its OpenLayers.Feature. Makes the popup visible. + */ + markerMouseOver: function(e) { + var feature = this; + + feature.layer.showPopup(feature); + + OpenLayers.Event.stop(e); + }, + + /** + * Is run on the “mouseout” event of a marker in the context of + * its OpenLayers.Feature. Hides the popup (if it has not been + * clicked). + */ + markerMouseOut: function(e) { + var feature = this; + + if (feature.popup && feature.popup.visible()) + feature.layer.hidePopup(feature); + + OpenLayers.Event.stop(e); + }, + + /** + * Add a new note. + */ + addNote: function(lonlat) { + var layer = this; + var map = this.map; + var lonlatApi = lonlat.clone().transform(map.getProjectionObject(), this.apiProjection); + var feature = new OpenLayers.Feature(this, lonlat, { icon: this.iconNew.clone(), autoSize: true }); + feature.popupClass = OpenLayers.Popup.FramedCloud.Notes; + var marker = feature.createMarker(); + marker.feature = feature; + this.addMarker(marker); + + + /** Implement a drag and drop for markers */ + /* TODO: veryfy that the scoping of variables works correctly everywhere */ + var dragging = false; + var dragMove = function(e) { + lonlat = map.getLonLatFromViewPortPx(e.xy); + lonlatApi = lonlat.clone().transform(map.getProjectionObject(), map.noteLayer.apiProjection); + marker.moveTo(map.getLayerPxFromViewPortPx(e.xy)); + marker.popup.moveTo(map.getLayerPxFromViewPortPx(e.xy)); + marker.popup.updateRelativePosition(); + return false; + }; + var dragComplete = function(e) { + map.events.unregister("mousemove", map, dragMove); + map.events.unregister("mouseup", map, dragComplete); + dragMove(e); + dragging = false; + return false; + }; + + marker.events.register("mouseover", this, function() { + map.viewPortDiv.style.cursor = "move"; + }); + marker.events.register("mouseout", this, function() { + if (!dragging) + map.viewPortDiv.style.cursor = "default"; + }); + marker.events.register("mousedown", this, function() { + dragging = true; + map.events.register("mousemove", map, dragMove); + map.events.register("mouseup", map, dragComplete); + return false; + }); + + var newContent = document.createElement("div"); + var el1,el2,el3; + el1 = document.createElement("h3"); + el1.appendChild(document.createTextNode(i18n("javascripts.note.create_title"))); + newContent.appendChild(el1); + newContent.appendChild(document.createTextNode(i18n("javascripts.note.create_help1"))); + newContent.appendChild(document.createElement("br")); + newContent.appendChild(document.createTextNode(i18n("javascripts.note.create_help2"))); + newContent.appendChild(document.createElement("br")); + newContent.appendChild(document.createElement("br")); + + var el_form = document.createElement("form"); + + el1 = document.createElement("dl"); + el2 = document.createElement("dt"); + el2.appendChild(document.createTextNode(i18n("javascripts.note.nickname"))); + el1.appendChild(el2); + el2 = document.createElement("dd"); + var inputUsername = document.createElement("input");; + if (typeof loginName === 'undefined') { + inputUsername.value = this.username; + } else { + inputUsername.value = loginName; + inputUsername.setAttribute('disabled','true'); + } + inputUsername.className = "username"; + + inputUsername.onkeyup = function() { + this.setUserName(inputUsername.value); + }; + el2.appendChild(inputUsername); + el3 = document.createElement("a"); + el3.setAttribute("href","login"); + el3.className = "hide_if_logged_in"; + el3.appendChild(document.createTextNode(i18n("javascripts.note.login"))); + el2.appendChild(el3); + el1.appendChild(el2); + el2 = document.createElement("br"); + el1.appendChild(el2); + + el2 = document.createElement("dt"); + el2.appendChild(document.createTextNode(i18n("javascripts.note.description"))); + el1.appendChild(el2); + el2 = document.createElement("dd"); + var inputDescription = document.createElement("textarea"); + inputDescription.setAttribute("cols",40); + inputDescription.setAttribute("rows",3); + el2.appendChild(inputDescription); + el1.appendChild(el2); + el_form.appendChild(el1); + + el1 = document.createElement("div"); + el2 = document.createElement("input"); + el2.setAttribute("type", "button"); + el2.value = i18n("javascripts.note.report"); + el2.onclick = function() { + layer.createNote(lonlatApi, inputDescription.value); + marker.feature = null; + feature.destroy(); + return false; + }; + el1.appendChild(el2); + el2 = document.createElement("input"); + el2.setAttribute("type", "button"); + el2.value = i18n("javascripts.note.cancel"); + el2.onclick = function(){ feature.destroy(); }; + el1.appendChild(el2); + el_form.appendChild(el1); + newContent.appendChild(el_form); + + el2 = document.createElement("hr"); + el1.appendChild(el2); + el2 = document.createElement("a"); + el2.setAttribute("href","edit"); + el2.appendChild(document.createTextNode(i18n("javascripts.note.edityourself"))); + el1.appendChild(el2); + + feature.data.popupContentHTML = newContent; + var popup = feature.createPopup(true); + popup.events.register("close", this, function() { + feature.destroy(); + }); + map.addPopup(popup); + popup.updateSize(); + marker.popup = popup; + }, + + CLASS_NAME: "OpenLayers.Layer.Notes" +}); + + +/** + * This class changes the usual OpenLayers.Popup.FramedCloud class by + * using a DOM element instead of an innerHTML string as content for + * the popup. This is necessary for creating valid onclick handlers + * that still work with multiple Notes layer objects. + */ +OpenLayers.Popup.FramedCloud.Notes = new OpenLayers.Class(OpenLayers.Popup.FramedCloud, { + contentDom : null, + autoSize : true, + + /** + * See OpenLayers.Popup.FramedCloud.initialize() for + * parameters. As fourth parameter, pass a DOM node instead of a + * string. + */ + initialize: function() { + this.displayClass = this.displayClass + " " + this.CLASS_NAME.replace("OpenLayers.", "ol").replace(/\./g, ""); + + var args = new Array(arguments.length); + for(var i=0; i/); + for(var i=0; i").replace(/&/g, "&"); + putAJAXMarker.notes[id] = [ + new OpenLayers.LonLat(lon, lat), + comments, + closed + ]; + for(var i=0; i "not_found", :status => :not_found end + + def note + @type = "note" + @note = Note.find(params[:id]) + @next = Note.find(:first, :order => "id ASC", :conditions => [ "status != 'hidden' AND id > :id", { :id => @note.id }] ) + @prev = Note.find(:first, :order => "id DESC", :conditions => [ "status != 'hidden' AND id < :id", { :id => @note.id }] ) + rescue ActiveRecord::RecordNotFound + render :action => "not_found", :status => :not_found + end end diff --git a/app/controllers/note_controller.rb b/app/controllers/note_controller.rb new file mode 100644 index 000000000..4c0c98b63 --- /dev/null +++ b/app/controllers/note_controller.rb @@ -0,0 +1,355 @@ +class NoteController < ApplicationController + + layout 'site', :only => [:mine] + + before_filter :check_api_readable + before_filter :authorize_web, :only => [:create, :close, :update, :delete, :mine] + before_filter :check_api_writable, :only => [:create, :close, :update, :delete] + before_filter :set_locale, :only => [:mine] + after_filter :compress_output + around_filter :api_call_handle_error, :api_call_timeout + + ## + # Return a list of notes in a given area + def list + # Figure out the bbox - we prefer a bbox argument but also + # support the old, deprecated, method with four arguments + if params[:bbox] + bbox = BoundingBox.from_bbox_params(params) + else + raise OSM::APIBadUserInput.new("No l was given") unless params[:l] + raise OSM::APIBadUserInput.new("No r was given") unless params[:r] + raise OSM::APIBadUserInput.new("No b was given") unless params[:b] + raise OSM::APIBadUserInput.new("No t was given") unless params[:t] + + bbox = BoundingBox.from_lrbt_params(params) + end + + # Get any conditions that need to be applied + notes = closed_condition(Note.scoped) + + # Check that the boundaries are valid + bbox.check_boundaries + + # Check the the bounding box is not too big + bbox.check_size(MAX_NOTE_REQUEST_AREA) + + # Find the notes we want to return + @notes = notes.bbox(bbox).order("updated_at DESC").limit(result_limit).preload(:comments) + + # Render the result + respond_to do |format| + format.rss + format.xml + format.json + format.gpx + end + end + + ## + # Create a new note + def create + # Check the arguments are sane + raise OSM::APIBadUserInput.new("No lat was given") unless params[:lat] + raise OSM::APIBadUserInput.new("No lon was given") unless params[:lon] + raise OSM::APIBadUserInput.new("No text was given") unless params[:text] + + # Extract the arguments + lon = params[:lon].to_f + lat = params[:lat].to_f + comment = params[:text] + name = params[:name] + + # Include in a transaction to ensure that there is always a note_comment for every note + Note.transaction do + # Create the note + @note = Note.create(:lat => lat, :lon => lon) + raise OSM::APIBadUserInput.new("The note is outside this world") unless @note.in_world? + + #TODO: move this into a helper function + begin + url = "http://nominatim.openstreetmap.org/reverse?lat=" + lat.to_s + "&lon=" + lon.to_s + "&zoom=16" + response = REXML::Document.new(Net::HTTP.get(URI.parse(url))) + + if result = response.get_text("reversegeocode/result") + @note.nearby_place = result.to_s + else + @note.nearby_place = "unknown" + end + rescue Exception => err + @note.nearby_place = "unknown" + end + + # Save the note + @note.save + + # Add a comment to the note + add_comment(@note, comment, name, "opened") + end + + # Send an OK response + render_ok + end + + ## + # Add a comment to an existing note + def update + # Check the arguments are sane + raise OSM::APIBadUserInput.new("No id was given") unless params[:id] + raise OSM::APIBadUserInput.new("No text was given") unless params[:text] + + # Extract the arguments + id = params[:id].to_i + comment = params[:text] + name = params[:name] or "NoName" + + # Find the note and check it is valid + note = Note.find(id) + raise OSM::APINotFoundError unless note + raise OSM::APIAlreadyDeletedError unless note.visible? + + # Add a comment to the note + Note.transaction do + add_comment(note, comment, name, "commented") + end + + # Send an OK response + render_ok + end + + ## + # Close a note + def close + # Check the arguments are sane + raise OSM::APIBadUserInput.new("No id was given") unless params[:id] + + # Extract the arguments + id = params[:id].to_i + name = params[:name] + + # Find the note and check it is valid + note = Note.find_by_id(id) + raise OSM::APINotFoundError unless note + raise OSM::APIAlreadyDeletedError unless note.visible? + + # Close the note and add a comment + Note.transaction do + note.close + + add_comment(note, nil, name, "closed") + end + + # Send an OK response + render_ok + end + + ## + # Get a feed of recent notes and comments + def rss + # Get any conditions that need to be applied + notes = closed_condition(Note.scoped) + + # Process any bbox + if params[:bbox] + bbox = BoundingBox.from_bbox_params(params) + + bbox.check_boundaries + bbox.check_size(MAX_NOTE_REQUEST_AREA) + + notes = notes.bbox(bbox) + end + + # Find the comments we want to return + @comments = NoteComment.where(:note => notes).order("created_at DESC").limit(result_limit).include(:note) + + # Render the result + respond_to do |format| + format.rss + end + end + + ## + # Read a note + def read + # Check the arguments are sane + raise OSM::APIBadUserInput.new("No id was given") unless params[:id] + + # Find the note and check it is valid + @note = Note.find(params[:id]) + raise OSM::APINotFoundError unless @note + raise OSM::APIAlreadyDeletedError unless @note.visible? + + # Render the result + respond_to do |format| + format.xml + format.rss + format.json + format.gpx + end + end + + ## + # Delete (hide) a note + def delete + # Check the arguments are sane + raise OSM::APIBadUserInput.new("No id was given") unless params[:id] + + # Extract the arguments + id = params[:id].to_i + name = params[:name] + + # Find the note and check it is valid + note = Note.find(id) + raise OSM::APINotFoundError unless note + raise OSM::APIAlreadyDeletedError unless note.visible? + + # Mark the note as hidden + Note.transaction do + note.status = "hidden" + note.save + + add_comment(note, nil, name, "hidden") + end + + # Render the result + render :text => "ok\n", :content_type => "text/html" + end + + ## + # Return a list of notes matching a given string + def search + # Check the arguments are sane + raise OSM::APIBadUserInput.new("No query string was given") unless params[:q] + + # Get any conditions that need to be applied + conditions = closed_condition + conditions = cond_merge conditions, ['note_comments.body ~ ?', params[:q]] + + # Find the notes we want to return + @notes = Note.find(:all, + :conditions => conditions, + :order => "updated_at DESC", + :limit => result_limit, + :joins => :comments, + :include => :comments) + + # Render the result + respond_to do |format| + format.html { render :action => :list, :format => :rjs, :content_type => "text/javascript"} + format.rss { render :action => :list } + format.js + format.xml { render :action => :list } + format.json { render :action => :list } + format.gpx { render :action => :list } + end + end + + def mine + if params[:display_name] + @user2 = User.find_by_display_name(params[:display_name], :conditions => { :status => ["active", "confirmed"] }) + + if @user2 + if @user2.data_public? or @user2 == @user + conditions = ['note_comments.author_id = ?', @user2.id] + else + conditions = ['false'] + end + else #if request.format == :html + @title = t 'user.no_such_user.title' + @not_found_user = params[:display_name] + render :template => 'user/no_such_user', :status => :not_found + return + end + end + + if @user2 + user_link = render_to_string :partial => "user", :object => @user2 + end + + @title = t 'note.mine.title', :user => @user2.display_name + @heading = t 'note.mine.heading', :user => @user2.display_name + @description = t 'note.mine.description', :user => user_link + + @page = (params[:page] || 1).to_i + @page_size = 10 + + @notes = Note.find(:all, + :include => [:comments, {:comments => :author}], + :joins => :comments, + :order => "updated_at DESC", + :conditions => conditions, + :offset => (@page - 1) * @page_size, + :limit => @page_size).uniq + end + +private + #------------------------------------------------------------ + # utility functions below. + #------------------------------------------------------------ + + ## + # Render an OK response + def render_ok + if params[:format] == "js" + render :text => "osbResponse();", :content_type => "text/javascript" + else + render :text => "ok " + @note.id.to_s + "\n", :content_type => "text/plain" if @note + render :text => "ok\n", :content_type => "text/plain" unless @note + end + end + + ## + # Get the maximum number of results to return + def result_limit + if params[:limit] and params[:limit].to_i > 0 and params[:limit].to_i < 10000 + params[:limit].to_i + else + 100 + end + end + + ## + # Generate a condition to choose which bugs we want based + # on their status and the user's request parameters + def closed_condition(notes) + if params[:closed] + closed_since = params[:closed].to_i + else + closed_since = 7 + end + + if closed_since < 0 + notes = notes.where("status != 'hidden'") + elsif closed_since > 0 + notes = notes.where("(status = 'open' OR (status = 'closed' AND closed_at > '#{Time.now - closed_since.days}'))") + else + notes = notes.where("status = 'open'") + end + + return notes + end + + ## + # Add a comment to a note + def add_comment(note, text, name, event) + name = "NoName" if name.nil? + + attributes = { :visible => true, :event => event, :body => text } + + if @user + attributes[:author_id] = @user.id + attributes[:author_name] = @user.display_name + else + attributes[:author_ip] = request.remote_ip + attributes[:author_name] = name + " (a)" + end + + note.comments.create(attributes) + + note.comments.map { |c| c.author }.uniq.each do |user| + if user and user != @user + Notifier.deliver_note_comment_notification(comment, user) + end + end + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index ce0c5d67b..dfbb04138 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -125,6 +125,26 @@ module ApplicationHelper end end + def friendly_date(date) + content_tag(:span, time_ago_in_words(date), :title => l(date, :format => :friendly)) + end + + def note_author(object, link_options = {}) + if object.author.nil? + h(object.author_name) + else + link_to h(object.author_name), link_options.merge({:controller => "user", :action => "view", :display_name => object.author_name}) + end + end + + def with_format(format, &block) + old_format = @template_format + @template_format = format + result = block.call + @template_format = old_format + return result + end + private def javascript_strings_for_key(key) diff --git a/app/models/note.rb b/app/models/note.rb new file mode 100644 index 000000000..a2937074c --- /dev/null +++ b/app/models/note.rb @@ -0,0 +1,75 @@ +class Note < ActiveRecord::Base + include GeoRecord + + has_many :comments, :class_name => "NoteComment", + :foreign_key => :note_id, + :order => :created_at, + :conditions => { :visible => true } + + validates_presence_of :id, :on => :update + validates_uniqueness_of :id + validates_numericality_of :latitude, :only_integer => true + validates_numericality_of :longitude, :only_integer => true + validates_presence_of :closed_at if :status == "closed" + validates_inclusion_of :status, :in => ["open", "closed", "hidden"] + validate :validate_position + + # Sanity check the latitude and longitude and add an error if it's broken + def validate_position + errors.add_to_base("Note is not in the world") unless in_world? + end + + # Fill in default values for new notes + def after_initialize + self.status = "open" unless self.attribute_present?(:status) + end + + # Close a note + def close + self.status = "closed" + self.closed_at = Time.now.getutc + self.save + end + + # Return a flattened version of the comments for a note + def flatten_comment(separator_char, upto_timestamp = :nil) + resp = "" + comment_no = 1 + self.comments.each do |comment| + next if upto_timestamp != :nil and comment.created_at > upto_timestamp + resp += (comment_no == 1 ? "" : separator_char) + resp += comment.body if comment.body + resp += " [ " + resp += comment.author_name if comment.author_name + resp += " " + comment.created_at.to_s + " ]" + comment_no += 1 + end + + return resp + end + + # Check if a note is visible + def visible? + return status != "hidden" + end + + # Return the author object, derived from the first comment + def author + self.comments.first.author + end + + # Return the author IP address, derived from the first comment + def author_ip + self.comments.first.author_ip + end + + # Return the author id, derived from the first comment + def author_id + self.comments.first.author_id + end + + # Return the author name, derived from the first comment + def author_name + self.comments.first.author_name + end +end diff --git a/app/models/note_comment.rb b/app/models/note_comment.rb new file mode 100644 index 000000000..bcbcf79be --- /dev/null +++ b/app/models/note_comment.rb @@ -0,0 +1,21 @@ +class NoteComment < ActiveRecord::Base + belongs_to :note, :foreign_key => :note_id + belongs_to :author, :class_name => "User", :foreign_key => :author_id + + validates_presence_of :id, :on => :update + validates_uniqueness_of :id + validates_presence_of :note_id + validates_associated :note + validates_presence_of :visible + validates_associated :author + validates_inclusion_of :event, :in => [ "opened", "closed", "reopened", "commented", "hidden" ] + + # Return the author name + def author_name + if self.author_id.nil? + self.read_attribute(:author_name) + else + self.author.display_name + end + end +end diff --git a/app/models/notifier.rb b/app/models/notifier.rb index 343c3db22..2fb00c96f 100644 --- a/app/models/notifier.rb +++ b/app/models/notifier.rb @@ -114,6 +114,22 @@ class Notifier < ActionMailer::Base :subject => I18n.t('notifier.friend_notification.subject', :user => friend.befriender.display_name, :locale => @locale) end + def note_comment_notification(comment, recipient) + common_headers recipient + owner = (recipient == comment.note.author); + subject I18n.t('notifier.note_plain.subject_own', :commenter => comment.author_name) if owner + subject I18n.t('notifier.note_plain.subject_other', :commenter => comment.author_name) unless owner + + body :nodeurl => url_for(:host => SERVER_URL, + :controller => "browse", + :action => "note", + :id => comment.note_id), + :place => comment.note.nearby_place, + :comment => comment.body, + :owner => owner, + :commenter => comment.author_name + end + private def from_address(name, type, id, digest) diff --git a/app/views/browse/_map.html.erb b/app/views/browse/_map.html.erb index 6df060d5c..d3af9bc15 100644 --- a/app/views/browse/_map.html.erb +++ b/app/views/browse/_map.html.erb @@ -5,14 +5,18 @@
- <% if map.instance_of? Changeset or (map.instance_of? Node and map.version > 1) or map.visible %> + <% if map.instance_of? Changeset or (map.instance_of? Node and map.version > 1) or map.visible? %>
<%= t 'browse.map.loading' %> + <% if map.instance_of? Note -%> + <%= link_to(t("browse.map.larger.area"), { :controller => :site, :action => :index, :notes => "yes" }, { :id => "area_larger_map", :class => "geolink bbox" }) %> + <% else -%> <%= link_to(t("browse.map.larger.area"), { :controller => :site, :action => :index, :box => "yes" }, { :id => "area_larger_map", :class => "geolink bbox" }) %> + <% end -%>
<%= link_to(t("browse.map.edit.area"), { :controller => :site, :action => :edit }, { :id => "area_edit", :class => "geolink bbox" }) %> - <% unless map.instance_of? Changeset %> + <% unless map.instance_of? Changeset or map.instance_of? Note %>
<%= link_to("", { :controller => :site, :action => :index }, { :id => "object_larger_map", :class => "geolink object" }) %>
@@ -39,7 +43,7 @@
-<% if map.instance_of? Changeset or (map.instance_of? Node and map.version > 1) or map.visible %> +<% if map.instance_of? Changeset or (map.instance_of? Node and map.version > 1) or map.visible? %>