You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@whimsical.apache.org by Sam Ruby <ru...@apache.org> on 2015/12/13 16:14:54 UTC

[whimsy.git] [25/37] Commit c797456: delete logic

Commit c797456d861551e09df38cc8f96a856bd609ed89:
    delete logic


Branch: refs/heads/secmail
Author: Sam Ruby <ru...@intertwingly.net>
Committer: Sam Ruby <ru...@intertwingly.net>
Pusher: rubys <ru...@apache.org>

------------------------------------------------------------
mailbox.rb                                                   | ++++++++ --
parsemail.rb                                                 | +++++ ------
server.rb                                                    | +++++++++++++++ 
views/app.js.rb                                              | + -
views/http.js.rb                                             | ++++++++ 
views/index.html.rb                                          | + ----------
views/index.js.rb                                            | +++++++++ -
views/index.json.rb                                          | ++ --
views/utils.js.rb                                            |  ------------
------------------------------------------------------------
395 changes: 259 additions, 136 deletions.
------------------------------------------------------------


diff --git a/mailbox.rb b/mailbox.rb
index 030f960..d914f12 100644
--- a/mailbox.rb
+++ b/mailbox.rb
@@ -18,13 +18,29 @@ def initialize(name)
     name = File.basename(name, '.yml') if name.end_with? '.yml'
 
     if name =~ /^\d+$/
-      @filename = Dir["#{ARCHIVE}/#{name}", "#{ARCHIVE}/#{name}.gz"].first
+      @filename = Dir["#{ARCHIVE}/#{name}", "#{ARCHIVE}/#{name}.gz"].first.
+        untaint
     else
       @filename = name
     end
   end
 
   #
+  # encapsulate updates to a mailbox
+  #
+  def self.update(name)
+    yaml = Mailbox.new(name).yaml_file
+    File.open(yaml, File::RDWR|File::CREAT, 0644) do |file| 
+      file.flock(File::LOCK_EX)
+      mbox = YAML.load(file.read) || {} rescue {}
+      yield mbox
+      file.rewind
+      file.write YAML.dump(mbox)
+      file.truncate(file.pos)
+    end
+  end
+
+  #
   # Determine whether or not the mailbox exists
   #
   def exist?
@@ -73,11 +89,19 @@ def each(&block)
   end
 
   #
+  # name of associated yaml file
+  #
+  def yaml_file
+    source = File.basename(@filename, '.gz').untaint
+    "#{ARCHIVE}/#{source}.yml"
+  end
+
+  #
   # return headers
   #
   def headers
     source = File.basename(@filename, '.gz').untaint
-    messages = YAML.load_file("#{ARCHIVE}/#{source}.yml") rescue {}
+    messages = YAML.load_file(yaml_file) rescue {}
     messages.delete :mtime
     messages.each do |key, value|
       value[:source]=source
@@ -98,23 +122,23 @@ def self.headers(part)
     # extract all fields from the mail (recovering from bad encoding issues)
     fields = part.header_fields.map do |field|
       begin
-	next [field.name, field.to_s] if field.to_s.valid_encoding?
+        next [field.name, field.to_s] if field.to_s.valid_encoding?
       rescue
       end
 
       if field.value and field.value.valid_encoding?
-	[field.name, field.value]
+        [field.name, field.value]
       else
-	[field.name, field.value.inspect]
+        [field.name, field.value.inspect]
       end
     end
 
     # group fields by name
     fields = fields.group_by(&:first).map do |name, values|
       if values.length == 1
-	[name, values.first.last]
+        [name, values.first.last]
       else
-	[name, values.map(&:last)]
+        [name, values.map(&:last)]
       end
     end
 
diff --git a/parsemail.rb b/parsemail.rb
index be4f846..cefa47d 100644
--- a/parsemail.rb
+++ b/parsemail.rb
@@ -37,9 +37,7 @@
   next if mbox[:mtime] == File.mtime(name)
 
   # open the YAML file for real (locking it this time)
-  File.open(yaml, File::RDWR|File::CREAT, 0644) do |file| 
-    file.flock(File::LOCK_EX)
-    mbox = YAML.load_file(yaml) || {} rescue {}
+  Mailbox.update(name) do |mbox|
     mbox[:mtime] = File.mtime(name)
 
     # read (and unzip) the mailbox
@@ -54,9 +52,9 @@
 
       # parse from address
       begin
-	from = Mail::Address.new(mail[:from].value).display_name
+        from = Mail::Address.new(mail[:from].value).display_name
       rescue Exception
-	from = mail[:from].value
+        from = mail[:from].value
       end
 
       # determine who should be copied on any responses
