Merge branch 'master' into openstreetbugs
authorTom Hughes <tom@compton.nu>
Sun, 18 Sep 2011 13:26:59 +0000 (14:26 +0100)
committerTom Hughes <tom@compton.nu>
Sun, 18 Sep 2011 13:26:59 +0000 (14:26 +0100)
52 files changed:
app/controllers/browse_controller.rb
app/controllers/note_controller.rb [new file with mode: 0644]
app/helpers/application_helper.rb
app/models/note.rb [new file with mode: 0644]
app/models/note_comment.rb [new file with mode: 0644]
app/models/notifier.rb
app/views/browse/_map.html.erb
app/views/browse/note.html.erb [new file with mode: 0644]
app/views/note/_description.html.erb [new file with mode: 0644]
app/views/note/_note.gpx.builder [new file with mode: 0644]
app/views/note/_note.rss.builder [new file with mode: 0644]
app/views/note/_note.xml.builder [new file with mode: 0644]
app/views/note/_notes_paging_nav.html.erb [new file with mode: 0644]
app/views/note/_user.html.erb [new file with mode: 0644]
app/views/note/list.gpx.builder [new file with mode: 0644]
app/views/note/list.rjs [new file with mode: 0644]
app/views/note/list.rss.builder [new file with mode: 0644]
app/views/note/list.xml.builder [new file with mode: 0644]
app/views/note/mine.html.erb [new file with mode: 0644]
app/views/note/read.gpx.builder [new file with mode: 0644]
app/views/note/read.rss.builder [new file with mode: 0644]
app/views/note/read.xml.builder [new file with mode: 0644]
app/views/note/rss.rss.builder [new file with mode: 0644]
app/views/notifier/note_comment_notification.html.erb [new file with mode: 0644]
app/views/site/index.html.erb
app/views/user/view.html.erb
config/example.application.yml
config/initializers/mime_types.rb
config/locales/de.yml
config/locales/en.yml
config/routes.rb
db/migrate/053_add_map_bug_tables.rb [new file with mode: 0644]
db/migrate/054_refactor_map_bug_tables.rb [new file with mode: 0644]
db/migrate/055_change_map_bug_comment_type.rb [new file with mode: 0644]
db/migrate/056_add_date_closed.rb [new file with mode: 0644]
db/migrate/057_add_map_bug_comment_event.rb [new file with mode: 0644]
db/migrate/20110508145337_cleanup_bug_tables.rb [new file with mode: 0644]
db/migrate/20110521142405_rename_bugs_to_notes.rb [new file with mode: 0644]
lib/geo_record.rb
lib/map_boundary.rb
lib/migrate.rb
lib/osm.rb
public/images/closed_note_marker.png [new file with mode: 0644]
public/images/new_note_marker.png [new file with mode: 0644]
public/images/open_note_marker.png [new file with mode: 0644]
public/javascripts/notes.js [new file with mode: 0644]
public/stylesheets/common.css
public/stylesheets/large.css
public/stylesheets/notes.css [new file with mode: 0644]
test/fixtures/note_comments.yml [new file with mode: 0644]
test/fixtures/notes.yml [new file with mode: 0644]
test/functional/note_controller_test.rb [new file with mode: 0644]

index a7dd5f5c95ff8b745b19fc3cac1831ac38c2177a..2036e4f10a83f547244b22d18e00590c44afe042 100644 (file)
@@ -79,4 +79,13 @@ class BrowseController < ApplicationController
   rescue ActiveRecord::RecordNotFound
     render :action => "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 (file)
index 0000000..c461450
--- /dev/null
@@ -0,0 +1,387 @@
+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
+
+  # Help methods for checking boundary sanity and area size
+  include MapBoundary
+
+  ##
+  # 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]
+      raise OSM::APIBadUserInput.new("Invalid bbox") unless params[:bbox].count(",") == 3
+
+      bbox = params[:bbox].split(",")
+    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 = [ params[:l], params[:b], params[:r], params[:t] ]
+    end
+
+    # Get the sanitised boundaries
+    @min_lon, @min_lat, @max_lon, @max_lat = sanitise_boundaries(bbox)
+
+    # Get any conditions that need to be applied
+    conditions = closed_condition
+
+    # Check that the boundaries are valid
+    check_boundaries(@min_lon, @min_lat, @max_lon, @max_lat, MAX_NOTE_REQUEST_AREA)
+
+    # Find the notes we want to return
+    @notes = Note.find_by_area(@min_lat, @min_lon, @max_lat, @max_lon,
+                               :include => :comments, 
+                               :conditions => conditions,
+                               :order => "updated_at DESC", 
+                               :limit => result_limit)
+
+    # Render the result
+    respond_to do |format|
+      format.html { render :format => :rjs, :content_type => "text/javascript" }
+      format.rss
+      format.js
+      format.xml
+      format.json { render :json => @notes.to_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
+    conditions = closed_condition
+
+    # Process any bbox
+    if params[:bbox]
+      raise OSM::APIBadUserInput.new("Invalid bbox") unless params[:bbox].count(",") == 3
+
+      @min_lon, @min_lat, @max_lon, @max_lat = sanitise_boundaries(params[:bbox].split(','))
+
+      check_boundaries(@min_lon, @min_lat, @max_lon, @max_lat, MAX_NOTE_REQUEST_AREA)
+
+      conditions = cond_merge conditions, [OSM.sql_for_area(@min_lat, @min_lon, @max_lat, @max_lon, "notes.")]
+    end
+
+    # Find the comments we want to return
+    @comments = NoteComment.find(:all, 
+                                 :conditions => conditions,
+                                 :order => "created_at DESC",
+                                 :limit => result_limit,
+                                 :joins => :note, 
+                                 :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 { render :json => @note.to_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 :json => @notes.to_json }
+      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. 
+  #------------------------------------------------------------   
+  ## 
+  # merge two conditions 
+  # TODO: this is a copy from changeset_controler.rb and should be factored out to share
+  def cond_merge(a, b) 
+    if a and b 
+      a_str = a.shift 
+      b_str = b.shift 
+      return [ a_str + " AND " + b_str ] + a + b 
+    elsif a  
+      return a 
+    else b 
+      return b 
+    end 
+  end 
+
+  ##
+  # 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
+    if params[:closed]
+      closed_since = params[:closed].to_i
+    else
+      closed_since = 7
+    end
+       
+    if closed_since < 0
+      conditions = ["status != 'hidden'"]
+    elsif closed_since > 0
+      conditions = ["(status = 'open' OR (status = 'closed' AND closed_at > '#{Time.now - closed_since.days}'))"]
+    else
+      conditions = ["status = 'open'"]
+    end
+
+    return conditions
+  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
index 6e2ecd3234fcf1c68a5ba72284483a6899288153..ec6c455b3dcfb24d9ce6fa145e6c731390a01669 100644 (file)
@@ -129,6 +129,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 (file)
index 0000000..892ada1
--- /dev/null
@@ -0,0 +1,88 @@
+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
+
+  # Custom JSON output routine for notes
+  def to_json(options = {})
+    super options.reverse_merge(
+      :methods => [ :lat, :lon ], 
+      :only => [ :id, :status, :created_at ],
+      :include => {
+         :comments => {
+           :only => [ :event, :author_name, :created_at, :body ]
+         }
+      }
+    )
+  end
+end
diff --git a/app/models/note_comment.rb b/app/models/note_comment.rb
new file mode 100644 (file)
index 0000000..bcbcf79
--- /dev/null
@@ -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
index e6058d4b7374e486e51e37ce91e7b1707047c092..f025da7b19c6ddda686a687b0e30a5cf2d085f97 100644 (file)
@@ -95,6 +95,22 @@ class Notifier < ActionMailer::Base
     body :friend => friend
   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 common_headers(recipient)
index 1ff86cd4efa52468240a57e5e0c7fdfdeebe10c8..2d04efe04fe6d068698bfb196d78bbb4442db485 100644 (file)
@@ -6,14 +6,18 @@
 </iframe>
 
 <div id="browse_map">
-  <% 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? %>
   <div id="small_map">
   </div>
   <span id="loading"><%= t 'browse.map.loading' %></span>
+  <% 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 -%>
   <br />
   <%= 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 %>
     <br />
     <%= link_to("", { :controller => :site, :action => :index }, { :id => "object_larger_map", :class => "geolink object" }) %>
     <br />
@@ -40,7 +44,7 @@
   </ul>
 </div>
 
-<% 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? %>
   <script type="text/javascript">
     OpenLayers.Lang.setCode("<%= I18n.locale.to_s %>");
 
         <% end %>
 
         updatelinks(centre.lon, centre.lat, 16, null, minlon, minlat, maxlon, maxlat)
+      <% elsif map.instance_of? Note %>
+        var centre = new OpenLayers.LonLat(<%= map.lon %>, <%= map.lat %>);
+
+        setMapCenter(centre, 16);
+        addMarkerToMap(centre);
+
+        var bbox = getMapExtent();
+
+        $("loading").style.display = "none";
+
+        $$("#browse_map .geolink").each(function (link) {
+          link.style.display = "inline";
+        });
+
+        $("remote_area_edit").observe("click", function (event) {
+          remoteEditHandler(event, bbox);
+        });
+
+        <% if preferred_editor == "remote" %>
+          $("area_edit").observe("click", function (event) {
+            remoteEditHandler(event, bbox);
+          });
+        <% end %>
+
+        updatelinks(centre.lon, centre.lat, 16, null, bbox.left, bbox.bottom, bbox.right, bbox.top)
       <% else %>
         var obj_type = "<%= map.class.name.downcase %>";
         var obj_id = <%= map.id %>;
diff --git a/app/views/browse/note.html.erb b/app/views/browse/note.html.erb
new file mode 100644 (file)
index 0000000..59c96c4
--- /dev/null
@@ -0,0 +1,58 @@
+<%= render :partial => "navigation" %>
+
+<h2>
+  <%= image_tag "#{@note.status}_note_marker.png", :alt => @note.status %>
+  <%= t "browse.note.#{@note.status}_title", :note_name => @note.id %>
+</h2>
+
+<%= render :partial => "map", :object => @note %>
+
+<table class="browse_details">
+
+  <tr>
+    <th><%= t "browse.note.opened" %></th>
+    <td><%= t "browse.note.at_by", :when => friendly_date(@note.created_at), :user => note_author(@note) %></td>
+  </tr>  
+
+  <% if @note.status == "closed" %>
+    <tr>
+      <th><%= t "browse.note.closed" %></th>
+      <td><%= t "browse.note.at_by", :when => friendly_date(@note.closed_at), :user => note_author(@note.comments.last) %></td>
+    </tr>  
+  <% elsif @note.comments.length > 1 %>
+    <tr>
+      <th><%= t "browse.note.last_modified" %></th>
+      <td><%= t "browse.note.at_by", :when => friendly_date(@note.updated_at), :user => note_author(@note.comments.last) %></td>
+    </tr>  
+  <% end %>
+
+  <tr>
+    <th><%= t "browse.note.description" %></th>
+    <td><%= h(@note.comments.first.body) %></td>
+  </tr>
+
+  <tr>
+    <th><%= t "browse.node_details.coordinates" %></th>
+    <td><div class="geo"><%= link_to ("<span class='latitude'>#{number_with_delimiter(@note.lat)}</span>, <span class='longitude'>#{number_with_delimiter(@note.lon)}</span>"), {:controller => 'site', :action => 'index', :lat => h(@note.lat), :lon => h(@note.lon), :zoom => "18"} %></div></td>
+  </tr>
+
+  <% if @note.comments.length > 1 %>
+    <tr valign="top">
+      <th><%= t "browse.note.comments" %></th>
+      <td class="browse_comments">
+        <table>
+          <% @note.comments[1..-1].each do |comment| %>
+            <tr>
+              <td>
+                <%= h(comment.body) %>
+                <br />
+                <span class="by"><%= t "browse.note.at_by", :when => friendly_date(comment.created_at), :user => note_author(comment) %></span>
+              </td>
+            </tr>
+          <% end %>
+        </table>
+      </td>
+    </tr>
+  <% end %>
+
+</table>
diff --git a/app/views/note/_description.html.erb b/app/views/note/_description.html.erb
new file mode 100644 (file)
index 0000000..596d632
--- /dev/null
@@ -0,0 +1,8 @@
+<div>
+  <% description.comments.each do |comment| -%>
+  <div class="note-comment" style="margin-top: 5px">
+    <div class="note-comment-description" style="font-size: smaller; color: #999999"><%= t "note.description.#{comment.event}_at_by", :when => friendly_date(comment.created_at), :user => note_author(comment, :only_path => false) %></div>
+    <div class="note-comment-text"><%= comment.body %></div>
+  </div>
+  <% end -%>
+</div>
diff --git a/app/views/note/_note.gpx.builder b/app/views/note/_note.gpx.builder
new file mode 100644 (file)
index 0000000..8b599eb
--- /dev/null
@@ -0,0 +1,17 @@
+xml.wpt("lon" => note.lon, "lat" => note.lat) do
+  with_format(:html) do
+    xml.desc do
+      xml.cdata! render(:partial => "description", :object => note, :format => :html)
+    end
+  end
+
+  xml.extension do
+    if note.status = "open"
+      xml.closed "0"
+    else
+      xml.closed "1"
+    end
+
+    xml.id note.id
+  end
+end
diff --git a/app/views/note/_note.rss.builder b/app/views/note/_note.rss.builder
new file mode 100644 (file)
index 0000000..49f0a51
--- /dev/null
@@ -0,0 +1,20 @@
+xml.item do
+  if note.status == "closed"
+    xml.title t('note.rss.closed', :place => note.nearby_place)        
+  elsif note.comments.length > 1
+    xml.title t('note.rss.comment', :place => note.nearby_place)
+  else
+    xml.title t('note.rss.new', :place => note.nearby_place)
+  end
+
+  xml.link url_for(:controller => "browse", :action => "note", :id => note.id, :only_path => false)
+  xml.guid url_for(:controller => "note", :action => "read", :id => note.id, :only_path => false)
+  with_format(:html) do
+    xml.description render(:partial => "description", :object => note)
+  end
+  xml.author note.author_name
+  xml.pubDate note.updated_at.to_s(:rfc822)
+  xml.geo :lat, note.lat
+  xml.geo :long, note.lon
+  xml.georss :point, "#{note.lat} #{note.lon}"
+end
diff --git a/app/views/note/_note.xml.builder b/app/views/note/_note.xml.builder
new file mode 100644 (file)
index 0000000..2a2b2ff
--- /dev/null
@@ -0,0 +1,21 @@
+xml.note("lon" => note.lon, "lat" => note.lat) do
+  xml.id note.id
+  xml.date_created note.created_at
+  xml.nearby note.nearby_place
+  xml.status note.status
+
+  if note.status == "closed"
+    xml.date_closed note.closed_at
+  end
+
+  xml.comments do
+    note.comments.each do |comment|
+      xml.comment do
+        xml.date comment.created_at
+        xml.uid comment.author_id unless comment.author_id.nil?
+        xml.user comment.author_name
+        xml.text comment.body
+      end      
+    end
+  end
+end
diff --git a/app/views/note/_notes_paging_nav.html.erb b/app/views/note/_notes_paging_nav.html.erb
new file mode 100644 (file)
index 0000000..108cbb3
--- /dev/null
@@ -0,0 +1,17 @@
+<p>
+
+<% if @page > 1 %>
+<%= link_to t('changeset.changeset_paging_nav.previous'), params.merge({ :page => @page - 1 }) %>
+<% else %>
+<%= t('changeset.changeset_paging_nav.previous') %>
+<% end %>
+
+| <%= t('changeset.changeset_paging_nav.showing_page', :page => @page) %> |
+
+<% if @notes.size < @page_size %>
+<%= t('changeset.changeset_paging_nav.next') %>
+<% else %>
+<%= link_to t('changeset.changeset_paging_nav.next'), params.merge({ :page => @page + 1 }) %>
+<% end %>
+
+</p>
diff --git a/app/views/note/_user.html.erb b/app/views/note/_user.html.erb
new file mode 100644 (file)
index 0000000..0e95076
--- /dev/null
@@ -0,0 +1 @@
+<%= link_to user.display_name, :controller => "user", :action => "view", :display_name => user.display_name %>
diff --git a/app/views/note/list.gpx.builder b/app/views/note/list.gpx.builder
new file mode 100644 (file)
index 0000000..7a30460
--- /dev/null
@@ -0,0 +1,7 @@
+xml.instruct!
+
+xml.gpx("version" => "1.1", 
+        "xmlns:xsi" => "http://www.w3.org/2001/XMLSchema-instance",
+        "xsi:schemaLocation" => "http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd") do
+  xml << render(:partial => "note", :collection => @notes)
+end
diff --git a/app/views/note/list.rjs b/app/views/note/list.rjs
new file mode 100644 (file)
index 0000000..1113c3f
--- /dev/null
@@ -0,0 +1,6 @@
+@notes.each do |note|
+  page.call "putAJAXMarker",
+            note.id, note.lon, note.lat,
+            note.flatten_comment("<hr />"),
+            note.status == "open" ? 0 : 1
+end
diff --git a/app/views/note/list.rss.builder b/app/views/note/list.rss.builder
new file mode 100644 (file)
index 0000000..d6ee2bb
--- /dev/null
@@ -0,0 +1,13 @@
+xml.instruct!
+
+xml.rss("version" => "2.0", 
+        "xmlns:geo" => "http://www.w3.org/2003/01/geo/wgs84_pos#",
+        "xmlns:georss" => "http://www.georss.org/georss") do
+  xml.channel do
+    xml.title t('note.rss.title')
+    xml.description t('note.rss.description_area', :min_lat => @min_lat, :min_lon => @min_lon, :max_lat => @max_lat, :max_lon => @max_lon )
+    xml.link url_for(:controller => "site", :action => "index", :only_path => false)
+
+    xml << render(:partial => "note", :collection => @notes)
+  end
+end
diff --git a/app/views/note/list.xml.builder b/app/views/note/list.xml.builder
new file mode 100644 (file)
index 0000000..38b239a
--- /dev/null
@@ -0,0 +1,3 @@
+xml.instruct!
+
+xml << render(:partial => "note", :collection => @notes)
diff --git a/app/views/note/mine.html.erb b/app/views/note/mine.html.erb
new file mode 100644 (file)
index 0000000..d818243
--- /dev/null
@@ -0,0 +1,37 @@
+<h1><%= @heading %></h1>
+<p><%= @description %></p>
+
+<%= render :partial => 'notes_paging_nav' %>
+
+<table id="note_list" cellpadding="3">
+  <tr>
+    <th></th>
+    <th><%= t'note.mine.id' %></th>
+    <th><%= t'changeset.changesets.user' %></th>
+    <th><%= t'changeset.changesets.comment' %></th>
+    <th><%= t'changeset.changesets.saved_at' %></th>
+    <th><%= t'note.mine.last_changed' %></th>
+  </tr>
+<% @notes.each do |note| %>
+  <tr<% if note.author != @user2 %> bgcolor="#EEEEEE"<% end %>>
+    <td>
+      <% if note.status == "closed" %>
+        <%= image_tag("closed_note_marker.png", :alt => 'closed') %>
+      <% else %>
+        <%= image_tag("open_note_marker.png", :alt => 'open') %>
+      <% end %>
+    </td>
+    <td><%= link_to note.id.to_s, :controller => "browse", :action => "note", :id => note.id %></td>
+    <% if note.author.nil? %> 
+      <td> <%= note.author_name %> </td> 
+    <% else %> 
+      <td><%= link_to h(note.author_name), :controller => "user", :action => "view", :display_name => note.author_name %></td>
+    <% end %>
+    <td> <%= htmlize note.comments.first.body  %> </td>        
+    <td><%= l note.created_at %></td>
+    <td><%= l note.updated_at %></td>
+  </tr>
+<% end %>
+</table>
+
+<%= render :partial => 'notes_paging_nav' %>
diff --git a/app/views/note/read.gpx.builder b/app/views/note/read.gpx.builder
new file mode 100644 (file)
index 0000000..e54d772
--- /dev/null
@@ -0,0 +1,7 @@
+xml.instruct!
+
+xml.gpx("version" => "1.1", 
+        "xmlns:xsi" => "http://www.w3.org/2001/XMLSchema-instance",
+        "xsi:schemaLocation" => "http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd") do
+  xml << render(:partial => "note", :object => @note)
+end
diff --git a/app/views/note/read.rss.builder b/app/views/note/read.rss.builder
new file mode 100644 (file)
index 0000000..e566ff0
--- /dev/null
@@ -0,0 +1,13 @@
+xml.instruct!
+
+xml.rss("version" => "2.0", 
+        "xmlns:geo" => "http://www.w3.org/2003/01/geo/wgs84_pos#",
+        "xmlns:georss" => "http://www.georss.org/georss") do
+  xml.channel do
+    xml.title t('note.rss.title')
+    xml.description t('note.rss.description_item', :id => @note.id)
+    xml.link url_for(:controller => "site", :action => "index", :only_path => false)
+
+    xml << render(:partial => "note", :object => @note)
+  end
+end
diff --git a/app/views/note/read.xml.builder b/app/views/note/read.xml.builder
new file mode 100644 (file)
index 0000000..cfb28c2
--- /dev/null
@@ -0,0 +1,3 @@
+xml.instruct!
+
+xml << render(:partial => "note", :object => @note)
diff --git a/app/views/note/rss.rss.builder b/app/views/note/rss.rss.builder
new file mode 100644 (file)
index 0000000..d22d673
--- /dev/null
@@ -0,0 +1,46 @@
+xml.instruct!
+
+xml.rss("version" => "2.0", 
+        "xmlns:geo" => "http://www.w3.org/2003/01/geo/wgs84_pos#",
+        "xmlns:georss" => "http://www.georss.org/georss") do
+  xml.channel do
+    xml.title t('note.rss.title')
+    xml.description t('note.rss.description_area', :min_lat => @min_lat, :min_lon => @min_lon, :max_lat => @max_lat, :max_lon => @max_lon )
+    xml.link url_for(:controller => "site", :action => "index", :only_path => false)
+
+    @comments.each do |comment|
+      xml.item do
+        if comment.event == "closed"
+          xml.title t('note.rss.closed', :place => comment.note.nearby_place)  
+        elsif comment.event == "commented"
+          xml.title t('note.rss.comment', :place => comment.note.nearby_place)
+        elsif comment.event == "opened"
+          xml.title t('note.rss.new', :place => comment.note.nearby_place)
+        else
+          xml.title "unknown event"
+        end
+        
+        xml.link url_for(:controller => "browse", :action => "note", :id => comment.note.id, :only_path => false)
+        xml.guid url_for(:controller => "browse", :action => "note", :id => comment.note.id, :only_path => false)
+
+        description_text = ""
+
+        if comment.event == "commented" and not comment.nil?
+          description_text += "<b>Comment:</b><br>"
+          description_text += htmlize(comment.body)
+          description_text += "<br>"
+        end
+
+        description_text += "<b>Full note:</b><br>"
+        description_text += comment.note.flatten_comment("<br>", comment.created_at)
+
+        xml.description description_text 
+        xml.author comment.author_name
+        xml.pubDate comment.created_at.to_s(:rfc822)
+        xml.geo :lat, comment.note.lat
+        xml.geo :long, comment.note.lon
+        xml.georss :point, "#{comment.note.lat} #{comment.note.lon}"
+      end
+    end
+  end
+end
diff --git a/app/views/notifier/note_comment_notification.html.erb b/app/views/notifier/note_comment_notification.html.erb
new file mode 100644 (file)
index 0000000..fade148
--- /dev/null
@@ -0,0 +1,15 @@
+<%= t 'notifier.note_plain.greeting' %>
+
+<% if @owner %>
+<%= t 'notifier.note_plain.your_note', :commenter => @commenter, :place => @place %>
+<% else %>
+<%= t 'notifier.note_plain.commented_note', :commenter => @commenter, :place => @place %>
+<% end %>
+
+==
+<%= @comment %>
+==
+
+<%= t 'notifier.note_plain.details', :URL => @noteurl %>
+
+
index c4d990618af9a4163372bb561220565d2c2775ae..6412962ad20c287e9ea008e8c4923d9226abb21c 100644 (file)
@@ -20,6 +20,7 @@
   <div id="permalink">
     <a href="/" id="permalinkanchor" class="geolink llz layers object"><%= t 'site.index.permalink' %></a><br/>
     <a href="/" id="shortlinkanchor"><%= t 'site.index.shortlink' %></a>
+    <a href="#" id="createnoteanchor">Report a problem</a>     
   </div>
 </div>
 
@@ -122,6 +123,7 @@ end
 
 <%= javascript_include_tag '/openlayers/OpenLayers.js' %>
 <%= javascript_include_tag '/openlayers/OpenStreetMap.js' %>
+<%= javascript_include_tag 'notes.js' %>
 <%= javascript_include_tag 'map.js' %>
 
 <%= render :partial => 'resize' %>
@@ -132,6 +134,10 @@ end
 
   OpenLayers.Lang.setCode("<%= I18n.locale.to_s %>");
 
+  <% if @user %>
+    var loginName = "<%= @user.display_name %>"
+  <% end %>
+
   function mapInit(){
     map = createMap("map");
 
@@ -139,6 +145,17 @@ end
       map.dataLayer = new OpenLayers.Layer("<%= I18n.t 'browse.start_rjs.data_layer_name' %>", { "visibility": false });
       map.dataLayer.events.register("visibilitychanged", map.dataLayer, toggleData);
       map.addLayer(map.dataLayer);
+
+      map.noteLayer = new OpenLayers.Layer.Notes("Notes", {
+          setCookie: false,
+          permalinkURL: "http://www.openstreetmap.org/",
+          visibility: <%= params[:notes] == "yes" %>
+      });
+      map.addLayer(map.noteLayer);
+
+      $("createnoteanchor").observe("click", addNote);
+
+      map.events.register("zoomend", map, allowNoteReports);
     <% end %>
 
     <% unless object_zoom %>
@@ -267,6 +284,19 @@ end
     <% end %>
   }
 
+  function addNote() {
+    map.noteLayer.setVisibility(true);
+    map.noteLayer.addNote(map.getCenter());
+  }
+
+  function allowNoteReports() { 
+    if (map.getZoom() > 11) {
+      $("createnoteanchor").style.visibility = "visible";
+    } else {
+      $("createnoteanchor").style.visibility = "hidden";
+    }
+  }
+
   document.observe("dom:loaded", mapInit);
   document.observe("dom:loaded", installEditHandler);
   document.observe("dom:loaded", handleResize);
index 834e8571b6cc358da50b4f7a58869db5181130bb..08c4669aebd80d256837510ca46437f35a1599b4 100644 (file)
@@ -23,7 +23,9 @@
     |
     <%= link_to t('user.view.my edits'), :controller => 'changeset', :action => 'list', :display_name => @user.display_name %>
     |
-    <%= link_to t('user.view.my traces'), :controller => 'trace', :action=>'mine' %>
+    <%= link_to t('user.view.my traces'), :controller => 'trace', :action=> 'mine' %>
+    |
+    <%= link_to t('user.view.my notes'), :controller => 'note', :action=> 'mine' %>
     |
     <%= link_to t('user.view.my settings'), :controller => 'user', :action => 'account', :display_name => @user.display_name %>
     |
@@ -43,6 +45,8 @@
     |
     <%= link_to t('user.view.traces'), :controller => 'trace', :action => 'view', :display_name => @this_user.display_name %>
     |
+    <%= link_to t('user.view.notes'), :controller => 'note', :action=> 'mine' %>
+    |
     <% if @user and @user.is_friends_with?(@this_user) %>
       <%= link_to t('user.view.remove as friend'), :controller => 'user', :action => 'remove_friend', :display_name => @this_user.display_name %>
     <% else %>
index f4a369487441054529f6f6a03924b87bc14f5974..69665ae953d36ce48362cd49f6f80c68bb7ea257 100644 (file)
@@ -24,6 +24,8 @@ standard_settings: &standard_settings
   max_number_of_nodes: 50000
   # Maximum number of nodes that can be in a way (checked on save)
   max_number_of_way_nodes: 2000
+  # The maximum area you're allowed to request notes from, in square degrees
+  max_note_request_area: 25
   # Zoom level to use for postcode results from the geocoder
   postcode_zoom: 15
   # Zoom level to use for geonames results from the geocoder
index 72aca7e441e1855f8c7a7ac1f1cbe5d42cd1235b..18df05cf2c4f6ffb1be0c7e794cb7b7c4eb606b1 100644 (file)
@@ -1,5 +1,3 @@
-# Be sure to restart your server when you modify this file.
-
 # Add new mime types for use in respond_to blocks:
-# Mime::Type.register "text/richtext", :rtf
-# Mime::Type.register_alias "text/html", :iphone
+
+Mime::Type.register "application/gpx+xml", :gpx
index 389774e64399eacd6ff3a574c24a3ab19973adef..c26bcb9ea55f445bb4a7be50b5bcd5b585ecaf9d 100644 (file)
@@ -911,6 +911,22 @@ de:
       history_disabled_tooltip: Reinzoomen um Änderungen für diesen Bereich anzuzeigen
       history_tooltip: Änderungen für diesen Bereich anzeigen
       history_zoom_alert: Du musst näher heranzoomen, um die Chronik zu sehen
+    osb:
+      Fixed Error: Behobener Fehler
+      Unresolved Error: Offener Fehler
+      Description: Beschreibung
+      Comment: Kommentar
+      Has been fixed: Der Fehler wurde bereits behoben. Es kann jedoch bis zu einigen Tagen dauern, bis die Kartenansicht aktualisiert wird.
+      Comment/Close: Kommentieren/Schließen
+      Nickname: Benutzername
+      Add comment: Kommentar hinzufügen
+      Mark as fixed: Als behoben markieren
+      Cancel: Abbrechen
+      Create OpenStreetBug: OpenStreetBug melden
+      Create bug: Bug anlegen
+      Bug description: Fehlerbeschreibung
+      Create: Anlegeeen
+      Permalink: Permalink
   layouts: 
     community_blogs: Blogs
     community_blogs_title: Blogs von Mitwirkenden bei OpenStreetMap
index fd9c49b2b9e5cc2136cf26da4a3bb4c1ba354590..63b15233fac80936f728c4220bf7c3527d768ac6 100644 (file)
@@ -121,6 +121,8 @@ en:
         next_relation_tooltip: "Next relation"
         prev_changeset_tooltip: "Previous changeset"
         next_changeset_tooltip: "Next changeset"
+        prev_note_tooltip: "Previous note"
+        next_note_tooltip: "Next note"
     changeset_details:
       created_at: "Created at:"
       closed_at: "Closed at:"
@@ -282,6 +284,15 @@ en:
       download_xml: "Download XML"
       view_history: "view history"
       edit: "edit"
+    note:
+      open_title: "Unresolved issue: %{note_name}"
+      closed_title: "Resolved issue: %{note_name}"
+      opened: "Opened:"
+      last_modified: "Last modified:"
+      closed: "Closed:"
+      at_by: "%{when} ago by %{user}"
+      description: "Description:"
+      comments: "Comments:"
   changeset:
     changeset_paging_nav:
       showing_page: "Showing page %{page}"
@@ -1176,6 +1187,13 @@ en:
       greeting: "Hi,"
       hopefully_you: "Someone (possibly you) has asked for the password to be reset on this email address's openstreetmap.org account."
       click_the_link: "If this is you, please click the link below to reset your password."
+    note_plain:
+      subject_own: "[OpenStreetMap] %{commenter} has commented on one of your notes"
+      subject_other: "[OpenStreetMap] %{commenter} has commented on a note you are interested in"
+      greeting: "Hi,"
+      your_note: "%{commenter} has left a comment on one of your map notes near %{place}."
+      commented_note: "%{commenter} has left a comment on a map note you have commented on. The note is near %{place}."
+      details: "More details about the note can be found at %{URL}."
   message:
     inbox:
       title: "Inbox"
@@ -1642,6 +1660,7 @@ en:
       new diary entry: new diary entry
       my edits: my edits
       my traces: my traces
+      my notes: my map notes
       my settings: my settings
       oauth settings: oauth settings
       blocks on me: blocks on me
@@ -1650,6 +1669,7 @@ en:
       diary: diary
       edits: edits
       traces: traces
+      notes: map notes
       remove as friend: remove as friend
       add as friend: add as friend
       mapper since: "Mapper since:"
@@ -1901,6 +1921,25 @@ en:
       back: "View all blocks"
       revoker: "Revoker:"
       needs_view: "The user needs to log in before this block will be cleared."
+  note:
+    description:
+      opened_at_by: "Created %{when} ago by %{user}"
+      commented_at_by: "Updated %{when} ago by %{user}"
+      closed_at_by: "Resolved %{when} ago by %{user}"
+      reopened_at_by: "Reactivated %{when} ago by %{user}"
+    rss:
+      title: "OpenStreetMap Notes"
+      description_area: "A list of notes, reported, commented on or closed in your area [(%{min_lat}|%{min_lon}) -- (%{max_lat}|%{max_lon})]"
+      description_item: "An rss feed for note %{id}"
+      closed: "closed note (near %{place})"
+      new: "new note (near %{place})"
+      comment: "new comment (near %{place})"
+    mine:
+      title: "Notes submitted or commented on by %{user}"
+      heading: "%{user}'s notes"
+      description: "Notes submitted or commented on by %{user}"
+      id: "Id"
+      last_changed: "Last changed"
   javascripts:
     map:
       base:
@@ -1917,3 +1956,23 @@ en:
       history_tooltip: View edits for this area
       history_disabled_tooltip: Zoom in to view edits for this area
       history_zoom_alert: You must zoom in to view edits for this area
+    note:
+      closed: Closed Note
+      open: Open Note
+      details: Details
+      permalink: Permalink
+      description: Description
+      comment: Comment
+      render_warning: This error has been fixed already. However, it might take a couple of days before the map image is updated.
+      update: Update
+      nickname: Nickname
+      login: Login
+      add_comment: Add Comment
+      close: Close
+      cancel: Cancel
+      create: Create Note
+      create_title: Report a problem with the map
+      create_help1: Please drag the marker to the location of the problem
+      create_help2: and descripe it as accurate as possible
+      report: Report Problem
+      edityourself: You can also edit the map directly your self
index 56a59a207d7a52ef57f5b649cae6fafe22103d58..6c30f1170ac33a5817d494860fd2c4bc61e5810e 100644 (file)
@@ -74,7 +74,25 @@ ActionController::Routing::Routes.draw do |map|
   map.connect "api/#{API_VERSION}/amf/read", :controller =>'amf', :action =>'amf_read'
   map.connect "api/#{API_VERSION}/amf/write", :controller =>'amf', :action =>'amf_write'
   map.connect "api/#{API_VERSION}/swf/trackpoints", :controller =>'swf', :action =>'trackpoints'
-  
+
+  # Map notes API
+  map.connect "api/#{API_VERSION}/notes", :controller => 'note', :action => 'list'
+  map.connect "api/#{API_VERSION}/notes.:format", :controller => 'note', :action => 'list'
+  map.connect "api/#{API_VERSION}/notes/search", :controller => 'note', :action => 'search'
+  map.connect "api/#{API_VERSION}/notes/rss", :controller =>'notes', :action => 'rss'
+  map.connect "api/#{API_VERSION}/note/create", :controller => 'note', :action => 'create'
+  map.connect "api/#{API_VERSION}/note/:id/comment", :controller => 'note', :action => 'update', :id => /\d+/
+  map.connect "api/#{API_VERSION}/note/:id/close", :controller => 'note', :action => 'close', :id => /\d+/
+  map.connect "api/#{API_VERSION}/note/:id", :controller => 'note', :action => 'read', :id => /\d+/, :conditions => { :method => :get }
+  map.connect "api/#{API_VERSION}/note/:id.:format", :controller => 'note', :action => 'read', :id => /\d+/, :conditions => { :method => :get }
+  map.connect "api/#{API_VERSION}/note/:id", :controller => 'note', :action => 'delete', :id => /\d+/, :conditions => { :method => :delete }
+  map.connect "api/#{API_VERSION}/notes/getBugs", :controller => 'note', :action => 'list'
+  map.connect "api/#{API_VERSION}/notes/addPOIexec", :controller => 'note', :action => 'create'
+  map.connect "api/#{API_VERSION}/notes/closePOIexec", :controller => 'note', :action => 'close'
+  map.connect "api/#{API_VERSION}/notes/editPOIexec", :controller => 'note', :action => 'update'
+  map.connect "api/#{API_VERSION}/notes/getGPX", :controller => 'note', :action => 'list', :format => :gpx
+  map.connect "api/#{API_VERSION}/notes/getRSSfeed", :controller => 'note', :action => 'rss'
+
   # Data browsing
   map.connect '/browse/start', :controller => 'browse', :action => 'start'
   map.connect '/browse/way/:id', :controller => 'browse', :action => 'way', :id => /\d+/
@@ -88,6 +106,8 @@ ActionController::Routing::Routes.draw do |map|
   map.connect '/user/:display_name/edits', :controller => 'changeset', :action => 'list'
   map.connect '/browse/changesets/feed', :controller => 'changeset', :action => 'list', :format => :atom
   map.connect '/browse/changesets', :controller => 'changeset', :action => 'list'
+  map.connect '/browse/note/:id', :controller => 'browse', :action => 'note', :id => /\d+/
+  map.connect '/user/:display_name/notes', :controller => 'note', :action => 'mine'
   map.connect '/browse', :controller => 'changeset', :action => 'list'
 
   # web site
diff --git a/db/migrate/053_add_map_bug_tables.rb b/db/migrate/053_add_map_bug_tables.rb
new file mode 100644 (file)
index 0000000..8d444a4
--- /dev/null
@@ -0,0 +1,33 @@
+require 'lib/migrate'
+
+class AddMapBugTables < ActiveRecord::Migration
+  def self.up
+    create_enumeration :map_bug_status_enum, ["open", "closed", "hidden"]
+
+    create_table :map_bugs do |t|
+      t.column :id, :bigint, :null => false 
+      t.integer :latitude, :null => false 
+      t.integer :longitude, :null => false 
+      t.column :tile, :bigint, :null => false
+      t.datetime :last_changed, :null => false
+      t.datetime :date_created, :null => false 
+      t.string :nearby_place 
+      t.string :text
+      t.column :status, :map_bug_status_enum, :null => false
+    end
+
+    add_index :map_bugs, [:tile, :status], :name => "map_bugs_tile_idx"
+    add_index :map_bugs, [:last_changed], :name => "map_bugs_changed_idx"
+    add_index :map_bugs, [:date_created], :name => "map_bugs_created_idx"
+  end
+
+  def self.down
+    remove_index :map_bugs, :name => "map_bugs_tile_idx"
+    remove_index :map_bugs, :name => "map_bugs_changed_idx"
+    remove_index :map_bugs, :name => "map_bugs_created_idx"
+
+    drop_table :map_bugs
+
+    drop_enumeration :map_bug_status_enum
+  end
+end
diff --git a/db/migrate/054_refactor_map_bug_tables.rb b/db/migrate/054_refactor_map_bug_tables.rb
new file mode 100644 (file)
index 0000000..6d259d2
--- /dev/null
@@ -0,0 +1,34 @@
+require 'lib/migrate'
+
+class RefactorMapBugTables < ActiveRecord::Migration
+  def self.up
+    create_table :map_bug_comment do |t|
+      t.column :id, :bigint, :null => false
+      t.column :bug_id, :bigint, :null => false
+      t.boolean :visible, :null => false 
+      t.datetime :date_created, :null => false
+      t.string :commenter_name
+      t.string :commenter_ip
+      t.column :commenter_id, :bigint
+      t.string :comment
+    end
+
+    remove_column :map_bugs, :text 
+
+    add_index :map_bug_comment, [:bug_id], :name => "map_bug_comment_id_idx"
+
+    add_foreign_key :map_bug_comment, [:bug_id], :map_bugs, [:id]
+    add_foreign_key :map_bug_comment, [:commenter_id], :users, [:id]
+  end
+
+  def self.down
+    remove_foreign_key :map_bug_comment, [:commenter_id]
+    remove_foreign_key :map_bug_comment, [:bug_id]
+
+    remove_index :map_bugs, :name => "map_bug_comment_id_idx"
+
+    add_column :map_bugs, :text, :string
+
+    drop_table :map_bug_comment
+  end
+end
diff --git a/db/migrate/055_change_map_bug_comment_type.rb b/db/migrate/055_change_map_bug_comment_type.rb
new file mode 100644 (file)
index 0000000..2a64bf2
--- /dev/null
@@ -0,0 +1,11 @@
+require 'lib/migrate'
+
+class ChangeMapBugCommentType < ActiveRecord::Migration
+  def self.up
+    change_column :map_bug_comment, :comment, :text
+  end
+
+  def self.down
+    change_column :map_bug_comment, :comment, :string
+  end
+end
diff --git a/db/migrate/056_add_date_closed.rb b/db/migrate/056_add_date_closed.rb
new file mode 100644 (file)
index 0000000..c5aa2c2
--- /dev/null
@@ -0,0 +1,11 @@
+require 'lib/migrate'
+
+class AddDateClosed < ActiveRecord::Migration
+  def self.up
+    add_column :map_bugs, :date_closed, :timestamp
+  end
+
+  def self.down
+    remove_column :map_bugs, :date_closed 
+  end
+end
diff --git a/db/migrate/057_add_map_bug_comment_event.rb b/db/migrate/057_add_map_bug_comment_event.rb
new file mode 100644 (file)
index 0000000..c13c1f9
--- /dev/null
@@ -0,0 +1,15 @@
+require 'lib/migrate'
+
+class AddMapBugCommentEvent < ActiveRecord::Migration
+  def self.up
+    create_enumeration :map_bug_event_enum, ["opened", "closed", "reopened", "commented", "hidden"]
+
+    add_column :map_bug_comment, :event, :map_bug_event_enum
+  end
+
+  def self.down
+    remove_column :map_bug_comment, :event
+
+    drop_enumeration :map_bug_event_enum
+  end
+end
diff --git a/db/migrate/20110508145337_cleanup_bug_tables.rb b/db/migrate/20110508145337_cleanup_bug_tables.rb
new file mode 100644 (file)
index 0000000..e7dfcb7
--- /dev/null
@@ -0,0 +1,25 @@
+class CleanupBugTables < ActiveRecord::Migration
+  def self.up
+    rename_column :map_bugs, :date_created, :created_at
+    rename_column :map_bugs, :last_changed, :updated_at
+    rename_column :map_bugs, :date_closed, :closed_at
+
+    rename_column :map_bug_comment, :date_created, :created_at
+    rename_column :map_bug_comment, :commenter_name, :author_name
+    rename_column :map_bug_comment, :commenter_ip, :author_ip
+    rename_column :map_bug_comment, :commenter_id, :author_id
+    rename_column :map_bug_comment, :comment, :body
+  end
+
+  def self.down
+    rename_column :map_bug_comment, :body, :comment
+    rename_column :map_bug_comment, :author_id, :commenter_id
+    rename_column :map_bug_comment, :author_ip, :commenter_ip
+    rename_column :map_bug_comment, :author_name, :commenter_name
+    rename_column :map_bug_comment, :created_at, :date_created
+
+    rename_column :map_bugs, :closed_at, :date_closed
+    rename_column :map_bugs, :updated_at, :last_changed
+    rename_column :map_bugs, :created_at, :date_created
+  end
+end
diff --git a/db/migrate/20110521142405_rename_bugs_to_notes.rb b/db/migrate/20110521142405_rename_bugs_to_notes.rb
new file mode 100644 (file)
index 0000000..240d447
--- /dev/null
@@ -0,0 +1,55 @@
+require 'lib/migrate'
+
+class RenameBugsToNotes < ActiveRecord::Migration
+  def self.up
+    rename_enumeration "map_bug_status_enum", "note_status_enum"
+    rename_enumeration "map_bug_event_enum", "note_event_enum"
+
+    rename_table :map_bugs, :notes
+    rename_sequence :notes, "map_bugs_id_seq", "notes_id_seq"
+    rename_index :notes, "map_bugs_pkey", "notes_pkey"
+    rename_index :notes, "map_bugs_changed_idx", "notes_updated_at_idx"
+    rename_index :notes, "map_bugs_created_idx", "notes_created_at_idx"
+    rename_index :notes, "map_bugs_tile_idx", "notes_tile_status_idx"
+
+    remove_foreign_key :map_bug_comment, [:bug_id], :map_bugs, [:id]
+    rename_column :map_bug_comment, :author_id, :commenter_id
+    remove_foreign_key :map_bug_comment, [:commenter_id], :users, [:id]
+    rename_column :map_bug_comment, :commenter_id, :author_id
+
+    rename_table :map_bug_comment, :note_comments
+    rename_column :note_comments, :bug_id, :note_id
+    rename_sequence :note_comments, "map_bug_comment_id_seq", "note_comments_id_seq"
+    rename_index :note_comments, "map_bug_comment_pkey", "note_comments_pkey"
+    rename_index :note_comments, "map_bug_comment_id_idx", "note_comments_note_id_idx"
+
+    add_foreign_key :note_comments, [:note_id], :notes, [:id]
+    add_foreign_key :note_comments, [:author_id], :users, [:id]
+  end
+
+  def self.down
+    remove_foreign_key :note_comments, [:author_id], :users, [:id]
+    remove_foreign_key :note_comments, [:note_id], :notes, [:id]
+
+    rename_index :note_comments, "note_comments_note_id_idx", "map_bug_comment_id_idx"
+    rename_index :notes, "note_comments_pkey", "map_bug_comment_pkey"
+    rename_column :note_comments, :note_id, :bug_id
+    rename_sequence :note_comments, "note_comments_id_seq", "map_bug_comment_id_seq"
+    rename_table :note_comments, :map_bug_comment
+
+    rename_column :map_bug_comment, :author_id, :commenter_id
+    add_foreign_key :map_bug_comment, [:commenter_id], :users, [:id]
+    rename_column :map_bug_comment, :commenter_id, :author_id
+    add_foreign_key :map_bug_comment, [:bug_id], :notes, [:id]
+
+    rename_index :notes, "notes_tile_status_idx", "map_bugs_tile_idx"
+    rename_index :notes, "notes_created_at_idx", "map_bugs_created_idx"
+    rename_index :notes, "notes_updated_at_idx", "map_bugs_changed_idx"
+    rename_index :notes, "notes_pkey", "map_bugs_pkey"
+    rename_sequence :notes, "notes_id_seq", "map_bugs_id_seq"
+    rename_table :notes, :map_bugs
+
+    rename_enumeration "note_event_enum", "map_bug_event_enum"
+    rename_enumeration "note_status_enum", "map_bug_status_enum"
+  end
+end
index 2740eab0c5472da4c76d95128c5f8253dd440cbb..90dee5f1dc43ceb424d1f78c934082d2370e0ce1 100644 (file)
@@ -56,4 +56,3 @@ private
     end
   end
 end
-
index f3accf2da4e5b12241c0a0ac4091ec9d5791e929..b3085d0ec0c83ed6ceb3150bca329eb35d28dd1e 100644 (file)
@@ -9,7 +9,7 @@ module MapBoundary
     return min_lon, min_lat, max_lon, max_lat
   end
 
-  def check_boundaries(min_lon, min_lat, max_lon, max_lat)
+  def check_boundaries(min_lon, min_lat, max_lon, max_lat, max_area = MAX_REQUEST_AREA)
     # check the bbox is sane
     unless min_lon <= max_lon
       raise OSM::APIBadBoundingBox.new("The minimum longitude must be less than the maximum longitude, but it wasn't")
@@ -24,8 +24,8 @@ module MapBoundary
 
     # check the bbox isn't too large
     requested_area = (max_lat-min_lat)*(max_lon-min_lon)
-    if requested_area > MAX_REQUEST_AREA
-      raise OSM::APIBadBoundingBox.new("The maximum bbox size is " + MAX_REQUEST_AREA.to_s + 
+    if requested_area > max_area
+      raise OSM::APIBadBoundingBox.new("The maximum bbox size is " + max_area.to_s + 
         ", and your request was too large. Either request a smaller area, or use planet.osm")
     end
   end
index 81cdd4d0541bc485f362c87f4b40b995466b21f6..8e6629f0d2639d25e40590cfcbc7cd5ce7233a21 100644 (file)
@@ -105,11 +105,11 @@ module ActiveRecord
         @enumerations ||= Hash.new
       end
 
-      def create_enumeration (enumeration_name, values)
+      def create_enumeration(enumeration_name, values)
         enumerations[enumeration_name] = values
       end
 
-      def drop_enumeration (enumeration_name)
+      def drop_enumeration(enumeration_name)
         enumerations.delete(enumeration_name)
       end
 
@@ -158,29 +158,34 @@ module ActiveRecord
         return ""
       end
  
-      def change_engine (table_name, engine)
+      def change_engine(table_name, engine)
       end
 
-      def add_fulltext_index (table_name, column)
-        execute "CREATE INDEX #{table_name}_#{column}_idx on #{table_name} (#{column})"
+      def add_fulltext_index(table_name, column)
+        execute "CREATE INDEX #{table_name}_#{column}_idx ON #{table_name} (#{column})"
       end
 
       def enumerations
         @enumerations ||= Hash.new
       end
 
-      def create_enumeration (enumeration_name, values)
+      def create_enumeration(enumeration_name, values)
         enumerations[enumeration_name] = values
-        execute "create type #{enumeration_name} as enum ('#{values.join '\',\''}')"
+        execute "CREATE TYPE #{enumeration_name} AS ENUM ('#{values.join '\',\''}')"
       end
 
-      def drop_enumeration (enumeration_name)
-        execute "drop type #{enumeration_name}"
+      def drop_enumeration(enumeration_name)
+        execute "DROP TYPE #{enumeration_name}"
         enumerations.delete(enumeration_name)
       end
 
+      def rename_enumeration(old_name, new_name)
+        execute "ALTER TYPE #{quote_table_name(old_name)} RENAME TO #{quote_table_name(new_name)}"
+      end
+
       def alter_primary_key(table_name, new_columns)
-        execute "alter table #{table_name} drop constraint #{table_name}_pkey; alter table #{table_name} add primary key (#{new_columns.join(',')})"
+        execute "ALTER TABLE #{table_name} DROP CONSTRAINT #{table_name}_pkey"
+        execute "ALTER TABLE #{table_name} ADD PRIMARY KEY (#{new_columns.join(',')})"
       end
 
       def interval_constant(interval)
@@ -201,6 +206,14 @@ module ActiveRecord
         quoted_column_names = column_names.map { |e| quote_column_name(e) }.join(", ")
         execute "CREATE #{index_type} INDEX #{quote_column_name(index_name)} ON #{quote_table_name(table_name)} USING #{index_method} (#{quoted_column_names})"
       end
+
+      def rename_index(table_name, old_name, new_name)
+        execute "ALTER INDEX #{quote_table_name(old_name)} RENAME TO #{quote_table_name(new_name)}"
+      end
+
+      def rename_sequence(table_name, old_name, new_name)
+        execute "ALTER SEQUENCE #{quote_table_name(old_name)} RENAME TO #{quote_table_name(new_name)}"
+      end
     end
   end
 end
index eaee7c328032f0373073b7f70aab777f20c169a0..9ee36e0c7aa6b26db8b085b152d2acf73d52eeec 100644 (file)
@@ -499,6 +499,7 @@ module OSM
   # Return an SQL fragment to select a given area of the globe
   def self.sql_for_area(minlat, minlon, maxlat, maxlon, prefix = nil)
     tilesql = QuadTile.sql_for_area(minlat, minlon, maxlat, maxlon, prefix)
+
     minlat = (minlat * 10000000).round
     minlon = (minlon * 10000000).round
     maxlat = (maxlat * 10000000).round
diff --git a/public/images/closed_note_marker.png b/public/images/closed_note_marker.png
new file mode 100644 (file)
index 0000000..bf6d6bb
Binary files /dev/null and b/public/images/closed_note_marker.png differ
diff --git a/public/images/new_note_marker.png b/public/images/new_note_marker.png
new file mode 100644 (file)
index 0000000..671cf42
Binary files /dev/null and b/public/images/new_note_marker.png differ
diff --git a/public/images/open_note_marker.png b/public/images/open_note_marker.png
new file mode 100644 (file)
index 0000000..a580316
Binary files /dev/null and b/public/images/open_note_marker.png differ
diff --git a/public/javascripts/notes.js b/public/javascripts/notes.js
new file mode 100644 (file)
index 0000000..626a169
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/> 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<arguments.length; i++)
+            args[i] = arguments[i];
+
+        // Unset original contentHTML parameter
+        args[3] = null;
+
+        var closeCallback = arguments[6];
+
+        // Add close event trigger to the closeBoxCallback parameter
+        args[6] = function(e){ if(closeCallback) closeCallback(); else this.hide(); OpenLayers.Event.stop(e); this.events.triggerEvent("close"); };
+
+        OpenLayers.Popup.FramedCloud.prototype.initialize.apply(this, args);
+
+        this.events.addEventType("close");
+
+        this.setContentHTML(arguments[3]);
+    },
+
+    /**
+     * Like OpenLayers.Popup.FramedCloud.setContentHTML(), but takes a
+     * DOM element as parameter.
+     */
+    setContentHTML: function(contentDom) {
+        if(contentDom != null)
+            this.contentDom = contentDom;
+
+        if(this.contentDiv == null || this.contentDom == null || this.contentDom == this.contentDiv.firstChild)
+            return;
+
+        while(this.contentDiv.firstChild)
+            this.contentDiv.removeChild(this.contentDiv.firstChild);
+
+        this.contentDiv.appendChild(this.contentDom);
+
+        // Copied from OpenLayers.Popup.setContentHTML():
+        if(this.autoSize)
+        {
+            this.registerImageListeners();
+            this.updateSize();
+        }
+    },
+
+    destroy: function() {
+        this.contentDom = null;
+        OpenLayers.Popup.FramedCloud.prototype.destroy.apply(this, arguments);
+    },
+
+    CLASS_NAME: "OpenLayers.Popup.FramedCloud.Notes"
+});
+
+
+/**
+ * This global function is executed by the OpenStreetMap API getBugs script.
+ *
+ * Each Notes layer adds itself to the putAJAXMarker.layer array. The
+ * putAJAXMarker() function executes the createMarker() method on each
+ * layer in that array each time it is called. This has the
+ * side-effect that notes displayed in one map on a page are already
+ * loaded on the other map as well.
+ */
+function putAJAXMarker(id, lon, lat, text, closed)
+{
+    var comments = text.split(/<hr \/>/);
+    for(var i=0; i<comments.length; i++)
+        comments[i] = comments[i].replace(/&quot;/g, "\"").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
+    putAJAXMarker.notes[id] = [
+        new OpenLayers.LonLat(lon, lat),
+        comments,
+        closed
+    ];
+    for(var i=0; i<putAJAXMarker.layers.length; i++)
+        putAJAXMarker.layers[i].createMarker(id);
+}
+
+/**
+ * This global function is executed by the OpenStreetMap API. The
+ * “create note”, “comment” and “close note” scripts execute it to give
+ * information about their success.
+ *
+ * In case of success, this function is called without a parameter, in
+ * case of an error, the error message is passed. This is lousy
+ * workaround to make it any functional at all, the OSB API is likely
+ * to be extended later (then it will provide additional information
+ * such as the ID of a created note and similar).
+ */
+function osbResponse(error)
+{
+    if(error)
+        alert("Error: "+error);
+
+    for(var i=0; i<putAJAXMarker.layers.length; i++)
+        putAJAXMarker.layers[i].loadNotes();
+}
+
+putAJAXMarker.layers = [ ];
+putAJAXMarker.notes = { };
index e256bb3f6a380a508fe8fd62cbbd155cb70d510b..9b68bd03e28e03068c194de04187acfacf06fd8f 100644 (file)
@@ -651,6 +651,23 @@ table.browse_details th {
   white-space: nowrap;
 }
 