@@ -66,19 +64,19 @@
 
       # remove secretary and anybody on the to field from the cc list
       cc.reject! do |email|
-	begin
-	  address = Mail::Address.new(email).address
-	  return true if address == 'secretary@apache.org'
-	  return true if mail.from_addrs.include? address
-	rescue Exception
-	  true
-	end
+        begin
+          address = Mail::Address.new(email).address
+          return true if address == 'secretary@apache.org'
+          return true if mail.from_addrs.include? address
+        rescue Exception
+          true
+        end
       end
 
       # start an entry for this mail
       mbox[id] = {
-	from: mail.from_addrs.first,
-	name: from,
+        from: mail.from_addrs.first,
+        name: from,
         time: (mail.date.to_time.gmtime.iso8601 rescue nil),
         cc: cc
       }
@@ -88,11 +86,11 @@
 
       # add in attachments
       if mail.attachments.length > 0
-	attachments = mail.attachments.map do |attach|
-	  description = {
+        attachments = mail.attachments.map do |attach|
+          description = {
             name: attach.filename,
             length: attach.body.to_s.length,
-	    mime: attach.mime_type
+            mime: attach.mime_type
           }
 
           if description[:name].empty? and attach['Content-ID']
@@ -100,14 +98,11 @@
           end
 
           description.merge(Mailbox.headers(attach))
-	end
+        end
 
-	mbox[id][:attachments] = attachments
+        mbox[id][:attachments] = attachments
       end
     end
-
-    # update YAML file
-    YAML.dump(mbox, file)
   end
 end
 
diff --git a/server.rb b/server.rb
index 52f85b1..1c565bc 100644
--- a/server.rb
+++ b/server.rb
@@ -30,6 +30,21 @@
   _html :message
 end
 
+# a single message
+delete %r{^/(\d+)/(\w+)/$} do |month, hash|
+  success = false
+
+  Mailbox.update(month) do |headers|
+    if headers[hash]
+      headers[hash][:status] = :deleted
+      success = true
+    end
+  end
+
+  pass unless success
+  _json success: true
+end
+
 # list of parts for a single message
 get %r{^/(\d+)/(\w+)/_index_$} do |month, hash|
   @message = Mailbox.new(month).headers[hash]
diff --git a/views/app.js.rb b/views/app.js.rb
index 6a6bd2f..5906d9c 100644
--- a/views/app.js.rb
+++ b/views/app.js.rb
@@ -1,4 +1,4 @@
-require_relative 'utils'
+require_relative 'http'
 
 require_relative 'index'
 require_relative 'parts'
diff --git a/views/http.js.rb b/views/http.js.rb
new file mode 100644
index 0000000..4934d57
--- /dev/null
+++ b/views/http.js.rb
@@ -0,0 +1,127 @@
+#
+# Encapsulations for asynchronous HTTP requests.  Uses older XMLHttpRequest
+# API over fetch as fetch isn't widely supported yet:
+# https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API#Browser_compatibility
+#
+
+
+class HTTP
+  # "AJAX" style post request to the server, with a callback
+  def self.post(target, data, &block)
+    xhr = XMLHttpRequest.new()
+    xhr.open('POST', target, true)
+    xhr.setRequestHeader('Content-Type', 'application/json;charset=utf-8')
+    xhr.responseType = 'text'
+
+    def xhr.onreadystatechange()
+      if xhr.readyState == 4
+        data = nil
+
+        begin
+          if xhr.status == 200
+            data = JSON.parse(xhr.responseText) 
+            alert "Exception\n#{data.exception}" if data.exception
+          elsif xhr.status == 404
+            alert "Not Found: #{target}"
+          elsif xhr.status >= 400
+            console.log(xhr.response)
+            if not xhr.response
+              alert "Exception - #{xhr.statusText}"
+            elsif xhr.response.exception
+              alert "Exception\n#{xhr.response.exception}"
+            else
+              alert "Exception\n#{JSON.parse(xhr.responseText).exception}"
+            end
+          end
+        rescue => e
+          console.log(e)
+        end
+
+        block(data)
+      end
+    end
+
+    xhr.send(JSON.stringify(data))
+  end
+
+  # "AJAX" style delete request to the server, with a callback
+  def self.delete(target, &block)
+    xhr = XMLHttpRequest.new()
+    xhr.open('DELETE', target, true)
+
+    def xhr.onreadystatechange()
+      if xhr.readyState == 4
+
+        begin
+          if xhr.status == 404
+            alert "Not Found: #{target}"
+          elsif xhr.status >= 400
+            console.log(xhr.response)
+            if not xhr.response
+              alert "Exception - #{xhr.statusText}"
+            elsif xhr.response.exception
+              alert "Exception\n#{xhr.response.exception}"
+            else
+              alert "Exception\n#{JSON.parse(xhr.responseText).exception}"
+            end
+          end
+        rescue => e
+          console.log(e)
+        end
+
+        block()
+      end
+    end
+
+    xhr.send()
+  end
+
+  # "AJAX" style get request to the server, with a callback
+  def self.ge(target, type, &block)
+    xhr = XMLHttpRequest.new()
+
+    def xhr.onreadystatechange()
+      if xhr.readyState == 1
+        clock_counter += 1
+        setTimeout(0) {Main.refresh()}
+      elsif xhr.readyState == 4
+        data = nil
+
+        begin
+          if xhr.status == 200
+            if type == :json
+              data = xhr.response || JSON.parse(xhr.responseText) 
+            else
+              data = xhr.responseText
+            end
+          elsif xhr.status == 404
+            alert "Not Found: #{type}/#{target}"
+          elsif xhr.status >= 400
+            console.log(xhr.response)
+            if not xhr.response
+              alert "Exception - #{xhr.statusText}"
+            elsif xhr.response.exception
+              alert "Exception\n#{xhr.response.exception}"
+            else
+              alert "Exception\n#{JSON.parse(xhr.responseText).exception}"
+            end
+          end
+        rescue => e
+          console.log(e)
+        end
+
+        block(data)
+        clock_counter -= 1
+      end
+    end
+
+    if target =~ /^https?:/
+      xhr.open('GET', target, true)
+      xhr.setRequestHeader("Accept", "application/json") if type == :json
+    else
+      xhr.open('GET', "../#{type}/#{target}", true)
+    end
+    xhr.responseType = type
+    xhr.send()
+  end
+end
diff --git a/views/index.html.rb b/views/index.html.rb
index 554e5c6..51ecb2f 100644
--- a/views/index.html.rb
+++ b/views/index.html.rb
@@ -1,14 +1,5 @@
 _html do
-  _style %{
-    td:nth-child(2), th:nth-child(2) {
-      padding-right: 7px;
-      padding-left: 7px;
-    }
-
-   .selected {
-     background-color: yellow
-   }
-  }
+  _link rel: 'stylesheet', type: 'text/css', href: 'secmail.css'
 
   _div.index!
 
diff --git a/views/index.js.rb b/views/index.js.rb
index a48480d..a72ad0a 100644
--- a/views/index.js.rb
+++ b/views/index.js.rb
@@ -6,6 +6,7 @@ class Index < React
   def initialize
     @selected = nil
     @messages = []
+    @undoStack = []
   end
 
   def render
@@ -20,7 +21,14 @@ def render
 
       _tbody do
         @messages.each do |message|
-          _tr class: ('selected' if message.href == @selected) do
+
+          # determine the 'color' to use for the row
+          color = nil
+          color = 'deleted' if message.status == :deletePending
+          color = 'hidden' if message.status == :deleted
+          color = 'selected' if message.href == @selected
+
+          _tr class: color, onClick: self.selectRow, onDoubleClick: self.nav do
             _td do
               _a message.time, href: "#{message.href}"
             end 
@@ -46,17 +54,25 @@ def componentDidMount()
     window.onkeydown = self.keydown
   end
 
-  # when content changes, scroll to selected message
+  # when content changes, ensure selected message is visible
   def componentDidUpdate()
     if @selected
       selected = document.querySelector("a[href='#{@selected}']")
-      selected.scrollIntoView() if selected
+      if selected
+        rect = selected.getBoundingClientRect()
+        if
+          rect.top < 0 or rect.left < 0 or 
+          rect.bottom > window.innerHeight or rect.right > window.innerWidth
+        then
+          selected.scrollIntoView()
+        end
+      end
     end
   end
 
   # fetch a month's worth of messages
   def fetch_month()
-    post('', mbox: @latest) do |response|
+    HTTP.post('', mbox: @latest) do |response|
       # update latest mbox
       @latest = response.mbox if response.mbox
 
@@ -68,18 +84,62 @@ def fetch_month()
     end
   end
 
+  # select
+  def selectRow(event)
+    @selected = event.currentTarget.querySelector('a').getAttribute('href')
+  end
+
+  # navigate
+  def nav(event)
+    @selected = event.currentTarget.querySelector('a').getAttribute('href')
+    window.location.href = @selected
+    window.getSelection().removeAllRanges()
+    event.preventDefault()
+  end
+
   # handle keyboard events
   def keydown(event)
     if event.keyCode == 38 # up
       index = @messages.findIndex {|m| return m.href == @selected}
       @selected = @messages[index-1].href if index > 0
       event.preventDefault()
+
     elsif event.keyCode == 40 # down
       index = @messages.findIndex {|m| return m.href == @selected}
       @selected = @messages[index+1].href if index < @messages.length-1
       event.preventDefault()
+
+    elsif event.keyCode == 13 or event.keyCode == 39 # enter/return or right
+      selected = @messages.find {|m| return m.href == @selected}
+      window.location.href = selected.href if selected
+
+    elsif event.keyCode == 8 # delete
+      event.preventDefault()
+      # mark item as delete pending
+      selected = @selected
+      index = @messages.findIndex {|m| return m.href == selected}
+      @messages[index].status = :deletePending if index >= 0
+
+      # move selected pointer
+      if index > 0
+        @selected = @messages[index-1].href
+      elsif index < @messages.length - 1
+        @selected = @messages[index+1].href
+      else
+        @selected = nil
+      end
+
+      # send request to server to perform delete
+      HTTP.delete(selected) do
+        index = @messages.findIndex {|m| return m.href == selected}
+        console.log index
+        @messages[index].status = :deleted if index >= 0
+        @undoStack << selected
+        self.forceUpdate()
+      end
+
     else
-      alert event.keyCode
+      console.log "keydown: #{event.keyCode}"
     end
   end
 end
diff --git a/views/index.json.rb b/views/index.json.rb
index 77a3a9a..f373acf 100644
--- a/views/index.json.rb
+++ b/views/index.json.rb
@@ -3,11 +3,11 @@
   available = Dir["#{ARCHIVE}/*.yml"].sort
   index = available.find_index "#{ARCHIVE}/#{@mbox}.yml"
 
-  # if found and not first, process it
+  # if found, process it
   if index
     # fetch a list of headers for all messages in the maibox with attachments
     headers = Mailbox.new(@mbox).headers.to_a.select do |id, message|
-      message[:attachments]
+      message[:attachments] and not message[:status] == :deleted
     end
 
     # extract relevant fields from the headers
diff --git a/views/utils.js.rb b/views/utils.js.rb
deleted file mode 100644
index f8db8eb..0000000
--- a/views/utils.js.rb
+++ /dev/null
@@ -1,89 +0,0 @@
-# "AJAX" style post request to the server, with a callback
-def post(target, data, &block)
-  xhr = XMLHttpRequest.new()
-  xhr.open('POST', target, true)
-  xhr.setRequestHeader('Content-Type', 'application/json;charset=utf-8')
-  xhr.responseType = 'text'
-
-  def xhr.onreadystatechange()
-    if xhr.readyState == 4
-      data = nil
-
-      begin
-        if xhr.status == 200
-          data = JSON.parse(xhr.responseText) 
-          alert "Exception\n#{data.exception}" if data.exception
-        elsif xhr.status == 404
-          alert "Not Found: json/#{target}"
-        elsif xhr.status >= 400
-          console.log(xhr.response)
-          if not xhr.response
-            alert "Exception - #{xhr.statusText}"
-          elsif xhr.response.exception
-            alert "Exception\n#{xhr.response.exception}"
-          else
-            alert "Exception\n#{JSON.parse(xhr.responseText).exception}"
-          end
-        end
-      rescue => e
-        console.log(e)
-      end
-
-      block(data)
-      Main.refresh()
-    end
-  end
-
-  xhr.send(JSON.stringify(data))
-end
-
-# "AJAX" style get request to the server, with a callback
-def fetch(target, type, &block)
-  xhr = XMLHttpRequest.new()
-
-  def xhr.onreadystatechange()
-    if xhr.readyState == 1
-      clock_counter += 1
-      setTimeout(0) {Main.refresh()}
-    elsif xhr.readyState == 4
-      data = nil
-
-      begin
-        if xhr.status == 200
-          if type == :json
-            data = xhr.response || JSON.parse(xhr.responseText) 
-          else
-            data = xhr.responseText
-          end
-        elsif xhr.status == 404
-          alert "Not Found: #{type}/#{target}"
-        elsif xhr.status >= 400
-          console.log(xhr.response)
-          if not xhr.response
-            alert "Exception - #{xhr.statusText}"
-          elsif xhr.response.exception
-            alert "Exception\n#{xhr.response.exception}"
-          else
-            alert "Exception\n#{JSON.parse(xhr.responseText).exception}"
-          end
-        end
-      rescue => e
-        console.log(e)
-      end
-
-      block(data)
-      clock_counter -= 1
-      Main.refresh()
-    end
-  end
-
-  if target =~ /^https?:/
-    xhr.open('GET', target, true)
-    xhr.setRequestHeader("Accept", "application/json") if type == :json
-  else
-    xhr.open('GET', "../#{type}/#{target}", true)
-  end
-  xhr.responseType = type
-  xhr.send()
-end
-