+td.browse_comments {
+  padding: 0px;
+}
+
+td.browse_comments table {
+  border-collapse: collapse;
+}
+
+td.browse_comments table td {
+  padding-bottom: 10px;
+}
+
+td.browse_comments table td span.by {
+  font-size: small;
+  color: #999999;
+}
+
 #browse_map {
   width: 250px;
 }
index 1c4d493ec532bb51a81e5734e747911b3c7816c4..9d664ef2662195f6c42ae8bfeeaa5b7bed75ec7b 100644 (file)
@@ -17,3 +17,9 @@
 .olControlPanZoom {
   display: none;
 }
+
+/* Rules for map bug reporting */
+
+#reportbuganchor { 
+  font-size: 150%;
+}
diff --git a/public/stylesheets/notes.css b/public/stylesheets/notes.css
new file mode 100644 (file)
index 0000000..ee6198b
--- /dev/null
@@ -0,0 +1,42 @@
+.olPopupFramedCloudNotes dl {
+    margin: 0px;
+    padding: 0px;
+}
+
+.olPopupFramedCloudNotes dt {
+    margin: 0px;
+    padding: 0px;
+    font-weight: bold;
+    float: left;
+    clear: left;
+}
+
+.olPopupFramedCloudNotes dt:after {
+    content: ": ";
+}
+
+.olPopupFramedCloudNotes dt {
+    margin-right: 1ex;
+}
+
+.olPopupFramedCloudNotes dd {
+    margin: 0px;
+    padding: 0px;
+}
+
+.olPopupFramedCloudNotes ul.buttons {
+    list-style-type: none;
+    padding: 0px;
+    margin: 0px;
+}
+
+.olPopupFramedCloudNotes ul.buttons li {
+    display: inline;
+    margin: 0px;
+    padding: 0px;
+}
+
+.olPopupFramedCloudNotes h3 {
+    font-size: 1.2em;
+    margin: 0.2em 0em 0.7em 0em;
+}
diff --git a/test/fixtures/note_comments.yml b/test/fixtures/note_comments.yml
new file mode 100644 (file)
index 0000000..e078b99
--- /dev/null
@@ -0,0 +1,117 @@
+t1:
+  id: 1
+  note_id: 1
+  visible: true
+  created_at: 2007-01-01 00:00:00
+  author_name: 'testname'
+  author_ip: '192.168.1.1'
+  body: 'This is the initial description of the note 1'
+
+t2:
+  id: 2
+  note_id: 2
+  visible: true
+  created_at: 2007-01-01 00:00:00
+  author_name: 'testname'
+  author_ip: '192.168.1.1'
+  body: 'This is the initial description of the note 2'
+
+t3:
+  id: 3
+  note_id: 2
+  visible: true
+  created_at: 2007-02-01 00:00:00
+  author_name: 'testname'
+  author_ip: '192.168.1.1'
+  body: 'This is an additional comment for note 2'
+
+t4:
+  id: 4
+  note_id: 3
+  visible: true
+  created_at: 2007-01-01 00:00:00
+  author_name: 'testname'
+  author_ip: '192.168.1.1'
+  body: 'This is the initial comment for note 3'
+
+t5:
+  id: 5
+  note_id: 4
+  visible: true
+  created_at: 2007-01-01 00:00:00
+  author_name: 'testname'
+  author_ip: '192.168.1.1'
+  body: 'Spam for note 4'
+
+t6:
+  id: 6
+  note_id: 5
+  visible: true
+  created_at: 2007-01-01 00:00:00
+  author_name: 'testname'
+  author_ip: '192.168.1.1'
+  body: 'Valid comment for note 5'
+
+t7:
+  id: 7
+  note_id: 5
+  visible: false
+  created_at: 2007-02-01 00:00:00
+  author_name: 'testname'
+  author_ip: '192.168.1.1'
+  body: 'Spam for note 5'
+
+t8:
+  id: 8
+  note_id: 5
+  visible: true
+  created_at: 2007-02-01 00:00:00
+  author_name: 'testname'
+  author_ip: '192.168.1.1'
+  body: 'Another valid comment for note 5'
+
+t9:
+  id: 9
+  note_id: 6
+  visible: true
+  created_at: 2007-01-01 00:00:00
+  event: opened
+  author_id: 1
+  body: 'This is a note with from a logged-in user'
+
+t10:
+  id: 10
+  note_id: 6
+  visible: true
+  created_at: 2007-02-01 00:00:00
+  event: commented
+  author_id: 4
+  body: 'A comment from another logged-in user'
+
+t11:
+  id: 11
+  note_id: 7
+  visible: true
+  event: opened
+  created_at: 2007-01-01 00:00:00
+  author_name: 'testname'
+  author_ip: '192.168.1.1'
+  body: 'Initial note description'
+
+t12:
+  id: 12
+  note_id: 7
+  visible: true
+  event: commented
+  created_at: 2007-02-01 00:00:00
+  author_name: 'testname'
+  author_ip: '192.168.1.1'
+  body: 'A comment description'
+
+t13:
+  id: 13
+  note_id: 7
+  visible: true
+  event: closed
+  created_at: 2007-03-01 00:00:00
+  author_id: 4
diff --git a/test/fixtures/notes.yml b/test/fixtures/notes.yml
new file mode 100644 (file)
index 0000000..ffecba8
--- /dev/null
@@ -0,0 +1,67 @@
+# Read about fixtures at http://ar.rubyonrails.org/classes/Fixtures.html
+<% SCALE = 10000000 unless defined?(SCALE) %>
+
+open_note:
+  id: 1
+  latitude: <%= 1*SCALE %>
+  longitude: <%= 1*SCALE %>
+  status: open
+  tile: <%= QuadTile.tile_for_point(1,1) %>
+  created_at: 2007-01-01 00:00:00
+  updated_at: 2007-01-01 00:00:00
+
+open_note_with_comment:
+  id: 2
+  latitude: <%= 1.1*SCALE %>
+  longitude: <%= 1.1*SCALE %>
+  status: open
+  tile: <%= QuadTile.tile_for_point(1.1,1.1) %>
+  created_at: 2007-01-01 00:00:00
+  updated_at: 2007-02-01 00:00:00
+
+closed_note_with_comment:
+  id: 3
+  latitude: <%= 1.2*SCALE %>
+  longitude: <%= 1.2*SCALE %>
+  status: closed
+  tile: <%= QuadTile.tile_for_point(1.2,1.2) %>
+  created_at: 2007-01-01 00:00:00
+  updated_at: 2007-03-01 00:00:00
+  closed_at:  2007-03-01 00:00:00
+
+hidden_note_with_comment:
+  id: 4
+  latitude: <%= 1.3*SCALE %>
+  longitude: <%= 1.3*SCALE %>
+  status: hidden
+  tile: <%= QuadTile.tile_for_point(1.3,1.3) %>
+  created_at: 2007-01-01 00:00:00
+  updated_at: 2007-03-01 00:00:00
+
+note_with_hidden_comment:
+  id: 5
+  latitude: <%= 1.4*SCALE %>
+  longitude: <%= 1.4*SCALE %>
+  status: open
+  tile: <%= QuadTile.tile_for_point(1.4,1.4) %>
+  created_at: 2007-01-01 00:00:00
+  updated_at: 2007-03-01 00:00:00
+
+note_with_comments_by_users:
+  id: 6
+  latitude: <%= 1.5*SCALE %>
+  longitude: <%= 1.5*SCALE %>
+  status: open
+  tile: <%= QuadTile.tile_for_point(1.5,1.5) %>
+  created_at: 2007-01-01 00:00:00
+  updated_at: 2007-03-01 00:00:00
+
+note_closed_by_user:
+  id: 7
+  latitude: <%= 1.6*SCALE %>
+  longitude: <%= 1.6*SCALE %>
+  status: closed
+  tile: <%= QuadTile.tile_for_point(1.6,1.6) %>
+  created_at: 2007-01-01 00:00:00
+  updated_at: 2007-03-01 00:00:00
+  closed_at:  2007-03-01 00:00:00
diff --git a/test/functional/note_controller_test.rb b/test/functional/note_controller_test.rb
new file mode 100644 (file)
index 0000000..2e4a01b
--- /dev/null
@@ -0,0 +1,324 @@
+require File.dirname(__FILE__) + '/../test_helper'
+
+class NoteControllerTest < ActionController::TestCase
+  fixtures :users, :notes, :note_comments
+
+  def test_note_create_success
+    assert_difference('Note.count') do
+      assert_difference('NoteComment.count') do
+        post :create, {:lat => -1.0, :lon => -1.0, :name => "new_tester", :text => "This is a comment"}
+      end
+    end
+    assert_response :success
+    id = @response.body.sub(/ok/,"").to_i
+
+    get :read, {:id => id, :format => 'json'}
+    assert_response :success
+    js = ActiveSupport::JSON.decode(@response.body)
+    assert_not_nil js
+    assert_equal id, js["note"]["id"]
+    assert_equal "open", js["note"]["status"]
+    assert_equal "opened", js["note"]["comments"].last["event"]
+    assert_equal "This is a comment", js["note"]["comments"].last["body"]
+    assert_equal "new_tester (a)", js["note"]["comments"].last["author_name"]
+  end
+
+  def test_note_create_fail
+    assert_no_difference('Note.count') do
+      assert_no_difference('NoteComment.count') do
+        post :create, {:lon => -1.0, :name => "new_tester", :text => "This is a comment"}
+      end
+    end
+    assert_response :bad_request
+
+    assert_no_difference('Note.count') do
+      assert_no_difference('NoteComment.count') do
+        post :create, {:lat => -1.0, :name => "new_tester", :text => "This is a comment"}
+      end
+    end
+    assert_response :bad_request
+
+    assert_no_difference('Note.count') do
+      assert_no_difference('NoteComment.count') do
+        post :create, {:lat => -1.0, :lon => -1.0, :name => "new_tester"}
+      end
+    end
+    assert_response :bad_request
+
+    assert_no_difference('Note.count') do
+      assert_no_difference('NoteComment.count') do
+        post :create, {:lat => -100.0, :lon => -1.0, :name => "new_tester", :text => "This is a comment"}
+      end
+    end
+    assert_response :bad_request
+
+    assert_no_difference('Note.count') do
+      assert_no_difference('NoteComment.count') do
+        post :create, {:lat => -1.0, :lon => -200.0, :name => "new_tester", :text => "This is a comment"}
+      end
+    end
+    assert_response :bad_request
+  end
+
+  def test_note_comment_create_success
+    assert_difference('NoteComment.count') do
+      post :update, {:id => notes(:open_note_with_comment).id, :name => "new_tester2", :text => "This is an additional comment"}
+    end
+    assert_response :success
+
+    get :read, {:id => notes(:open_note_with_comment).id, :format => 'json'}
+    assert_response :success
+    js = ActiveSupport::JSON.decode(@response.body)
+    assert_not_nil js
+    assert_equal notes(:open_note_with_comment).id, js["note"]["id"]
+    assert_equal "open", js["note"]["status"]
+    assert_equal "commented", js["note"]["comments"].last["event"]
+    assert_equal "This is an additional comment", js["note"]["comments"].last["body"]
+    assert_equal "new_tester2 (a)", js["note"]["comments"].last["author_name"]
+  end
+
+  def test_note_comment_create_fail
+    assert_no_difference('NoteComment.count') do
+      post :update, {:name => "new_tester2", :text => "This is an additional comment"}
+    end
+    assert_response :bad_request
+
+    assert_no_difference('NoteComment.count') do
+      post :update, {:id => notes(:open_note_with_comment).id, :name => "new_tester2"}
+    end
+    assert_response :bad_request
+
+    assert_no_difference('NoteComment.count') do
+      post :update, {:id => 12345, :name => "new_tester2", :text => "This is an additional comment"}
+    end
+    assert_response :not_found
+
+    assert_no_difference('NoteComment.count') do
+      post :update, {:id => notes(:hidden_note_with_comment).id, :name => "new_tester2", :text => "This is an additional comment"}
+    end
+    assert_response :gone
+  end
+
+  def test_note_close_success
+    post :close, {:id => notes(:open_note_with_comment).id}
+    assert_response :success
+
+    get :read, {:id => notes(:open_note_with_comment).id, :format => 'json'}
+    assert_response :success
+    js = ActiveSupport::JSON.decode(@response.body)
+    assert_not_nil js
+    assert_equal notes(:open_note_with_comment).id, js["note"]["id"]
+    assert_equal "closed", js["note"]["status"]
+    assert_equal "closed", js["note"]["comments"].last["event"]
+    assert_equal "NoName (a)", js["note"]["comments"].last["author_name"]
+  end
+
+  def test_note_close_fail
+    post :close
+    assert_response :bad_request
+
+    post :close, {:id => 12345}
+    assert_response :not_found
+
+    post :close, {:id => notes(:hidden_note_with_comment).id}
+    assert_response :gone
+  end
+
+  def test_note_read_success
+    get :read, {:id => notes(:open_note).id}
+    assert_response :success      
+    assert_equal "application/xml", @response.content_type
+
+    get :read, {:id => notes(:open_note).id, :format => "xml"}
+    assert_response :success
+    assert_equal "application/xml", @response.content_type
+
+    get :read, {:id => notes(:open_note).id, :format => "rss"}
+    assert_response :success
+    assert_equal "application/rss+xml", @response.content_type
+
+    get :read, {:id => notes(:open_note).id, :format => "json"}
+    assert_response :success
+    assert_equal "application/json", @response.content_type
+
+    get :read, {:id => notes(:open_note).id, :format => "gpx"}
+    assert_response :success
+    assert_equal "application/gpx+xml", @response.content_type
+  end
+
+  def test_note_read_hidden_comment
+    get :read, {:id => notes(:note_with_hidden_comment).id, :format => 'json'}
+    assert_response :success
+    js = ActiveSupport::JSON.decode(@response.body)
+    assert_not_nil js
+    assert_equal notes(:note_with_hidden_comment).id, js["note"]["id"]
+    assert_equal 2, js["note"]["comments"].count
+    assert_equal "Valid comment for note 5", js["note"]["comments"][0]["body"]
+    assert_equal "Another valid comment for note 5", js["note"]["comments"][1]["body"]
+  end
+
+  def test_note_read_fail
+    post :read
+    assert_response :bad_request
+
+    get :read, {:id => 12345}
+    assert_response :not_found
+
+    get :read, {:id => notes(:hidden_note_with_comment).id}
+    assert_response :gone
+  end
+
+  def test_note_delete_success
+    delete :delete, {:id => notes(:open_note_with_comment).id}
+    assert_response :success
+
+    get :read, {:id => notes(:open_note_with_comment).id, :format => 'json'}
+    assert_response :gone
+  end
+
+  def test_note_delete_fail
+    delete :delete
+    assert_response :bad_request
+
+    delete :delete, {:id => 12345}
+    assert_response :not_found
+
+    delete :delete, {:id => notes(:hidden_note_with_comment).id}
+    assert_response :gone
+  end
+
+  def test_get_notes_success
+    get :list, {:bbox => '1,1,1.2,1.2'}
+    assert_response :success
+    assert_equal "text/javascript", @response.content_type
+
+    get :list, {:bbox => '1,1,1.2,1.2', :format => 'rss'}
+    assert_response :success
+    assert_equal "application/rss+xml", @response.content_type
+
+    get :list, {:bbox => '1,1,1.2,1.2', :format => 'json'}
+    assert_response :success
+    assert_equal "application/json", @response.content_type
+
+    get :list, {:bbox => '1,1,1.2,1.2', :format => 'xml'}
+    assert_response :success
+    assert_equal "application/xml", @response.content_type
+
+    get :list, {:bbox => '1,1,1.2,1.2', :format => 'gpx'}
+    assert_response :success
+    assert_equal "application/gpx+xml", @response.content_type
+  end
+
+  def test_get_notes_large_area
+    get :list, {:bbox => '-2.5,-2.5,2.5,2.5'}
+    assert_response :success
+
+    get :list, {:l => '-2.5', :b => '-2.5', :r => '2.5', :t => '2.5'}
+    assert_response :success
+
+    get :list, {:bbox => '-10,-10,12,12'}
+    assert_response :bad_request
+
+    get :list, {:l => '-10', :b => '-10', :r => '12', :t => '12'}
+    assert_response :bad_request
+  end
+
+  def test_get_notes_closed
+    get :list, {:bbox=>'1,1,1.7,1.7', :closed => '7', :format => 'json'}
+    assert_response :success
+    assert_equal "application/json", @response.content_type
+    js = ActiveSupport::JSON.decode(@response.body)
+    assert_not_nil js
+    assert_equal 4, js.count
+
+    get :list, {:bbox=>'1,1,1.7,1.7', :closed => '0', :format => 'json'}
+    assert_response :success
+    assert_equal "application/json", @response.content_type
+    js = ActiveSupport::JSON.decode(@response.body)
+    assert_not_nil js
+    assert_equal 4, js.count
+
+    get :list, {:bbox=>'1,1,1.7,1.7', :closed => '-1', :format => 'json'}
+    assert_response :success
+    assert_equal "application/json", @response.content_type
+    js = ActiveSupport::JSON.decode(@response.body)
+    assert_not_nil js
+    assert_equal 6, js.count
+  end
+
+  def test_get_notes_bad_params
+    get :list, {:bbox => '-2.5,-2.5,2.5'}
+    assert_response :bad_request
+
+    get :list, {:bbox => '-2.5,-2.5,2.5,2.5,2.5'}
+    assert_response :bad_request
+
+    get :list, {:b => '-2.5', :r => '2.5', :t => '2.5'}
+    assert_response :bad_request
+
+    get :list, {:l => '-2.5', :r => '2.5', :t => '2.5'}
+    assert_response :bad_request
+
+    get :list, {:l => '-2.5', :b => '-2.5', :t => '2.5'}
+    assert_response :bad_request
+
+    get :list, {:l => '-2.5', :b => '-2.5', :r => '2.5'}
+    assert_response :bad_request
+  end
+
+  def test_search_success
+    get :search, {:q => 'note 1'}
+    assert_response :success
+    assert_equal "text/javascript", @response.content_type
+
+    get :search, {:q => 'note 1', :format => 'xml'}
+    assert_response :success
+    assert_equal "application/xml", @response.content_type
+
+    get :search, {:q => 'note 1', :format => 'json'}
+    assert_response :success
+    assert_equal "application/json", @response.content_type
+
+    get :search, {:q => 'note 1', :format => 'rss'}
+    assert_response :success
+    assert_equal "application/rss+xml", @response.content_type
+
+    get :search, {:q => 'note 1', :format => 'gpx'}
+    assert_response :success
+    assert_equal "application/gpx+xml", @response.content_type
+  end
+
+  def test_search_bad_params
+    get :search
+    assert_response :bad_request
+  end
+
+  def test_rss_success
+    get :rss
+    assert_response :success
+    assert_equal "application/rss+xml", @response.content_type
+
+    get :rss, {:bbox=>'1,1,1.2,1.2'}
+    assert_response :success   
+    assert_equal "application/rss+xml", @response.content_type
+  end
+
+  def test_rss_fail
+    get :rss, {:bbox=>'1,1,1.2'}
+    assert_response :bad_request
+
+    get :rss, {:bbox=>'1,1,1.2,1.2,1.2'}
+    assert_response :bad_request
+  end
+
+  def test_user_notes_success
+    get :mine, {:display_name=>'test'}
+    assert_response :success
+
+    get :mine, {:display_name=>'pulibc_test2'}
+    assert_response :success
+
+    get :mine, {:display_name=>'non-existent'}
+    assert_response :not_found 
+  end
+end